diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1619a8862..6efd578c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index e3d7418d7..309f3f0ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`** 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,25 +45,22 @@ 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.]` 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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 31ea4b474..2c7618112 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 @@ -71,9 +71,10 @@ Generated-in-code assets are original and have no file to list: | `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 | diff --git a/CLAUDE.md b/CLAUDE.md index 8e4a1fdfa..659cc8c2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`** 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,7 +44,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. 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. diff --git a/Cargo.lock b/Cargo.lock index 51693203d..c2f4ce914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -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,15 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.14.0" +version = "0.17.0" dependencies = [ "acadrust", + "log", ] [[package]] name = "cadcraft-dxf" -version = "0.14.0" +version = "0.17.0" dependencies = [ "encoding_rs", "log", @@ -703,7 +705,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.14.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -724,24 +726,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 +765,7 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.14.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -775,7 +778,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.14.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -789,7 +792,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.14.0" +version = "0.17.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -815,7 +818,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.14.0" +version = "0.17.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -1115,6 +1118,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,7 +1203,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", @@ -1643,7 +1655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2435,6 +2447,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" @@ -3435,7 +3470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4030,7 +4065,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4087,7 +4122,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4562,10 +4597,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5471,7 +5506,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5894,7 +5929,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.17.0" dependencies = [ "chrono", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index da6bca3b4..f51aa8909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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) @@ -66,7 +69,7 @@ egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +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 } diff --git a/README.md b/README.md index c45b619dc..611110c3c 100644 --- a/README.md +++ b/README.md @@ -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. | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | -| | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **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) | +| | **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. diff --git a/ROADMAP.md b/ROADMAP.md index 239e10ac7..48bd33032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..fbad5435b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security policy + +## Reporting a vulnerability + +Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private. + +Maintainers read reports as they arrive, but there is no fixed response time yet. + +## Supported versions + +Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it. + +## What counts + +CADCraft treats these as untrusted, and a way past these defences is a vulnerability: + +- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case. +- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load. +- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability. +- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them. +- **Updates.** A release must not be replaceable by someone other than the project. + +## Known limits + +These are known and being worked on, so they don't need a report: + +- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet. +- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit. +- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app. +- The web build has no autosave yet. + +## What stays on your machine + +Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere. diff --git a/apps/cadcraft-cli/src/convert.rs b/apps/cadcraft-cli/src/convert.rs index 6a9c4edbf..35ffc6aed 100644 --- a/apps/cadcraft-cli/src/convert.rs +++ b/apps/cadcraft-cli/src/convert.rs @@ -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) diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 7561b96b5..866923c9a 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -1,6 +1,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] fn main() { + // A copy of this program started to read or write a DWG file does only that. + if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() { + std::process::exit(code); + } + cadcraft_io::dwg_worker::use_own_worker(); // `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be // reported as an error, not panic before anything is printed. let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr()); diff --git a/apps/cadcraft-cli/src/output.rs b/apps/cadcraft-cli/src/output.rs index d783267dc..83fa17dfd 100644 --- a/apps/cadcraft-cli/src/output.rs +++ b/apps/cadcraft-cli/src/output.rs @@ -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, 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, 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, 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 { + 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 { 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"] + ); + } +} diff --git a/apps/cadcraft-cli/src/sample.rs b/apps/cadcraft-cli/src/sample.rs index 9b6405cb5..3f93bc5cf 100644 --- a/apps/cadcraft-cli/src/sample.rs +++ b/apps/cadcraft-cli/src/sample.rs @@ -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(()) } diff --git a/apps/cadcraft-cli/tests/dwg_worker.rs b/apps/cadcraft-cli/tests/dwg_worker.rs new file mode 100644 index 000000000..8656b84ff --- /dev/null +++ b/apps/cadcraft-cli/tests/dwg_worker.rs @@ -0,0 +1,33 @@ +//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save, +//! not the app. The worker setting is process-wide, so everything runs in one test. + +#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))] + +use cadcraft_io::dwg_worker; + +#[test] +fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() { + let drawing = cadcraft_engine::sample::bracket(); + let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap(); + + dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]); + let written = cadcraft_io::write(&drawing, "a.dwg").unwrap(); + assert_eq!(written.get(..6), in_process.get(..6), "the same version of file"); + let read = cadcraft_io::read(&written, "a.dwg").unwrap(); + assert_eq!(read.model.len(), drawing.model.len()); + + // A decode error comes back as that error, not as the worker stopping. + let mut bad = in_process.clone(); + bad.truncate(64); + let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string(); + assert!(!e.contains("worker"), "{e}"); + + // The worker aborts as the third-party code can. This process goes on. + let e = dwg_worker::crash_worker_for_test().unwrap_err(); + assert!(e.contains("worker stopped"), "{e}"); + assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker"); + + // No worker to start: the work happens here, as without one. + dwg_worker::use_worker("no-such-program-here".into(), Vec::new()); + assert!(cadcraft_io::read(&written, "a.dwg").is_ok()); +} diff --git a/apps/cadcraft-cli/tests/read_notes.rs b/apps/cadcraft-cli/tests/read_notes.rs new file mode 100644 index 000000000..2f443f03a --- /dev/null +++ b/apps/cadcraft-cli/tests/read_notes.rs @@ -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, Vec) { + 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); +} diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index d8b4ef4c5..ee1b8b9c8 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -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] diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 4d7bdc39b..71700095d 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -86,7 +86,7 @@ impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.cad.system_animations = self.system_animations.current(); // Consume control/open/screenshot queues and time-based autosave, including hidden ticks. - self.cad.logic(ctx); + self.cad.guarded_logic(ctx); // Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which // never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a // workspace changed while hidden is persisted. `current_pass_index()` is zero on the first @@ -116,7 +116,7 @@ impl eframe::App for App { cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.cad.ui(ui); + self.cad.guarded_ui(ui); #[cfg(target_os = "macos")] craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); } @@ -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::>(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; @@ -214,6 +258,11 @@ fn version_string() -> String { /// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe. fn main() -> eframe::Result { + // A copy of this program started to read or write a DWG file does only that. + if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() { + std::process::exit(code); + } + cadcraft_io::dwg_worker::use_own_worker(); // First, so every start-up record is captured; see `logging`. let logger = logging::install(); // The update helper relaunches this binary after a self-update; it runs and exits here. @@ -245,6 +294,9 @@ fn main() -> eframe::Result { } install_io(); cadcraft_engine::settings_dir::set(vars.config_dir.clone()); + if let Some(dir) = &vars.config_dir { + cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR)); + } if let Some(name) = &vars.font_alt { cadcraft_engine::fonts::ttf::set_font_alt(name); } @@ -256,22 +308,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 +337,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 +430,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 +448,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] diff --git a/contributors/people.toml b/contributors/people.toml index 28564415e..8bb73b9ea 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" @@ -99,3 +96,16 @@ sync_github_name = false [people.dylanmtaylor] sync_github_name = true + +[people.GULERTOLGA] +sync_github_name = true + +[people.Tyler-Chiriboga] +real_name = "Tyler Chiriboga" +sync_github_name = true + +[people.starise] +real_name = "Andrea Brandi" + +[people.qinshah] +sync_github_name = true diff --git a/crates/constraints/src/dof.rs b/crates/constraints/src/dof.rs index 559452223..e6128665c 100644 --- a/crates/constraints/src/dof.rs +++ b/crates/constraints/src/dof.rs @@ -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 { 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 { 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()); } } diff --git a/crates/constraints/src/expr.rs b/crates/constraints/src/expr.rs index c71ed3486..057ef3e1c 100644 --- a/crates/constraints/src/expr.rs +++ b/crates/constraints/src/expr.rs @@ -284,7 +284,14 @@ impl Env { pub fn new() -> Self { Env::default() } + /// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so + /// only a bounded prefix of it is kept: it still fails (as too long), without the environment + /// holding or copying the whole text. pub fn insert(&mut self, name: &str, expr: &str) { + let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) { + Some(end) => expr.get(..end).unwrap_or(expr), + None => expr, + }; self.exprs.insert(name.to_string(), expr.to_string()); self.cache.clear(); } @@ -309,8 +316,9 @@ impl Env { if stack.len() >= MAX_DEPTH { return Err(ExprError::TooDeep); } - let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?; - let e = parse(&text)?; + // Parse the stored text in place: `parse` refuses an oversized expression before reading + // it, so a long imported expression referenced many times is never copied (CWE-400). + let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?; stack.push(name.to_string()); let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth); stack.pop(); @@ -400,6 +408,22 @@ mod tests { .unwrap(); } + /// An oversized named expression is kept only as a bounded prefix and still fails, however + /// often it is referenced. + #[test] + fn oversized_named_expression_is_bounded_and_fails() { + let mut env = Env::new(); + let long = format!("1+{}", "é".repeat(500_000)); + env.insert("big", &long); + assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4)); + assert_eq!(env.value("big"), Err(ExprError::TooDeep)); + for _ in 0..200 { + assert_eq!(env.eval("big"), Err(ExprError::TooDeep)); + } + env.insert("ok", "1+1+1"); + assert_eq!(env.value("ok"), Ok(3.0)); + } + #[test] fn rename_replaces_whole_names_only() { assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)"); diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index 89c6276cd..5b01fc022 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -30,7 +30,7 @@ mod lm; mod model; mod residual; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle}; use cadcraft_geom::Vec2; @@ -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 { @@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool { d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name) } +/// Highest numbered default name [`next_name`] tries. +const MAX_NAME_SUFFIX: u32 = 100_000; + /// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …). pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String { let prefix = kind.name_prefix(); - (1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x")) + // Collect the suffixes in use in one pass, then take the first free one: checking every + // candidate against every name is quadratic in the number of names (CWE-407). Only a + // suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`). + let taken: HashSet = d + .parametric + .parameters + .iter() + .map(|p| p.name.as_str()) + .chain(d.constraints.iter().map(|c| c.name.as_str())) + .filter_map(|n| n.strip_prefix(prefix)) + .filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit())) + .filter_map(|s| s.parse::().ok()) + .collect(); + (1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}")) } /// Next free constraint id. @@ -251,12 +272,41 @@ impl Comp { pub(crate) fn rows(&self, skip: Option) -> 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) -> f64 { + let rows = self.rows(skip) as f64; + let evals = 2.0 * free.len() as f64 + TRIAL_EVALS; + let mut splines: Vec = 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) -> Result { 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| 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 +460,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, 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, 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 = 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 +690,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 = c.refs.iter().map(|r| r.handle).collect(); for h in &handles { match d.entity(*h) { @@ -648,7 +703,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 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result Vec { let mut gone = Vec::new(); - let keep: Vec = 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 = 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 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result MAX_EVAL_WORK { + return Err(SolveError::TooLarge); + } let after = comp.free(None); let locked: Vec = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect(); let rows_old = comp.rows(Some(pos)); diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index d18646c32..f9ff51fe5 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -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, Rc)>; + #[derive(Clone, Debug, Default)] pub(crate) struct Model { pub slots: Vec, @@ -79,20 +84,75 @@ pub(crate) struct Model { splines: HashMap, /// Block reference rigid points relative to the insertion point at rotation 0. rigid: HashMap>, + /// 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>, +} + +/// 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>>, + /// Lower-cased block name to the stored name it resolves to, built on the first exact miss. + folded: Option>, + rebuilt: Rc>, +} + +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> { + 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 { + 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 { - 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 { - 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 { + fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option { 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 = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect(); + let pts: Vec = 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 { + /// 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> { 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::>>()?; - 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 = (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); } } diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 35b73fea2..eadc7a3c2 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() { } } } + +/// The default name is the first free numbered one, found in one pass over many names in use. +#[test] +fn next_name_skips_names_in_use() { + let mut d = Drawing::default(); + assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1"); + for i in (1..=20_000).filter(|i| *i != 15_000) { + d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() }); + } + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000"); + d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() }); + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001"); + // A differently spelled suffix does not occupy the plain name. + let mut d = Drawing::default(); + for name in ["d01", "d+1", "d1x", "D1"] { + d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() }); + } + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1"); + d.constraints.push(Constraint { + id: 1, + kind: ConstraintKind::Distance(DistAxis::Aligned), + refs: Vec::new(), + name: "d1".into(), + expr: "1".into(), + }); + assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2"); +} diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index ef0a88be2..409d403dd 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -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, #[serde(serialize_with = "hex_vertices")] @@ -321,8 +323,9 @@ impl<'a> Cursor<'a> { } } -/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one. -fn graph_members<'a>(objects: &HashMap, xdict: Handle) -> Option> { +/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the +/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph. +fn evaluation_graph<'a>(objects: &HashMap, xdict: Handle) -> Option<&'a ForeignObject> { let dict = objects.get(&xdict)?; let mut graph = None; let mut tags = dict.tags.iter(); @@ -332,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap, xdict: Handle break; } } - let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?; + objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH")) +} + +/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one. +fn graph_members<'a>(objects: &HashMap, xdict: Handle) -> Option> { + let graph = evaluation_graph(objects, xdict)?; let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value)); // Deduplicate member handles before resolving them: a graph can list the same member many // times, and parsing each occurrence into its own owned parameter multiplies a single large @@ -341,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap, xdict: Handle Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect()) } +/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller +/// that asks about many blocks (listing them) builds it once and passes it to +/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which +/// multiplies the block count by the object count (CWE-407). +pub struct ObjectIndex<'a>(HashMap); + +impl<'a> ObjectIndex<'a> { + pub fn new(d: &'a Drawing) -> Self { + ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect()) + } +} + +/// The extension dictionary of block `name`'s record, if it has one. +fn block_xdict(d: &Drawing, name: &str) -> Option { + d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict +} + /// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a /// block's own drawing), or `None` when it has none. pub fn dynamic_block(d: &Drawing, name: &str) -> Option { - let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?; - let objects: HashMap = d.foreign.objects.iter().map(|o| (o.handle, o)).collect(); + // A block without an extension dictionary is never dynamic: answer before indexing objects. + block_xdict(d, name)?; + dynamic_block_in(d, &ObjectIndex::new(d), name) +} + +/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its +/// parameters and actions; `objects` is the drawing's [`ObjectIndex`]. +pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool { + block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some() +} + +/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built. +pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option { + let xdict = block_xdict(d, name)?; let mut out = DynamicBlock::default(); - for o in graph_members(&objects, xdict)? { + for o in graph_members(&objects.0, xdict)? { let secs = sections(&o.tags); let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90); let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300); diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index ed5281bf6..c22a6df86 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option { /// The object entry `key` of dictionary `dict` refers to. fn entry(d: &Drawing, dict: Handle, key: &str) -> Option { - let o = d.foreign.objects.iter().find(|o| o.handle == dict)?; + dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key) +} + +/// The object entry `key` (any case) of dictionary object `o` names. +fn dictionary_entry(o: &ForeignObject, key: &str) -> Option { let mut tags = o.tags.iter(); while let Some(t) = tags.next() { if t.code == 3 && t.value.eq_ignore_ascii_case(key) { @@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option { /// Every entry of dictionary `dict`: (key, object). fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> { - let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() }; + d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default() +} + +/// Every entry of dictionary object `o`: (key, object). +fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> { let mut out = Vec::new(); let mut key: Option<&str> = None; for t in &o.tags { @@ -403,7 +411,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) -> Option> { + let idx: Vec = 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 +444,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 +499,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 +511,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> = 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 = HashSet::new(); for h in &a.selection { if !handled.insert(*h) { @@ -502,7 +542,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)); } } @@ -649,19 +700,33 @@ fn refill_hatches(b: &mut Block) { /// The value records of the references to the dynamic block whose record is `def_record` (from /// any one of them, by graph id): AutoCAD's layout of each record, class ids included. +/// +/// Each record is copied once at most: a key naming a record an earlier key already took is +/// skipped. Records are copied per key (and copied again per key when a new reference's data is +/// made), so a file listing one large record under many keys would otherwise multiply its size by +/// the key count (CWE-400); AutoCAD gives every key its own record. fn templates(d: &Drawing, def_record: Handle) -> HashMap> { let objects: HashMap = d.foreign.objects.iter().map(|o| (o.handle, o)).collect(); + // Dictionary entries looked up through the index, not by scanning every object per + // dictionary (which is quadratic in the object count). + let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key)); let mut out: HashMap> = HashMap::new(); + let mut taken: HashSet = HashSet::new(); for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) { // A representation dictionary whose data names this dynamic block. - let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue }; + let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue }; if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) { continue; } - let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue }; - for (k, h) in entries(d, values) { + let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue }; + let Some(values) = objects.get(&values) else { continue }; + for (k, h) in dictionary_entries(values) { + if out.contains_key(&k) || taken.contains(&h) { + continue; + } if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) { - out.entry(k).or_insert_with(|| x.tags.clone()); + taken.insert(h); + out.insert(k, x.tags.clone()); } } } @@ -1001,4 +1066,23 @@ mod tests { assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]); assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]); } + + #[test] + fn a_value_record_listed_under_many_keys_is_copied_once() { + // One large XRECORD named by many keys of a reference's value dictionary: the templates + // keep one copy, not one per key. + let mut d = Drawing::new_imperial(); + let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24)); + let keys: Vec = (0..1000).map(|i| i.to_string()).collect(); + let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect(); + let f = std::sync::Arc::make_mut(&mut d.foreign); + f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)])); + f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] }); + f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)])); + f.objects.push(dictionary(values, cache, &entries)); + f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() }); + let t = templates(&d, def_record); + assert_eq!(t.len(), 1, "one record, one template"); + assert_eq!(t.get("0").map(Vec::len), Some(1000)); + } } diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index d13b82722..a516137fa 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -48,7 +48,7 @@ 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}; pub use layout::{Image, Table, TableCell, Viewport}; @@ -86,6 +86,7 @@ pub enum EntityKind { Solid(Solid), Trace(Solid), Face3d(Face3d), + PolyfaceMesh(PolyfaceMesh), Region(Region), Viewport(Viewport), Image(Image), @@ -140,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", @@ -175,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", @@ -218,10 +221,10 @@ 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) => { @@ -424,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); @@ -465,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| { + 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 { // A broken table has a grip for each part. @@ -526,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(); diff --git a/crates/doc/src/entity/curves.rs b/crates/doc/src/entity/curves.rs index 8eeb556c4..f32319b02 100644 --- a/crates/doc/src/entity/curves.rs +++ b/crates/doc/src/entity/curves.rs @@ -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, + 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 { + 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 { + let mut corners: Vec = 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, diff --git a/crates/doc/src/entity/dim.rs b/crates/doc/src/entity/dim.rs index d10237b26..dc4635c7a 100644 --- a/crates/doc/src/entity/dim.rs +++ b/crates/doc/src/entity/dim.rs @@ -52,6 +52,11 @@ pub struct Dimension { /// Anonymous block with the rendered geometry (from files); regenerated when edited. #[serde(default)] pub block: Option, + /// 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")] diff --git a/crates/doc/src/entity/mline.rs b/crates/doc/src/entity/mline.rs index 62ab1d8f3..f6ceef519 100644 --- a/crates/doc/src/entity/mline.rs +++ b/crates/doc/src/entity/mline.rs @@ -207,6 +207,41 @@ pub struct MLineGeometry { pub fill: Vec>, } +/// For each segment direction in `dirs`, the first non-zero direction at or after it and the +/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero). +/// +/// Two linear passes: searching from every segment separately is quadratic in a run of +/// coincident vertices, which a hostile file can make as long as it likes (CWE-407). +fn usable_directions(dirs: &[Vec2]) -> (Vec, Vec) { + let n = dirs.len(); + let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else { + return (vec![Vec2::X; n], vec![Vec2::X; n]); + }; + let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]); + // Walking backwards from the last usable direction, the latest one seen is the next ahead. + let mut cur = Vec2::X; + for k in 0..n { + let i = (last + n - k) % n; + if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) { + cur = *d; + } + if let Some(a) = ahead.get_mut(i) { + *a = cur; + } + } + // Walking forwards from the first usable direction, the latest one seen is the nearest behind. + for k in 0..n { + let i = (first + k) % n; + if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) { + cur = *d; + } + if let Some(b) = behind.get_mut(i) { + *b = cur; + } + } + (ahead, behind) +} + impl MLine { /// A multiline through `pts` in style `def`. pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine { @@ -268,16 +303,11 @@ impl MLine { let dirs = self.directions(); let n = self.vertices.len(); let nseg = dirs.len(); - // A degenerate segment takes its neighbour's direction. - let dir = |i: usize| -> Vec2 { - (0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X) - }; - let dir_back = |i: usize| -> Vec2 { - (0..nseg) - .map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default()) - .find(|d| *d != Vec2::ZERO) - .unwrap_or(Vec2::X) - }; + // A degenerate segment takes its neighbour's direction: the first usable one at or after + // it (`ahead`), or at or before it (`behind`), wrapping around. + let (ahead, behind) = usable_directions(&dirs); + let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) }; + let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) }; let end = |d: Vec2, angle: f64| { let u = d.rotate(angle); let s = angle.sin(); @@ -426,3 +456,61 @@ impl crate::Drawing { self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default() } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The nearest usable directions found by searching from each segment, as miters did before. + fn searched(dirs: &[Vec2]) -> (Vec, Vec) { + let n = dirs.len(); + let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)); + let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)); + (ahead.collect(), behind.collect()) + } + + #[test] + fn usable_directions_match_a_search_from_each_segment() { + let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8)); + for dirs in [ + vec![], + vec![z], + vec![a], + vec![z, z, z], + vec![a, z, z, b], + vec![z, a, z, b, z], + vec![z, z, c], + vec![c, z, z], + vec![a, b, c], + vec![z, a, z, z, b, c, z], + ] { + assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}"); + } + } + + #[test] + fn coincident_vertices_at_a_corner_take_the_corner_miter() { + // Every vertex of a run on one point mitres between the segment before the run and the one after. + let style = MLineStyle::default(); + let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0); + let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)]; + let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0); + let (expected, got) = (corner.miters(), run.miters()); + assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..])); + assert_eq!((got.first(), got.last()), (expected.first(), expected.last())); + } + + #[test] + fn a_long_run_of_coincident_vertices_takes_linear_time() { + // 200 000 vertices on one point but the last: the miters must not search the run once per + // vertex (quadratic, minutes) to find the one usable direction. + let mut pts = vec![Vec2::ZERO; 200_000]; + pts.push(Vec2::new(1.0, 0.0)); + let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0); + let t = std::time::Instant::now(); + let miters = m.miters(); + assert_eq!(miters.len(), pts.len()); + assert!(miters.iter().all(|v| v.is_finite())); + assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed()); + } +} diff --git a/crates/doc/src/entity/region.rs b/crates/doc/src/entity/region.rs index 0d2594ea8..8c63b43bf 100644 --- a/crates/doc/src/entity/region.rs +++ b/crates/doc/src/entity/region.rs @@ -9,6 +9,9 @@ use super::HatchLoop; /// Most loops one region has (hostile files and edits). pub const MAX_REGION_LOOPS: usize = 10_000; +/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests. +pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Region { /// Closed loops with bulges; `outer` false for islands. @@ -76,20 +79,49 @@ impl Region { } /// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is - /// an outer loop, odd an island. - pub fn renest(&mut self) { + /// an outer loop, odd an island. `false`, with the flags left as they were, when that takes + /// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests. + #[must_use] + pub fn renest(&mut self) -> bool { let polys = self.tessellate(self.fine_tol()); - let depth: Vec = polys + // A loop's probe can only be inside loops whose box holds it: test only those. A box with + // a non-finite corner says nothing (an edge through a non-finite point never counts, so + // such a polygon need not be closed), so its loop is always tested. + let boxes: Vec> = polys .iter() - .enumerate() - .map(|(i, l)| { - let Some(p) = probe(l) else { return 0 }; - polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count() + .map(|l| { + let b = Bounds2::from_points(l.iter().copied()); + (l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b) }) .collect(); + let mut visits = 0usize; + let mut depth = Vec::with_capacity(polys.len()); + for (i, l) in polys.iter().enumerate() { + let Some(p) = probe(l) else { + depth.push(0); + continue; + }; + let mut d = 0; + for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() { + if j == i || b.is_some_and(|b| !b.contains(p)) { + continue; + } + // Each loop against every other is loops x vertices: bounded so a region of many + // large nested loops cannot hold the thread (CWE-407). + visits = visits.saturating_add(o.len()); + if visits > MAX_RENEST_EDGE_VISITS { + return false; + } + if point_in_polygon(o, p) { + d += 1; + } + } + depth.push(d); + } for (l, d) in self.loops.iter_mut().zip(depth) { l.outer = d % 2 == 0; } + true } /// Area, perimeter, bounding box, centroid and second moments (outer loops counted @@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option { (a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite()) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop { + // `n` vertices along the bottom edge, then the other three corners. + let mut vertices: Vec = + (0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect(); + vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new)); + HatchLoop { vertices, outer, sources: Vec::new() } + } + + #[test] + fn renest_sets_depth_parity_and_skips_loops_far_away() { + // Three nested squares, and one beside them. + let mut r = + Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] }; + assert!(r.renest()); + assert_eq!(r.loops.iter().map(|l| l.outer).collect::>(), [true, false, true, true]); + } + + #[test] + fn renest_of_many_large_nested_loops_stops_at_its_budget() { + // Concentric loops of many vertices each: each probe is inside the box of every larger + // loop (about loops² / 2 pairs), so each of those tests is a full polygon scan. + let loops = 300; + let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops); + let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() }; + let before = r.clone(); + assert!(!r.renest()); + assert_eq!(r, before, "flags left as they were"); + } +} diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 353a42b53..e44809df4 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 { /// 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) + bounds_of(d, e, depth, false, &mut HashMap::new()) } /// 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) + bounds_of(d, e, 0, true, &mut HashMap::new()) } -fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 { +/// The bounds of block contents measured so far, kept across the entities of one unchanged +/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`] +/// and one cache measures each block definition once per depth for all of them; a fresh cache per +/// entity would measure a block again for every reference to it, R references to a block of B +/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged. +#[derive(Default)] +pub struct BlockBoundsCache<'a> { + /// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]). + drawn: HashMap<(&'a str, usize), Bounds2>, + /// The same with those contents counted ([`extent_bounds`]). + extents: HashMap<(&'a str, usize), Bounds2>, +} + +/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`. +pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 { + bounds_of(d, e, depth, false, &mut cache.drawn) +} + +/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`. +pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 { + bounds_of(d, e, 0, true, &mut cache.extents) +} + +fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> 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(), off_counts), + crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts), } } - bounds_in(d, e, depth, &mut HashMap::new(), off_counts) + bounds_in(d, e, depth, blocks, off_counts) } /// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed, @@ -212,6 +235,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< 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 @@ -438,4 +465,53 @@ mod tests { empty.text = Some(crate::MText { contents: " ".into(), ..mtext }); assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0); } + + #[test] + fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() { + let line = |a: (f64, f64), b: (f64, f64), layer: &str| { + let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })); + e.common.layer = layer.into(); + e + }; + let ins = |block: &str, at: f64| { + Entity::new( + crate::Handle(2), + EntityKind::Insert(crate::Insert { + block: block.into(), + insert: Vec3::new(at, at, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }), + ) + }; + let mut d = Drawing::new_imperial(); + d.ensure_layer("OFF"); + if let Some(l) = d.layer_mut("OFF") { + l.on = false; + } + // A block with a part on a layer turned off (left out when drawn, counted in extents), + // and a block that nests it. + let mut b = crate::Block::new("B"); + b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0")); + b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF")); + d.blocks.insert("B".into(), std::sync::Arc::new(b)); + let mut n = crate::Block::new("N"); + n.entities.push(ins("B", 10.0)); + d.blocks.insert("N".into(), std::sync::Arc::new(n)); + let ents: Vec = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect(); + // One cache for both kinds of measure, interleaved: neither may answer for the other. + let mut cache = BlockBoundsCache::default(); + for e in &ents { + let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache)); + let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e)); + assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max)); + assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max)); + assert!(extents.width() > drawn.width()); + } + } } diff --git a/crates/doc/src/foreign.rs b/crates/doc/src/foreign.rs index e0ebb81a4..406f4e12b 100644 --- a/crates/doc/src/foreign.rs +++ b/crates/doc/src/foreign.rs @@ -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 { - 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) -> 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 { + 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 { + 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 { + // 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 = 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| 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()); + } + } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e07a08609..19f486240 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, extent_bounds}; +pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached}; 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, + /// 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, } } @@ -378,10 +383,13 @@ impl Drawing { /// 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; + // One block-bounds cache for the whole space: every reference to a block reuses its + // measured contents instead of measuring the definition again. + let mut cache = BlockBoundsCache::default(); if let Some(store) = self.space(space) { for e in store.iter() { if self.in_extents(e) { - b = b.union(&extent_bounds(self, e)); + b = b.union(&extent_bounds_cached(self, e, &mut cache)); } } } diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs index 3e9b440e1..00b04970e 100644 --- a/crates/doc/src/store.rs +++ b/crates/doc/src/store.rs @@ -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, Option)> { + 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))); + } } diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index a2c63ca6a..a5f02454f 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -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, } 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 } } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f0f979f12..29d55e30f 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() { 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. @@ -637,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(); diff --git a/crates/doc/tests/it/face3d_hidden_edges.rs b/crates/doc/tests/it/face3d_hidden_edges.rs new file mode 100644 index 000000000..ee49f462f --- /dev/null +++ b/crates/doc/tests/it/face3d_hidden_edges.rs @@ -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); +} diff --git a/crates/doc/tests/it/main.rs b/crates/doc/tests/it/main.rs index dcad121df..18d01e293 100644 --- a/crates/doc/tests/it/main.rs +++ b/crates/doc/tests/it/main.rs @@ -1,2 +1,3 @@ +mod face3d_hidden_edges; mod lin_pat_parse; mod sub_segment_alias; diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index a409e5b90..23d1c82d2 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -8,6 +8,12 @@ license.workspace = true [lints] workspace = true -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +[dependencies] +log = { workspace = true } + +# 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" diff --git a/crates/dwg/build.rs b/crates/dwg/build.rs new file mode 100644 index 000000000..e97782ab4 --- /dev/null +++ b/crates/dwg/build.rs @@ -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"); + } +} diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index f7bf13dd6..70646a19a 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -19,10 +19,8 @@ //! crashing. //! //! These bound the input- and output-size variants. A small file that declares a huge internal -//! table or section can still make the dependency allocate before any size is known; fully fixing -//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory -//! budget) or running the decode in a separate worker with an OS memory limit. The limits here -//! keep the blast radius bounded in the meantime. +//! table or section can still make the dependency allocate before any size is known, so the app +//! runs both directions in a child process ([`worker`]): what that kills is the child. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -58,13 +56,18 @@ 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))] +pub mod worker; +#[cfg(not(no_dwg_bridge))] +pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker}; + +#[cfg(not(no_dwg_bridge))] mod native { use std::io::{Cursor, Write}; use std::panic::AssertUnwindSafe; @@ -110,8 +113,11 @@ mod native { 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); \ @@ -124,6 +130,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; @@ -241,12 +335,51 @@ mod native { /// 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) @@ -344,7 +477,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 }; @@ -730,6 +863,34 @@ mod native { 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() { @@ -741,18 +902,18 @@ mod native { } } -#[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(target_arch = "wasm32")))] +#[cfg(all(test, not(no_dwg_bridge)))] thread_local! { static PANIC_IN: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(not(no_dwg_bridge))] #[allow(clippy::panic)] fn fail_in_test(_stage: &str) { #[cfg(test)] @@ -762,31 +923,53 @@ fn fail_in_test(_stage: &str) { } /// 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" }; + +/// There's no worker without a bridge. +#[cfg(no_dwg_bridge)] +pub const WORKER_ARG: &str = "--dwg-worker"; +#[cfg(no_dwg_bridge)] +pub fn serve_if_asked() -> Option { + None +} +#[cfg(no_dwg_bridge)] +pub fn use_own_worker() {} +#[cfg(no_dwg_bridge)] +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + dwg_to_dxf_noted(bytes, limits) +} +#[cfg(no_dwg_bridge)] +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + dxf_to_dwg_as(dxf, version) +} + +#[cfg(no_dwg_bridge)] pub fn dwg_to_dxf(_bytes: &[u8]) -> Result, 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, Vec), 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, 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, 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, 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)] @@ -794,14 +977,14 @@ mod tests { use super::*; /// The DXF of an empty drawing, enough for the bridge to make a small DWG. - #[cfg(not(target_arch = "wasm32"))] + #[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(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] struct PanicIn; - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] impl PanicIn { fn at(stage: &'static str) -> Self { PANIC_IN.with(|p| p.set(Some(stage))); @@ -809,7 +992,7 @@ mod tests { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] impl Drop for PanicIn { fn drop(&mut self) { PANIC_IN.with(|p| p.set(None)); @@ -818,7 +1001,7 @@ mod tests { /// 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(target_arch = "wasm32"))] + #[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(); @@ -829,7 +1012,7 @@ mod tests { } /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(no_dwg_bridge))] #[test] fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { let _panic = PanicIn::at("convert"); @@ -846,7 +1029,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"; @@ -866,7 +1049,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 @@ -876,7 +1059,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 { @@ -893,7 +1076,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])]; @@ -940,7 +1123,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 { let lines: Vec<&str> = dxf.lines().collect(); lines @@ -957,7 +1140,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::*; @@ -1072,4 +1255,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}"); + } + } } diff --git a/crates/dwg/src/worker.rs b/crates/dwg/src/worker.rs new file mode 100644 index 000000000..5ccf5cf6a --- /dev/null +++ b/crates/dwg/src/worker.rs @@ -0,0 +1,311 @@ +//! The DWG reader and writer in a child process. +//! +//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and +//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says +//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker +//! that reads one request from standard input and writes one reply to standard output. A file that +//! kills the worker then costs that one open or save, and every other open drawing stays safe. +//! +//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`] +//! run in the calling process, as they always did. There's no memory limit on the worker yet: that +//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate. +//! +//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as +//! untrusted input. + +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use super::Limits; + +/// The argument that makes the app a worker. +pub const WORKER_ARG: &str = "--dwg-worker"; +/// Bumped when the messages change: an app updated while running may start a newer copy. +const PROTOCOL: u8 = 1; +const DECODE: u8 = 1; +const ENCODE: u8 = 2; +const CRASH: u8 = 0xff; +/// How long one file may take before the worker is stopped. It can't be interrupted otherwise. +const TIMEOUT: Duration = Duration::from_secs(30 * 60); +/// Most bytes accepted for what a reply carries besides the drawing. +const MAX_NOTES_BYTES: u64 = 64 << 20; +/// Most bytes accepted as a written DWG. +const MAX_DWG_REPLY: u64 = 2 << 30; +/// Most bytes one request holds: the largest input, whichever the direction, and its header. +const MAX_REQUEST: u64 = (2 << 30) + 256; + +struct Worker { + program: PathBuf, + args: Vec, +} + +static WORKER: Mutex> = Mutex::new(None); + +/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`]. +pub fn use_worker(program: PathBuf, args: Vec) { + if let Ok(mut w) = WORKER.lock() { + *w = Some(Worker { program, args }); + } +} + +/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be +/// found, the reader and writer stay in this process. +pub fn use_own_worker() { + match std::env::current_exe() { + Ok(program) => use_worker(program, vec![WORKER_ARG.into()]), + Err(e) => log::warn!("DWG worker: can't find this program: {e}"), + } +} + +/// Serve one request and return the exit code if this process was started as a worker. +pub fn serve_if_asked() -> Option { + (std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock())) +} + +/// [`super::dwg_to_dxf_noted`] in the worker, when there is one. +pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec, Vec), String> { + // Before starting a process, and so the same limit holds with or without one. + if bytes.len() > limits.max_dwg_bytes { + return super::dwg_to_dxf_noted(bytes, limits); + } + let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES); + match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") { + Some(reply) => reply?, + None => super::dwg_to_dxf_noted(bytes, limits), + } +} + +/// [`super::dxf_to_dwg_as`] in the worker, when there is one. +pub fn encode_as(dxf: &[u8], version: &str) -> Result, String> { + if u8::try_from(version.len()).is_err() { + return Err(super::unsupported(version)); + } + match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") { + Some(reply) => reply?.map(|(dwg, _)| dwg), + None => super::dxf_to_dwg_as(dxf, version), + } +} + +/// Make the worker abort, as the third-party code can: what the tests prove isolation with. +#[doc(hidden)] +pub fn crash_worker_for_test() -> Result<(), String> { + call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ()) +} + +fn request(op: u8, limits: Limits, version: &str) -> Vec { + let mut r = vec![PROTOCOL, op]; + r.extend((limits.max_dwg_bytes as u64).to_le_bytes()); + r.extend((limits.max_dxf_bytes as u64).to_le_bytes()); + r.push(u8::try_from(version.len()).unwrap_or(0)); + r.extend(version.as_bytes()); + r +} + +type Reply = Result<(Vec, Vec), String>; + +/// Run one request in a worker. `None`: none could be started, so the caller does the work. +/// `Err`: the worker went wrong, and `doing` says at what, for the user. +fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option> { + let mut command = { + let worker = WORKER.lock().ok()?; + let worker = worker.as_ref()?; + let mut c = Command::new(&worker.program); + c.args(&worker.args); + c + }; + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + log::warn!("DWG worker: can't start it: {e}"); + return None; + } + }; + let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let received = std::thread::scope(|scope| { + // Written beside the read: a worker that replies before it has read everything, or dies, + // must block neither side. A failed write shows up as the reply going wrong. + scope.spawn(move || { + let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload)); + }); + let (tx, rx) = channel(); + scope.spawn(move || { + let mut reply = Vec::new(); + let read = from_child.take(reply_limit).read_to_end(&mut reply); + let _ = tx.send(read.map(|_| reply)); + }); + let received = rx.recv_timeout(TIMEOUT); + if matches!(received, Err(RecvTimeoutError::Timeout)) { + let _ = child.kill(); + } + received + }); + let status = child.wait(); + let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected"); + Some(match (received, status) { + (Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")), + (Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped), + _ => Err(stopped()), + }) +} + +/// Take `n` bytes off the front of `b`. +fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { + let (head, tail) = b.split_at_checked(n)?; + *b = tail; + Some(head) +} + +fn take_u32(b: &mut &[u8]) -> Option { + let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?; + usize::try_from(u32::from_le_bytes(bytes)).ok() +} + +fn take_u64(b: &mut &[u8]) -> Option { + Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?)) +} + +/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1 +/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most +/// of the memory the app may use: they are kept, not copied. +fn parse_reply(mut reply: Vec) -> Option { + let total = reply.len(); + let mut b = &reply[..]; + match *take(&mut b, 1)?.first()? { + 0 => { + let count = take_u32(&mut b)?; + let mut notes = Vec::new(); + for _ in 0..count { + let n = take_u32(&mut b)?; + notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned()); + } + let n = usize::try_from(take_u64(&mut b)?).ok()?; + if b.len() != n { + return None; + } + reply.drain(..total - n); + Some(Ok((reply, notes))) + } + 1 => Some(Err(String::from_utf8_lossy(b).into_owned())), + _ => None, + } +} + +fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> { + match reply { + Ok((bytes, notes)) => { + out.write_all(&[0])?; + out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?; + for note in ¬es { + out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?; + if u32::try_from(note.len()).is_ok() { + out.write_all(note.as_bytes())?; + } + } + out.write_all(&(bytes.len() as u64).to_le_bytes())?; + out.write_all(&bytes)?; + } + Err(e) => { + out.write_all(&[1])?; + out.write_all(e.as_bytes())?; + } + } + out.flush() +} + +/// What a request asks for, or why it can't be served. +fn handle(mut b: &[u8]) -> Reply { + let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string(); + if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) { + return Err(bad()); + } + let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?; + let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?; + let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?; + match op { + DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }), + ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())), + CRASH => std::process::abort(), + _ => Err(bad()), + } +} + +/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent. +fn serve(input: impl Read, mut output: impl Write) -> i32 { + let mut request = Vec::new(); + let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) { + Ok(_) => handle(&request), + Err(e) => Err(format!("the DWG worker could not read its request: {e}")), + }; + i32::from(write_reply(&mut output, reply).is_err()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wire(reply: Reply) -> Vec { + let mut out = Vec::new(); + write_reply(&mut out, reply).unwrap(); + out + } + + #[test] + fn replies_round_trip() { + let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()])); + assert_eq!(parse_reply(wire(ok.clone())), Some(ok)); + let failed: Reply = Err("DWG: no".into()); + assert_eq!(parse_reply(wire(failed.clone())), Some(failed)); + } + + #[test] + fn a_reply_cut_short_or_made_up_is_refused() { + let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()]))); + for n in 0..whole.len() { + assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}"); + } + assert_eq!(parse_reply(vec![7, 0, 0]), None); + // A note count far beyond what follows ends at the first missing note. + assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None); + assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None); + } + + #[test] + fn a_request_it_cannot_read_is_an_error_not_a_crash() { + for request in [ + &[][..], + &[PROTOCOL + 1, DECODE], + &[PROTOCOL], + &[PROTOCOL, DECODE, 1, 2, 3], + &[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ] { + assert!(handle(request).is_err(), "{request:?}"); + } + } + + #[test] + fn serving_reports_a_decode_failure_as_a_reply() { + let mut request = self::request(DECODE, Limits::DEFAULT, ""); + request.extend(b"AC1032 not really a drawing"); + let mut out = Vec::new(); + assert_eq!(serve(&request[..], &mut out), 0); + assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}"); + } +} diff --git a/crates/dxf/src/codepage.rs b/crates/dxf/src/codepage.rs index 6f27b5be4..9377404d9 100644 --- a/crates/dxf/src/codepage.rs +++ b/crates/dxf/src/codepage.rs @@ -69,6 +69,42 @@ pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option { 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 { + 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) -> 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 { diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index b9e394766..c31defc21 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] use std::fmt::Write as _; +use std::io::Write as _; mod version; pub use version::Version; @@ -22,8 +23,11 @@ pub enum DxfError { Eof, /// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the /// limit was reached), `limit` the maximum. - #[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads")] + #[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")] TooLarge { tags: usize, limit: usize }, + /// The memory for `tags` group codes couldn't be had: an error, not the process ending. + #[error("not enough memory to read this drawing's {tags} DXF group codes")] + OutOfMemory { tags: usize }, } pub type Result = std::result::Result; @@ -162,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value { } const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0"; -/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the -/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million). -pub const MAX_TAGS: usize = 50_000_000; +/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this +/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine +/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB +/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to +/// raise or lower it. +pub const MAX_TAGS: usize = 100_000_000; + +/// Environment variable overriding [`MAX_TAGS`] (a positive integer). +pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS"; + +/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else +/// [`MAX_TAGS`]. +pub fn max_tags() -> usize { + max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref()) +} + +fn max_tags_from(v: Option<&str>) -> usize { + v.and_then(|s| s.trim().replace('_', "").parse::().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS) +} mod codepage; pub use codepage::decode_code_page; /// Parse a DXF file (ASCII or binary). pub fn parse(bytes: &[u8]) -> Result> { - parse_with_limit(bytes, MAX_TAGS) + parse_with_limit(bytes, max_tags()) } /// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from @@ -198,7 +218,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { if tags > max_tags { return Err(DxfError::TooLarge { tags, limit: max_tags }); } - parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), max_tags) + parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags) } /// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one. @@ -270,12 +290,17 @@ fn unescape_unicode(s: &str) -> String { } pub fn parse_ascii(text: &str) -> Result> { - parse_ascii_with_limit(text, MAX_TAGS) + parse_ascii_counted(text, max_tags(), 0) } +/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first, +/// with an allocation that can fail: where the system refuses that much memory, opening the file +/// is an error instead of the end of the app. Growing the list instead could also briefly take +/// half as much again. #[allow(clippy::while_let_loop)] -fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result> { +fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result> { let mut tags = Vec::new(); + tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?; let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate(); loop { let Some((ln, code_line)) = lines.next() else { break }; @@ -356,7 +381,7 @@ impl<'a> Cur<'a> { /// Binary DXF (R13+: 2-byte group codes). pub fn parse_binary(b: &[u8]) -> Result> { - parse_binary_with_limit(b, MAX_TAGS) + parse_binary_with_limit(b, max_tags()) } fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result> { @@ -421,17 +446,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 { + 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), +} + +fn ascii(tags: &[Tag], strings: Strings) -> Vec { + 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 } @@ -444,6 +500,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 { + 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 { + binary(tags, Strings::Escaped(codepage::encoder(code_page))) +} + +fn binary(tags: &[Tag], strings: Strings) -> Vec { let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12); out.extend_from_slice(BINARY_SENTINEL); for t in tags { @@ -456,9 +522,9 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec { 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 => { @@ -512,19 +578,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) { let chars: Vec = 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}"); @@ -534,6 +608,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) -> 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 { @@ -713,3 +801,27 @@ mod tests { assert_eq!(parse(&b).unwrap()[0].str(), "图号"); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn tag_limit_defaults_and_env_override() { + assert_eq!(max_tags_from(None), MAX_TAGS); + assert_eq!(max_tags_from(Some("")), MAX_TAGS); + assert_eq!(max_tags_from(Some("0")), MAX_TAGS); + assert_eq!(max_tags_from(Some("abc")), MAX_TAGS); + assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000); + // Issue #1948: the default must admit a DXF rendition well above 50 million group codes. + const { assert!(MAX_TAGS >= 100_000_000) }; + } + + #[test] + fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() { + let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap(); + assert_eq!((tags.len(), tags.capacity()), (3, 3)); + let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err(); + assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}"); + } +} diff --git a/crates/dxf/tests/code_page_write.rs b/crates/dxf/tests/code_page_write.rs new file mode 100644 index 000000000..75d647c08 --- /dev/null +++ b/crates/dxf/tests/code_page_write.rs @@ -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 { + 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}"); + } + } +} diff --git a/crates/dxf/tests/conformance.rs b/crates/dxf/tests/conformance.rs new file mode 100644 index 000000000..089ed8063 --- /dev/null +++ b/crates/dxf/tests/conformance.rs @@ -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")]); + } + } +} diff --git a/crates/dxf/tests/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs new file mode 100644 index 000000000..e3fdc6948 --- /dev/null +++ b/crates/dxf/tests/hex_chunks.rs @@ -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 { + (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::().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); + } + } + } +} diff --git a/crates/dxf/tests/roundtrip.rs b/crates/dxf/tests/roundtrip.rs new file mode 100644 index 000000000..acdcb7a78 --- /dev/null +++ b/crates/dxf/tests/roundtrip.rs @@ -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, ""), + ], + ); +} diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..2ec83b006 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -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 { + 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, 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> { + 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> { + // 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() { diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 480d14c3b..0caac6143 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -4,15 +4,18 @@ //! autosave is written as DXF to `/__.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 (`.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 (`.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 `.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 = Mutex::new(String::new()); /// SAVETIME: minutes between autosaves; 0 turns autosave off. @@ -137,13 +156,21 @@ pub struct Autosaver { docs: HashMap, /// Drawings whose autosave is being written right now. busy: HashSet, - /// 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, + /// 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)>, + running: Vec<(u64, std::thread::JoinHandle>)>, #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, #[cfg(not(target_arch = "wasm32"))] last_beat: Option, + /// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain + /// in the old folder. Each with the file it holds open for the system lock, or `None` where + /// locks don't work and only the file's age counts. + #[cfg(not(target_arch = "wasm32"))] + locks: Vec<(std::path::PathBuf, Option)>, } impl Autosaver { @@ -152,12 +179,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"))] + locks: Vec::new(), } } @@ -175,6 +205,11 @@ impl Autosaver { self.docs.values().filter_map(|t| t.file.clone()).collect() } + /// The drawings with no up-to-date recovery copy and why, for the host to show the user. + pub fn failures(&self) -> impl Iterator { + self.failed.iter().map(|(uid, why)| (*uid, why.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. @@ -208,6 +243,7 @@ impl Autosaver { jobs.push(Job::Delete { path }); } *t = Tracked::default(); + self.failed.remove(&st.uid); continue; } let same = |w: &Option>, d: &Arc| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d))); @@ -236,6 +272,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 } @@ -265,12 +303,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. @@ -295,6 +335,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 { @@ -304,7 +366,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 }; @@ -346,7 +408,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)); } } @@ -404,24 +466,67 @@ 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()) + } + + /// Whether a write's result hasn't been taken in yet, finished or not: the host needs + /// another [`Autosaver::tick`] to learn whether it failed. + pub fn results_pending(&self) -> bool { + !self.running.is_empty() + } + /// 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::, _>(|(_, 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); - } + self.settle(uid, h); } } - fn unwritten(&mut self, uid: u64) { + fn settle(&mut self, uid: u64, h: std::thread::JoinHandle>) { + self.busy.remove(&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), + } + } + + /// Write every drawing with changes not yet autosaved and wait until it is done, because + /// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`] + /// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns + /// how many drawings were written. + pub fn emergency_save(&mut self, s: &Session) -> usize { + for (uid, h) in std::mem::take(&mut self.running) { + self.settle(uid, h); + } + let now = self.start.elapsed().as_secs_f64(); + let dir = savefilepath(); + let jobs = self.plan_with(s, now, savetime().max(1), &dir, true); + let uids: Vec = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect(); + self.perform(jobs); + self.last_beat = None; + self.heartbeat(&dir, now); + for (uid, h) in std::mem::take(&mut self.running) { + self.settle(uid, h); + } + uids.iter().filter(|uid| !self.failed.contains_key(uid)).count() + } + + 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. @@ -432,16 +537,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 { @@ -451,7 +557,7 @@ mod native { } Err(e) => { log::warn!("autosave: {e}"); - self.unwritten(uid); + self.unwritten(uid, e.to_string()); } } } @@ -466,12 +572,40 @@ 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 this app's lock files, and start one at `path` if there isn't one yet, taking + /// the system lock on it if it can. Without that lock the file still works by its age. + /// Only trouble at `path` is an error: an older folder may be gone by now. + fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> { + let id = std::process::id(); + let mut current = None; + for (held, file) in &self.locks { + let refreshed = match file { + Some(file) => file.set_modified(SystemTime::now()), + None => std::fs::write(held, id.to_string()), + }; + if held == path { + current = Some(refreshed); + } + } + if let Some(refreshed) = current { + return refreshed; + } + let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?; + if file.try_lock().is_err() { + self.locks.push((path.to_path_buf(), None)); + return std::fs::write(path, id.to_string()); + } + file.set_len(0)?; + file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?; + self.locks.push((path.to_path_buf(), Some(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) { @@ -483,6 +617,10 @@ mod native { discard(&path); } self.docs.clear(); + for (path, file) in self.locks.drain(..) { + drop(file); + let _ = std::fs::remove_file(path); + } let dir = savefilepath(); if !dir.is_empty() && is_instance_id(&self.instance) { let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance)); diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index fae52757e..89da207f0 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -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 }; diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 172e416d1..bc93b9f55 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 08f87fc4b..d983ce25d 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -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 { .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?}") diff --git a/crates/engine/src/cmd/annotate/common.rs b/crates/engine/src/cmd/annotate/common.rs index 398102797..6854b4fb4 100644 --- a/crates/engine/src/cmd/annotate/common.rs +++ b/crates/engine/src/cmd/annotate/common.rs @@ -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, }) } diff --git a/crates/engine/src/cmd/annotate/leader.rs b/crates/engine/src/cmd/annotate/leader.rs new file mode 100644 index 000000000..1c5ce7276 --- /dev/null +++ b/crates/engine/src/cmd/annotate/leader.rs @@ -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, + spline: bool, + no_arrow: bool, + lines: Vec, + block: String, + block_at: Vec2, + block_scale: Vec2, +} + +/// What follows the leader. +enum Annotation { + Text(Vec), + /// 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 ": 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 { + 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 = 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 { + match i { + Input::Enter => Some(1.0), + Input::Text(t) => t.trim().parse::().ok().filter(|v| v.is_finite() && *v != 0.0), + _ => None, + } +} + +impl LeaderM { + fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result { + 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> { + 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> { + 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()) +} diff --git a/crates/engine/src/cmd/annotate/mleader.rs b/crates/engine/src/cmd/annotate/mleader.rs index 536e83b8c..9b4e74128 100644 --- a/crates/engine/src/cmd/annotate/mleader.rs +++ b/crates/engine/src/cmd/annotate/mleader.rs @@ -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 { 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::>() })) } /// The MLEADER Options prompt being answered. diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 9d4a2fd9c..da96ccfed 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -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 { "{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()) @@ -369,10 +374,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result { 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, diff --git a/crates/engine/src/cmd/array/classic.rs b/crates/engine/src/cmd/array/classic.rs new file mode 100644 index 000000000..5fecefa2a --- /dev/null +++ b/crates/engine/src/cmd/array/classic.rs @@ -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] ": 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] ". 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, + stage: Stage, + rows: u64, + columns: u64, + row_distance: f64, + center: Vec2, + base: Option, + 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 { + (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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 8cc248685..45ab8ae15 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -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 { vec![ @@ -45,10 +47,11 @@ pub fn specs() -> Vec { .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()); @@ -361,53 +371,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": s.add_entity(k)?.hex() })) } -fn run_wblock(s: &mut Session, p: &Value) -> Result { - 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 = 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 { let at = point_req("base", p, "at")?; s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at))); @@ -416,8 +379,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result { fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; + // One object index for the whole listing: rebuilding it per block multiplies the block count + // by the drawing's kept object count (CWE-407). + let objects = cadcraft_doc::dynblock::ObjectIndex::new(d); let block = |b: &Block| { - let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some(); + let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name); json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic}) }; Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::>() })) @@ -595,6 +561,8 @@ enum InsStage { ZScale, Rotation, Attribs, + /// A Verify attribute asked again. + Verify, } #[derive(Default)] @@ -615,8 +583,11 @@ struct InsertM { /// Where the block was dropped to pick a Basepoint on it. dropped: Option, rotation: Option, - 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, + /// 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. @@ -633,6 +604,17 @@ 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) @@ -646,20 +628,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 } @@ -744,9 +732,14 @@ impl Interactive for InsertM { 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 { @@ -905,7 +898,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, @@ -913,6 +906,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) } @@ -934,52 +941,6 @@ impl Interactive for InsertM { } } -#[derive(Default)] -struct AttdefM { - tag: Option, - prompt: Option, - default: Option, -} - -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 { - 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) - } -} - mod attribs; mod battman; mod blockreplace; diff --git a/crates/engine/src/cmd/blocks/attdef.rs b/crates/engine/src/cmd/blocks/attdef.rs new file mode 100644 index 000000000..0803cfe95 --- /dev/null +++ b/crates/engine/src/cmd/blocks/attdef.rs @@ -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 { + 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 { + 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 { + 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 = 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) + } +} diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs index 4d0af93b3..a00dbc64c 100644 --- a/crates/engine/src/cmd/blocks/insert_file.rs +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option { } /// 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 { 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 { 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) -> Entity { +fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap) -> 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 = (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) { diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 68aea3c36..b91507b15 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec { .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 { 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(v: &mut Vec, 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 { let zero = |e: &EntityKind| match e { diff --git a/crates/engine/src/cmd/blocks/refedit.rs b/crates/engine/src/cmd/blocks/refedit.rs index 0a61122a5..18d0d436b 100644 --- a/crates/engine/src/cmd/blocks/refedit.rs +++ b/crates/engine/src/cmd/blocks/refedit.rs @@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result { } 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 })) } diff --git a/crates/engine/src/cmd/blocks/wblock.rs b/crates/engine/src/cmd/blocks/wblock.rs new file mode 100644 index 000000000..373e471bf --- /dev/null +++ b/crates/engine/src/cmd/blocks/wblock.rs @@ -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), + /// The whole drawing. + Whole, +} + +fn run_wblock(s: &mut Session, p: &Value) -> Result { + 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 { + 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)> { + 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) { + 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) -> 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 = 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] ". + 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 { + 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 { + 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::().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 { + 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 { + 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) + } +} diff --git a/crates/engine/src/cmd/blocks/xref/load.rs b/crates/engine/src/cmd/blocks/xref/load.rs index fac289f23..b47aa00e0 100644 --- a/crates/engine/src/cmd/blocks/xref/load.rs +++ b/crates/engine/src/cmd/blocks/xref/load.rs @@ -208,7 +208,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result = { let _ = files; @@ -217,6 +217,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result Option { + 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 { let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?; diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 1b0e0c1cf..cebc44e6f 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -33,6 +33,7 @@ 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 diff --git a/crates/engine/src/cmd/draw2/curves3d.rs b/crates/engine/src/cmd/draw2/curves3d.rs index 005c49f07..faad5442c 100644 --- a/crates/engine/src/cmd/draw2/curves3d.rs +++ b/crates/engine/src/cmd/draw2/curves3d.rs @@ -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), diff --git a/crates/engine/src/cmd/draw2/rect.rs b/crates/engine/src/cmd/draw2/rect.rs index 4b19dc703..245ef24f8 100644 --- a/crates/engine/src/cmd/draw2/rect.rs +++ b/crates/engine/src/cmd/draw2/rect.rs @@ -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, } +/// 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 { + 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 { 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 { + 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 { diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs index 8eedd3772..efad025ca 100644 --- a/crates/engine/src/cmd/draw2/tests.rs +++ b/crates/engine/src/cmd/draw2/tests.rs @@ -996,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:?}"), diff --git a/crates/engine/src/cmd/draw2/xline.rs b/crates/engine/src/cmd/draw2/xline.rs index 31f152d16..78ab5c610 100644 --- a/crates/engine/src/cmd/draw2/xline.rs +++ b/crates/engine/src/cmd/draw2/xline.rs @@ -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}") diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a4c43e73..754248cd3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result { fn run_clear(s: &mut Session, _p: &Value) -> Result { 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 })) } diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index e28de89fc..2a6a01f67 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -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 // `.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 { 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 { 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 { ..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 { 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(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c26a71f9..76a53da46 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -85,12 +85,13 @@ pub fn deliver() -> Option { 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, 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 { 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 { 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 { } pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { - 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.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 // `.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 `.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::(), + ), + _ => { + 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 { + 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 { + 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 = 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, Vec, 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 { 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 { /// 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 { 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 { /// 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 { let mut out: std::collections::BTreeMap = 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) -> Op return None; } let list: Vec = 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 { + 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) -> bool { Format::from_path(path).is_some_and(Format::is_drawing) } +/// Whether `path` is a DXF file. +fn is_dxf_file(path: impl AsRef) -> 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 = 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 { + #[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) { + 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) { +pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap) { 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) -> Result { +fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result, asked: Option) -> Result { // 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::>().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::>())); + } +} + fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result { - save_reporting(s, p, run_save) + save_reporting(s, p, run_save, None) } fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result { - 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+/"; diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index ca7defed8..a362a3502 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -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; diff --git a/crates/engine/src/cmd/hatch/assoc.rs b/crates/engine/src/cmd/hatch/assoc.rs index b653aa451..e67782938 100644 --- a/crates/engine/src/cmd/hatch/assoc.rs +++ b/crates/engine/src/cmd/hatch/assoc.rs @@ -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()); } diff --git a/crates/engine/src/cmd/helpers.rs b/crates/engine/src/cmd/helpers.rs index 54c5ffdaf..ed5891203 100644 --- a/crates/engine/src/cmd/helpers.rs +++ b/crates/engine/src/cmd/helpers.rs @@ -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 { 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 { - 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 ---------- diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 0fa342e23..283551dfd 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { "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)) @@ -864,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" => { @@ -901,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) } } @@ -1115,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 } diff --git a/crates/engine/src/cmd/inquiry/regions.rs b/crates/engine/src/cmd/inquiry/regions.rs index 9e6b11a49..033d6e0c2 100644 --- a/crates/engine/src/cmd/inquiry/regions.rs +++ b/crates/engine/src/cmd/inquiry/regions.rs @@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result> { } loops.retain(|l| l.vertices.len() >= 2); let mut r = Region { loops }; - r.renest(); + if !r.renest() { + return Err(other("These regions are too complex to combine.")); + } Ok(Some(r)) } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 582a93635..c5fa89403 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result 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 { @@ -250,6 +250,7 @@ fn run_set(s: &mut Session, p: &Value) -> Result { 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); } diff --git a/crates/engine/src/cmd/layer/merge.rs b/crates/engine/src/cmd/layer/merge.rs index b2ac19e1f..de8c31800 100644 --- a/crates/engine/src/cmd/layer/merge.rs +++ b/crates/engine/src/cmd/layer/merge.rs @@ -115,6 +115,39 @@ pub(super) fn forget_in_viewports(d: &mut Drawing, set: &HashSet) { } } +/// 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 = 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 { names.iter().map(|n| n.to_ascii_lowercase()).collect() } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a538d73b5..ba5e711e4 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -84,11 +84,11 @@ pub fn specs() -> Vec { .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 { 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 { let path = _path; // Exclusively create a fresh temporary file and rename it into place so a planted // `.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")] { diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs index daf39ba8d..d432343c2 100644 --- a/crates/engine/src/cmd/layout/plotstyle.rs +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result { 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")] diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs index b6d3f6873..0a0b2b5f8 100644 --- a/crates/engine/src/cmd/layout/saveas.rs +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result { 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 // `.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")] { diff --git a/crates/engine/src/cmd/layout/tests.rs b/crates/engine/src/cmd/layout/tests.rs index 03356b892..4a631ccb0 100644 --- a/crates/engine/src/cmd/layout/tests.rs +++ b/crates/engine/src/cmd/layout/tests.rs @@ -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}"); + } +} diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index bbcd25267..f68d08ebb 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -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 { .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::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"]) diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs index 46ada655d..b4edc2e80 100644 --- a/crates/engine/src/cmd/modify/explode.rs +++ b/crates/engine/src/cmd/modify/explode.rs @@ -1,19 +1,74 @@ +use cadcraft_geom::{Ellipse as GEllipse, Mat3}; + use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3}; // ---------------- explode / array / order / break / join ---------------- +/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it. +fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option { + let g = ell.transformed(m); + let len = g.major.len(); + (len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| { + EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end }) + }) +} + +/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block +/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle +/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no +/// widths) its lines and elliptical arcs. `None`: the object transforms as itself. +fn stretched_pieces(be: &Entity, m: &Mat3) -> Option> { + let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end }; + let kinds = match &be.kind { + EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?], + EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?], + EntityKind::Ellipse(el) => { + let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }; + vec![placed_ellipse(g, m, el.center.z)?] + } + EntityKind::LwPolyline(p) + if p.vertices.iter().any(|v| v.bulge != 0.0) + && p.const_width == 0.0 + && p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) => + { + let z = p.elevation; + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + pl.segments() + .into_iter() + .map(|s| match s { + Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })), + Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z), + }) + .collect::>>()? + } + _ => return None, + }; + // The pieces of a polyline are new objects, as when it explodes itself: its xdata goes. + let mut common = be.common.clone(); + if matches!(be.kind, EntityKind::LwPolyline(_)) { + common.xdata.clear(); + } + Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect()) +} + fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { if let Some(items) = crate::cmd::array::explode_array(d, e) { return Some(items); } let blk = d.block(&ins.block)?; let m = ins.transform(blk.base.xy()); + let stretched = m.similarity_scale().is_none(); Some( blk.entities .iter() - .map(|be| { - let mut ne = (**be).clone(); - ne.kind.transform(&m); + .flat_map(|be| { + stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| { + let mut ne = (**be).clone(); + ne.kind.transform(&m); + vec![ne] + }) + }) + .map(|mut ne| { if ne.common.layer == "0" { ne.common.layer = e.common.layer.clone(); } @@ -151,6 +206,12 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { + 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. diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs index 17cd15b09..74dc0fb79 100644 --- a/crates/engine/src/cmd/modify/offset.rs +++ b/crates/engine/src/cmd/modify/offset.rs @@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { 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 { +/// `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 { + 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"); } diff --git a/crates/engine/src/cmd/modify/oops.rs b/crates/engine/src/cmd/modify/oops.rs new file mode 100644 index 000000000..6308cc615 --- /dev/null +++ b/crates/engine/src/cmd/modify/oops.rs @@ -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, + below: Option, + above: Option, +} + +/// What the drawing's last erase removed, for OOPS. +#[derive(Clone, Debug, Default)] +pub(crate) struct Erased(Vec); + +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 { + let d = s.doc_mut()?; + // Every neighbour is read before anything goes: objects erased together may be neighbours. + let at: Vec<(Handle, Space, Option, Option)> = 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 { + let taken = s.state()?.erased.0.clone(); + let editing = s.state()?.block_edit.as_ref().map(|b| b.name.clone()); + let back: Vec = { + 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 { + let hs: HashSet = 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 = taken.iter().enumerate().map(|(i, t)| (t.entity.handle, i)).collect(); + let next: HashMap = 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, 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, + mut h: Option, + step: fn(&Taken) -> Option, +) -> Option> { + 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 +} diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs index 10bf08399..009a95281 100644 --- a/crates/engine/src/cmd/modify/select_then.rs +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -1,6 +1,6 @@ use super::break_join::join; use super::explode::explode; -use super::transform::{erase, transform_entities}; +use super::transform::{at_elevation, erase, scale_entities, transform_entities}; use super::{ Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number, }; @@ -242,7 +242,7 @@ impl SelectThen { let f = require_length(Some(f))?; // The next SCALE offers this factor. s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } @@ -439,13 +439,13 @@ impl Interactive for SelectThen { }; if f > 1e-12 && f.is_finite() { s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?; } s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Mirror, 1, Input::Point(p)) => { - if p.near(self.pts[0], 1e-12) { + if p.near(self.pts[0], cadcraft_geom::EPS) { return Err(EngineError::Other("Mirror line points must differ.".into())); } self.pts.push(p); diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 5a86d9e07..11e52ea23 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -1,8 +1,33 @@ -use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets}; +use cadcraft_geom::Vec3; + +use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, erase_kept, f64_req, json, point_param, point_req, targets}; // ---------------- core operations ---------------- pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { + edit_entities(s, hs, copy, |e| e.kind.transform(m)) +} + +/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY, +/// and every height of the objects about its Z, their thickness too. +pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result> { + let m = Mat3::scale_about(base.xy(), f); + edit_entities(s, hs, copy, |e| { + e.kind.transform(&m); + e.kind.scale_heights(base.z, f); + e.common.thickness *= f; + }) +} + +/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one. +pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 { + let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite()); + Vec3::new(p.x, p.y, z.unwrap_or(0.0)) +} + +/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object +/// on a locked layer is left alone unless it is copied. The handles edited or made. +fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result> { let d = s.doc_mut()?; let mut out = Vec::new(); for h in hs { @@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; let nh = d.new_handle(); let mut ne = Entity { handle: nh, ..e }; - ne.kind.transform(m); + edit(&mut ne); if let Some(st) = d.space_mut(&sp) { st.push(ne); out.push(nh); @@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: if locked { continue; } - d.modify_entity(*h, |e| e.kind.transform(m))?; + d.modify_entity(*h, &edit)?; out.push(*h); } } @@ -29,14 +54,9 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: } pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result { - let d = s.doc_mut()?; - let mut n = 0; - for h in hs { - let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); - if !locked && d.remove_entity(*h).is_some() { - n += 1; - } - } + let d = s.doc()?; + let free: Vec = hs.iter().copied().filter(|h| !d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked)).collect(); + let n = erase_kept(s, &free)?; s.set_selection(Vec::new()); Ok(n) } @@ -92,7 +112,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result { if f <= 0.0 { return Err(bad("scale", "factor must be positive")); } - let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; + // A Z given with the base point, as AutoCAD takes one; without one, the current elevation. + let base = match p.get("base").and_then(|b| b.get(2)) { + Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?, + None => at_elevation(s, base), + }; + let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } @@ -100,7 +125,7 @@ pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let a = point_req("mirror", p, "p1")?; let b = point_req("mirror", p, "p2")?; - if a.near(b, 1e-12) { + if a.near(b, cadcraft_geom::EPS) { return Err(bad("mirror", "mirror line points coincide")); } let erase_src = bool_or(p, "erase", false); diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs index 66eab0be8..9f8e856dd 100644 --- a/crates/engine/src/cmd/modify/trim.rs +++ b/crates/engine/src/cmd/modify/trim.rs @@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 { } } +/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep` +/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the +/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle. +fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> { + let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true }; + let a_end = ga.center.angle_to(end); + let ccw = sweep > 0.0; + // How far past `end` each crossing is, turning the way the segment turns; crossings on the + // segment itself (the last |sweep| of a turn) are not ahead of it. + let (x, more) = cut + .iter() + .flat_map(|c| intersect_ext(&full, c, false)) + .map(|x| { + let a = ga.center.angle_to(x); + (x, norm_angle(if ccw { a - a_end } else { a_end - a })) + }) + .filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + let total = sweep.abs() + more; + Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total }))) +} + /// Extend the end of `h` nearest `pick` to the nearest boundary edge. pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { if crate::cmd::curves::is_locked(s, h) { @@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); - let dir = (end - prev).normalized(); - let ray = Segment::Line(Line::new(end, end + dir * 1e8)); - let np = cut - .iter() - .flat_map(|c| intersect_ext(&ray, c, false)) - .filter(|x| (*x - end).dot(dir) > 1e-9) - .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) - .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; - if let Some(v) = vs.get_mut(i_end) { - v.p = np; + // The end segment's bulge, signed for travel from `prev` out to `end`. + let i_seg = if from_end { i_prev } else { i_end }; + let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge }); + let none = || EngineError::Other("Object does not intersect an edge.".into()); + if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) { + // An arc end segment runs on round its circle, as an arc does. + let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } + if let Some(v) = vs.get_mut(i_seg) { + v.bulge = if from_end { b } else { -b }; + } + } else { + let dir = (end - prev).normalized(); + let ray = Segment::Line(Line::new(end, end + dir * 1e8)); + let np = cut + .iter() + .flat_map(|c| intersect_ext(&ray, c, false)) + .filter(|x| (*x - end).dot(dir) > 1e-9) + .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) + .ok_or_else(none)?; + if let Some(v) = vs.get_mut(i_end) { + v.p = np; + } } - lwpoly(vs, false) + // Width, elevation and linetype generation stay as they were. + EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() }) } k => crate::cmd::modify2::extend_other(k, &cut, pick)?, }; diff --git a/crates/engine/src/cmd/modify2/blend.rs b/crates/engine/src/cmd/modify2/blend.rs index 32b477eea..ef2258969 100644 --- a/crates/engine/src/cmd/modify2/blend.rs +++ b/crates/engine/src/cmd/modify2/blend.rs @@ -81,7 +81,7 @@ impl Interactive for BlendM { return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent"); } match self.first { - None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]), + None => Prompt::new("Select first object", Accept::POINT).kw(&["CONtinuity"]), Some(_) => Prompt::new("Select second point", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify2/lengthen.rs b/crates/engine/src/cmd/modify2/lengthen.rs index 0c86c2c9c..7597ca771 100644 --- a/crates/engine/src/cmd/modify2/lengthen.rs +++ b/crates/engine/src/cmd/modify2/lengthen.rs @@ -194,14 +194,14 @@ impl Interactive for LengthenM { fn prompt(&self, _s: &Session) -> Prompt { match self.phase { LPhase::Main => { - Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") + Prompt::new("Select an object to measure", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic") } - LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), + LPhase::Delta => Prompt::new("Enter delta length", Accept::NUMBER).kw(&["Angle"]).default("0.0000"), LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"), LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"), - LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), + LPhase::Total => Prompt::new("Specify total length", Accept::NUMBER).kw(&["Angle"]).default("1.0000"), LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"), - LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]), + LPhase::Change => Prompt::new("Select an object to change", Accept::POINT).kw(&["Undo"]), LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT), } } diff --git a/crates/engine/src/cmd/modify2/ncopy.rs b/crates/engine/src/cmd/modify2/ncopy.rs index 475256e84..9b6f9eeb2 100644 --- a/crates/engine/src/cmd/modify2/ncopy.rs +++ b/crates/engine/src/cmd/modify2/ncopy.rs @@ -87,8 +87,8 @@ impl Interactive for NcopyM { } fn prompt(&self, _s: &Session) -> Prompt { match (self.selecting_done, self.base) { - (false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]), - (true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), + (false, _) => Prompt::new("Select nested objects to copy", Accept::POINT).kw(&["Settings"]), + (true, None) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"), (true, Some(b)) => Prompt::new("Specify second point or ", Accept::POINT).base(b), } } diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs index 0c1b64879..59d614323 100644 --- a/crates/engine/src/cmd/modify2/pedit.rs +++ b/crates/engine/src/cmd/modify2/pedit.rs @@ -398,7 +398,7 @@ impl Interactive for PeditM { fn prompt(&self, s: &Session) -> Prompt { match self.phase { PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(), - PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), + PPhase::Select => Prompt::new("Select polyline", Accept::POINT).kw(&["Multiple"]), PPhase::Convert => { Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") } diff --git a/crates/engine/src/cmd/modify2/selop.rs b/crates/engine/src/cmd/modify2/selop.rs index f1b63e3ce..53939d4b6 100644 --- a/crates/engine/src/cmd/modify2/selop.rs +++ b/crates/engine/src/cmd/modify2/selop.rs @@ -103,6 +103,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) { EntityKind::Hatch(h) => h.elevation = 0.0, EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z), + EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z), EntityKind::Region(_) => {} EntityKind::Image(i) => { z(&mut i.insert); diff --git a/crates/engine/src/cmd/overkill.rs b/crates/engine/src/cmd/overkill.rs index abfdff2eb..632001461 100644 --- a/crates/engine/src/cmd/overkill.rs +++ b/crates/engine/src/cmd/overkill.rs @@ -146,20 +146,49 @@ impl Opts { } } -/// Round every number of a JSON value to a multiple of `q`. -fn quantize(v: &mut Value, q: f64) { +/// Take the measurements (floating-point numbers) out of a JSON value, in order, leaving a marker +/// in their place: two objects are equal when what is left matches, counts and flags included, +/// and each pair of measurements is within the tolerance. +fn split_numbers(v: &mut Value, nums: &mut Vec) { match v { Value::Number(n) => { - if let Some(x) = n.as_f64() { - *v = json!((x / q).round()); + if n.is_f64() + && let Some(x) = n.as_f64() + { + nums.push(x); + *v = Value::String(NUMBER.into()); } } - Value::Array(a) => a.iter_mut().for_each(|x| quantize(x, q)), - Value::Object(o) => o.values_mut().for_each(|x| quantize(x, q)), + Value::Array(a) => a.iter_mut().for_each(|x| split_numbers(x, nums)), + Value::Object(o) => o.values_mut().for_each(|x| split_numbers(x, nums)), _ => {} } } +/// What [`split_numbers`] leaves where a number was. +const NUMBER: &str = "\u{0}#"; + +/// Whether two lists of numbers match pairwise to within `tol`. +fn within(a: &[f64], b: &[f64], tol: f64) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) +} + +/// The numbers summed with weights in [1, 2) that no simple pattern shares (golden-ratio steps), +/// so points on a diagonal of a grid don't all get the same sum; and the sum of the weights. +fn weighted_sum(nums: &[f64]) -> (f64, f64) { + nums.iter().enumerate().fold((0.0, 0.0), |(sum, total), (j, x)| { + let w = 1.0 + (j as f64 * 0.618_033_988_749_895).fract(); + (sum + w * x, total + w) + }) +} + +/// The cell of `x` on a grid of `step`. Values within one step of each other land in the same or +/// neighbouring cells; values too large for the grid share the outermost ones. +fn cell_of(x: f64, step: f64) -> i64 { + let k = (x / step).floor(); + if k.is_finite() { k.clamp(-9e15, 9e15) as i64 } else { i64::MIN } +} + #[derive(Default)] struct Report { duplicates: usize, @@ -234,33 +263,37 @@ fn overkill(s: &mut Session, hs: Option>, o: &Opts) -> Result = HashMap::new(); - for (i, c) in cands.iter().enumerate() { + // Keepers so far: (properties, shape) → cell of the sum → their numbers. + type Keepers = HashMap>>; + let mut seen: HashMap<(u32, String), Keepers> = HashMap::new(); + // Followed objects first, so a keeper is never displaced: everything deleted is within the + // tolerance of an object that stays. + let (followed, rest): (Vec, Vec) = (0..cands.len()).partition(|i| cands.get(*i).is_some_and(|c| c.protected)); + for i in followed.into_iter().chain(rest) { + let Some(c) = cands.get(i) else { continue }; let Ok(mut v) = serde_json::to_value(&c.e.kind) else { continue }; - if o.tol > 0.0 { - quantize(&mut v, o.q()); + let mut nums = Vec::new(); + split_numbers(&mut v, &mut nums); + let (sum, weights) = weighted_sum(&nums); + let cell = cell_of(sum, o.q() * weights.max(1.0)); + let cells = seen.entry((c.prop, v.to_string())).or_default(); + let found = [cell.saturating_sub(1), cell, cell.saturating_add(1)] + .into_iter() + .any(|k| cells.get(&k).is_some_and(|keepers| keepers.iter().any(|n| within(n, &nums, o.tol)))); + if !found || c.protected { + // A followed object stays even when it matches another. + cells.entry(cell).or_default().push(nums); + continue; } - let key = (c.prop, v.to_string()); - match seen.get(&key).copied() { - None => { - seen.insert(key, i); - } - Some(k) => { - let keeper_protected = cands.get(k).is_some_and(|x| x.protected); - let victim = match (keeper_protected, c.protected) { - (_, false) => i, - (false, true) => { - seen.insert(key, i); - k - } - (true, true) => continue, - }; - if let Some(g) = gone.get_mut(victim) { - *g = true; - rep.duplicates += 1; - } - } + if let Some(g) = gone.get_mut(i) { + *g = true; + rep.duplicates += 1; } } } @@ -421,8 +454,8 @@ fn collinear(members: &[&LineRec], cands: &[Cand], o: &Opts, gone: &mut [bool], fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap, rep: &mut Report) { let q = o.q(); let g = q.max(1e-9); - let cell = |x: f64| (x / g).round() as i64; - let mut buckets: HashMap<(u32, i64, i64, i64, i64), Vec> = HashMap::new(); + // (index, properties, centre and radius); cells of `g` hold them. + let mut recs: Vec<(usize, u32, [f64; 4])> = Vec::new(); for (i, c) in cands.iter().enumerate() { if gone.get(i).copied().unwrap_or(true) || c.protected { continue; @@ -442,12 +475,45 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap = buckets.keys().copied().collect(); - keys.sort_unstable(); - for key in keys { - let Some(group) = buckets.get(&key) else { continue }; + // Those within `g` of each other in centre and radius are on one circle. Each object joins the + // first group whose every member is within `g` of it, or starts one: matches don't chain, so + // nothing is deleted for matching only an object that itself matches the keeper. Members are + // looked for in the neighbouring cells too, so values on either side of a cell boundary meet. + let mut grid: HashMap<(u32, [i64; 4]), Vec> = HashMap::new(); + let mut group_of: Vec = Vec::with_capacity(recs.len()); + // Each group's records, in draw order; groups in the order of their first object. + let mut members: Vec> = Vec::new(); + for (ri, (_, prop, v)) in recs.iter().enumerate() { + let cells = v.map(|x| cell_of(x, g)); + let mut near_groups: Vec = Vec::new(); + for n in 0..81_i64 { + let near = [n % 3 - 1, n / 3 % 3 - 1, n / 9 % 3 - 1, n / 27 % 3 - 1]; + let mut key = cells; + for (k, d) in key.iter_mut().zip(near) { + *k = k.saturating_add(d); + } + near_groups.extend(grid.get(&(*prop, key)).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|rj| group_of.get(*rj))); + } + near_groups.sort_unstable(); + near_groups.dedup(); + let fits = |gi: &usize| members.get(*gi).is_some_and(|m| m.iter().all(|rk| recs.get(*rk).is_some_and(|(_, _, w)| within(v, w, g)))); + let gi = match near_groups.into_iter().find(fits) { + Some(gi) => gi, + None => { + members.push(Vec::new()); + members.len() - 1 + } + }; + if let Some(m) = members.get_mut(gi) { + m.push(ri); + } + group_of.push(gi); + grid.entry((*prop, cells)).or_default().push(ri); + } + let groups = members.iter().map(|m| m.iter().filter_map(|ri| recs.get(*ri).map(|r| r.0)).collect::>()); + for group in groups { if group.len() < 2 { continue; } @@ -465,7 +531,7 @@ fn arcs(cands: &[Cand], o: &Opts, gone: &mut [bool], changed: &mut HashMap Vec { CommandSpec::new("chprop", "Change Properties", chprop::run_chprop) .params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)") .interactive(|_| Ok(Box::new(chprop::ChpropM::new()))), + CommandSpec::new("change", "Change", change::run_change) + .alias(&["-ch"]) + .params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)") + .interactive(|_| Ok(Box::new(change::ChangeM::new()))), CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop) .menu(&["Modify", "Match Properties"]) .alias(&["ma", "painter"]) @@ -38,6 +44,7 @@ pub fn specs() -> Vec { CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}").interactive(|_| Ok(Box::new(LtscaleM))), CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), + CommandSpec::new("-units", "Units", run_units).alias(&["-un"]).params("(as UNITS)").interactive(|_| Ok(Box::new(dash_units::DashUnits::default()))), CommandSpec::new("thickness", "Thickness", run_thickness) .menu(&["Format", "Thickness"]) .params("{thickness} (THICKNESS: the 3D thickness given to new lines, arcs, circles, points and polylines; may be negative) → {thickness}") @@ -758,10 +765,13 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result { ok() } -/// Text style names used by entities (in model, paper spaces and blocks) and dimension styles. +/// Text style names used by entities (in model, paper spaces and blocks), dimension, +/// multileader and table styles (their cell styles), and the text of complex linetypes. pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec { let mut out: Vec = d.dim_styles.iter().map(|s| s.text_style.clone()).collect(); out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone())); + out.extend(d.table_styles.iter().flat_map(|s| s.cell_styles.iter().map(|c| c.text_style.clone()))); + out.extend(d.linetypes.iter().flat_map(|lt| lt.pattern.iter().filter_map(|el| el.style.clone()))); let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); for st in stores { for e in st.iter() { @@ -1181,6 +1191,11 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?; } "linetype" | "ltype" => { + // Stored trimmed, as -RENAME types it: lookups compare names untrimmed. + let to = to.trim().to_string(); + if !rename::valid_name(&to) { + return Err(bad("rename", format!("`{to}` is not a valid linetype name"))); + } let d = s.doc_mut()?; if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) { return Err(bad("rename", format!("the {from} linetype cannot be renamed"))); @@ -1246,6 +1261,19 @@ mod tests { assert_eq!(col(&s), Color::ByBlock); } + #[test] + fn rename_linetype_rejects_invalid_names() { + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + for to in ["", " ", "A;B", "A|B", "*X"] { + let r = s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": to })); + assert!(r.is_err(), "renamed DASHED to {to:?}: {r:?}"); + assert!(s.doc().unwrap().linetype("DASHED").is_some()); + } + s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": " DASH2 " })).unwrap(); + assert!(s.doc().unwrap().linetype("DASH2").is_some()); + } + #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props/change.rs b/crates/engine/src/cmd/props/change.rs new file mode 100644 index 000000000..64ff4b1ac --- /dev/null +++ b/crates/engine/src/cmd/props/change.rs @@ -0,0 +1,544 @@ +//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does. +//! +//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked +//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set +//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change +//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the +//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole, +//! every object read back with `entget`): +//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives +//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or +//! plumb with the other one ((60,0)). Enter leaves lines alone; +//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify +//! new circle radius "; +//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new +//! text insertion point "; then "Enter new text style", "Specify new height" (not +//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for +//! Aligned or Fit text, whose two points set it) and "Enter new text". An +//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in +//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE +//! command.`; +//! - a block reference is placed by the point, its attributes moving and turning with it, or asks +//! "Specify new block insertion point "; then "Specify new block rotation angle"; +//! - the point places a circle, text or block reference only when it is the one object selected: +//! with others, "Change point ignored" comes before that object's questions, which are asked as +//! for Enter; +//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No +//! changeable object selected". +//! +//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points, +//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary +//! associativity removed."), texts, multiline texts and block references. + +use std::collections::{HashSet, VecDeque}; + +use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::{bad, f64_req, point_req, targets}; +use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// A question CHANGE asks about one object. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ask { + /// Not a question: "Change point ignored" as the object comes up. + Ignored, + Radius, + TextPoint, + Style, + Height, + Rotation, + Value, + Tag, + AttPrompt, + Default, + BlockPoint, + BlockRotation, +} + +enum Stage { + Select, + Point, + Properties(Box), + /// The questions left, in order, each about one object. + Asking(VecDeque<(Handle, Ask)>), +} + +pub(super) struct ChangeM { + select: SelectPhase, + handles: Vec, + stage: Stage, +} + +impl ChangeM { + pub(super) fn new() -> Self { + ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select } + } + + /// Apply the change point `p` (`None`: Enter) and line up the questions it leaves. + fn change_point(&mut self, s: &mut Session, p: Option) -> Result { + let alone = self.handles.len() == 1; + let ortho = s.drafting_settings().orthomode; + let mut asks = VecDeque::new(); + let mut changeable = false; + for &h in &self.handles { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + if let Some(p) = p { + move_end(s, h, p, ortho)?; + changeable = true; + } + continue; + } + let Some(questions) = questions(&kind) else { continue }; + changeable = true; + let placed = p.filter(|_| alone); + if p.is_some() && !alone { + asks.push_back((h, Ask::Ignored)); + } + if let Some(q) = placed { + place(s, h, q)?; + } + // The point answers the first question: the radius or the insertion point. + asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a))); + } + if !changeable { + s.echo("No changeable object selected"); + return Ok(Step::Done); + } + self.stage = Stage::Asking(asks); + self.advance(s) + } + + /// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`] + /// says isn't asked. Done when nothing is left to ask. + fn advance(&mut self, s: &mut Session) -> Result { + let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) }; + while let Some(&(h, ask)) = asks.front() { + match ask { + Ask::Ignored => s.echo("Change point ignored"), + a if skipped(s, h, a) => {} + _ => return Ok(Step::Continue), + } + asks.pop_front(); + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// The questions about an object of `kind`, the first answered by the change point when it is +/// the one object; `None` when the change point leaves it alone. +fn questions(kind: &EntityKind) -> Option<&'static [Ask]> { + Some(match kind { + EntityKind::Circle(_) => &[Ask::Radius], + EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value], + EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default], + EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation], + _ => return None, + }) +} + +fn text_of(kind: &EntityKind) -> Option<&Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +/// Where a circle, text or block reference is measured from: its centre, the point it is placed by +/// (the alignment point of justified text) or its insertion point. +fn anchor(kind: &EntityKind) -> Option { + match kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Insert(i) => Some(i.insert.xy()), + k => text_of(k).map(Text::justify_point), + } +} + +/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed +/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it). +fn skipped(s: &Session, h: Handle, ask: Ask) -> bool { + let Ok(d) = s.doc() else { return false }; + let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false }; + match ask { + Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0), + Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit), + _ => false, + } +} + +/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end. +fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> { + let settings = s.drafting_settings(); + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Line(l) = &mut e.kind { + let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) }; + let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p }; + end.x = q.x; + end.y = q.y; + } + })?; + Ok(()) +} + +/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block +/// reference moves there (with its attributes). +fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Circle(c) = &mut e.kind { + let r = c.center.xy().dist(q); + if r > 0.0 && r.is_finite() { + c.radius = r; + } + } else if let Some(at) = anchor(&e.kind) { + e.kind.transform(&Mat3::translate(q - at)); + } + })?; + Ok(()) +} + +/// Set a circle's radius or a text's height. +fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| match &mut e.kind { + EntityKind::Circle(c) => c.radius = v, + k => { + if let Some(t) = text_of_mut(k) { + t.height = v; + } + } + })?; + Ok(()) +} + +/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its +/// insertion point. +fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Insert(i) = &e.kind { + let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation); + e.kind.transform(&m); + } else if let Some(t) = text_of_mut(&mut e.kind) { + t.rotation = cadcraft_geom::norm_angle(angle); + } + })?; + Ok(()) +} + +/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again). +fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result { + let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO); + match (ask, i.clone()) { + // Enter keeps what there is. + (_, Input::Enter) => {} + (Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?, + (Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())), + (Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => { + let v = match &i { + Input::Point(q) => q.dist(at), + Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?, + _ => 0.0, + }; + if !(v > 0.0 && v.is_finite()) { + return Err(EngineError::Other("Value must be positive and nonzero.".into())); + } + set_length(s, h, v)?; + } + (Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?, + (Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => { + let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?; + set_rotation(s, h, a)?; + } + (Ask::Style, Input::Text(t) | Input::Keyword(t)) => { + let name = t.trim(); + let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else { + s.echo(format!("Style \"{name}\" not defined - use STYLE command.")); + return Ok(false); + }; + s.doc_mut()?.modify_entity(h, |e| { + if let Some(t) = text_of_mut(&mut e.kind) { + t.style = style; + } + })?; + } + (Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => { + s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) { + (EntityKind::AttDef(a), Ask::Tag) => a.tag = t, + (EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t, + (k, _) => { + if let Some(x) = text_of_mut(k) { + x.value = t; + } + } + })?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The question `ask` about `h`. +fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt { + let d = s.doc().ok(); + let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind); + let at = kind.and_then(anchor); + let text = kind.and_then(text_of); + // As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text + // at 0° as 270.00). + let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees()); + match ask { + Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(), + Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"), + Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"), + Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()), + Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER) + .default(format!("{:.4}", text.map_or(0.0, |t| t.height))) + .base_opt(at) + .distance(), + Ask::Rotation => { + Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at) + } + Ask::BlockRotation => { + let r = match kind { + Some(EntityKind::Insert(i)) => i.rotation, + _ => 0.0, + }; + Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at) + } + Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Tag | Ask::AttPrompt => { + let (msg, v) = match (kind, ask) { + (Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()), + (Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()), + (_, Ask::Tag) => ("Enter new tag", String::new()), + _ => ("Enter new prompt", String::new()), + }; + Prompt::new(msg, Accept::TEXT).default(v) + } + Ask::Ignored => Prompt::new("", Accept::TEXT), + } +} + +/// The one z of `zs`, to rounding; `None` when they differ. +fn shared_z(zs: impl IntoIterator) -> Option { + let mut zs = zs.into_iter(); + let first = zs.next().unwrap_or(0.0); + zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first) +} + +/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between +/// two heights), `None` for the kinds Elev leaves alone. +pub(super) fn elevation_of(kind: &EntityKind) -> Option> { + match kind { + EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])), + EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))), + EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))), + EntityKind::Point(p) => Some(Some(p.p.z)), + EntityKind::Circle(c) => Some(Some(c.center.z)), + EntityKind::Arc(a) => Some(Some(a.center.z)), + EntityKind::Ellipse(e) => Some(Some(e.center.z)), + EntityKind::LwPolyline(p) => Some(Some(p.elevation)), + EntityKind::Hatch(h) => Some(Some(h.elevation)), + EntityKind::MText(t) => Some(Some(t.insert.z)), + EntityKind::Insert(i) => Some(Some(i.insert.z)), + k => text_of(k).map(|t| Some(t.insert.z)), + } +} + +/// Move `text` up by `dz`. +fn raise_text(text: &mut Text, dz: f64) { + for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) { + p.z += dz; + } +} + +fn set_elevation(kind: &mut EntityKind, z: f64) { + let set = |p: &mut Vec3| p.z = z; + match kind { + EntityKind::Line(l) => { + set(&mut l.a); + set(&mut l.b); + } + EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set), + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set), + EntityKind::Point(p) => set(&mut p.p), + EntityKind::Circle(c) => set(&mut c.center), + EntityKind::Arc(a) => set(&mut a.center), + EntityKind::Ellipse(e) => set(&mut e.center), + EntityKind::LwPolyline(p) => p.elevation = z, + EntityKind::Hatch(h) => { + h.elevation = z; + crate::cmd::hatch::detach(h); + } + EntityKind::MText(t) => set(&mut t.insert), + // A block reference's attributes go up with it. + EntityKind::Insert(i) => { + let dz = z - i.insert.z; + set(&mut i.insert); + for a in &mut i.attribs { + raise_text(&mut a.text, dz); + if let Some(ml) = &mut a.multiline { + ml.mtext.insert.z += dz; + } + } + } + k => { + if let Some(t) = text_of_mut(k) { + let dz = z - t.insert.z; + raise_text(t, dz); + } + } + } +} + +/// What [`elevate`] did. +pub(super) struct Elevated { + /// The objects whose elevation was set. + pub(super) set: Vec, + /// Objects whose points differ in z, which keep theirs. + pub(super) differing: usize, + /// Associative hatches, which no longer are. + pub(super) detached: usize, +} + +/// Set the elevation of the objects among `hs` whose points share one. +pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result { + let d = s.doc_mut()?; + let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 }; + for &h in hs { + let Some(e) = d.entity(h) else { continue }; + match elevation_of(&e.kind) { + Some(Some(_)) => { + r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative)); + d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?; + r.set.push(h); + } + Some(None) => r.differing += 1, + None => {} + } + } + Ok(r) +} + +/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or +/// changed besides. +pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> { + let r = elevate(s, hs, z)?; + if r.differing > 0 { + s.echo("Cannot change elevation of objects with differing Z coordinates."); + } + if r.detached > 0 { + s.echo("Hatch boundary associativity removed."); + } + Ok(()) +} + +/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles, +/// texts and block references it leaves alone because others were selected too), `elevation` as +/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`: +/// associative hatches that no longer are); objects on locked layers are left out (`locked`). +pub(super) fn run_change(s: &mut Session, p: &Value) -> Result { + // A point or an elevation that can't be read is refused, and so is a call with neither. + let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?; + let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?; + if point.is_none() && elevation.is_none() { + return Err(bad("change", "`point` or `elevation` is required")); + } + let hs = targets(s, p)?; + let locked = locked(s, &hs); + let free: Vec = hs.into_iter().filter(|h| !locked.contains(h)).collect(); + let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0); + if let Some(q) = point { + let ortho = s.drafting_settings().orthomode; + for &h in &free { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + move_end(s, h, q, ortho)?; + changed.insert(h); + } else if questions(&kind).is_some() { + if free.len() == 1 { + place(s, h, q)?; + changed.insert(h); + } else { + ignored += 1; + } + } + } + } + if let Some(z) = elevation { + let r = elevate(s, &free, z)?; + changed.extend(r.set); + (differing, detached) = (r.differing, r.detached); + } + Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() })) +} + +impl Interactive for ChangeM { + fn name(&self) -> &'static str { + "CHANGE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.select = begin_unlocked(s); + if self.select.done { + self.handles = self.select.picked.clone(); + self.stage = Stage::Point; + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.stage { + Stage::Select => self.select.prompt(), + Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]), + Stage::Properties(m) => m.prompt(s), + Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match &mut self.stage { + Stage::Select => { + return Ok(match feed_unlocked(&mut self.select, s, i)? { + SelOutcome::More => Step::Continue, + SelOutcome::Done(hs) => { + self.handles = hs; + self.stage = Stage::Point; + Step::Continue + } + SelOutcome::Empty => { + s.set_selection(Vec::new()); + Step::Done + } + }); + } + Stage::Properties(m) => return m.input(s, i), + Stage::Asking(asks) => { + let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) }; + if !answer(s, h, ask, i)? { + return Ok(Step::Continue); + } + asks.pop_front(); + return self.advance(s); + } + Stage::Point => {} + } + match i { + Input::Keyword(k) if k == "Properties" => { + self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone()))); + Ok(Step::Continue) + } + Input::Point(p) => self.change_point(s, Some(p)), + Input::Enter => self.change_point(s, None), + _ => Err(EngineError::Other("Point or option keyword required.".into())), + } + } +} diff --git a/crates/engine/src/cmd/props/chprop.rs b/crates/engine/src/cmd/props/chprop.rs index 0949abee9..fac500ecd 100644 --- a/crates/engine/src/cmd/props/chprop.rs +++ b/crates/engine/src/cmd/props/chprop.rs @@ -28,6 +28,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; +/// CHANGE's Properties: CHPROP's list with Elev. +const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; + /// What CHPROP asks for. #[derive(Clone, Copy, Debug, PartialEq)] enum Ask { @@ -40,6 +43,7 @@ enum Ask { Lineweight, Thickness, Transparency, + Elevation, } pub(super) struct ChpropM { @@ -47,11 +51,35 @@ pub(super) struct ChpropM { handles: Vec, /// `None` while objects are selected. ask: Option, + /// CHANGE's Properties: Elev is on the list. + change: bool, + /// The elevation typed at Elev, set when the command ends. + elevation: Option, } impl ChpropM { pub(super) fn new() -> Self { - ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None } + ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None } + } + + /// CHANGE's Properties option on `handles`. + pub(super) fn change_properties(handles: Vec) -> Self { + ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None } + } + + fn keywords(&self) -> &'static [&'static str] { + if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES } + } + + /// The selection's common elevation, or "varies" (as a line between two heights does alone). + fn common_elevation(&self, s: &Session) -> String { + let Ok(d) = s.doc() else { return String::new() }; + let zs: Vec> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect(); + match zs.first() { + None => format!("{:.4}", 0.0), + Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"), + Some(_) => "varies".into(), + } } /// The selection's common value of a property, or "varies". @@ -151,6 +179,13 @@ impl ChpropM { return Ok(false); } } + Ask::Elevation => match number(word).filter(|v| v.is_finite()) { + Some(z) => self.elevation = Some(z), + None => { + s.echo("Requires a number."); + return Ok(false); + } + }, Ask::Property => {} } Ok(true) @@ -158,7 +193,7 @@ impl ChpropM { } /// The objects among `hs` on locked layers. -fn locked(s: &Session, hs: &[Handle]) -> HashSet { +pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet { hs.iter().copied().filter(|h| is_locked(s, *h)).collect() } @@ -170,6 +205,38 @@ fn say_locked(s: &mut Session, n: usize) { } } +/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and +/// saying how many); `done` when some of it is left. +pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase { + let mut select = SelectPhase::begin(s); + if select.done { + let locked = locked(s, &select.picked); + if !locked.is_empty() { + select.picked.retain(|h| !locked.contains(h)); + say_locked(s, locked.len()); + s.set_selection(select.picked.clone()); + // Nothing left of the pickfirst selection: ask for objects instead. + select.done = !select.picked.is_empty(); + } + } + select +} + +/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after +/// what was found. +pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result { + let (i, n) = match i { + Input::Pick(hs) if !select.removing => { + let locked = locked(s, &hs); + (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) + } + i => (i, 0), + }; + let outcome = select.feed(s, &i)?; + say_locked(s, n); + Ok(outcome) +} + /// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the /// command leaves out too; "locked" says how many were. pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result { @@ -242,17 +309,7 @@ impl Interactive for ChpropM { "CHPROP" } fn begin(&mut self, s: &mut Session) -> Result { - self.select = SelectPhase::begin(s); - if self.select.done { - let locked = locked(s, &self.select.picked); - if !locked.is_empty() { - self.select.picked.retain(|h| !locked.contains(h)); - say_locked(s, locked.len()); - s.set_selection(self.select.picked.clone()); - // Nothing left of the pickfirst selection: ask for objects instead. - self.select.done = !self.select.picked.is_empty(); - } - } + self.select = begin_unlocked(s); if self.select.done { self.handles = self.select.picked.clone(); self.ask = Some(Ask::Property); @@ -262,7 +319,7 @@ impl Interactive for ChpropM { fn prompt(&self, s: &Session) -> Prompt { let Some(ask) = self.ask else { return self.select.prompt() }; match ask { - Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES), + Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(self.keywords()), Ask::Color => { Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase())) } @@ -275,21 +332,13 @@ impl Interactive for ChpropM { Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))), Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))), Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())), + Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { let Some(ask) = self.ask else { // Objects on locked layers are left out, and said after what was found. - let (i, n) = match i { - Input::Pick(hs) if !self.select.removing => { - let locked = locked(s, &hs); - (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) - } - i => (i, 0), - }; - let outcome = self.select.feed(s, &i)?; - say_locked(s, n); - return Ok(match outcome { + return Ok(match feed_unlocked(&mut self.select, s, i)? { SelOutcome::More => Step::Continue, SelOutcome::Done(hs) => { self.handles = hs; @@ -304,6 +353,10 @@ impl Interactive for ChpropM { }; match (ask, i) { (Ask::Property, Input::Enter) => { + // CHANGE sets the elevation as it ends. + if let Some(z) = self.elevation { + super::change::elevate_and_say(s, &self.handles, z)?; + } s.set_selection(Vec::new()); return Ok(Step::Done); } @@ -315,12 +368,13 @@ impl Interactive for ChpropM { "ltScale" => Ask::LtScale, "LWeight" => Ask::Lineweight, "Thickness" => Ask::Thickness, + "Elev" => Ask::Elevation, _ => Ask::Transparency, }); } (Ask::Property, Input::Text(t)) => { let t = t.trim().trim_start_matches('_').to_ascii_lowercase(); - let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); + let n = self.keywords().iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." }); } (Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor), diff --git a/crates/engine/src/cmd/props/dash_units.rs b/crates/engine/src/cmd/props/dash_units.rs new file mode 100644 index 000000000..95b6bc021 --- /dev/null +++ b/crates/engine/src/cmd/props/dash_units.rs @@ -0,0 +1,213 @@ +//! -UNITS: the drawing's units at the command line, with AutoCAD 2024's prompts and listings. + +use std::f64::consts::TAU; + +use crate::units::{AngleSettings, format_angle}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +const REPORT_FORMATS: [&str; 10] = [ + "Report formats: (Examples)", + " 1. Scientific 1.55E+01", + " 2. Decimal 15.50", + " 3. Engineering 1'-3.50\"", + " 4. Architectural 1'-3 1/2\"", + " 5. Fractional 15 1/2", + "With the exception of Engineering and Architectural formats,", + "these formats can be used with any basic unit of measurement.", + "For example, Decimal mode is perfect for metric units as well", + "as decimal English units.", +]; + +const ANGLE_SYSTEMS: [&str; 6] = [ + "Systems of angle measure: (Examples)", + " 1. Decimal degrees 45.0000", + " 2. Degrees/minutes/seconds 45d0'0\"", + " 3. Grads 50.0000g", + " 4. Radians 0.7854r", + " 5. Surveyor's units N 45d0'0\" E", +]; + +const DENOMINATOR: &str = "Enter denominator of smallest fraction to display"; + +/// The drawing's units, as -UNITS shows and asks them. +struct Units { + lunits: i64, + luprec: i64, + aunits: i64, + auprec: i64, + angbase: f64, + clockwise: bool, +} + +impl Units { + fn of(s: &Session) -> Units { + let Ok(d) = s.doc() else { return Units { lunits: 2, luprec: 4, aunits: 0, auprec: 0, angbase: 0.0, clockwise: false } }; + let h = &d.header; + let angbase = h.f64("ANGBASE", 0.0); + Units { + lunits: h.i64("LUNITS", 2).clamp(1, 5), + luprec: h.i64("LUPREC", 4).clamp(0, 8), + aunits: h.i64("AUNITS", 0).clamp(0, 4), + auprec: h.i64("AUPREC", 0).clamp(0, 8), + angbase: if angbase.is_finite() { angbase } else { 0.0 }, + clockwise: h.i64("ANGDIR", 0) != 0, + } + } + + /// Architectural and fractional units give their precision as a fraction's denominator. + fn fractional(&self) -> bool { + matches!(self.lunits, 4 | 5) + } + + /// Direction `a` (radians counterclockwise from +X) in the angle format, measured as the + /// drawing measures angles but from +X: -UNITS shows north as 270 when angles turn clockwise. + fn direction_text(&self, a: f64) -> String { + format_angle(if self.clockwise { -a } else { a }, self.aunits, self.auprec) + } + + /// A typed direction of angle 0, in radians counterclockwise from +X: an amount in the angle + /// format, or with its own unit (`45d`, `0.5r`, `100g`, a bearing `N45dE`, `N`), measured + /// from +X as the drawing measures angles. + fn direction(&self, t: &str) -> Option { + let a = AngleSettings { aunits: self.aunits, ..AngleSettings::default() }.amount(t)?; + let a = if self.clockwise { -a } else { a }; + a.is_finite().then(|| a.rem_euclid(TAU)) + } +} + +/// An integer answer in `lo..=hi`, or AutoCAD's message for one that isn't. +fn integer(t: &str, lo: i64, hi: i64) -> Result { + let v = t.trim().parse::().map_err(|_| EngineError::Other("Requires an integer value.".into()))?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(EngineError::Other(format!("Requires an integer between {lo} and {hi}."))) } +} + +#[derive(Clone, Copy, Default)] +enum Ask { + #[default] + LengthFormat, + LengthPrecision, + AngleFormat, + AnglePrecision, + Direction, + Clockwise, +} + +/// -UNITS: the length format and precision, the angle format and precision, the direction of +/// angle 0 and whether angles turn clockwise, asked in that order. As in AutoCAD, each answer is +/// kept as it is given (Esc keeps the ones before it), and choosing another format resets its +/// precision to 4, the precision then offered. +#[derive(Default)] +pub(super) struct DashUnits { + ask: Ask, +} + +impl Interactive for DashUnits { + fn name(&self) -> &'static str { + "-UNITS" + } + + fn begin(&mut self, s: &mut Session) -> Result { + for l in REPORT_FORMATS { + s.echo(l); + } + Ok(Step::Continue) + } + + fn prompt(&self, s: &Session) -> Prompt { + let u = Units::of(s); + match self.ask { + Ask::LengthFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default(u.lunits.to_string()), + Ask::LengthPrecision if u.fractional() => { + Prompt::new("(1, 2, 4, 8, 16, 32, 64, 128, or 256)", Accept::NUMBER).default((1_i64 << u.luprec).to_string()) + } + Ask::LengthPrecision => { + Prompt::new("Enter number of digits to right of decimal point (0 to 8)", Accept::NUMBER).default(u.luprec.to_string()) + } + Ask::AngleFormat => Prompt::new("Enter choice, 1 to 5", Accept::NUMBER).default((u.aunits + 1).to_string()), + Ask::AnglePrecision => { + Prompt::new("Enter number of fractional places for display of angles (0 to 8)", Accept::NUMBER).default(u.auprec.to_string()) + } + Ask::Direction => { + Prompt::new(format!("Enter direction for angle {}", u.direction_text(0.0)), Accept::TEXT).default(u.direction_text(u.angbase)) + } + Ask::Clockwise => Prompt::new("Measure angles clockwise?", Accept::TEXT).kw(&["Yes", "No"]).default(if u.clockwise { "Y" } else { "N" }), + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let u = Units::of(s); + let text = match &i { + Input::Text(t) => Some(t.as_str()), + Input::Enter => None, + Input::Keyword(k) => Some(k.as_str()), + _ => return Ok(Step::Continue), + }; + let h = &mut s.doc_mut()?.header; + match self.ask { + Ask::LengthFormat => { + let l = text.map_or(Ok(u.lunits), |t| integer(t, 1, 5))?; + if l != u.lunits { + h.set_i64("LUNITS", l); + h.set_i64("LUPREC", 4); + } + self.ask = Ask::LengthPrecision; + if matches!(l, 4 | 5) { + s.echo(DENOMINATOR); + } + } + Ask::LengthPrecision => { + let prec = if u.fractional() { + let den = text.map_or(Some(1 << u.luprec), |t| t.trim().parse::().ok()); + let Some(p) = den.and_then(|d| (0..=8).find(|p| 1_i64 << p == d)) else { + s.echo("Invalid choice."); + s.echo(DENOMINATOR); + return Ok(Step::Continue); + }; + p + } else { + text.map_or(Ok(u.luprec), |t| integer(t, 0, 8))? + }; + h.set_i64("LUPREC", prec); + self.ask = Ask::AngleFormat; + for l in ANGLE_SYSTEMS { + s.echo(l); + } + } + Ask::AngleFormat => { + let a = text.map_or(Ok(u.aunits + 1), |t| integer(t, 1, 5))? - 1; + if a != u.aunits { + h.set_i64("AUNITS", a); + h.set_i64("AUPREC", 4); + } + self.ask = Ask::AnglePrecision; + } + Ask::AnglePrecision => { + h.set_i64("AUPREC", text.map_or(Ok(u.auprec), |t| integer(t, 0, 8))?); + self.ask = Ask::Direction; + let u = Units::of(s); + s.echo(format!("Direction for angle {}:", u.direction_text(0.0))); + for (name, deg) in [("East 3", 0.0), ("North 12", 90.0), ("West 9", 180.0), ("South 6", 270.0)] { + s.echo(format!(" {name} o'clock = {}", u.direction_text(f64::to_radians(deg)))); + } + } + Ask::Direction => { + if let Some(t) = text { + let a = u.direction(t).ok_or_else(|| EngineError::Other("Requires valid numeric angle or two points.".into()))?; + h.set_f64("ANGBASE", a); + } + self.ask = Ask::Clockwise; + } + Ask::Clockwise => { + let clockwise = match text.map(|t| t.trim().trim_start_matches('_').to_ascii_lowercase()) { + None => u.clockwise, + Some(t) if t == "y" || t == "yes" => true, + Some(t) if t == "n" || t == "no" => false, + Some(_) => return Err(EngineError::Other("Invalid option keyword.".into())), + }; + h.set_i64("ANGDIR", i64::from(clockwise)); + return Ok(Step::Done); + } + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/props/rename.rs b/crates/engine/src/cmd/props/rename.rs index fe2e289ce..3513c1254 100644 --- a/crates/engine/src/cmd/props/rename.rs +++ b/crates/engine/src/cmd/props/rename.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::super::bad; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; -fn valid_name(n: &str) -> bool { +pub(super) fn valid_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } diff --git a/crates/engine/src/cmd/table/breaks.rs b/crates/engine/src/cmd/table/breaks.rs index 38ed33bce..6f207ec50 100644 --- a/crates/engine/src/cmd/table/breaks.rs +++ b/crates/engine/src/cmd/table/breaks.rs @@ -112,7 +112,7 @@ impl Interactive for BreakM { fn prompt(&self, _s: &Session) -> Prompt { match self.table { None => Prompt::new("Select a table", Accept::SELECT), - Some(_) => Prompt::new("Enter maximum part height or", Accept::POINT_OR_NUMBER).kw(&["Off"]), + Some(_) => Prompt::new("Enter maximum part height", Accept::POINT_OR_NUMBER).kw(&["Off"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/src/cmd/utility.rs b/crates/engine/src/cmd/utility.rs index 97678ae24..8faab4f9c 100644 --- a/crates/engine/src/cmd/utility.rs +++ b/crates/engine/src/cmd/utility.rs @@ -26,7 +26,10 @@ pub fn specs() -> Vec { .params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}") .noundo() .interactive(|_| Ok(Box::::default())), - CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"), + CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params( + "{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \ + {matches: [{handle, type, count, snippet, hit, hitLen}], total, message}", + ), CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom) .params("{mode: distance|radius|angle|area|volume, ...}") .noundo() @@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result { // ---------------- FIND ---------------- +/// Characters of context kept on each side of a hit in the Find dialog's result list. +const SNIPPET_CONTEXT: usize = 18; + +/// The text around the first hit of `find` in `text`, for the result list: at most +/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the +/// snippet (in characters) and its length (in characters). +/// +/// The offset and length are character counts, not bytes: the dialog slices the snippet by +/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a +/// string's character count in rare scripts, so every slice goes through `get` and a snippet that +/// cannot be cut is reported as absent rather than guessed at. +fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> { + if find.is_empty() { + return None; + } + let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) }; + let mut at = 0; + let pos = loop { + let rest = hay.get(at..)?; + let p = rest.find(&needle)? + at; + let end = p + needle.len(); + let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric()); + let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric()); + if !whole || (boundary(p) && after) { + break p; + } + at = end.max(p + 1); + if at >= hay.len() { + return None; + } + }; + let start = hay.get(..pos)?.chars().count(); + let hit_len = needle.chars().count(); + let chars: Vec = text.chars().collect(); + let from = start.saturating_sub(SNIPPET_CONTEXT); + let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len()); + let snippet: String = chars.get(from..to)?.iter().collect(); + Some((snippet, start.saturating_sub(from), hit_len)) +} + fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) { if find.is_empty() { return (text.to_string(), 0); @@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result { let rep = str_param(p, "replace").map(str::to_string); let case = bool_or(p, "matchCase", false); let whole = bool_or(p, "wholeWord", false); + // Only the named object types, when the dialog narrows the search; `type_name()` is the same + // name the result list shows, so a caller never has to guess the spelling. + let types: Option> = + p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect()); let space = s.space(); let d = s.doc()?; - let hs: Vec = d.space(&space).map(|st| st.handles()).unwrap_or_default(); + // The whole current space by default; the Find window narrows it to the objects its result + // list already holds, so Replace All touches exactly what the user can see. + let hs: Vec = match p.get("handles").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(), + None => d.space(&space).map(|st| st.handles()).unwrap_or_default(), + }; let mut found: Vec = Vec::new(); let mut total = 0; let mut edits: Vec<(Handle, EntityKind)> = Vec::new(); for h in hs { let Some(e) = d.entity(h) else { continue }; + if let Some(names) = &types + && !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase()) + { + continue; + } let mut k = e.kind.clone(); let mut hits = 0; + // The first hit's surroundings, taken before a replacement rewrites the text. + let mut snippet: Option<(String, usize, usize)> = None; let r = rep.as_deref().unwrap_or(&find); let mut apply = |t: &mut String| { let (nt, n) = replace_in(t, &find, r, case, whole); if n > 0 { hits += n; + if snippet.is_none() { + snippet = snippet_at(t, &find, case, whole); + } if rep.is_some() { *t = nt; } @@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result { } if hits > 0 { total += hits; - found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits })); + let (text, at, len) = snippet.unwrap_or_default(); + found.push(json!({ + "handle": h.hex(), + "type": e.kind.type_name(), + "count": hits, + "snippet": text, + "hit": at, + "hitLen": len, + })); if rep.is_some() { edits.push((h, k)); } @@ -577,4 +647,35 @@ mod tests { assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3)); assert_eq!(replace_in("x", "", "y", true, false).1, 0); } + + /// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0. + #[test] + fn snippet_reports_the_hit_offset() { + assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7))); + } + + /// Matching ignores case but the snippet keeps the text as it is written, so the result list + /// shows the drawing's own spelling. + #[test] + fn snippet_keeps_the_original_case() { + assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7))); + } + + /// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in + /// characters so a CJK hit is never split. + #[test] + fn snippet_is_windowed_in_characters() { + let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30)); + let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit"); + assert_eq!((hit, len), (SNIPPET_CONTEXT, 2)); + assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2); + } + + /// A whole-word search skips an embedded hit, and text without a hit has no snippet. + #[test] + fn snippet_respects_whole_word() { + assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None); + assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2)); + assert_eq!(snippet_at("nothing", "BOLT", true, false), None); + } } diff --git a/crates/engine/src/cmd/utility/calc.rs b/crates/engine/src/cmd/utility/calc.rs index 05c6fc52e..0c0d29b32 100644 --- a/crates/engine/src/cmd/utility/calc.rs +++ b/crates/engine/src/cmd/utility/calc.rs @@ -333,6 +333,9 @@ fn call(name: &str, args: &[Val]) -> Result { "min" | "max" => { arity(1, MAX_ARGS)?; let vs = (0..args.len()).map(n).collect::, _>>()?; + if vs.iter().any(|v| !v.is_finite()) { + return Err("result is not a finite number".into()); + } let f = if name == "min" { f64::min } else { f64::max }; Ok(Val::Num(vs.into_iter().reduce(f).unwrap_or(0.0))) } @@ -597,6 +600,10 @@ mod tests { assert!(ev("[1").is_err()); assert!(ev(&"[".repeat(5000)).is_err()); assert_eq!(ev("min(3,1,2)"), Ok(Val::Num(1.0))); + assert!(ev("min(sqrt(-1), 5)").is_err()); + assert!(ev("max(5, ln(-1))").is_err()); + assert!(ev("min(exp(1000), 5)").is_err()); + assert!(ev("max(exp(1000), 5)").is_err()); assert_eq!(ev("1e-3*2"), Ok(Val::Num(0.002))); assert!(ev("max(1,2,3,4,5,6,7,8,9)").is_err()); } diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index c70989a58..31d23a94e 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -35,6 +35,11 @@ pub struct DocState { /// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the /// drawing itself waits in it ([`DocState::drawing`]). pub block_edit: Option, + /// What the last erase removed, for OOPS. + pub(crate) erased: crate::cmd::Erased, + /// The fonts missing on Windows that a save last named: the next save names them again only + /// when they change. + pub(crate) fonts_noted: String, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -61,6 +66,8 @@ impl DocState { uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), binary_dxf: false, block_edit: None, + erased: Default::default(), + fonts_noted: String::new(), } } /// The drawing has changes not saved to its file, or the Block Editor has changes not yet @@ -68,6 +75,14 @@ impl DocState { pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved() } + /// Show the drawing an undo or redo step holds, with nothing selected; returns the drawing as + /// it was, as the step back. + pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot { + let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) }; + self.follow_layout(&cur.doc); + self.revision += 1; + cur + } /// After undo/redo replaced the drawing (`before` is the one shown until then): if the current /// layout no longer exists, follow it to its other name when it was renamed (same tab order, /// a name `before` did not have), else go to the Model tab. diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 8c58e30b5..b0c1a1bca 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -226,6 +226,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option set_xy(s.corners.get_mut(index)?, to), EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to), + EntityKind::PolyfaceMesh(m) => set_xy(m.vertices.get_mut(index)?, to), EntityKind::Region(_) => return Some(translated(kind, delta)), EntityKind::Viewport(v) => { let opposite = *grips.get((index + 2) % 4)?; diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 8a64e2b74..799b7046d 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -105,10 +105,51 @@ impl Interactive for Guarded { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::Running; + thread_local! { + static PANIC_AFTER_COMMAND: Cell = const { Cell::new(false) }; + } + + /// Called at the start of the passes after a command, which nothing can make panic on demand. + pub(crate) fn panic_if_armed() { + assert!(!PANIC_AFTER_COMMAND.get(), "a pass after the command panicked"); + } + + /// A panic in the passes after a JSON command: an error, and the drawing as it was. + #[test] + fn a_panic_after_a_command_restores_the_drawing() { + let mut s = Session::new(); + s.execute("line", &serde_json::json!({"points": [[0, 0], [1, 0]]})).unwrap(); + let undo = s.state().unwrap().undo.len(); + PANIC_AFTER_COMMAND.set(true); + let r = s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})); + PANIC_AFTER_COMMAND.set(false); + assert!(matches!(&r, Err(EngineError::Internal(id, m)) if id == "circle" && m.contains("after the command")), "{r:?}"); + assert_eq!(points(&s), 1, "the circle is gone with the failed command"); + assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for it"); + s.execute("circle", &serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + assert_eq!(points(&s), 2, "the session still works"); + } + + /// The same at the end of an interactive command, where nothing returns the error to a caller. + #[test] + fn a_panic_after_an_interactive_command_restores_the_drawing() { + let mut s = Session::new(); + s.cmdline("line 0,0 1,0").unwrap(); + PANIC_AFTER_COMMAND.set(true); + let r = s.input(Input::Enter); + PANIC_AFTER_COMMAND.set(false); + assert!(r.is_ok(), "{r:?}"); + assert!(s.running.is_none()); + assert_eq!(points(&s), 0, "the drawing is restored"); + assert!(s.log.iter().any(|l| l.contains("Internal error in line")), "{:?}", s.log); + s.cmdline("line 0,0 1,0").unwrap(); + assert!(s.running.is_some(), "the session still works"); + } + fn dot(p: Vec2) -> EntityKind { EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 }) } diff --git a/crates/engine/src/last_used.rs b/crates/engine/src/last_used.rs index 73c610329..e884caf1b 100644 --- a/crates/engine/src/last_used.rs +++ b/crates/engine/src/last_used.rs @@ -25,6 +25,10 @@ pub struct LastUsed { pub mleader_block: String, /// BREAKLINE's Block, Size and Extension. pub breakline: cmd::BreaklineOptions, + /// -ARRAY's type: Polar instead of Rectangular. + pub classic_array_polar: bool, + /// RECTANG's chamfer, fillet, width, elevation, thickness and rotation. + pub rectang: RectangModes, } impl Default for LastUsed { @@ -39,10 +43,26 @@ impl Default for LastUsed { mleader: MLeaderOptions::default(), mleader_block: String::new(), breakline: cmd::BreaklineOptions::default(), + classic_array_polar: false, + rectang: RectangModes::default(), } } } +/// The modes RECTANG keeps from one rectangle to the next, as AutoCAD 2024 does. Chamfer and +/// fillet exclude each other: setting one clears the other. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RectangModes { + /// First and second chamfer distances. + pub chamfer: (f64, f64), + pub fillet: f64, + pub width: f64, + pub elevation: f64, + pub thickness: f64, + /// Radians. + pub rotation: f64, +} + /// Which part of a multileader MLEADER places first. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum LeaderOrder { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f84b35d7..d1fa2b03d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -92,6 +92,12 @@ pub struct Session { pub last_command: Option, /// LASTPOINT. pub last_point: Vec2, + /// AREA and PERIMETER: the last area AREA or MEASUREGEOM measured (in their Add and Subtract + /// modes the running total) and that area's perimeter. Not saved, as in AutoCAD. + pub area: f64, + pub perimeter: f64, + /// DISTANCE: the last distance DIST or MEASUREGEOM measured (with Multiple points, the total). + pub distance: f64, /// The last segment given at a point prompt (its base point and the point): polar angles /// relative to the last segment (POLARMODE bit 1) are measured from it. pub last_segment: Option<(Vec2, Vec2)>, @@ -173,6 +179,9 @@ impl Session { log: Vec::new(), last_command: None, last_point: Vec2::ZERO, + area: 0.0, + perimeter: 0.0, + distance: 0.0, last_segment: None, last_polar: None, point_mods: Vec::new(), diff --git a/crates/engine/src/lisp.rs b/crates/engine/src/lisp.rs index b3175c512..977d4f551 100644 --- a/crates/engine/src/lisp.rs +++ b/crates/engine/src/lisp.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use cadcraft_doc::{Drawing, Handle}; -use crate::{Input, Prompt, Session}; +use crate::{Input, Prompt, Session, undoctl}; use eval::{Ctx, Machine, Need, Outcome}; use value::{Sym, Value}; @@ -111,6 +111,8 @@ pub struct LispState { run: Option>, /// A routine runs, from its start to its end (also while its machine is out of `run`). active: bool, + /// The running routine's own undo steps (also while its machine is out of `run`). + steps: RoutineSteps, /// Files loaded by APPLOAD, by name, for `(load name)` where there is no file system. files: HashMap, /// Where startup files are read from (see `startup.rs`). @@ -123,6 +125,19 @@ pub struct LispState { pub(crate) limits: Limits, } +/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing +/// was before it, last last. The routine is still one undo step once it ends. +#[derive(Default)] +struct RoutineSteps { + /// The drawing the routine runs in. + uid: u64, + undo: std::collections::VecDeque, + redo: std::collections::VecDeque, + /// Where the routine's undo step starts, when U or REDO inside it went on to the drawing's + /// own steps; else where the routine started. + base: Option<(Arc, Vec)>, +} + /// What a run may use (see [`value`]'s memory notes). #[derive(Clone, Copy, Debug)] pub(crate) struct Limits { @@ -247,6 +262,7 @@ impl Session { let st = self.state()?; let before = Some((st.doc.clone(), st.selection.clone())); let uid = st.uid; + self.lisp.steps = RoutineSteps { uid, ..RoutineSteps::default() }; self.lisp.run = Some(Box::new(Run { machine, uid, @@ -324,7 +340,7 @@ impl Session { Ok(o) => o, Err(p) => { // A panic inside a built-in: put the drawing back as the routine found it. - if let Some((doc, sel)) = &run.before + if let Some((doc, sel)) = self.lisp.steps.base.as_ref().or(run.before.as_ref()) && let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid) { st.doc = doc.clone(); @@ -422,6 +438,7 @@ impl Session { fn lisp_end(&mut self, result: std::result::Result) { let Some(run) = self.lisp.run.take() else { return }; self.lisp.active = false; + let steps = std::mem::take(&mut self.lisp.steps); let mut env = self.lisp.envs.remove(&run.uid).unwrap_or_default(); self.lisp_flush(&mut env, true); self.lisp.envs.insert(run.uid, env); @@ -431,7 +448,7 @@ impl Session { self.echo(text); } } - if let Some((doc, sel)) = run.before + if let Some((doc, sel)) = steps.base.or(run.before) && let Some(st) = self.docs.iter_mut().find(|d| d.uid == run.uid) && !Arc::ptr_eq(&doc, &st.doc) { @@ -489,6 +506,7 @@ impl Session { self.lisp_error(LispError::quit(msg)); } else if self.lisp.active { self.lisp.active = false; + self.lisp.steps = RoutineSteps::default(); self.echo(format!("; error: {msg}")); self.lisp_last = Some(Err(msg)); } else { @@ -577,6 +595,67 @@ impl Session { self.lisp.active } + /// A command the running routine ran changed the drawing (`snap` holds it as it was): U inside + /// the routine takes it back, as AutoCAD's does, while the routine stays one undo step. UNDO + /// Control One keeps only the last, None none. + pub(crate) fn lisp_record_step(&mut self, snap: crate::Snapshot) { + let Some(uid) = self.routine_steps() else { return }; + let mut control = undoctl::Control::All; + // A command ends what REDO could bring back, the drawing's own steps too. + if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) { + st.redo.clear(); + control = st.undo_ctl.control; + } + let steps = &mut self.lisp.steps; + steps.redo.clear(); + match control { + undoctl::Control::None => steps.undo.clear(), + undoctl::Control::One => { + steps.undo.clear(); + steps.undo.push_back(snap); + } + // As many as the drawing keeps. + undoctl::Control::All => { + steps.undo.push_back(snap); + if steps.undo.len() > undoctl::MAX_UNDO { + steps.undo.pop_front(); + } + } + } + } + + /// U (or with `redo`, REDO) inside the running routine: take its last command back (or bring + /// back what U took). None when it has none left, and the drawing's own steps go on from there. + pub(crate) fn lisp_take_back(&mut self, redo: bool) -> Option { + let uid = self.routine_steps()?; + let st = self.docs.iter_mut().find(|d| d.uid == uid)?; + let steps = &mut self.lisp.steps; + let (from, to) = if redo { (&mut steps.redo, &mut steps.undo) } else { (&mut steps.undo, &mut steps.redo) }; + let cur = st.swap_in(from.pop_back()?); + let label = cur.label.clone(); + to.push_back(cur); + Some(label) + } + + /// U or REDO inside the running routine went on to the drawing's steps before it: the + /// routine's own undo step starts from the drawing as that left it, and REDO brings back that + /// step before what U took back of the routine's. + pub(crate) fn lisp_rebase(&mut self) { + if self.routine_steps().is_none() { + return; + } + if let Ok(st) = self.state() { + self.lisp.steps.base = Some((st.doc.clone(), st.selection.clone())); + self.lisp.steps.redo.clear(); + } + } + + /// The active drawing's uid when a routine runs in it. + fn routine_steps(&self) -> Option { + let uid = self.state().ok()?.uid; + (self.lisp.active && self.lisp.steps.uid == uid).then_some(uid) + } + /// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted. pub fn lisp_commands(&self) -> Vec { let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() }; diff --git a/crates/engine/src/lisp/builtins/strings.rs b/crates/engine/src/lisp/builtins/strings.rs index 46d985bf1..853e0656f 100644 --- a/crates/engine/src/lisp/builtins/strings.rs +++ b/crates/engine/src/lisp/builtins/strings.rs @@ -60,20 +60,43 @@ pub(super) fn atoi(a: &[Value]) -> R { Ok(Value::Int(t.get(..end).and_then(|n| n.parse::().ok()).map(|n| n as i32).unwrap_or(0))) } -/// C's `atof`: the longest number at the start; 0.0 when there is none. +/// C's `atof`: the number token at the start; 0.0 when there is none. +/// An overflowing token clamps to the largest finite value of its sign. pub(super) fn atof(a: &[Value]) -> R { arity(a, 1, 1)?; let s = string(&a[0])?; - // The longest prefix (of the first 64 characters) that reads as a finite number. - let t: String = s.trim_start().chars().take(64).collect(); - let ends: Vec = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect(); - let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e]))); - Ok(Value::Real(v.unwrap_or(0.0))) -} - -/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity"). -fn p_is_word(p: &str) -> bool { - p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E')) + let b = s.trim_start().as_bytes(); + let digits = |mut i: usize| { + while b.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + i + }; + let mut i = usize::from(matches!(b.first(), Some(b'-' | b'+'))); + let int_end = digits(i); + let mut end = int_end; + let mut mantissa = int_end > i; + if b.get(end) == Some(&b'.') { + let frac_end = digits(end + 1); + if mantissa || frac_end > end + 1 { + mantissa = true; + end = frac_end; + } + } + if mantissa && matches!(b.get(end), Some(b'e' | b'E')) { + i = end + 1 + usize::from(matches!(b.get(end + 1), Some(b'-' | b'+'))); + let exp_end = digits(i); + if exp_end > i { + end = exp_end; + } + } + let v = if mantissa { b.get(..end).and_then(|t| std::str::from_utf8(t).ok()).and_then(|t| t.parse::().ok()) } else { None }; + let v = match v { + Some(v) if v.is_finite() => v, + Some(v) if v.is_infinite() => f64::MAX.copysign(v), + _ => 0.0, + }; + Ok(Value::Real(v)) } pub(super) fn chr(a: &[Value]) -> R { diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index 31e78bd15..0ed4b22fd 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -405,7 +405,7 @@ fn entity_lists_hold_angles_in_radians() { // MTEXT: 50 before the column type (75) is the rotation, after it a column height. ok( &mut s, - "(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 30.0) '(50 . 20.0)))", + "(entmake (list '(0 . \"MTEXT\") '(10 0.0 0.0 0.0) '(40 . 2.5) '(41 . 10.0) '(1 . \"abc\") (cons 50 (/ pi 3)) '(75 . 2) '(76 . 2) '(79 . 0) '(48 . 10.0) '(49 . 1.0) '(50 . 2.0) '(50 . 30.0) '(50 . 20.0)))", ); let mtext = s.doc().unwrap().model.iter().find_map(|e| match &e.kind { cadcraft_doc::EntityKind::MText(t) => Some((t.rotation, t.columns.as_ref().map(|c| c.heights.clone()))), @@ -1039,3 +1039,12 @@ fn a_lisp_value_at_a_prompt_is_not_evaluated_again() { }); assert_eq!(r, Some(4.0)); } + +#[test] +fn atof_out_of_range_exponent_reads_whole_token() { + let mut s = Session::new(); + // Overflow must not fall back to a shorter prefix such as "1e40". + assert_eq!(ok(&mut s, "(> (atof \"1e400\") 1e300)"), "T"); + assert_eq!(ok(&mut s, "(< (atof \"-1e400\") -1e300)"), "T"); + assert_eq!(ok(&mut s, "(= (atof \"1e-400\") 0.0)"), "T"); +} diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 1563be702..e3498dcf0 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -26,6 +26,41 @@ impl Accept { pub const SELECT: Accept = Accept { point: true, number: false, text: false, select: true, enter: true }; } +/// Prompts that ask for one of their keywords, which AutoCAD 2024 writes without "or" before them: +/// "Enter an option [?/Make/Set/…]:", "Enter layout option [Copy/Delete/…/?] :", "New color +/// [Truecolor/COlorbook] :". A prompt asking for something else besides its keywords has +/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from +/// AutoCAD's command line over measured runs, where no prompt was written both ways. +const NO_OR: [&str; 27] = [ + "Bring to front", + "Calculate rectangle dimensions based on", + "Choose mode for selected object conversion", + "Create annotative block", + "Enter a configuration option", + "Enter a plotting preference", + "Enter an annotation option", + "Enter an option", + "Enter an option to change", + "Enter attribute visibility setting", + "Enter justification", + "Enter layout option", + "Enter leader format option", + "Enter line spacing type", + "Enter object ordering option", + "Enter polyline linetype generation option", + "Enter property to change", + "Enter the type of array", + "Enter type of unused objects to purge", + "Enter viewport arrangement", + "Explode block", + "New color", + "Orient relative to sheet in paper space viewports", + "Select a content type", + "Select a leader type", + "Use landing", + "Viewport View Locking", +]; + /// A prompt shown on the command line. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -148,15 +183,7 @@ impl Prompt { } let mut s = self.message.clone(); if !self.keywords.is_empty() { - // A question offers its answers ("Fix any errors detected? [Yes/No]"); anything else - // offers options besides what it asks for ("Specify next point or [Undo]"). - s.push_str(if self.message.is_empty() { - "[" - } else if self.message.ends_with('?') { - " [" - } else { - " or [" - }); + s.push_str(self.keyword_lead()); s.push_str(&self.keywords.join("/")); s.push(']'); } @@ -166,6 +193,20 @@ impl Prompt { s.push(':'); s } + /// What goes between the message and its keywords. A question offers its answers ("Fix any + /// errors detected? [Yes/No]"), and so does a prompt for an option ([`NO_OR`]): " [". Anything + /// else offers options besides what it asks for ("Specify next point or [Undo]"): " or [". With + /// no message, "[". The command line in the window draws the keywords itself and takes this + /// too. + pub fn keyword_lead(&self) -> &'static str { + if self.message.is_empty() { + "[" + } else if self.message.ends_with('?') || NO_OR.contains(&self.message.as_str()) { + " [" + } else { + " or [" + } + } /// Match typed text against keywords: full word or the capitalised shortcut, case-insensitive. pub fn match_keyword(&self, text: &str) -> Option { let t = text.trim(); @@ -338,6 +379,13 @@ mod tests { assert_eq!(p.display(), "Specify next point or [Undo/Close]:"); let q = Prompt::new("Erase polyline?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); assert_eq!(q.display(), "Erase polyline? [Yes/No] :"); + // A prompt for an option has no "or", as AutoCAD writes it. + let o = Prompt::new("Enter an option", Accept::TEXT).kw(&["Close", "Join"]); + assert_eq!(o.display(), "Enter an option [Close/Join]:"); + let c = Prompt::new("New color", Accept::TEXT).kw(&["Truecolor", "COlorbook"]).default("BYLAYER"); + assert_eq!(c.display(), "New color [Truecolor/COlorbook] :"); + let b = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]); + assert_eq!(b.display(), "Enter block name or [?]:"); assert_eq!(p.match_keyword("u").as_deref(), Some("Undo")); assert_eq!(p.match_keyword("CL").as_deref(), Some("Close")); assert_eq!(p.match_keyword("x"), None); diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 85a222f64..4e615a4f2 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -32,6 +32,7 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }) } @@ -182,6 +183,7 @@ pub fn bracket() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ); add( @@ -203,6 +205,7 @@ pub fn bracket() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ); diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 6c70bee37..6721cdad8 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -29,7 +29,7 @@ pub fn hit_polylines(d: &Drawing, e: &Entity, tol: f64) -> Vec> { let list = cadcraft_render::build_entities( d, std::iter::once(e), - &cadcraft_render::Options { tolerance: tol, text: false, ..Default::default() }, + &cadcraft_render::Options { tolerance: tol, text: false, linetypes: false, ..Default::default() }, ); v = list.prims.iter().map(|p| list.points(p).to_vec()).collect(); v.push(vec![c[0], c[1], c[2], c[3], c[0]]); @@ -117,11 +117,15 @@ fn in_text(d: &Drawing, e: &Entity, p: Vec2) -> bool { // A dimension with a block (as AutoCAD saves them) is drawn from that block: its text is // the text there, not the text the dimension's own geometry would place. EntityKind::Dimension(dm) => match dm.block.as_ref().and_then(|n| d.block(n)) { - Some(blk) => blk.entities.iter().any(|be| match &be.kind { - EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p), - EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p), - _ => false, - }), + // The block draws translated by the insertion point (group 12): test there (#2634). + Some(blk) => { + let p = p - dm.insertion_point.xy(); + blk.entities.iter().any(|be| match &be.kind { + EntityKind::MText(t) => in_box(cadcraft_render::layout_mtext_entity(d, t).strokes.into_iter().flatten(), t.rotation, p), + EntityKind::Text(_) => entity_bounds(d, be, 0).contains(p), + _ => false, + }) + } None => { let g = cadcraft_render::dimension_in(d, dm); in_box(g.text.iter().flatten().chain(g.text_glyphs.iter().flatten().flatten()).copied(), g.text_angle, p) @@ -502,6 +506,56 @@ mod tests { // Only one within reach: that one, whatever the order. assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line)); } + + /// A block holding a diagonal dashed line, inserted at `scale`. + fn dashed_block(length: f64, scale: f64) -> (Drawing, Handle) { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + let mut line = + Entity::new(Handle(1), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(length, length, 0.0) })); + line.common.linetype = "DASHED".into(); + let mut blk = cadcraft_doc::Block::new("B"); + blk.entities.push(line); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let insert = cadcraft_doc::Insert { + block: "B".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(scale, scale, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); + (d, h) + } + + /// Every mouse move over a block hit-tests what it draws. Two blocks with a fine pattern on + /// long lines (inserted at 0.001) made 5.8 million dashes, and half a second, per move (#1285). + #[test] + fn hit_testing_a_block_does_not_lay_out_its_dashes() { + let (d, h) = dashed_block(20_000.0, 0.001); + let e = d.entity(h).unwrap(); + let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() }); + assert!(drawn.prims.len() > 10_000, "the pattern draws {} dashes", drawn.prims.len()); + // The line, and the block's bounding rectangle. + assert_eq!(hit_polylines(&d, e, 0.01).len(), 2); + } + + /// A dashed line in a block is hit across its gaps, as one outside a block is. + #[test] + fn a_dashed_line_in_a_block_is_picked_in_a_gap() { + let (d, h) = dashed_block(100.0, 1.0); + let e = d.entity(h).unwrap(); + let drawn = cadcraft_render::build_entities(&d, std::iter::once(e.as_ref()), &cadcraft_render::Options { text: false, ..Default::default() }); + let (a, b) = (drawn.prims.first().unwrap(), drawn.prims.get(1).unwrap()); + let (end, start) = (*drawn.points(a).last().unwrap(), *drawn.points(b).first().unwrap()); + assert!(end.dist(start) > 0.01, "a gap between the first two dashes"); + let gap = end + (start - end) * 0.5; + assert_eq!(pick(&d, &Space::Model, gap, end.dist(start) / 8.0), Some(h)); + } } #[cfg(test)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d1ae67ee1..f465b7a55 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -13,3 +13,5 @@ mod files; mod interactive; mod picking; mod zoom; + +pub(crate) use picking::group_10; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 7b0547404..230422e09 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -4,7 +4,27 @@ impl Session { // ---------------- programmatic execution ---------------- /// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes. + /// + /// The command itself is guarded below, where its own panic can be told apart. This guard is + /// for everything around it: the passes after a command (constraints, associativity, groups) + /// are as able to panic as the command, and a panic there restores the drawing the same way. pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid)); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute_guarded(id, params))) { + Ok(r) => r, + Err(p) => { + if let Some((doc, sel, uid)) = before + && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) + { + st.doc = doc; + st.selection = sel; + } + Err(EngineError::Internal(id.into(), crate::guard::message(&*p))) + } + } + } + + fn execute_guarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; (spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?; if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) { @@ -46,17 +66,25 @@ impl Session { (r, _) => r, }; let grouped = self.lisp_groups_undo(); + let mut in_routine = None; if spec.undoable && let Some((doc, sel, uid)) = before && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&doc, &st.doc) { - // Inside a LISP routine the routine's single undo step records it. - if !grouped { - st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel }); + let snap = Snapshot { label: spec.label.to_string(), doc, selection: sel }; + // Inside a LISP routine the routine's single undo step records it, and the routine + // keeps it for U inside it. + if grouped { + in_routine = Some(snap); + } else { + st.push_undo(snap); } st.revision += 1; } + if let Some(snap) = in_routine { + self.lisp_record_step(snap); + } result } @@ -69,27 +97,32 @@ impl Session { // ---------------- undo ---------------- + /// Take the last step back. Inside a LISP routine, the routine's last command first; past + /// those, the drawing's step before the routine, which then starts from there. pub fn undo(&mut self) -> Result> { + if let Some(label) = self.lisp_take_back(false) { + return Ok(Some(label)); + } let st = self.state_mut()?; let Some(snap) = st.undo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); + let cur = st.swap_in(snap); + let label = cur.label.clone(); st.redo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) + self.lisp_rebase(); + Ok(Some(label)) } + /// Bring back what the last undo took back; inside a LISP routine, what U inside it took first. pub fn redo(&mut self) -> Result> { + if let Some(label) = self.lisp_take_back(true) { + return Ok(Some(label)); + } let st = self.state_mut()?; let Some(snap) = st.redo.pop() else { return Ok(None) }; - let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; - st.doc = snap.doc; - st.follow_layout(&cur.doc); - st.selection = Vec::new(); + let cur = st.swap_in(snap); + let label = cur.label.clone(); st.undo.push(cur); - st.revision += 1; - Ok(Some(snap.label)) + self.lisp_rebase(); + Ok(Some(label)) } } diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs index 13d944d63..aa66bd4c5 100644 --- a/crates/engine/src/session/interactive.rs +++ b/crates/engine/src/session/interactive.rs @@ -341,7 +341,21 @@ impl Session { } } + /// End a command: every way out of one comes through here. The passes after a command + /// (constraints, associativity, groups) can panic like the command itself; a panic restores + /// the drawing as it was before the command, as a panic in its `input` does. fn finish(&mut self, run: Running, cancelled: bool) { + let (id, before, selection) = (run.id.clone(), run.before.clone(), run.selection_before.clone()); + if let Err(p) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.finish_guarded(run, cancelled))) { + if let Ok(st) = self.state_mut() { + st.doc = before; + st.selection = selection; + } + self.echo(format!("Internal error in {id}: {}", crate::guard::message(&*p))); + } + } + + fn finish_guarded(&mut self, run: Running, cancelled: bool) { // A typed selection mode or SIngle never outlives its command. self.select_mode = None; self.select_single = false; @@ -365,12 +379,16 @@ impl Session { } self.after_command(Some(&run.before), None, true); // A transparent command's changes belong to the command it ran inside, and a command a - // LISP routine ran belongs to the routine's single undo step. + // LISP routine ran belongs to the routine's single undo step (the routine keeps it for U + // inside it). if self.suspended.is_some() || self.lisp_groups_undo() { if let Ok(st) = self.state_mut() && !Arc::ptr_eq(&run.before, &st.doc) { st.revision += 1; + if self.suspended.is_none() { + self.lisp_record_step(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before }); + } } return; } @@ -391,6 +409,8 @@ impl Session { /// started in, so associativity is not applied across a document switch; `ok` is false for a /// failed or non-undoable programmatic command, which skips the constraint pass. pub(super) fn after_command(&mut self, before: Option<&Arc>, uid: Option, ok: bool) { + #[cfg(test)] + crate::guard::tests::panic_if_armed(); cmd::groups::after_command(self, before); cmd::constraints::after_command(self, before, ok); assoc::after_command(self, before, uid); diff --git a/crates/engine/src/session/picking.rs b/crates/engine/src/session/picking.rs index a9d146571..1ab773a00 100644 --- a/crates/engine/src/session/picking.rs +++ b/crates/engine/src/session/picking.rs @@ -162,7 +162,7 @@ impl Session { /// An object's DXF group 10 point: the first point of its entity list, or its first grip where /// entity lists are not built in. -fn group_10(d: &Drawing, e: &Entity) -> Option { +pub(crate) fn group_10(d: &Drawing, e: &Entity) -> Option { let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() }; let (_, tags) = (codec.groups)(d, e); let i = tags.iter().position(|t| t.code == 10)?; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 32507162f..736cec91c 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -127,6 +127,9 @@ pub struct Settings { pub polardist: f64, /// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0). pub auditctl: bool, + /// ATTREQ: 0 inserts blocks with their attributes' default values instead of asking for them + /// (default: 1). + pub attreq: bool, } impl Default for Settings { @@ -191,6 +194,7 @@ impl Default for Settings { polarsnap: false, polardist: 0.0, auditctl: false, + attreq: true, } } } diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 8def404d2..e2e24d9bc 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -813,22 +813,13 @@ fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool { b.expand(aperture).intersects(probe) } -/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None` -/// when it distorts shapes (non-uniform scale or shear). -fn similarity_scale(m: &Mat3) -> Option { - let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d)); - let (lu, lv) = (u.len(), v.len()); - let tol = 1e-9 * lu.max(lv); - ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) -} - /// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale /// become ellipses and elliptical arcs. fn xform_prim(p: &Prim, m: &Mat3) -> Prim { let mirror = m.is_mirroring(); match p { Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))), - Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) { + Prim::Seg(Segment::Arc { arc, ccw }) => match m.similarity_scale() { Some(s) => { let c = m.apply(arc.center); let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point()))); @@ -837,14 +828,14 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim { } None => { let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end }; - Prim::Ellipse(xform_ellipse(&e, m)) + Prim::Ellipse(e.transformed(m)) } }, - Prim::Circle(c) => match similarity_scale(m) { + Prim::Circle(c) => match m.similarity_scale() { Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)), - None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)), + None => Prim::Ellipse(Ellipse::full(c.center, Vec2::X * c.radius, 1.0).transformed(m)), }, - Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)), + Prim::Ellipse(e) => Prim::Ellipse(e.transformed(m)), Prim::Spline(s) => { let mut s = s.clone(); s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q)); @@ -856,24 +847,6 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim { } } -/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate -/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them -/// (and reverse under a mirroring transform). -fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse { - let center = m.apply(e.center); - let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor())); - // |am·cos t + an·sin t| is largest at t0: the new major axis. - let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); - let (s0, c0) = t0.sin_cos(); - let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); - let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; - if e.is_full() { - return Ellipse::full(center, u, ratio); - } - let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) }; - Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) } -} - /// The nearest deferred tangent/perpendicular within `aperture` of `cursor`. fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option) -> Option { let mut best: Option<(f64, SnapHit)> = None; diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index ca15bd4c4..a62537262 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -15,7 +15,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; -use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds}; +use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached}; use cadcraft_geom::Bounds2; use rstar::primitives::{GeomWithData, Rectangle}; use rstar::{AABB, RTree}; @@ -44,6 +44,8 @@ struct ChunkIx { impl ChunkIx { fn build(d: &Drawing, key: usize, items: &[Arc]) -> ChunkIx { let mut infinite = Vec::new(); + // One block-bounds cache for the chunk: references to the same block measure it once. + let mut cache = BlockBoundsCache::default(); let bounds = items .iter() .enumerate() @@ -51,7 +53,7 @@ impl ChunkIx { if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) { infinite.push(u32::try_from(i).unwrap_or(u32::MAX)); } - entity_bounds(d, e, 0) + entity_bounds_cached(d, e, 0, &mut cache) }) .collect(); let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -94,6 +96,28 @@ fn hidden_layers(d: &Drawing) -> impl Iterator { d.layers.iter().filter(|l| !l.visible()).map(|l| l.name.as_str()) } +/// A copy of `tree` with `removed` taken out and `inserted` added, or `None` when the caller must +/// bulk-load instead. rstar 0.13 can panic ("This is a bug in rstar.") when inserting after removals +/// in a bulk-loaded tree (#2468), and picking is outside the command guard, so that panic would end +/// the app. The web build can't catch a panic, so it always bulk-loads. +fn patch_tree(tree: &RTree, removed: impl Iterator, inserted: impl Iterator) -> Option> { + if cfg!(target_arch = "wasm32") { + return None; + } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut tree = tree.clone(); + for it in removed { + tree.remove(&it); + } + for it in inserted { + tree.insert(it); + } + tree + })) + .map_err(|_| log::warn!("spatial index: rstar failed to patch the tree; rebuilding it")) + .ok() +} + impl SpatialIndex { /// Build an index over a store (no caching). pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex { @@ -127,23 +151,11 @@ impl SpatialIndex { offset = offset.saturating_add(n); } // Patch the previous tree when most of it survives; otherwise bulk-load. - let tree = match prev { - Some(p) if kept.len() * 2 >= chunks.len().max(1) => { - let mut tree = p.tree.clone(); - for (_, c) in p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)) { - for it in c.items() { - tree.remove(&it); - } - } - for c in &fresh { - for it in c.items() { - tree.insert(it); - } - } - tree - } - _ => RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect()), - }; + let patched = prev.filter(|_| kept.len() * 2 >= chunks.len().max(1)).and_then(|p| { + let removed = p.chunks.iter().filter(|(_, c)| !kept.contains(&c.serial)).flat_map(|(_, c)| c.items()); + patch_tree(&p.tree, removed, fresh.iter().flat_map(|c| c.items())) + }); + let tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flat_map(|(_, c)| c.items()).collect())); let offsets = chunks.iter().map(|(off, c)| (c.serial, *off)).collect(); let infinite = chunks.iter().flat_map(|(off, c)| c.infinite.iter().map(move |i| off + i)).collect(); SpatialIndex { @@ -190,7 +202,7 @@ impl SpatialIndex { let (c, l) = self.locate(i)?; c.ents.get(l) } - /// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them). + /// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them). pub fn bounds(&self, i: u32) -> Bounds2 { self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY) } @@ -367,6 +379,40 @@ mod tests { Some(Arc::new(SpatialIndex::build(d, &d.model))) } + /// The edits of #2468, on rstar alone: its insert panics for seed 8. A failed patch falls back + /// to a bulk load, and every tree holds exactly the live items. + #[test] + fn rstar_patch_failures_fall_back_to_a_rebuild() { + fn item(r: &mut Rng, s: u32, i: u32) -> Item { + let (x, y, w, h) = (r.f() * 1000.0, r.f() * 1000.0, r.f() * 50.0, r.f() * 50.0); + GeomWithData::new(Rectangle::from_corners([x, y], [x + w, y + h]), (s, i)) + } + for seed in 1..=10u64 { + let mut r = Rng(seed * 7919); + let n_chunks = 4 + (r.f() * 20.0) as u32; + let mut chunks: Vec> = (0..n_chunks) + .map(|s| { + let n = 1 + (r.f() * 60.0) as u32; + (0..n).map(|i| item(&mut r, s, i)).collect() + }) + .collect(); + let mut tree = RTree::bulk_load(chunks.iter().flatten().cloned().collect()); + for serial in (n_chunks..).take(300) { + let c = (r.f() * chunks.len() as f64) as usize; + let n = (chunks[c].len() as i64 + if r.f() < 0.5 { -1 } else { 1 }).max(0) as u32; + let fresh: Vec = (0..n).map(|i| item(&mut r, serial, i)).collect(); + let patched = patch_tree(&tree, chunks[c].iter().cloned(), fresh.iter().cloned()); + chunks[c] = fresh; + tree = patched.unwrap_or_else(|| RTree::bulk_load(chunks.iter().flatten().cloned().collect())); + let mut held: Vec<(u32, u32)> = tree.iter().map(|it| it.data).collect(); + let mut live: Vec<(u32, u32)> = chunks.iter().flatten().map(|it| it.data).collect(); + held.sort_unstable(); + live.sort_unstable(); + assert_eq!(held, live, "seed {seed}, serial {serial}"); + } + } + } + #[test] fn pick_parity_with_scan() { for seed in [3, 11, 97] { diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 5c0f3951c..fcecbd2cf 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -8,6 +8,7 @@ mod table; const SESSION_VARS: &[&str] = &[ "AUDITCTL", + "ATTREQ", "OSMODE", "OSNAPHATCH", "ANNOAUTOSCALE", @@ -66,10 +67,14 @@ const SESSION_VARS: &[&str] = &[ "ZOOMFACTOR", "MEASUREINIT", "LASTPOINT", + "AREA", + "PERIMETER", + "DISTANCE", "FONTALT", "FONTFALLBACK", "SAVETIME", "SAVEFILEPATH", + "ISAVEBAK", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -143,6 +148,9 @@ pub fn get(s: &Session, name: &str) -> Option { "ZOOMFACTOR" => json!(st.zoomfactor), "MEASUREINIT" => json!(i32::from(st.measureinit)), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), + "AREA" => json!(s.area), + "PERIMETER" => json!(s.perimeter), + "DISTANCE" => json!(s.distance), // Held in radians (`$ANGBASE` in DXF is in degrees); reported in degrees, like POLARANG. "ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()), "CONSTRAINTINFER" => json!(i32::from(s.doc().ok()?.parametric.settings.infer)), @@ -154,6 +162,7 @@ pub fn get(s: &Session, name: &str) -> Option { // Autosave (profile settings too). "SAVETIME" => json!(crate::autosave::savetime()), "SAVEFILEPATH" => json!(crate::autosave::savefilepath()), + "ISAVEBAK" => json!(i32::from(crate::autosave::isavebak())), "CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()), "DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()), "DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)), @@ -246,6 +255,9 @@ const READ_ONLY: &[&str] = &[ "BLOCKEDITOR", "CTAB", "LASTPOINT", + "AREA", + "PERIMETER", + "DISTANCE", "VIEWCTR", "VIEWSIZE", "EXTMIN", @@ -279,7 +291,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") }; let st = &mut s.settings; match n.as_str() { - "OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff, + "OSMODE" => { + st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x| *x <= 0x7fff).ok_or_else(bad)? as u32 + } // The `color` command's input: ByLayer, ByBlock, a name, an index or a true colour. "CECOLOR" => crate::cmd::set_current_color_value(s, v).ok_or_else(bad)?, "OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?, @@ -366,6 +380,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32, ), "SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?), + "ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?), // A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line). "ANGBASE" => { let a = match v { @@ -421,7 +436,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { // Keep the header type stable for known numeric vars. match d.header.get(&n) { Some(cadcraft_doc::HVal::Real(_)) => { - d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?); + d.header.set_f64(&n, val.as_f64().filter(|f| f.is_finite()).ok_or_else(bad)?); return Ok(()); } Some(cadcraft_doc::HVal::Int(_)) => { @@ -458,11 +473,6 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { pub fn list(s: &Session) -> Value { let mut m = serde_json::Map::new(); - for n in SESSION_VARS { - if let Some(v) = get(s, n) { - m.insert((*n).to_string(), v); - } - } if let Ok(d) = s.doc() { for (k, v) in &d.header.vars { if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT" | "CECOLOR") { @@ -472,6 +482,13 @@ pub fn list(s: &Session) -> Value { } } } + // The session's values last: a drawing's header can carry the same names (ORTHOMODE, + // LWDISPLAY, an AREA), and `get` reads the session's. + for n in SESSION_VARS { + if let Some(v) = get(s, n) { + m.insert((*n).to_string(), v); + } + } table::list(s, &mut m); Value::Object(m) } diff --git a/crates/engine/src/sysvars/table.rs b/crates/engine/src/sysvars/table.rs index fa319d3f1..910068f5c 100644 --- a/crates/engine/src/sysvars/table.rs +++ b/crates/engine/src/sysvars/table.rs @@ -81,8 +81,11 @@ const LINEWEIGHTS: [i64; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, /// On/off session settings: name, read, write. type Toggle = (&'static str, fn(&Settings) -> bool, fn(&mut Settings, bool)); -const SETTINGS: &[Toggle] = - &[("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v)]; +const SETTINGS: &[Toggle] = &[ + ("AUDITCTL", |s| s.auditctl, |s, v| s.auditctl = v), + ("ATTREQ", |s| s.attreq, |s, v| s.attreq = v), + ("TRANSPARENCYDISPLAY", |s| s.transparency_display, |s, v| s.transparency_display = v), +]; fn header_var(name: &str) -> Option<&'static (&'static str, Kind, Def)> { HEADER.iter().find(|(n, _, _)| *n == name) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 427bfe8d4..fec3fbb18 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -653,6 +653,18 @@ fn setvar_rejects_out_of_range_units() { assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9); } +#[test] +fn setvar_rejects_out_of_range_osmode() { + let mut s = Session::new(); + s.execute("setvar", &json!({"name": "OSMODE", "value": 4133})).unwrap(); + for v in [4294967296u64, 65536, 32768] { + assert!(s.execute("setvar", &json!({"name": "OSMODE", "value": v})).is_err(), "{v}"); + assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(4133)), "{v}"); + } + s.execute("setvar", &json!({"name": "OSMODE", "value": 0x7fff})).unwrap(); + assert_eq!(sysvars::get(&s, "OSMODE"), Some(json!(0x7fff))); +} + #[test] fn dynamic_input_pointer_settings() { let mut s = Session::new(); @@ -700,25 +712,6 @@ fn cancel_keeps_partial_line_as_one_undo() { assert_eq!(s.doc().unwrap().model.len(), 0); } -#[test] -fn hostile_params_never_panic() { - let mut s = Session::new(); - for c in command_specs() { - for p in [ - json!(null), - json!([]), - json!("x"), - json!({"points": [[1e308, -1e308], ["a"]]}), - json!({"handles": ["zz", 99999]}), - json!({"radius": -1, "center": [0, 0]}), - json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), - json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}), - ] { - let _ = s.execute(c.id, &p); - } - } -} - #[test] fn every_interactive_command_starts_and_cancels() { for c in command_specs().iter().filter(|c| c.interactive.is_some()) { @@ -1484,3 +1477,16 @@ fn array_counts_reject_invalid_values() { } s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap(); } + +#[test] +fn setvar_rejects_non_finite_text_for_real_variables() { + let mut s = Session::new(); + for name in ["LTSCALE", "TEXTSIZE", "SNAPANG"] { + let before = s.execute("getvar", &json!({"name": name})).unwrap(); + for text in ["nan", "inf", "-inf"] { + let r = s.execute("setvar", &json!({"name": name, "value": text})); + assert!(r.is_err(), "SETVAR {name} {text} was accepted"); + assert_eq!(s.execute("getvar", &json!({"name": name})).unwrap(), before); + } + } +} diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 3c6f3456e..edfa24377 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -172,7 +172,16 @@ pub fn format_angle(a: f64, aunits: i64, auprec: i64) -> String { } else { ("S", "E", deg - 270.0) }; - format!("{ns} {} {ew}", format_dms(ang, prec)) + // A bearing shown as due north, south, east or west is that letter, as AutoCAD 2024 + // shows it ("E" for 0), except to whole degrees ("N 90d E"). + let dms = format_dms(ang, prec); + if prec > 0 && dms == format_dms(0.0, prec) { + ns.to_string() + } else if prec > 0 && dms == format_dms(90.0, prec) { + ew.to_string() + } else { + format!("{ns} {dms} {ew}") + } } _ => format!("{:.*}", prec, wrap_full_turn(deg, 360.0, prec)), } @@ -268,7 +277,8 @@ impl AngleSettings { } /// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare - /// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`. + /// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`; a + /// surveyor's bearing (`N45dE`, or `N`, `E`, `S`, `W`) is its angle counterclockwise from east. pub fn amount(&self, s: &str) -> Option { parse_units(s, self.aunits).map(|(a, _)| a) } @@ -279,20 +289,19 @@ impl AngleSettings { } /// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in - /// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from - /// +X counterclockwise whatever ANGBASE and ANGDIR say: `< Option { let t = s.trim(); if let Some(r) = t.strip_prefix("<<<") { return parse_units(r, self.aunits).map(|(a, _)| a); } if let Some(r) = t.strip_prefix("<<") { - return parse_units(r, 0).map(|(a, _)| a); - } - let (a, absolute) = parse_units(t, self.aunits)?; - if absolute { - return Some(a); + return parse_units(r, 0).filter(|(_, bearing)| !bearing).map(|(a, _)| a); } + let (a, _) = parse_units(t, self.aunits)?; let v = self.angbase + if self.clockwise { -a } else { a }; v.is_finite().then_some(v) } @@ -319,7 +328,8 @@ pub fn parse_angle(s: &str) -> Option { AngleSettings::default().direction(s) } -/// A typed angle in radians and whether it is absolute (a surveyor's bearing). +/// A typed angle in radians and whether it is a surveyor's bearing (`N45dE`, or `N`, `E`, `S`, +/// `W`), whose angle is its direction counterclockwise from east. fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { let t = s.trim().to_ascii_lowercase(); if t.is_empty() { @@ -330,7 +340,7 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { (num(r)?, false) } else if let Some(g) = t.strip_suffix('g') { ((num(g)? * 0.9).to_radians(), false) - } else if let Some(b) = bearing(&t) { + } else if let Some(b) = cardinal(&t).or_else(|| bearing(&t)) { (b, true) } else if let Some(v) = num(&t) { let a = match aunits { @@ -345,6 +355,18 @@ fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { v.0.is_finite().then_some(v) } +/// A compass point (`n`, `e`, `s`, `w`, lower case), a due bearing as surveyor's units show it, +/// as radians counterclockwise from +X. AutoCAD 2024 takes one in any angle format. +fn cardinal(t: &str) -> Option { + match t { + "e" => Some(0.0), + "n" => Some(PI / 2.0), + "w" => Some(PI), + "s" => Some(1.5 * PI), + _ => None, + } +} + /// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X. fn bearing(t: &str) -> Option { let north = t.starts_with('n'); @@ -542,6 +564,46 @@ mod tests { } } + #[test] + fn surveyors_units_read_a_due_bearing_typed_as_one_letter() { + // What surveyor's units show for a due bearing can be typed back, e.g. `@10 = std::sync::Mutex::new(()); + +fn folder_turn() -> std::sync::MutexGuard<'static, ()> { + FOLDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Run autosave once in `dir`, so `a` writes a file and takes its lock, and wait for the file. +fn autosave_once(a: &mut Autosaver, s: &Session, dir: &std::path::Path) -> String { + autosave::set_savefilepath(&dir.to_string_lossy()); + assert_eq!(a.save_now(s), 1); + for _ in 0..500 { + a.reap(); + if !a.writing() && !a.files().is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + a.files().pop().unwrap() +} + +#[test] +fn a_crashed_app_is_offered_at_once_and_a_running_one_never() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("instant"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("41-e"); + let file = autosave_once(&mut a, &s, &dir); + let lock = dir.join("41-e.lock"); + assert!(std::fs::metadata(&lock).is_ok()); + + // Running: not offered, even once its lock file looks old (a suspended laptop). + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now() - Duration::from_secs(3600)).ok(); + assert!(autosave::recoverable(&dir, "9-f").is_empty()); + autosave::prune_locks(&dir); + assert!(lock.exists(), "a running app's lock stays"); + + // Gone without closing, as in a crash: the file is fresh, and it is offered right away. + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now()).ok(); + drop(a); + let found = autosave::recoverable(&dir, "9-f"); + assert_eq!(found.iter().map(|r| r.path.as_str()).collect::>(), vec![file.as_str()]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("emergency"); + autosave::set_savefilepath(&dir.to_string_lossy()); + let before = autosave::savetime(); + autosave::set_savetime(0); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("43-e"); + assert_eq!(a.save_now(&s), 0, "an ordinary save honours SAVETIME 0"); + assert_eq!(a.emergency_save(&s), 1); + autosave::set_savetime(before); + let file = a.files().pop().unwrap(); + assert!(std::fs::read(&file).unwrap().ends_with(b"EOF\n"), "the file is whole on return"); + assert_eq!(a.emergency_save(&s), 0, "nothing changed since"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_new_autosave_folder_keeps_the_old_folder_locked() { + hooks(); + let _turn = folder_turn(); + let (old, new) = (temp_dir("moved-old"), temp_dir("moved-new")); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("44-e"); + autosave_once(&mut a, &s, &old); + autosave::set_savefilepath(&new.to_string_lossy()); + draw(&mut s); + assert_eq!(a.emergency_save(&s), 1); + assert!(new.join("44-e.lock").exists()); + assert!(autosave::recoverable(&old, "9-f").is_empty(), "this app is still running"); + a.shutdown(); + assert!(!old.join("44-e.lock").exists() && !new.join("44-e.lock").exists()); + let _ = std::fs::remove_dir_all(&old); + let _ = std::fs::remove_dir_all(&new); +} + +#[test] +fn a_normal_exit_removes_the_lock_file() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("exit"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("42-e"); + autosave_once(&mut a, &s, &dir); + assert!(dir.join("42-e.lock").exists()); + a.shutdown(); + assert!(!dir.join("42-e.lock").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_failed_autosave_is_tried_again_without_another_change() { hooks(); @@ -275,6 +386,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert!(rearmed, "a failed autosave must be tried again"); + assert!(a.failures().next().is_some_and(|(_, why)| why.contains("out"))); std::fs::remove_file(&out).unwrap(); let jobs = a.plan(&s, 180.0, 1, &dir); @@ -295,6 +407,36 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(a.next_due(1e4, 1), None); + assert_eq!(a.failures().next(), None, "a write that worked clears the failure"); + a.shutdown(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() { + hooks(); + let root = temp_dir("failed-close"); + let out = root.join("out"); + std::fs::write(&out, b"not a folder").unwrap(); + let dir = out.to_string_lossy().to_string(); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("32-e"); + a.plan(&s, 0.0, 1, &dir); + let jobs = a.plan(&s, 60.0, 1, &dir); + a.perform(jobs); + for _ in 0..500 { + a.reap(); + if a.failures().next().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(a.failures().next().is_some()); + let i = s.active; + s.execute("close", &json!({ "index": i })).unwrap(); + a.plan(&s, 120.0, 1, &dir); + assert_eq!(a.failures().next(), None); a.shutdown(); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index 0e4486ccb..a346fbba2 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -208,6 +208,7 @@ fn derived_dimension_fields_are_not_differences() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut s = Session::new(); s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim(30.0, 7.0))).unwrap(); diff --git a/crates/engine/tests/dash_wblock.rs b/crates/engine/tests/dash_wblock.rs new file mode 100644 index 000000000..52d456f42 --- /dev/null +++ b/crates/engine/tests/dash_wblock.rs @@ -0,0 +1,303 @@ +//! -WBLOCK at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, the +//! drawing read back afterwards). It writes objects from a base point and erases them, whatever +//! its mOde option says; a block by name, `=` (the block named as the file) or `*` (the whole +//! drawing); asks before replacing a file; and writes nothing when nothing is selected. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind, Handle}; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// One line per object written: a line's start (where the base point puts it), else its type. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut out = String::new(); + for e in d.model.iter() { + match &e.kind { + EntityKind::Line(l) => out.push_str(&format!("LINE {},{}\n", l.a.x, l.a.y)), + k => out.push_str(&format!("{}\n", k.dxf_name())), + } + } + Ok(out.into_bytes()) +} + +/// A session writing through [`write`], with the drawing the measured scripts start from: line A +/// (0,0)-(10,0), circle B at (5,5) radius 3, line C (0,20)-(10,20). +fn drawing() -> (Session, Handle, Handle, Handle) { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + let handle = |v: serde_json::Value| Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap(); + let a = handle(s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap()); + let b = handle(s.execute("circle", &json!({ "center": [5, 5], "radius": 3 })).unwrap()); + let c = handle(s.execute("line", &json!({ "points": [[0, 20], [10, 20]] })).unwrap()); + (s, a, b, c) +} + +/// A file in a test's own folder, with forward slashes for a LISP string. +fn file(dir: &TempDir, name: &str) -> String { + dir.path_str(name).replace('\\', "/") +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); +} + +fn ent(h: Handle) -> String { + format!("(handent \"{}\")", h.hex()) +} + +fn alive(s: &Session, h: Handle) -> bool { + s.doc().unwrap().entity(h).is_some() +} + +fn read(path: &str) -> Vec { + std::fs::read_to_string(path).unwrap().lines().map(str::to_string).collect() +} + +#[test] +fn objects_are_written_from_the_base_point_and_erased() { + // WBLOCK-000: A and B from (5,5); AutoCAD erases both and keeps C. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "WB00.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(5.0 5.0) {} {} \"\")", ent(a), ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(!alive(&s, a) && !alive(&s, b) && alive(&s, c)); + assert!(s.log.iter().any(|l| l == "Current settings: Object conversion=Delete"), "{:?}", s.log); +} + +#[test] +fn the_prompts_are_autocads() { + let (mut s, a, _, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.cmdline("-wblock").unwrap(); + assert!(s.prompt_text().ends_with("Enter name of output file:"), "{}", s.prompt_text()); + s.cmdline(&file(&dir, "P.dwg")).unwrap(); + assert!( + s.prompt_text().ends_with("Enter name of existing block or [= (block=output file)/* (whole drawing)] :"), + "{}", + s.prompt_text() + ); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + // WBLOCK-008: anything but a point or mOde there. + s.cmdline("").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Point or option keyword required.")); + s.cmdline("_O").unwrap(); + assert!(s.prompt_text().ends_with("[Convert to block/Retain/Delete] :"), "{}", s.prompt_text()); + s.cmdline("_R").unwrap(); + assert!(s.prompt_text().ends_with("Specify insertion base point or [mOde]:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Select objects:"), "{}", s.prompt_text()); + lisp(&mut s, &ent(a)); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn mode_is_asked_but_the_objects_are_erased_whatever_it_says() { + // WBM-01, WBM-02: Retain and Convert to block are taken, yet A is erased (no block reference + // takes its place), and the next -WBLOCK still says "Object conversion=Delete". + for mode in ["_R", "_C"] { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let first = file(&dir, "M01a.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{first}\" \"\" \"_O\" \"{mode}\" '(0.0 0.0) {} \"\")", ent(a))); + assert!(s.running.is_none(), "{mode}: {}", s.prompt_text()); + assert_eq!(read(&first), ["LINE 0,0"]); + assert!(!alive(&s, a) && alive(&s, b) && alive(&s, c), "{mode}"); + assert_eq!(s.doc().unwrap().model.len(), 2, "{mode}: nothing takes A's place"); + let before = s.log.len(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "M01b.dwg"), ent(b))); + assert!(s.log[before..].iter().any(|l| l == "Current settings: Object conversion=Delete"), "{mode}: {:?}", s.log); + assert!(!alive(&s, b) && alive(&s, c), "{mode}"); + } +} + +#[test] +fn nothing_selected_writes_nothing() { + // WBM-05: the command ends; no file, nothing erased. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M05.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" '(0.0 0.0) \"\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(!std::path::Path::new(&path).exists()); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn an_existing_file_is_replaced_only_on_yes() { + // WBM-06: No (the default) ends the command; WBM-07: Yes goes on to the block prompt. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + let path = file(&dir, "M06.dwg"); + std::fs::write(&path, "old").unwrap(); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.prompt_text().ends_with(&format!("{path} already exists, do you want to replace it? [Yes/No] :")), "{}", s.prompt_text()); + // Only Yes replaces it: other text is asked again. + s.cmdline("yodel").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + assert!(s.prompt_text().ends_with("[Yes/No] :"), "{}", s.prompt_text()); + s.cmdline("_N").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + // Enter is No too. + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "old"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"_Y\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(alive(&s, a) && !alive(&s, b)); +} + +#[test] +fn a_block_is_written_by_name_by_equals_or_the_whole_drawing_by_star() { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("block", &json!({ "name": "WB01K", "base": [5, 5], "handles": [a.hex(), b.hex()], "keep": "delete" })).unwrap(); + // WBLOCK-001: a block by name, from its base point; nothing is erased. + let path = file(&dir, "WB01.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"WB01K\")")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + assert!(alive(&s, c)); + // WBLOCK-002: "=" is the block named as the file. + let path = file(&dir, "WB01K.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"=\")")); + assert_eq!(read(&path), ["LINE -5,-5", "CIRCLE"]); + // WBLOCK-003: "*" is the whole drawing. + let path = file(&dir, "WB03.dwg"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"*\")")); + assert_eq!(read(&path), ["LINE 0,20"]); + assert!(alive(&s, c)); + // WBLOCK-005: a point given there is a name too. + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" '(0.0 0.0))", file(&dir, "WB05.dwg"))); + assert!(s.log.iter().any(|l| l == "Block \"0,0\" not found."), "{:?}", s.log); + s.cancel(); + // WBLOCK-008, WBLOCK-015: a name that isn't a block, typed or by "=", is refused and asked again. + for (answer, name) in [("WB08ZZ", "WB08ZZ"), ("=", "WB15")] { + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"{answer}\")", file(&dir, "WB15.dwg"))); + assert!(s.log.iter().any(|l| *l == format!("Block \"{name}\" not found.")), "{answer}: {:?}", s.log); + assert!(s.prompt_text().contains("Enter name of existing block"), "{}", s.prompt_text()); + s.cancel(); + } + assert!(!std::path::Path::new(&file(&dir, "WB15.dwg")).exists()); +} + +#[test] +fn a_name_without_extension_is_a_dwg_and_a_dxf_asks_for_its_precision() { + // WBLOCK-004: no extension. + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(0.0 0.0) {} \"\")", file(&dir, "WB04"), ent(a))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&file(&dir, "WB04.dwg")), ["LINE 0,0"]); + // WBLOCK-010: a DXF file asks for its decimal places first; Enter takes 16. + let path = file(&dir, "WB10.dxf"); + s.cmdline("-wblock").unwrap(); + s.cmdline(&path).unwrap(); + assert!(s.log.iter().any(|l| l.starts_with("Current DXF settings: Precision= 16 Format= ASCII")), "{:?}", s.log); + assert!(s.prompt_text().ends_with("Enter decimal places of accuracy (0 to 16) or [Binary/Preview] <16>:"), "{}", s.prompt_text()); + s.cancel(); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{path}\" \"\" \"\" '(0.0 0.0) {} \"\")", ent(b))); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(read(&path), ["CIRCLE"]); + assert!(!alive(&s, b)); +} + +#[test] +fn undo_brings_the_written_objects_back() { + // WBM-09: U after -WBLOCK. + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + lisp(&mut s, &format!("(command \"_.-WBLOCK\" \"{}\" \"\" '(5.0 5.0) {} {} \"\")", file(&dir, "M09.dwg"), ent(a), ent(b))); + assert!(!alive(&s, a) && !alive(&s, b)); + s.cmdline("u").unwrap(); + assert!(alive(&s, a) && alive(&s, b) && alive(&s, c)); +} + +#[test] +fn the_json_form_of_dash_wblock_erases_what_it_writes() { + let (mut s, a, b, _) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("wblock", &json!({ "path": file(&dir, "keep.dwg"), "handles": [a.hex()] })).unwrap(); + assert!(alive(&s, a), "WBLOCK's JSON form keeps them"); + s.execute("-wblock", &json!({ "path": file(&dir, "erase.dwg"), "handles": [b.hex()], "base": [5, 5] })).unwrap(); + assert_eq!(read(&file(&dir, "erase.dwg")), ["CIRCLE"]); + assert!(!alive(&s, b)); + // Nothing selected writes no file, as at the command line. + let none = file(&dir, "none.dwg"); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [] })).is_err()); + assert!(s.execute("-wblock", &json!({ "path": none, "handles": [b.hex()] })).is_err(), "erased already"); + assert!(s.execute("-wblock", &json!({ "path": none })).is_err()); + assert!(!std::path::Path::new(&none).exists()); +} + +#[test] +fn oops_brings_back_what_it_erased_where_it_was() { + // OOPSW-001 to OOPSW-005 (the drawing's `entnext` order read back): OOPS brings back what + // -WBLOCK erased, each where it was, as after ERASE. Only the last erase counts, and "*" (the + // whole drawing) erases nothing. + const OOPS: &str = "(command \"_.OOPS\")"; + // (script with @A to @D for the objects and @F for a new file, the order afterwards) + let cases: [(&[&str], &str); 5] = [ + (&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B @C \"\")", OOPS], "A B C D"), + (&["(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", "(command \"_.ERASE\" @D \"\")", OOPS], "A C D"), + (&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"\" '(0.0 0.0) @B \"\")", OOPS], "A B C"), + (&["(command \"_.ERASE\" @D \"\")", "(command \"_.-WBLOCK\" @F \"*\")", OOPS], "A B C D"), + (&["(command \"_.-WBLOCK\" @F \"\" \"_O\" \"_R\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"), + ]; + let dir = TempDir::new("dash-wblock"); + for (i, (script, want)) in cases.into_iter().enumerate() { + let (mut s, a, b, c) = drawing(); + let v = s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let d = Handle::parse_hex(v["handles"][0].as_str().or(v["handle"].as_str()).unwrap()).unwrap(); + let hs = [a, b, c, d]; + for line in script { + let mut line = line.to_string(); + for (n, h) in ["@A", "@B", "@C", "@D"].into_iter().zip(hs) { + line = line.replace(n, &ent(h)); + } + let line = line.replace("@F", &format!("\"{}\"", file(&dir, &format!("OOPSW{i}.dwg")))); + lisp(&mut s, &line); + assert!(s.running.is_none(), "{line}: {}", s.prompt_text()); + } + let order: Vec<&str> = + s.doc().unwrap().model.iter().map(|e| hs.iter().position(|h| *h == e.handle).map_or("E", |k| ["A", "B", "C", "D"][k])).collect(); + assert_eq!(order.join(" "), want, "{script:?}"); + } + // The JSON form too. + let (mut s, a, b, c) = drawing(); + s.execute("-wblock", &json!({ "path": file(&dir, "json.dwg"), "handles": [b.hex()] })).unwrap(); + assert!(!alive(&s, b)); + s.execute("oops", &json!({})).unwrap(); + let order: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + assert_eq!(order, [a, b, c]); +} + +#[test] +fn a_dash_wblock_refused_during_refedit_leaves_oops_as_it_was() { + let (mut s, a, b, c) = drawing(); + let dir = TempDir::new("dash-wblock"); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [a.hex()], "keep": "convert" })).unwrap(); + let ins = Handle::parse_hex(s.execute("entities", &json!({ "type": "INSERT" })).unwrap()["entities"][0]["handle"].as_str().unwrap()).unwrap(); + s.execute("erase", &json!({ "handles": [c.hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // B isn't in the working set: -WBLOCK is refused and the drawing put back. + assert!(s.execute("-wblock", &json!({ "path": file(&dir, "out.dwg"), "handles": [b.hex()] })).is_err()); + assert!(alive(&s, b)); + s.execute("refclose", &json!({ "save": false })).unwrap(); + // OOPS still has C, the last erase that stood. + s.execute("oops", &json!({})).unwrap(); + assert!(alive(&s, c)); +} diff --git a/crates/engine/tests/insert_file_field.rs b/crates/engine/tests/insert_file_field.rs new file mode 100644 index 000000000..1365f88b5 --- /dev/null +++ b/crates/engine/tests/insert_file_field.rs @@ -0,0 +1,107 @@ +//! `INSERT name=file` keeps the field of an attribute definition: the definition's extension +//! dictionary and the objects in it come along under new handles, so the attributes the block's +//! references get can carry the field when the drawing is saved. + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::foreign::{ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::{Attrib, Drawing, Entity, EntityKind, Handle, RawTag, Text}; +use cadcraft_engine::geom::Vec3; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +fn tag(code: i32, value: &str) -> RawTag { + RawTag { code, value: value.into() } +} + +fn object(handle: u64, kind: &str, tags: Vec) -> ForeignObject { + ForeignObject { handle: Handle(handle), kind: kind.into(), tags } +} + +/// A drawing file in a test format: model space holds a definition NUMBER (handle 0x50) whose +/// extension dictionary 0x60 owns the ACAD_FIELD dictionary 0x61, which owns the field 0x62 with +/// a child field 0x63. Handles 0x61..0x63 stand for the file's own; a second, unrelated object +/// 0x70 is not part of that tree. The bytes `plain` give the same definition without any of it. +fn read(bytes: &[u8], _name: &str) -> Result { + let mut d = Drawing::new_imperial(); + let text = Text { + insert: Vec3::ZERO, + align_pt: None, + height: 2.0, + value: "TEMPLATE".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + let def = Attrib { + tag: "NUMBER".into(), + text, + invisible: false, + constant: false, + prompt: String::new(), + props: Default::default(), + multiline: None, + modes: Default::default(), + }; + d.model.push(Entity::new(Handle(0x50), EntityKind::AttDef(def))); + if bytes == b"plain" { + return Ok(d); + } + let f = Arc::make_mut(&mut d.foreign); + f.entities.insert(Handle(0x50), ForeignExtra { xdict: Some(Handle(0x60)), ..Default::default() }); + f.objects.push(object(0x60, "DICTIONARY", vec![tag(5, "60"), tag(330, "50"), tag(3, "ACAD_FIELD"), tag(360, "61")])); + f.objects.push(object(0x61, "DICTIONARY", vec![tag(5, "61"), tag(330, "60"), tag(3, "TEXT"), tag(360, "62")])); + f.objects.push(object(0x62, "FIELD", vec![tag(5, "62"), tag(330, "61"), tag(1, "_text"), tag(360, "63")])); + f.objects.push(object(0x63, "FIELD", vec![tag(5, "63"), tag(330, "62"), tag(1, "AcVar")])); + f.objects.push(object(0x70, "XRECORD", vec![tag(5, "70"), tag(330, "0")])); + f.classes.push(vec![tag(1, "FIELD"), tag(2, "AcDbField")]); + Ok(d) +} + +fn hooks() { + set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); +} + +#[test] +fn the_definition_of_an_inserted_file_keeps_its_field() { + hooks(); + let dir = TempDir::new("insert-file-field"); + let path = dir.file("border.dxf", ""); + let mut s = Session::new(); + s.execute("insert", &json!({ "name": "BORDER", "file": path, "at": [0, 0], "attribs": { "NUMBER": "A-1" } })).unwrap(); + let d = s.doc().unwrap(); + let def = d.block("BORDER").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))).expect("the definition"); + assert_ne!(def.handle, Handle(0x50), "the definition has a handle of this drawing"); + let root = d.foreign.entities.get(&def.handle).and_then(|x| x.xdict).expect("the definition has an extension dictionary"); + let tree = d.foreign.tree(root); + assert_eq!(tree.len(), 4, "the dictionaries and both fields, not the unrelated object"); + assert!(tree.iter().all(|h| ![0x60, 0x61, 0x62, 0x63, 0x70].contains(&h.0)), "the copies have new handles"); + // The root's owner is the definition, the others point at each other. + let owner = + |h: Handle| d.foreign.objects.iter().find(|o| o.handle == h).and_then(|o| o.tags.iter().find(|t| t.code == 330)).map(|t| t.value.clone()); + assert_eq!(owner(tree[0]), Some(def.handle.hex())); + assert_eq!(owner(tree[1]), Some(tree[0].hex())); + assert_eq!(owner(tree[3]), Some(tree[2].hex())); + assert!(d.foreign.classes.iter().any(|c| c.iter().any(|t| t.code == 1 && t.value == "FIELD")), "the field class comes along"); +} + +#[test] +fn a_definition_without_a_field_gets_none() { + hooks(); + let dir = TempDir::new("insert-file-nofield"); + let path = dir.file("plain.dxf", "plain"); + let mut s = Session::new(); + s.execute("insert", &json!({ "name": "PLAIN", "file": path, "at": [0, 0] })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.block("PLAIN").is_some()); + assert!(d.foreign.is_empty(), "nothing is kept for a drawing without fields"); +} diff --git a/crates/engine/tests/it/angle_settings.rs b/crates/engine/tests/it/angle_settings.rs index 59591df86..ac5492263 100644 --- a/crates/engine/tests/it/angle_settings.rs +++ b/crates/engine/tests/it/angle_settings.rs @@ -4,6 +4,7 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; use cadcraft_engine::geom::Vec2; +use cadcraft_engine::lisp::value::Value; use cadcraft_engine::units::AngleSettings; use serde_json::json; @@ -40,8 +41,77 @@ fn polar_angles_measure_from_angbase_in_angdir() { assert_near(line_end(&mut s, "@10<<90"), 0.0, 10.0, "@10<<90"); setvar(&mut s, "AUNITS", json!(3)); assert_near(line_end(&mut s, "@10<<<3.141592653589793"), -10.0, 0.0, "@10<< assert_near(l.b.xy(), 0.0, -10.0, "ROTATE by N with ANGDIR 1"), + k => panic!("expected a line, got {k:?}"), + } + // ANGTOF reads them the same way: AutoCAD 2024 returns 0 for "N" with ANGBASE 90, ANGDIR 1 + // and AUNITS 3, and 4.18879020478639 for "S 30d W". + let mut s = Session::new(); + setvar(&mut s, "AUNITS", json!(3)); + setvar(&mut s, "ANGBASE", json!(90)); + setvar(&mut s, "ANGDIR", json!(1)); + let n = s.lisp_eval("(angtof \"N\")", false, true, Vec::new()).unwrap(); + assert!(matches!(n, Some(Ok(Value::Real(a))) if a.abs() < 1e-12), "{n:?}"); + let mut s = Session::new(); + setvar(&mut s, "AUNITS", json!(4)); + let sw = s.lisp_eval("(angtof \"S 30d W\")", false, true, Vec::new()).unwrap(); + assert!(matches!(sw, Some(Ok(Value::Real(a))) if (a - 4.18879020478639).abs() < 1e-12), "{sw:?}"); } #[test] diff --git a/crates/engine/tests/it/array_commands.rs b/crates/engine/tests/it/array_commands.rs index d420c36d6..61d5a0d02 100644 --- a/crates/engine/tests/it/array_commands.rs +++ b/crates/engine/tests/it/array_commands.rs @@ -212,3 +212,20 @@ fn arrays_over_maxarray_are_refused_not_shrunk() { assert!(s.current_prompt().is_none(), "{}", prompt(&s)); assert_eq!(circle_centers(&s).len(), 2000); } + +#[test] +fn json_arraypolar_rejects_bad_fill_angles() { + let mut s = Session::new(); + let h = s.execute("circle", &json!({"center": [5, 0], "radius": 1})).unwrap()["handle"].clone(); + let before = s.doc().unwrap().model.iter().count(); + for bad in [0.0, 720.0, -400.0, 360.5] { + let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": bad, "associative": false}); + let e = s.execute("arraypolar", &p).unwrap_err(); + assert!(e.to_string().contains("Requires a fill angle between -360 and 360 degrees"), "{bad}: {e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), before, "{bad}"); + } + for ok in [360.0, -360.0] { + let p = json!({"handles": [h], "center": [0, 0], "count": 4, "angle": ok, "associative": false}); + s.execute("arraypolar", &p).unwrap(); + } +} diff --git a/crates/engine/tests/it/assoc_array.rs b/crates/engine/tests/it/assoc_array.rs index 131210fa1..f672176b3 100644 --- a/crates/engine/tests/it/assoc_array.rs +++ b/crates/engine/tests/it/assoc_array.rs @@ -336,7 +336,7 @@ fn typed_arrayedit() { let a = rect_array(&mut s); typed(&mut s, &["arrayedit", "l"]); let p = s.prompt_text(); - assert!(p.contains("Enter an option or [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] "), "{p}"); + assert!(p.contains("Enter an option [Source/REPlace/Base point/Rows/Columns/Levels/RESet/eXit] "), "{p}"); typed(&mut s, &["c", "4", "t", "9", "r", "2", "5", ""]); assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text()); assert_eq!(centres(&s, &a).len(), 8); diff --git a/crates/engine/tests/it/attdef_command.rs b/crates/engine/tests/it/attdef_command.rs new file mode 100644 index 000000000..7bbec7c37 --- /dev/null +++ b/crates/engine/tests/it/attdef_command.rs @@ -0,0 +1,186 @@ +//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts with +//! TEXTSIZE 2.5 and AFLAGS 16, the drawing and AFLAGS read back afterwards). + +use cadcraft_engine::doc::{Attrib, EntityKind, HAlign, VAlign}; +use cadcraft_engine::{Session, sysvars}; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("setvar", &json!({ "name": "TEXTSIZE", "value": 2.5 })).unwrap(); + s.execute("setvar", &json!({ "name": "AFLAGS", "value": 16 })).unwrap(); + s +} + +fn attdefs(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.clone()) } else { None }).collect() +} + +fn aflags(s: &Session) -> i64 { + sysvars::get(s, "AFLAGS").and_then(|v| v.as_f64()).unwrap() as i64 +} + +fn run(script: &str) -> Session { + let mut s = session(); + s.cmdline(script).unwrap(); + assert!(s.running.is_none(), "{script}: {}", s.prompt_text()); + s +} + +#[test] +fn modes_are_toggled_kept_in_aflags_and_stored_on_the_definition() { + // (script, AFLAGS after, invisible, constant, verify, preset, lock position, tag, prompt, value) + type Case = (&'static str, i64, [bool; 5], &'static str, &'static str, &'static str); + let cases: [Case; 5] = [ + // ATTDEF-000: the modes as they are. + ( + "(command \"_.-ATTDEF\" \"\" \"T1\" \"Nhap ten\" \"MAC\" '(5.0 10.0) 2.5 0)", + 16, + [false, false, false, false, true], + "T1", + "Nhap ten", + "MAC", + ), + // ATTDEF-001: Invisible and Verify. + ( + "(command \"_.-ATTDEF\" \"_I\" \"_V\" \"\" \"T2\" \"Hoi hai\" \"D2\" '(5.0 10.0) 3.0 30)", + 21, + [true, false, true, false, true], + "T2", + "Hoi hai", + "D2", + ), + // ATTDEF-002: Constant asks for the value, not a prompt and a default. + ("(command \"_.-ATTDEF\" \"_C\" \"\" \"T3\" \"GIATRI\" '(5.0 10.0) 2.5 0)", 18, [false, true, false, false, true], "T3", "", "GIATRI"), + // ATTDEF-003: Preset. + ( + "(command \"_.-ATTDEF\" \"_P\" \"\" \"T4\" \"Hoi bon\" \"D4\" '(612350.678 2398775.432) 2.5 0)", + 24, + [false, false, false, true, true], + "T4", + "Hoi bon", + "D4", + ), + // ATTDEF-004: Lock position off. + ( + "(command \"_.-ATTDEF\" \"_L\" \"\" \"T5\" \"Hoi nam\" \"D5\" '(5.0 10.0) 2.5 0)", + 0, + [false, false, false, false, false], + "T5", + "Hoi nam", + "D5", + ), + ]; + for (script, flags, [inv, con, ver, pre, lock], tag, prompt, value) in cases { + let s = run(script); + assert_eq!(aflags(&s), flags, "{script}"); + let a = attdefs(&s).remove(0); + assert_eq!((a.invisible, a.constant, a.modes.verify, a.modes.preset, a.modes.lock_position), (inv, con, ver, pre, lock), "{script}"); + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), (tag, prompt, value), "{script}"); + } + // ATTDEF-001's height 3 and 30 degrees. + let a = attdefs(&run(cases[1].0)).remove(0); + assert!((a.text.height - 3.0).abs() < 1e-12 && (a.text.rotation - 30f64.to_radians()).abs() < 1e-12, "{a:?}"); +} + +#[test] +fn tags_justification_and_style_as_autocad_takes_them() { + // ATTDEF-021, ATTDEF-022: a tag is upper-cased, spaces and all. + for (typed, tag) in [("maso", "MASO"), ("ten ban ve", "TEN BAN VE")] { + let s = run(&format!("(command \"_.-ATTDEF\" \"\" \"{typed}\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)")); + assert_eq!(attdefs(&s)[0].tag, tag); + } + // ATTDEF-023: an empty prompt stays empty. + let s = run("(command \"_.-ATTDEF\" \"\" \"T10\" \"\" \"D10\" '(612350.678 2398775.432) 2.5 0)"); + assert_eq!(attdefs(&s)[0].prompt, ""); + // ATTDEF-025: an empty tag is refused and asked again. + let s = run("(command \"_.-ATTDEF\" \"\" \"\" \"T11\" \"Hoi muoi mot\" \"D11\" '(5.0 10.0) 2.5 0)"); + assert!(s.log.iter().any(|l| l == "Tag cannot be null"), "{:?}", s.log); + assert_eq!(attdefs(&s)[0].tag, "T11"); + // ATTDEF-005: Justify MC at the middle point. + let s = run("(command \"_.-ATTDEF\" \"\" \"T6\" \"Hoi sau\" \"D6\" \"_J\" \"_MC\" '(5.0 10.0) 2.5 0)"); + let a = attdefs(&s).remove(0); + assert_eq!((a.text.halign, a.text.valign), (HAlign::Center, VAlign::Middle)); + assert!(a.text.align_pt.is_some_and(|p| p.x == 5.0 && p.y == 10.0), "{a:?}"); + // ATTDEF-006: Style, then the point. + let s = run("(command \"_.-ATTDEF\" \"\" \"T7\" \"Hoi bay\" \"D7\" \"_S\" \"Standard\" '(0.505 0.26) 2.5 0)"); + let a = attdefs(&s).remove(0); + assert!(a.text.style == "Standard" && a.text.insert.x == 0.505 && a.text.insert.y == 0.26, "{a:?}"); +} + +#[test] +fn the_prompts_are_autocads() { + let mut s = session(); + s.cmdline("-attdef").unwrap(); + assert!(s.log.iter().any(|l| l == "Current attribute modes:"), "{:?}", s.log); + assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=N Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N")); + assert!(s.prompt_text().contains("[Invisible/Constant/Verify/Preset/Lock position/Annotative/Multiple lines] :"), "{}", s.prompt_text()); + s.cmdline("X").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + s.cmdline("_C").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invisible=N Constant=Y Verify=N Preset=N Lock position=Y Annotative=N Multiple line=N")); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Enter attribute tag name:"), "{}", s.prompt_text()); + s.cmdline("T3").unwrap(); + assert!(s.prompt_text().ends_with("Enter attribute value:"), "{}", s.prompt_text()); + s.cmdline("GIATRI").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current text style: \"Standard\" Text height: 2.5000 Justify: Left")); + assert!(s.prompt_text().ends_with("Specify start point of text or [Justify/Style]:"), "{}", s.prompt_text()); + s.cmdline("_J").unwrap(); + // A wrong answer, or a justification not available here, is asked again. + for typed in ["X", "_A"] { + s.cmdline(typed).unwrap(); + assert!(s.prompt_text().contains("[Left/Center/Right/Align/Middle/Fit/TL/"), "{typed}: {}", s.prompt_text()); + } + s.cmdline("_MC").unwrap(); + assert!(s.prompt_text().ends_with("Specify middle point of text:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Specify height <2.5000>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Specify rotation angle of text <0>:"), "{}", s.prompt_text()); + s.cancel(); +} + +#[test] +fn a_constant_attribute_stays_in_the_block() { + // INSERTATT-011: the constant TC is no attribute of the reference; the other one, TN, is. + let mut s = session(); + s.execute("line", &json!({ "points": [[612345.678, 2398765.432], [612355.678, 2398765.432]] })).unwrap(); + s.cmdline("(setq hl_A (entlast))").unwrap(); + s.cmdline("(progn (command \"_.-ATTDEF\" \"_C\" \"\" \"TC\" \"HANG\" '(612350.678 2398775.432) 2.5 0) (setq hl_a1 (entlast)))").unwrap(); + s.cmdline("(setvar \"AFLAGS\" 16)").unwrap(); + s.cmdline("(progn (command \"_.-ATTDEF\" \"\" \"TN\" \"Hoi N\" \"MN\" '(612350.678 2398785.432) 2.5 0) (setq hl_a2 (entlast)))").unwrap(); + s.cmdline("(command \"_.-BLOCK\" \"B11\" '(612345.678 2398765.432) hl_a1 hl_a2 hl_A \"\")").unwrap(); + s.cmdline("(command \"_.-INSERT\" \"B11\" '(612445.678 2398815.432) 1 1 0 \"XN\")").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let ins = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).unwrap(); + let tags: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect(); + assert_eq!(tags, [("TN".to_string(), "XN".to_string())]); +} + +#[test] +fn a_typed_height_is_a_distance() { + let mut s = session(); + for typed in ["-attdef", "", "T1", "P", "V", "5,10", "x"] { + s.cmdline(typed).unwrap(); + } + assert_eq!(s.log.last().map(String::as_str), Some("Requires a positive height.")); + // Feet and inches, as TEXT takes them. + for typed in ["1'6\"", "0"] { + s.cmdline(typed).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(attdefs(&s)[0].text.height, 18.0); +} + +#[test] +fn the_modes_start_from_aflags() { + // AFLAGS 0, not the default 16: Invisible toggled on gives 1, and nothing else. + let mut s = session(); + s.execute("setvar", &json!({ "name": "AFLAGS", "value": 0 })).unwrap(); + s.cmdline("(command \"_.-ATTDEF\" \"_I\" \"\" \"T1\" \"Hoi\" \"D\" '(5.0 10.0) 2.5 0)").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(aflags(&s), 1); + let a = attdefs(&s).remove(0); + assert_eq!((a.invisible, a.modes.lock_position), (true, false)); +} diff --git a/crates/engine/tests/it/change_command.rs b/crates/engine/tests/it/change_command.rs new file mode 100644 index 000000000..f497112c5 --- /dev/null +++ b/crates/engine/tests/it/change_command.rs @@ -0,0 +1,321 @@ +//! CHANGE as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back +//! with `entget`). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::doc::color::Color; +use serde_json::json; + +/// The measured drawing, its objects named in LISP: lines `a` (0,0)–(50,0) and `b` (0,20)–(50,30) +/// on layer L1, circle `c` (centre (100,0), radius 10), text `t` "ABC" at (0,50) 2.5 high, circle +/// `e` ((200,0), 3), arc `r` ((150,0), 10, 0°–90°) and polyline `p` (0,100)–(50,100)–(50,120); then +/// `code`. +fn run(code: &str) -> Session { + // `entmake` and `entget` go through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.doc_mut().unwrap().ensure_layer("L1"); + for (v, made) in [ + ("a", "(0 . \"LINE\") (10 0.0 0.0 0.0) (11 50.0 0.0 0.0)"), + ("b", "(0 . \"LINE\") (8 . \"L1\") (10 0.0 20.0 0.0) (11 50.0 30.0 0.0)"), + ("c", "(0 . \"CIRCLE\") (10 100.0 0.0 0.0) (40 . 10.0)"), + ("t", "(0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\")"), + ("e", "(0 . \"CIRCLE\") (10 200.0 0.0 0.0) (40 . 3.0)"), + ("r", "(0 . \"ARC\") (10 150.0 0.0 0.0) (40 . 10.0) (50 . 0.0) (51 . 1.5707963267948966)"), + ("p", "(0 . \"LWPOLYLINE\") (90 . 3) (70 . 0) (10 0.0 100.0) (10 50.0 100.0) (10 50.0 120.0)"), + ] { + s.cmdline(&format!("(setq {v} (progn (entmake '({made})) (entlast)))")).unwrap(); + } + s.log.clear(); + s.cmdline(&format!("(progn {code})")).unwrap(); + s +} + +/// The `i`th object drawn. +fn kind(s: &Session, i: usize) -> EntityKind { + s.doc().unwrap().model.iter().nth(i).unwrap().kind.clone() +} + +fn line(s: &Session, i: usize) -> [[f64; 3]; 2] { + let EntityKind::Line(l) = kind(s, i) else { panic!("not a line") }; + [[l.a.x, l.a.y, l.a.z], [l.b.x, l.b.y, l.b.z]] +} + +fn radius(s: &Session, i: usize) -> f64 { + let EntityKind::Circle(c) = kind(s, i) else { panic!("not a circle") }; + c.radius +} + +fn text(s: &Session, i: usize) -> cadcraft_engine::doc::Text { + match kind(s, i) { + EntityKind::Text(t) => t, + EntityKind::AttDef(a) => a.text, + _ => panic!("not a text"), + } +} + +fn said(s: &Session, line: &str) -> usize { + s.log.iter().filter(|l| l.as_str() == line).count() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn the_change_point_moves_the_nearer_end_of_each_line() { + let s = run("(command \"_.CHANGE\" a \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0), [[0.0, 0.0, 0.0], [60.0, 10.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a \"\" '(-5.0 5.0))"); + assert_eq!(line(&s, 0), [[-5.0, 5.0, 0.0], [50.0, 0.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)), ([60.0, 10.0, 0.0], [[0.0, 20.0, 0.0], [60.0, 10.0, 0.0]])); + // Under ortho the moved end stays level or plumb with the other one. + let s = run("(setvar \"ORTHOMODE\" 1) (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 0.0, 0.0], [60.0, 20.0, 0.0])); + // Arcs and polylines are left alone: with a line the point still moves it, without one there + // is nothing to change. Enter leaves lines alone too. + let s = run("(command \"_.CHANGE\" a r p \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0)[1], [60.0, 10.0, 0.0]); + assert_eq!(said(&s, "No changeable object selected"), 0, "{:?}", s.log); + for code in ["(command \"_.CHANGE\" r \"\" '(160.0 10.0))", "(command \"_.CHANGE\" p \"\" '(60.0 130.0))", "(command \"_.CHANGE\" a \"\" \"\")"] { + let s = run(code); + assert_eq!(said(&s, "No changeable object selected"), 1, "{code}: {:?}", s.log); + assert!(s.running.is_none(), "{code}"); + assert_eq!(line(&s, 0)[1], [50.0, 0.0, 0.0], "{code}"); + } +} + +#[test] +fn a_circle_passes_through_the_point_or_takes_a_radius() { + let s = run("(command \"_.CHANGE\" c \"\" '(115.0 0.0))"); + assert!(near(radius(&s, 2), 15.0)); + let s = run("(command \"_.CHANGE\" c \"\" \"\" 20.0)"); + assert!(near(radius(&s, 2), 20.0)); + // With other objects the point is ignored, said as each circle comes up. + let s = run("(command \"_.CHANGE\" c e \"\" '(110.0 10.0) 8.0)"); + assert_eq!((radius(&s, 2), radius(&s, 4)), (8.0, 3.0)); + assert_eq!(said(&s, "Change point ignored"), 2, "{:?}", s.log); + assert!(s.prompt_text().contains("Specify new circle radius "), "{}", s.prompt_text()); + let s = run("(command \"_.CHANGE\" a c \"\" '(60.0 10.0) 12.0)"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 12.0)); + assert_eq!(said(&s, "Change point ignored"), 1, "{:?}", s.log); +} + +#[test] +fn a_text_is_placed_by_the_point_then_asked_the_rest() { + let s = run("(command \"_.CHANGE\" t \"\" '(10.0 60.0) \"\" 4.0 \"\" \"\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (10.0, 60.0, 4.0, "ABC")); + // Enter asks for the insertion point first. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"\" 5.0 30 \"XYZ\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (0.0, 50.0, 5.0, "XYZ")); + assert!(near(t.rotation.to_degrees(), 30.0)); + // A style that does not exist is refused, and asked for again. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"5\")"); + assert_eq!(said(&s, "Style \"5\" not defined - use STYLE command."), 1, "{:?}", s.log); + assert!(s.prompt_text().contains("Enter new text style "), "{}", s.prompt_text()); + // Justified text is placed by its alignment point, with the point or after Enter. + for (justify, code) in [("(72 . 1) (73 . 2)", "'(10.0 60.0) \"\" \"\" \"\" \"\""), ("(72 . 2)", "\"\" '(10.0 60.0) \"\" \"\" \"\" \"\"")] { + let s = run(&format!( + "(entmake '((0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\") {justify} (11 0.0 50.0 0.0))) (command \"_.CHANGE\" (entlast) \"\" {code})" + )); + let at = text(&s, 7).align_pt.unwrap(); + assert_eq!((at.x, at.y), (10.0, 60.0), "{justify}"); + } +} + +#[test] +fn a_style_of_fixed_height_is_not_asked_for_a_height() { + let mut s = run(""); + s.execute("style", &json!({ "name": "FIXH", "height": 5.0 })).unwrap(); + s.cmdline("(entmake '((0 . \"TEXT\") (10 0.0 80.0 0.0) (40 . 5.0) (1 . \"FIX\") (7 . \"FIXH\")))").unwrap(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 30 \"Q\")").unwrap(); + let t = text(&s, 7); + assert_eq!((t.height, t.value.as_str()), (5.0, "Q")); + assert!(near(t.rotation.to_degrees(), 30.0)); + assert!(s.running.is_none()); +} + +#[test] +fn objects_are_asked_about_in_the_order_selected() { + let s = run( + "(setq d (progn (entmake '((0 . \"TEXT\") (10 0.0 70.0 0.0) (40 . 2.5) (1 . \"DEF\"))) (entlast))) (command \"_.CHANGE\" t d \"\" \"\" \"\" \"\" \"\" \"\" \"X1\" \"\" \"\" \"\" \"\" \"X2\")", + ); + assert_eq!((text(&s, 3).value.as_str(), text(&s, 7).value.as_str()), ("X1", "X2")); + let s = run("(command \"_.CHANGE\" t c \"\" \"\" \"\" \"\" \"\" \"\" \"TT\" 7.0)"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); + let s = run("(command \"_.CHANGE\" c t \"\" \"\" 7.0 \"\" \"\" \"\" \"\" \"TT\")"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); +} + +/// Block K: a circle and attribute TAG1 (default V) 5 above its base point, inserted at (200,0) +/// with the default value. +const BLOCK: &str = "(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0))) (entmake '((0 . \"ATTDEF\") (10 0.0 5.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (entmake '((0 . \"ENDBLK\"))) (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")"; + +#[test] +fn a_block_reference_is_placed_and_turned_with_its_attributes() { + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" '(250.0 50.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (250.0, 50.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); + let a = &i.attribs.first().expect("the attribute").text; + assert!(near(a.insert.x, 245.0) && near(a.insert.y, 50.0), "{:?}", a.insert); + assert!(near(a.rotation.to_degrees(), 90.0)); + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" \"\" '(220.0 30.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (220.0, 30.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); +} + +#[test] +fn an_attribute_definition_is_asked_its_tag_prompt_and_default() { + let s = run( + "(entmake '((0 . \"ATTDEF\") (10 300.0 0.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (command \"_.CHANGE\" (entlast) \"\" '(310.0 10.0) \"\" \"\" \"\" \"T2\" \"P2\" \"V2\")", + ); + let EntityKind::AttDef(a) = kind(&s, 7) else { panic!("not an attribute definition") }; + assert_eq!((a.text.insert.x, a.text.insert.y), (310.0, 10.0)); + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), ("T2", "P2", "V2")); +} + +#[test] +fn objects_on_locked_layers_are_left_out() { + let s = run("(command \"_.-LAYER\" \"_LO\" \"L1\" \"\") (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 10.0, 0.0], [50.0, 30.0, 0.0])); + assert_eq!(said(&s, "1 was on a locked layer."), 1, "{:?}", s.log); +} + +#[test] +fn properties_add_elev_which_is_set_as_the_command_ends() { + let s = run("(command \"_.CHANGE\" a \"\" \"_P\" \"_C\" 2 \"_E\" 10.0 \"\")"); + let d = s.doc().unwrap(); + assert_eq!(d.model.iter().next().unwrap().common.color, Color::Index(2)); + assert_eq!(line(&s, 0), [[0.0, 0.0, 10.0], [50.0, 0.0, 10.0]]); + let s = run("(command \"_.CHANGE\" a c \"\" \"_P\" \"_LA\" \"L1\" \"_T\" 3.0 \"\")"); + for e in s.doc().unwrap().model.iter().take(3).step_by(2) { + assert_eq!((e.common.layer.as_str(), e.common.thickness), ("L1", 3.0)); + } + let s = run("(command \"_.CHANGE\" c t r p \"\" \"_P\" \"_E\" 5.0 \"\")"); + let EntityKind::Circle(c) = kind(&s, 2) else { panic!() }; + let EntityKind::Arc(r) = kind(&s, 5) else { panic!() }; + let EntityKind::LwPolyline(p) = kind(&s, 6) else { panic!() }; + assert_eq!((c.center.z, text(&s, 3).insert.z, r.center.z, p.elevation), (5.0, 5.0, 5.0, 5.0)); + // A line between two heights keeps them, said as the command ends; the elevation asked shows + // it varies. + let mut s = run( + "(setq l (progn (entmake '((0 . \"LINE\") (10 0.0 200.0 0.0) (11 50.0 200.0 10.0))) (entlast))) (command \"_.CHANGE\" l \"\" \"_P\" \"_E\")", + ); + assert!(s.prompt_text().contains("Specify new elevation "), "{}", s.prompt_text()); + s.cmdline("(command 5.0 \"\")").unwrap(); + assert_eq!(line(&s, 7), [[0.0, 200.0, 0.0], [50.0, 200.0, 10.0]]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); +} + +#[test] +fn change_by_json() { + let mut s = run(""); + let h: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + let r = s.execute("change", &json!({ "handles": [h[0], h[2]], "point": [60, 10] })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["ignored"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 10.0)); + s.execute("change", &json!({ "handles": [h[2]], "point": [115, 0] })).unwrap(); + assert!(near(radius(&s, 2), 15.0)); + s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap(); + let r = s.execute("change", &json!({ "handles": [h[0], h[1]], "elevation": 4.0 })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[0][2], line(&s, 1)[0][2]), (4.0, 0.0)); + // Neither a point nor an elevation, or one that can't be read, is refused. + for bad in [json!({ "handles": [h[0]] }), json!({ "handles": [h[0]], "point": "nowhere" }), json!({ "handles": [h[0]], "elevation": "high" })] { + assert!(s.execute("change", &bad).is_err(), "{bad}"); + } + assert_eq!(line(&s, 0), [[0.0, 0.0, 4.0], [60.0, 10.0, 4.0]]); +} + +/// Elev on the other kinds AutoCAD 2024 sets it for, each alone: the z of a point, a block +/// reference with its attributes, a multiline text, an ellipse, a solid and a flat 3D polyline; +/// a 3D polyline between heights keeps them, and a hatch loses its associativity. +#[test] +fn elev_sets_the_z_of_every_kind_with_one() { + let elev = |made: &str| { + let mut s = run(made); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let k = s.doc().unwrap().model.iter().last().unwrap().kind.clone(); + (k, s) + }; + let (k, _) = elev("(entmake '((0 . \"POINT\") (10 5.0 5.0 0.0)))"); + assert!(matches!(k, EntityKind::Point(p) if p.p.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"MTEXT\") (10 0.0 150.0 0.0) (40 . 2.5) (1 . \"AB\")))"); + assert!(matches!(k, EntityKind::MText(t) if t.insert.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"ELLIPSE\") (10 50.0 150.0 0.0) (11 20.0 0.0 0.0) (40 . 0.5) (41 . 0.0) (42 . 6.283185307179586)))"); + assert!(matches!(k, EntityKind::Ellipse(e) if e.center.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"SOLID\") (10 0.0 170.0 0.0) (11 10.0 170.0 0.0) (12 0.0 180.0 0.0) (13 10.0 180.0 0.0)))"); + assert!(matches!(k, EntityKind::Solid(so) if so.corners.iter().all(|c| c.z == 4.0))); + // 3D polylines (made with their z, which a point given to a command doesn't carry). + let poly3d = |pts: serde_json::Value| { + let mut s = run(""); + s.execute("3dpoly", &json!({ "points": pts })).unwrap(); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let EntityKind::Polyline3d(p) = s.doc().unwrap().model.iter().last().unwrap().kind.clone() else { panic!("not a 3D polyline") }; + (p.points.iter().map(|q| q.z).collect::>(), s) + }; + let (zs, _) = poly3d(json!([[0, 220, 2], [10, 220, 2], [20, 230, 2]])); + assert_eq!(zs, [4.0, 4.0, 4.0]); + let (zs, s) = poly3d(json!([[0, 200, 0], [10, 200, 5], [20, 210, 0]])); + assert_eq!(zs, [0.0, 5.0, 0.0]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); + let (k, _) = elev(&format!("{BLOCK} (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")")); + let EntityKind::Insert(i) = k else { panic!("not a block reference") }; + assert_eq!((i.insert.z, i.attribs[0].text.insert.z), (4.0, 4.0)); + // A hatch: its elevation, and no longer associative. + let mut s = run("(entmake '((0 . \"LWPOLYLINE\") (90 . 4) (70 . 1) (10 100.0 150.0) (10 120.0 150.0) (10 120.0 170.0) (10 100.0 170.0)))"); + let poly = s.doc().unwrap().model.iter().last().unwrap().handle.hex(); + s.execute("hatch", &json!({ "handles": [poly], "associative": true })).unwrap(); + // The hatch is drawn behind its boundary, so it isn't the last object in the list. + let hatch = |s: &Session| { + let d = s.doc().unwrap(); + d.model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| (e.handle.hex(), e.kind.clone())).expect("a hatch") + }; + let (hex, k) = hatch(&s); + assert!(matches!(&k, EntityKind::Hatch(h) if h.associative)); + s.log.clear(); + s.cmdline(&format!("(command \"_.CHANGE\" (handent \"{hex}\") \"\" \"_P\" \"_E\" 4.0 \"\")")).unwrap(); + let (_, EntityKind::Hatch(h)) = hatch(&s) else { panic!("not a hatch") }; + assert_eq!((h.elevation, h.associative), (4.0, false)); + assert_eq!(said(&s, "Hatch boundary associativity removed."), 1, "{:?}", s.log); +} + +#[test] +fn the_rotation_asked_shows_as_the_drawing_measures_angles() { + // ANGBASE 90°: a text at 0° shows as 270.00. + let s = run("(setvar \"ANGBASE\" (/ pi 2)) (command \"_.CHANGE\" t \"\" \"\" \"\" \"\" \"\")"); + assert!(s.prompt_text().contains("Specify new rotation angle <270.00>"), "{}", s.prompt_text()); +} + +#[test] +fn aligned_and_fit_texts_are_set_by_their_two_points() { + // Aligned: neither height nor rotation is asked; Fit: no rotation. + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 300.0 0.0) (11 40.0 300.0 0.0) (40 . 2.5) (1 . \"AL\") (72 . 3))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" \"NEW\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (2.5, 0.0, "NEW")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 320.0 0.0) (11 40.0 320.0 0.0) (40 . 2.5) (1 . \"FI\") (72 . 5))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 5.0 \"FN\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (5.0, 0.0, "FN")); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn the_json_point_follows_ortho() { + let mut s = run("(setvar \"ORTHOMODE\" 1)"); + let a = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("change", &json!({ "handles": [a], "point": [60, 10] })).unwrap(); + assert_eq!(line(&s, 0)[1], [60.0, 0.0, 0.0]); +} diff --git a/crates/engine/tests/it/classic_array.rs b/crates/engine/tests/it/classic_array.rs new file mode 100644 index 000000000..8f74f8f48 --- /dev/null +++ b/crates/engine/tests/it/classic_array.rs @@ -0,0 +1,194 @@ +//! -ARRAY, the classic command-line array, as AutoCAD 2024 runs it from AutoLISP's `command` +//! (accoreconsole, every copy read back with `entget`). + +use std::f64::consts::TAU; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// A session with the object `made` (an entity list) as `a` in LISP, after `code`. +fn run(made: &str, code: &str) -> Session { + // `entmake` goes through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.cmdline(&format!("(progn (setq a (progn (entmake '({made})) (entlast))) {code})")).unwrap(); + s +} + +/// Where each object is: a line's start, a circle's or arc's centre, a polyline's first vertex, a +/// text's insertion point. +fn points(s: &Session) -> Vec<[f64; 2]> { + s.doc() + .unwrap() + .model + .iter() + .map(|e| match &e.kind { + EntityKind::Line(l) => [l.a.x, l.a.y], + EntityKind::Circle(c) => [c.center.x, c.center.y], + EntityKind::Arc(a) => [a.center.x, a.center.y], + EntityKind::LwPolyline(p) => [p.vertices[0].p.x, p.vertices[0].p.y], + EntityKind::Text(t) => [t.insert.x, t.insert.y], + k => panic!("unexpected {k:?}"), + }) + .collect() +} + +/// `got` holds each of `want` to `tol`, and nothing else. +fn same(got: &[[f64; 2]], want: &[[f64; 2]], tol: f64) -> bool { + got.len() == want.len() && want.iter().all(|w| got.iter().any(|g| (g[0] - w[0]).abs() <= tol && (g[1] - w[1]).abs() <= tol)) +} + +fn line(a: [f64; 2], b: [f64; 2]) -> String { + format!("(0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0)", a[0], a[1], b[0], b[1]) +} + +fn circle(c: [f64; 2], r: f64) -> String { + format!("(0 . \"CIRCLE\") (10 {} {} 0.0) (40 . {r})", c[0], c[1]) +} + +fn arc(c: [f64; 2], r: f64, a1: f64, a2: f64) -> String { + format!("(0 . \"ARC\") (10 {} {} 0.0) (40 . {r}) (50 . {}) (51 . {})", c[0], c[1], a1.to_radians(), a2.to_radians()) +} + +/// The measured polyline: four vertices with bulges 0.3, 0, −0.7, 0, from `at`, `scale` × the +/// drawn size, `width` wide. +fn polyline(at: [f64; 2], scale: f64, width: f64) -> String { + let v = [(0.0, 0.0, 0.3), (20.0, 5.0, 0.0), (35.0, -10.0, -0.7), (50.0, 0.0, 0.0)]; + let vs: String = v.iter().map(|(x, y, b)| format!(" (10 {} {}) (42 . {b})", at[0] + x * scale, at[1] + y * scale)).collect(); + format!("(0 . \"LWPOLYLINE\") (90 . 4) (70 . 0) (43 . {width}){vs}") +} + +#[test] +fn rectangular_copies_move_by_rows_and_columns() { + let s = run( + &line([-1.1929712440222175, 9.67888030686079], [44.51297824124873, 11.673136668914886]), + "(command \"_.-ARRAY\" a \"\" \"_R\" 2 2 -12.570757111654931 -18.0084920904546)", + ); + let want = [ + [-19.20146333447681, -2.891876804794138], + [-19.20146333447681, 9.67888030686079], + [-1.192971244022217, -2.891876804794138], + [-1.192971244022217, 9.67888030686079], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + // One row or one column: that distance isn't asked for. + let s = run(&circle([5.801226536663419, -5.877116775641122], 23.343762414841144), "(command \"_.-ARRAY\" a \"\" \"_R\" 1 3 -26.90504352387681)"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let want = [[-48.0088605110902, -5.877116775641122], [-21.10381698721339, -5.877116775641122], [5.801226536663419, -5.877116775641122]]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + let s = run(&polyline([0.5, 0.25], 0.001, 0.0005), "(command \"_.-ARRAY\" a \"\" \"_R\" 3 1 -0.010095474538067209)"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(same(&points(&s), &[[0.5, 0.2298090509238659], [0.5, 0.239904525461933], [0.5, 0.25]], 1e-12), "{:?}", points(&s)); + let s = run( + "(0 . \"TEXT\") (10 5.4185272544331635 4.443702697220587 0.0) (40 . 2.5) (1 . \"AB12\") (50 . 0.20972899)", + "(command \"_.-ARRAY\" a \"\" \"_R\" 1 4 26.03962020976745)", + ); + let want = [ + [83.53738788373552, 4.443702697220587], + [57.49776767396807, 4.443702697220587], + [31.45814746420061, 4.443702697220587], + [5.418527254433163, 4.443702697220587], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); +} + +#[test] +fn polar_copies_turn_about_the_centre() { + // Five items around a full circle: 72° apart. + let s = run( + &arc([8.737608057965836, 8.018370437941456], 25.76749991913238, 246.54512163341687, 4.3030133419271355), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.631794214160564 -5.07952370466397) 5 360 \"_Y\")", + ); + let want = [ + [22.50329607545565, 0.769426262850251], + [19.86297201814329, -14.56256796884801], + [4.465473991920418, -16.78931734426366], + [-2.410379072682379, -2.833529910999876], + [8.737608057965836, 8.018370437941456], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); + let starts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Arc(a) = &e.kind { Some(a.start) } else { None }).collect(); + for want in [3.046387065797107, 1.789750004361188, 0.5331129429252733, 5.559661188668941, 4.303024127233024] { + assert!(starts.iter().any(|a| ((a - want).rem_euclid(TAU) + 1e-9).rem_euclid(TAU) < 2e-9), "{want}: {starts:?}"); + } + // Six items in 270°: 54° apart. + let s = run( + &line([14.349623930607876, -18.237839127538827], [57.5895083326729, 8.515990272720988]), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(9.968618268950948 -10.513614962917108) 6 270 \"_Y\")", + ); + let want = [ + [2.244394104329227, -14.89462062457403], + [1.884125186851836, -6.839676863508278], + [8.18895082173591, -1.813636035924282], + [15.96098679213407, -3.960114445242588], + [18.79273740462331, -11.50949197954958], + [14.34962393060787, -18.23783912753882], + ]; + assert!(same(&points(&s), &want, 1e-9), "{:?}", points(&s)); +} + +#[test] +fn polar_copies_not_rotated_move_with_the_group_10_point() { + // A polyline moves with its first vertex: three items in 270°. + let s = run( + &polyline([612345.678, 2398765.432], 1.0, 0.5), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612362.9804954464 2398768.822523769) 3 270 \"_N\")", + ); + let want = [[612359.5899716774, 2398786.125019215], [612377.612669657, 2398758.985274256], [612345.678, 2398765.432]]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + // A circle with its centre, five items in −90° (clockwise). + let s = run( + &circle([612340.715727557, 2398755.830080357], 26.60109393014347), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612346.260090821 2398771.804712544) 5 -90 \"_N\")", + ); + let want = [ + [612330.2854586337, 2398777.349075808], + [612329.3797191395, 2398770.813809208], + [612331.0438632132, 2398764.429398659], + [612335.0245400043, 2398759.16781279], + [612340.715727557, 2398755.830080357], + ]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + // An arc with its centre, its angles unchanged. + let s = run( + &arc([612339.185864084, 2398766.23253733], 13.970352776505141, 238.67958380809327, 80.40871664680844), + "(command \"_.-ARRAY\" a \"\" \"_P\" '(612358.7800675274 2398749.633182222) 7 360 \"_N\")", + ); + let want = [ + [612359.541179917, 2398775.302075975], + [612379.3233625971, 2398765.042414074], + [612383.6360250826, 2398743.179286296], + [612369.2316445602, 2398726.176073787], + [612346.957013359, 2398726.836542366], + [612333.585383093, 2398744.663345723], + [612339.185864084, 2398766.23253733], + ]; + assert!(same(&points(&s), &want, 1e-6), "{:?}", points(&s)); + assert!(s.doc().unwrap().model.iter().all(|e| matches!(&e.kind, EntityKind::Arc(a) if (a.start - 4.165744594740973).abs() < 1e-9))); +} + +#[test] +fn the_type_asked_defaults_to_the_one_used_last() { + let mut s = run(&circle([0.0, 0.0], 1.0), "(command \"_.-ARRAY\" a \"\" \"_P\" '(10.0 0.0) 3 360 \"_Y\")"); + s.cmdline("(command \"_.-ARRAY\" a \"\")").unwrap(); + assert!(s.prompt_text().contains("[Rectangular/Polar]

"), "{}", s.prompt_text()); +} + +#[test] +fn classic_array_by_json() { + let mut s = run(&circle([0.0, 0.0], 1.0), ""); + let first = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + let r = s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [10, 0], "count": 4, "rotate": false })).unwrap(); + assert_eq!(r["created"].as_u64(), Some(3), "{r}"); + assert!(same(&points(&s), &[[0.0, 0.0], [10.0, -10.0], [20.0, 0.0], [10.0, 10.0]], 1e-9), "{:?}", points(&s)); + let r = s.execute("-array", &json!({ "handles": [first], "rows": 2, "cols": 3, "rowSpacing": 5, "colSpacing": 7 })).unwrap(); + assert_eq!(r["created"].as_u64(), Some(5), "{r}"); + // An unknown type, or an array past MAXARRAY, is refused before anything is made. + assert!(s.execute("-array", &json!({ "handles": [first], "type": "path" })).is_err()); + assert!(s.execute("-array", &json!({ "handles": [first], "rows": 4294967296u64, "cols": 4294967296u64 })).is_err()); + assert!(s.execute("-array", &json!({ "handles": [first], "type": "polar", "center": [0, 0], "count": u64::MAX })).is_err()); + assert_eq!(s.doc().unwrap().model.iter().count(), 4 + 5); +} diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 1aee1b9db..740133c2b 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -314,6 +314,7 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let blocks = |dim_end: f64, first: u64| { let mut part = Block::new("PART"); @@ -400,6 +401,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut part = Block::new("PART"); part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); @@ -514,6 +516,7 @@ fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut s = Session::new(); let mut drawn = Block::new("*D1"); @@ -833,6 +836,7 @@ fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; for k in 1..=MAX_BLOCK_DEPTH { let mut b = Block::new(&format!("B{k}")); diff --git a/crates/engine/tests/it/dash_units.rs b/crates/engine/tests/it/dash_units.rs new file mode 100644 index 000000000..f554378d8 --- /dev/null +++ b/crates/engine/tests/it/dash_units.rs @@ -0,0 +1,176 @@ +//! -UNITS asks for the drawing's units at the command line, as AutoCAD 2024 asks (measured with +//! accoreconsole, running these scripts from these settings): length format and precision, angle +//! format and precision, the direction of angle 0, and whether angles turn clockwise. + +use std::f64::consts::FRAC_PI_2; + +use cadcraft_engine::Session; + +/// LUNITS, LUPREC, AUNITS, AUPREC, ANGBASE (radians; AutoCAD's 1.570796326794896 is pi/2), ANGDIR. +type Units = (i64, i64, i64, i64, f64, i64); + +const DEFAULT: Units = (2, 4, 0, 0, 0.0, 0); + +fn session(u: Units) -> Session { + let mut s = Session::new(); + let h = &mut s.doc_mut().unwrap().header; + h.set_i64("LUNITS", u.0); + h.set_i64("LUPREC", u.1); + h.set_i64("AUNITS", u.2); + h.set_i64("AUPREC", u.3); + h.set_f64("ANGBASE", u.4); + h.set_i64("ANGDIR", u.5); + s +} + +fn units(s: &Session) -> Units { + let h = &s.doc().unwrap().header; + (h.i64("LUNITS", 2), h.i64("LUPREC", 4), h.i64("AUNITS", 0), h.i64("AUPREC", 0), h.f64("ANGBASE", 0.0), h.i64("ANGDIR", 0)) +} + +fn same(a: Units, b: Units) -> bool { + (a.0, a.1, a.2, a.3, a.5) == (b.0, b.1, b.2, b.3, b.5) && (a.4 - b.4).abs() <= 1e-12 +} + +fn run(s: &mut Session, answers: &str) { + s.cmdline(&format!("(command \"_.-UNITS\" {answers})")).unwrap(); + assert!(s.running.is_none(), "{answers}: {}", s.prompt_text()); +} + +#[test] +fn the_measured_scripts_set_the_units() { + let cases: [(Units, &str, Units); 22] = [ + (DEFAULT, "4 16 1 0 0 \"_N\"", (4, 4, 0, 0, 0.0, 0)), + (DEFAULT, "3 2 2 4 90 \"_Y\"", (3, 2, 1, 4, FRAC_PI_2, 1)), + (DEFAULT, "2 3 3 2 100 \"_N\"", (2, 3, 2, 2, FRAC_PI_2, 0)), + (DEFAULT, "5 8 5 2 \"N\" \"_N\"", (5, 3, 4, 2, FRAC_PI_2, 0)), + ((4, 3, 1, 2, 0.5, 1), "\"\" \"\" \"\" \"\" \"\" \"\"", (4, 3, 1, 2, 0.5, 1)), + ((2, 4, 0, 0, FRAC_PI_2, 1), "2 4 1 0 45 \"_N\"", (2, 4, 0, 0, 5.497787143782137, 0)), + (DEFAULT, "1 2 4 3 30 \"_N\"", (1, 2, 3, 3, 4.867258771281656, 0)), + ((2, 2, 0, 2, 0.0, 0), "3 \"\" 2 \"\" \"\" \"\"", (3, 4, 1, 4, 0.0, 0)), + ((2, 2, 0, 2, 0.0, 0), "2 \"\" 1 \"\" \"\" \"\"", (2, 2, 0, 2, 0.0, 0)), + (DEFAULT, "4 3 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)), + ((2, 4, 0, 0, 0.0, 1), "2 4 5 4 \"N45dE\" \"\"", (2, 4, 4, 4, 5.497787143782137, 1)), + (DEFAULT, "6 \"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 0, 0, 0.0, 0)), + ((2, 2, 0, 0, 0.0, 0), "4 \"\" \"\" \"\" \"\" \"\"", (4, 4, 0, 0, 0.0, 0)), + ((5, 6, 0, 0, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (5, 6, 0, 0, 0.0, 0)), + ((2, 4, 0, 0, 0.0, 1), "\"\" \"\" 5 \"\" \"N\" \"\"", (2, 4, 4, 4, 4.712388980384689, 1)), + ((2, 4, 3, 4, 0.0, 0), "\"\" \"\" \"\" \"\" \"45d\" \"\"", (2, 4, 3, 4, 0.7853981633974482, 0)), + (DEFAULT, "5 1 \"\" \"\" \"\" \"\"", (5, 0, 0, 0, 0.0, 0)), + ((1, 7, 0, 7, 0.0, 0), "3 \"\" 1 \"\" \"\" \"\"", (3, 4, 0, 7, 0.0, 0)), + (DEFAULT, "\"\" \"\" \"\" \"\" \"\" \"Yes\"", (2, 4, 0, 0, 0.0, 1)), + ((2, 4, 2, 6, 0.0, 0), "\"\" \"\" \"\" \"\" \"\" \"\"", (2, 4, 2, 6, 0.0, 0)), + ((2, 4, 4, 2, 0.0, 1), "\"\" \"\" \"\" \"\" \"S45dE\" \"\"", (2, 4, 4, 2, 0.7853981633974482, 1)), + (DEFAULT, "\"\" \"\" \"\" \"\" \"N\" \"\"", (2, 4, 0, 0, FRAC_PI_2, 0)), + ]; + for (from, answers, to) in cases { + let mut s = session(from); + run(&mut s, answers); + assert!(same(units(&s), to), "{answers}: {:?}, AutoCAD {to:?}", units(&s)); + } +} + +/// The prompt, after the command's name. +fn asks(s: &Session) -> String { + let p = s.prompt_text(); + p.strip_prefix("-UNITS ").unwrap_or(&p).to_string() +} + +fn ends_with(s: &Session, lines: &[&str]) -> bool { + s.log.len() >= lines.len() && s.log[s.log.len() - lines.len()..].iter().zip(lines).all(|(a, b)| a == b) +} + +#[test] +fn prompts_and_listings_show_the_drawings_units() { + // Architectural to 1/8, degrees/minutes/seconds to 2 places, angle 0 at 0.5 radians, clockwise. + let mut s = session((4, 3, 1, 2, 0.5, 1)); + s.cmdline("-units").unwrap(); + assert!(s.log.iter().any(|l| l == " 4. Architectural 1'-3 1/2\""), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <4>:"); + s.cmdline("").unwrap(); + assert!(ends_with(&s, &["Enter denominator of smallest fraction to display"]), "{:?}", s.log); + assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <8>:"); + s.cmdline("").unwrap(); + assert!(ends_with(&s, &[" 4. Radians 0.7854r", " 5. Surveyor's units N 45d0'0\" E"]), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:"); + s.cmdline("").unwrap(); + assert_eq!(asks(&s), "Enter number of fractional places for display of angles (0 to 8) <2>:"); + s.cmdline("").unwrap(); + let directions = [ + "Direction for angle 0d0':", + " East 3 o'clock = 0d0'", + " North 12 o'clock = 270d0'", + " West 9 o'clock = 180d0'", + " South 6 o'clock = 90d0'", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle 0d0' <331d21'>:"); + s.cmdline("").unwrap(); + assert_eq!(asks(&s), "Measure angles clockwise? [Yes/No] :"); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 3, 1, 2, 0.5, 1)), "{:?}", units(&s)); + + // Surveyor's units, clockwise: north is listed as S, as AutoCAD lists it. + let mut s = session((2, 4, 0, 0, 0.0, 1)); + s.script("-units\n\n\n5\n\n").unwrap(); + let directions = [ + "Direction for angle E:", + " East 3 o'clock = E", + " North 12 o'clock = S", + " West 9 o'clock = W", + " South 6 o'clock = N", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle E :"); + + // The listing shows directions to the precision just given. + let mut s = session(DEFAULT); + s.script("-units\n\n\n2\n2\n").unwrap(); + let directions = [ + "Direction for angle 0d0':", + " East 3 o'clock = 0d0'", + " North 12 o'clock = 90d0'", + " West 9 o'clock = 180d0'", + " South 6 o'clock = 270d0'", + ]; + assert!(ends_with(&s, &directions), "{:?}", s.log); + assert_eq!(asks(&s), "Enter direction for angle 0d0' <0d0'>:"); +} + +#[test] +fn a_rejected_answer_is_asked_again() { + let mut s = session(DEFAULT); + s.cmdline("-units").unwrap(); + s.cmdline("6").unwrap(); + assert!(ends_with(&s, &["Requires an integer between 1 and 5."]), "{:?}", s.log); + s.cmdline("2.5").unwrap(); + assert!(ends_with(&s, &["Requires an integer value."]), "{:?}", s.log); + assert_eq!(asks(&s), "Enter choice, 1 to 5 <2>:"); + s.cmdline("4").unwrap(); + s.cmdline("3").unwrap(); + assert!(ends_with(&s, &["Invalid choice.", "Enter denominator of smallest fraction to display"]), "{:?}", s.log); + assert_eq!(asks(&s), "(1, 2, 4, 8, 16, 32, 64, 128, or 256) <16>:"); + s.script("\n\n9\n").unwrap(); + assert!(ends_with(&s, &["Requires an integer between 0 and 8."]), "{:?}", s.log); + s.script("\nabc\n").unwrap(); + assert!(ends_with(&s, &["Requires valid numeric angle or two points."]), "{:?}", s.log); + s.script("\n\n").unwrap(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 4, 0, 0, 0.0, 0)), "{:?}", units(&s)); +} + +#[test] +fn each_answer_is_kept_as_it_is_given() { + // Esc after the angle format: the answers before it are kept, and the new angle format's + // precision is reset to 4. + let mut s = session(DEFAULT); + s.script("-units\n4\n16\n2\n").unwrap(); + s.cancel(); + assert!(s.running.is_none()); + assert!(same(units(&s), (4, 4, 1, 4, 0.0, 0)), "{:?}", units(&s)); + let mut s = session(DEFAULT); + s.script("-units\n3\n2\n3\n5\n90\n").unwrap(); + s.cancel(); + assert!(same(units(&s), (3, 2, 2, 5, 1.413716694115407, 0)), "{:?}", units(&s)); +} diff --git a/crates/engine/tests/it/dynamic_block_data.rs b/crates/engine/tests/it/dynamic_block_data.rs index 5b4cb11e3..2e3f0baaa 100644 --- a/crates/engine/tests/it/dynamic_block_data.rs +++ b/crates/engine/tests/it/dynamic_block_data.rs @@ -83,6 +83,10 @@ fn blocks_without_dynamic_data_and_bad_names() { s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap(); assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null)); assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null)); + let list = s.execute("blocks.list", &json!({})).unwrap(); + let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect(); + assert!(flags.contains(&("Window".into(), json!(false))), "{flags:?}"); + assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}"); for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] { assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}"); } diff --git a/crates/engine/tests/it/explode_stretched.rs b/crates/engine/tests/it/explode_stretched.rs new file mode 100644 index 000000000..d7a7ac843 --- /dev/null +++ b/crates/engine/tests/it/explode_stretched.rs @@ -0,0 +1,140 @@ +//! EXPLODE of a block reference whose X and Y scales differ: its circles, arcs, ellipses and +//! polyline arcs come out as the ellipses and elliptical arcs the reference draws, not as circles +//! scaled by the mean of the two scales. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Entity, EntityKind}; +use serde_json::json; + +const AT: [f64; 2] = [100.0, 50.0]; +const ROTATION: f64 = 30.0; + +/// Block S (base 0,0): a circle (r 5 at 0,0), an arc (r 4 at 20,0, 0°..90°), an elliptical arc +/// (centre 40,0, major 6 along X, ratio 0.5, 0°..180°), a slot polyline (two lines, two +/// semicircles of r 2 at 60,0 and 70,0) and a line. One reference to it at `AT`, rotated by +/// `ROTATION`, scaled by `scale`, exploded. +fn exploded(scale: [f64; 2]) -> Vec { + let mut s = Session::new(); + let mut hs = Vec::new(); + for (id, p) in [ + ("circle", json!({"center": [0, 0], "radius": 5})), + ("arc", json!({"center": [20, 0], "radius": 4, "start": 0, "end": 90})), + ("ellipse", json!({"center": [40, 0], "major": [6, 0], "ratio": 0.5, "start": 0, "end": 180})), + ("pline", json!({"vertices": [[60, -2], {"p": [70, -2], "bulge": 1}, [70, 2], {"p": [60, 2], "bulge": 1}], "closed": true})), + ("line", json!({"points": [[0, 10], [10, 10]]})), + ] { + s.execute(id, &p).unwrap(); + hs.push(s.doc().unwrap().model.iter().last().unwrap().handle.hex()); + } + s.execute("block", &json!({"name": "S", "base": [0, 0], "keep": "delete", "handles": hs})).unwrap(); + let r = s.execute("insert", &json!({"name": "S", "at": AT, "scale": scale, "rotation": ROTATION})).unwrap(); + s.execute("explode", &json!({"handles": [r["handle"]]})).unwrap(); + s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect() +} + +/// A drawing point back in block coordinates. +fn to_block(p: [f64; 2], scale: [f64; 2]) -> [f64; 2] { + let (s, c) = (-ROTATION.to_radians()).sin_cos(); + let (dx, dy) = (p[0] - AT[0], p[1] - AT[1]); + [(dx * c - dy * s) / scale[0], (dx * s + dy * c) / scale[1]] +} + +/// Points along an ELLIPSE from its start to its end parameter. +fn samples(e: &cadcraft_engine::doc::Ellipse) -> Vec<[f64; 2]> { + let mut sweep = e.end - e.start; + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + let (mx, my) = (e.major.x, e.major.y); + let (nx, ny) = (-my * e.ratio, mx * e.ratio); + (0..=16) + .map(|i| { + let t = e.start + sweep * f64::from(i) / 16.0; + [e.center.x + mx * t.cos() + nx * t.sin(), e.center.y + my * t.cos() + ny * t.sin()] + }) + .collect() +} + +fn ellipses(v: &[Entity]) -> Vec { + v.iter() + .filter_map(|e| match &e.kind { + EntityKind::Ellipse(el) => Some(el.clone()), + _ => None, + }) + .collect() +} + +fn near(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).hypot(a[1] - b[1]) < 1e-9 +} + +/// Every point of `el`, taken back into the block, lies on the circle of radius `r` about `c` +/// in block coordinates, and its ends are `ends` (the whole circle when `None`). +fn on_circle(el: &cadcraft_engine::doc::Ellipse, scale: [f64; 2], c: [f64; 2], r: f64, ends: Option<([f64; 2], [f64; 2])>) { + let pts: Vec<[f64; 2]> = samples(el).into_iter().map(|p| to_block(p, scale)).collect(); + for q in &pts { + let d = (q[0] - c[0]).hypot(q[1] - c[1]); + assert!((d - r).abs() < 1e-9, "{q:?} is {d} from {c:?}, not {r}: {el:?}"); + } + if let Some((a, b)) = ends { + let (first, last) = (pts[0], pts[pts.len() - 1]); + assert!(near(first, a) && near(last, b) || near(first, b) && near(last, a), "ends {first:?} {last:?}, expected {a:?} {b:?}"); + } +} + +#[test] +fn explode_of_a_stretched_reference_draws_circles_and_arcs_as_ellipses() { + for scale in [[2.0, 0.5], [-2.0, 0.5], [1.0, 3.0]] { + let v = exploded(scale); + assert!(!v.iter().any(|e| matches!(e.kind, EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_))), "{scale:?}: {v:?}"); + let els = ellipses(&v); + // Circle, arc, elliptical arc and the slot's two semicircles. + assert_eq!(els.len(), 5, "{scale:?}: {els:?}"); + let at = |c: [f64; 2]| els.iter().filter(move |e| near(to_block([e.center.x, e.center.y], scale), c)); + let circle = at([0.0, 0.0]).next().unwrap(); + on_circle(circle, scale, [0.0, 0.0], 5.0, None); + // Its axes are the scaled radii: 10 and 2.5 for 2 x 0.5. + let major = circle.major.x.hypot(circle.major.y); + let (big, small) = (5.0 * scale[0].abs().max(scale[1].abs()), 5.0 * scale[0].abs().min(scale[1].abs())); + assert!((major - big).abs() < 1e-9 && (major * circle.ratio - small).abs() < 1e-9, "{circle:?}"); + on_circle(at([20.0, 0.0]).next().unwrap(), scale, [20.0, 0.0], 4.0, Some(([24.0, 0.0], [20.0, 4.0]))); + on_circle(at([60.0, 0.0]).next().unwrap(), scale, [60.0, 0.0], 2.0, Some(([60.0, 2.0], [60.0, -2.0]))); + on_circle(at([70.0, 0.0]).next().unwrap(), scale, [70.0, 0.0], 2.0, Some(([70.0, -2.0], [70.0, 2.0]))); + // The elliptical arc: its points, back in the block, are on the block's ellipse + // ((x-40)/6)^2 + (y/3)^2 = 1, from (46,0) over the top to (34,0). + let ell = at([40.0, 0.0]).next().unwrap(); + let pts: Vec<[f64; 2]> = samples(ell).into_iter().map(|p| to_block(p, scale)).collect(); + for q in &pts { + let f = ((q[0] - 40.0) / 6.0).powi(2) + (q[1] / 3.0).powi(2); + assert!((f - 1.0).abs() < 1e-9 && q[1] > -1e-9, "{q:?}: {ell:?}"); + } + let (first, last) = (pts[0], pts[pts.len() - 1]); + assert!(near(first, [46.0, 0.0]) && near(last, [34.0, 0.0]) || near(first, [34.0, 0.0]) && near(last, [46.0, 0.0]), "{pts:?}"); + // The slot's straight sides and the line are lines, where the reference draws them. + let lines: Vec<[[f64; 2]; 2]> = v + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some([to_block([l.a.x, l.a.y], scale), to_block([l.b.x, l.b.y], scale)]), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 3); + for [a, b] in [[[60.0, -2.0], [70.0, -2.0]], [[70.0, 2.0], [60.0, 2.0]], [[0.0, 10.0], [10.0, 10.0]]] { + assert!(lines.iter().any(|l| near(l[0], a) && near(l[1], b)), "{a:?}-{b:?} not in {lines:?}"); + } + } +} + +#[test] +fn explode_of_an_evenly_scaled_or_mirrored_reference_keeps_circles_arcs_and_polylines() { + for scale in [[2.0, 2.0], [-1.0, 1.0]] { + let v = exploded(scale); + let count = |f: fn(&EntityKind) -> bool| v.iter().filter(|e| f(&e.kind)).count(); + assert_eq!(count(|k| matches!(k, EntityKind::Circle(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::Arc(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::LwPolyline(_))), 1, "{scale:?}"); + assert_eq!(count(|k| matches!(k, EntityKind::Ellipse(_))), 1, "{scale:?}"); + let r = v.iter().find_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }).unwrap(); + assert!((r - 5.0 * scale[0].abs()).abs() < 1e-9); + } +} diff --git a/crates/engine/tests/it/extend_polyline_arc_end.rs b/crates/engine/tests/it/extend_polyline_arc_end.rs new file mode 100644 index 000000000..496187767 --- /dev/null +++ b/crates/engine/tests/it/extend_polyline_arc_end.rs @@ -0,0 +1,137 @@ +//! EXTEND on an open polyline whose end segment is an arc: the arc runs on round its own circle +//! to the boundary, as EXTEND does with an ARC, instead of the end vertex sliding along the chord +//! with the old bulge (which changed the arc's radius and centre, or missed the boundary). The +//! polyline keeps its width, elevation and linetype generation. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, Entity, EntityKind, Handle, LwPolyline}; +use cadcraft_engine::geom::{PolyVertex, Vec2}; +use serde_json::json; + +const TOL: f64 = 1e-9; + +fn add(s: &mut Session, kind: EntityKind) -> String { + let space = s.space(); + let d = s.doc_mut().unwrap(); + let handle = d.new_handle(); + d.space_mut(&space).unwrap().push(Entity { handle, common: Common::default(), kind }); + handle.hex() +} + +fn v(x: f64, y: f64, bulge: f64) -> PolyVertex { + PolyVertex { p: Vec2::new(x, y), bulge, ..Default::default() } +} + +fn pline(vertices: Vec) -> LwPolyline { + LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false } +} + +fn edge(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + r["handles"][0].as_str().unwrap().to_string() +} + +fn polyline(s: &Session, h: &str) -> LwPolyline { + match &s.doc().unwrap().entity(Handle::parse_hex(h).unwrap()).unwrap().kind { + EntityKind::LwPolyline(p) => p.clone(), + k => panic!("not a polyline: {k:?}"), + } +} + +fn near(a: Vec2, x: f64, y: f64) -> bool { + (a.x - x).abs() < 1e-6 && (a.y - y).abs() < 1e-6 +} + +/// tan(22.5°): the bulge of a quarter circle. +fn quarter() -> f64 { + (std::f64::consts::PI / 8.0).tan() +} + +/// The bulge of an arc turning through `deg` degrees (positive counterclockwise). +fn bulge(deg: f64) -> f64 { + (deg.to_radians() / 4.0).tan() +} + +#[test] +fn a_counterclockwise_arc_at_the_end_runs_on_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // A line in from (20,0), then a quarter circle about the origin from (10,0) to (0,10). + let mut pl = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + pl.plinegen = true; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert_eq!(p.vertices.len(), 3); + // Round the circle (radius 10) through (-10,0) to y = -5: a 210° turn in all. + let end = p.vertices[2].p; + assert!(near(end, -(75.0f64.sqrt()), -5.0), "end {end:?}"); + assert!((p.vertices[1].bulge - bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); + assert!(near(p.vertices[0].p, 20.0, 0.0) && near(p.vertices[1].p, 10.0, 0.0)); + assert_eq!((p.const_width, p.elevation, p.plinegen), (0.5, 3.0, true)); +} + +#[test] +fn an_arc_at_the_start_runs_on_backwards_round_its_circle() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, -5.0], [50.0, -5.0]); + // The same path drawn the other way: clockwise from (0,10) to (10,0), then out to (20,0). + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(0.0, 10.0, -quarter()), v(10.0, 0.0, 0.0), v(20.0, 0.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[0].p, -(75.0f64.sqrt()), -5.0), "start {:?}", p.vertices[0].p); + assert!((p.vertices[0].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[0].bulge); + assert!(near(p.vertices[1].p, 10.0, 0.0) && near(p.vertices[2].p, 20.0, 0.0)); +} + +#[test] +fn a_clockwise_arc_at_the_end_runs_on_clockwise() { + let mut s = Session::new(); + let e = edge(&mut s, [-50.0, 5.0], [50.0, 5.0]); + // Clockwise about the origin from (10,0) to (0,-10); on round through (-10,0) to y = 5. + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, -quarter()), v(0.0, -10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, -9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -(75.0f64.sqrt()), 5.0), "end {:?}", p.vertices[2].p); + assert!((p.vertices[1].bulge + bulge(210.0)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn the_nearest_crossing_ahead_on_the_circle_wins() { + let mut s = Session::new(); + // A boundary crossing the circle twice ahead of the end: x = -6 at about 127° and 233°. + let e = edge(&mut s, [-6.0, -50.0], [-6.0, 50.0]); + let h = add(&mut s, EntityKind::LwPolyline(pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]))); + s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[2].p, -6.0, 8.0), "end {:?}", p.vertices[2].p); + let turn = 8.0f64.atan2(-6.0).to_degrees(); + assert!((p.vertices[1].bulge - bulge(turn)).abs() < TOL, "bulge {}", p.vertices[1].bulge); +} + +#[test] +fn an_arc_end_whose_circle_misses_every_edge_is_left_alone() { + let mut s = Session::new(); + // The chord (10,0)→(0,10) runs on to this edge, but the circle never reaches it. + let e = edge(&mut s, [-50.0, 30.0], [50.0, 30.0]); + let before = pline(vec![v(20.0, 0.0, 0.0), v(10.0, 0.0, quarter()), v(0.0, 10.0, 0.0)]); + let h = add(&mut s, EntityKind::LwPolyline(before.clone())); + assert!(s.execute("extend", &json!({ "handle": h, "pick": [0.5, 9.9], "edges": [e] })).is_err()); + assert_eq!(polyline(&s, &h), before); +} + +#[test] +fn a_straight_end_keeps_the_polyline_width_and_elevation() { + let mut s = Session::new(); + let e = edge(&mut s, [50.0, -50.0], [50.0, 50.0]); + let mut pl = pline(vec![v(0.0, 0.0, 0.0), v(10.0, 0.0, 0.0)]); + pl.const_width = 0.5; + pl.elevation = 3.0; + let h = add(&mut s, EntityKind::LwPolyline(pl)); + s.execute("extend", &json!({ "handle": h, "pick": [9.0, 0.0], "edges": [e] })).unwrap(); + let p = polyline(&s, &h); + assert!(near(p.vertices[1].p, 50.0, 0.0), "end {:?}", p.vertices[1].p); + assert_eq!((p.const_width, p.elevation), (0.5, 3.0)); +} diff --git a/crates/engine/tests/it/inquiry_variables.rs b/crates/engine/tests/it/inquiry_variables.rs new file mode 100644 index 000000000..f42749458 --- /dev/null +++ b/crates/engine/tests/it/inquiry_variables.rs @@ -0,0 +1,271 @@ +//! AREA, PERIMETER and DISTANCE as AutoCAD 2024 leaves them after AREA, DIST and MEASUREGEOM +//! (measured with accoreconsole, running these scripts on these drawings): AREA is the area just +//! measured, or the running total in Add and Subtract mode, PERIMETER the perimeter of the last +//! area; DISTANCE the distance measured, or the total of DIST's Multiple points. After DIST's two +//! points LASTPOINT is still the first; after Multiple points it is the last. + +use cadcraft_engine::doc::{Common, EntityKind, Handle}; +use cadcraft_engine::geom::{Spline, Vec2}; +use cadcraft_engine::{Input, Session, sysvars}; +use serde_json::{Value, json}; + +fn var(s: &Session, name: &str) -> f64 { + sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap_or_else(|| panic!("{name} not a number")) +} + +fn lastpoint(s: &Session) -> (f64, f64) { + let v = sysvars::get(s, "LASTPOINT").unwrap(); + (v[0].as_f64().unwrap(), v[1].as_f64().unwrap()) +} + +fn close(got: f64, want: f64) -> bool { + (got - want).abs() <= 1e-9 * (1.0 + want.abs()) +} + +fn handle(v: &Value) -> Handle { + Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap() +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +/// The closed polyline with an arc and the circle the Add-mode scripts pick. +fn p_and_c(s: &mut Session) -> (Handle, Handle) { + let p = handle(&s.execute("pline", &json!({ "vertices": [[0, 0], { "p": [40, 0], "bulge": 0.5 }, [40, 30], [0, 30]], "closed": true })).unwrap()); + let c = handle(&s.execute("circle", &json!({ "center": [150, 15], "radius": 10 })).unwrap()); + (p, c) +} + +#[test] +fn area_of_points_sets_area_and_perimeter() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")"); + assert!(close(var(&s, "AREA"), 1200.0) && close(var(&s, "PERIMETER"), 140.0)); +} + +#[test] +fn add_mode_totals_the_area_and_keeps_the_last_perimeter() { + // Points, then the circle as an object. + let mut s = Session::new(); + let (_, c) = p_and_c(&mut s); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" '(0.0 0.0) '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\" \"_O\" (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")", + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 1514.159265358979), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER")); + // Both as objects. + let mut s = Session::new(); + let (p, c) = p_and_c(&mut s); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(0.0 15.0)) (list (handent \"{}\") '(160.0 15.0)) \"\" \"\")", + p.hex(), + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 1671.411490437671), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 62.83185307179586), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn subtract_mode_takes_the_area_off_the_total() { + // At large coordinates: the polyline added, the circle subtracted. + let mut s = Session::new(); + let p = handle( + &s.execute( + "pline", + &json!({ "vertices": [[-35000, 81000], { "p": [-33000, 81000], "bulge": 0.5 }, [-33000, 82500], [-35000, 82500]], "closed": true }), + ) + .unwrap(), + ); + let c = handle(&s.execute("circle", &json!({ "center": [-27000, 81750], "radius": 500 })).unwrap()); + lisp( + &mut s, + &format!( + "(command \"_.AREA\" \"_A\" \"_O\" (list (handent \"{}\") '(-35000.0 81750.0)) \"\" \"_S\" \"_O\" (list (handent \"{}\") '(-27000.0 81750.0)) \"\" \"\")", + p.hex(), + c.hex() + ), + ); + assert!(close(var(&s, "AREA"), 2607732.399299283), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 3141.592653589793), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_an_open_spline_closes_it_by_its_chord() { + let control = [(0.8, 0.25), (0.82, 0.24), (0.84, 0.25), (0.84, 0.28), (0.82, 0.29), (0.8, 0.28), (0.8, 0.25)]; + let spline = Spline { + degree: 3, + knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 4.0, 4.0], + control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(), + weights: Vec::new(), + fit: Vec::new(), + closed: false, + fit_opts: Default::default(), + }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.8 0.265)))", h.hex())); + assert!(close(var(&s, "AREA"), 0.001308333333333334), "{}", var(&s, "AREA")); + // The spline's length, 0.13046952991870 by an independent integration. AutoCAD reported + // 0.1304733129441191, 2.9e-5 longer than that: its own approximation of the length. + assert!(close(var(&s, "PERIMETER"), 0.1304695299187), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_a_spline_counts_its_narrow_knot_spans() { + // A degree-1 spline is its control polygon, here the triangle (0,0) (1,0) (0,1): area 0.5, + // perimeter 2 + sqrt(2), though two of its three knot spans are a billionth of the domain. + let control = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]; + let spline = Spline { + degree: 1, + knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0], + control: control.iter().map(|&(x, y)| Vec2::new(x, y)).collect(), + ..Spline::default() + }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.5 0.0)))", h.hex())); + assert!(close(var(&s, "AREA"), 0.5), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 2.0 + 2f64.sqrt()), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn area_of_a_spline_with_more_spans_than_samples_counts_each() { + // The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: 5000 spans along the bottom, + // then two a millionth wide up the right side and across the top. Area 10000, perimeter (the + // open spline's length) 300. + let mut control: Vec = (0..=5000).map(|i| Vec2::new(i as f64 / 50.0, 0.0)).collect(); + control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]); + let mut knots: Vec = (0..=5000).map(f64::from).collect(); + knots.insert(0, 0.0); + knots.extend([5000.0 + 1e-6, 5000.0 + 2e-6, 5000.0 + 2e-6]); + let spline = Spline { degree: 1, knots, control, ..Spline::default() }; + let mut s = Session::new(); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(50.0 0.0)))", h.hex())); + assert!(close(var(&s, "AREA"), 1e4), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 300.0), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn a_spline_too_long_to_measure_is_refused_and_not_kept() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")"); + // Finite ends 2e308 apart: its chord closes no area, but its length is past f64's range. That is + // too large to report, as a polyline's is, not a perimeter of 0. + let spline = Spline::from_line(Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0), 1); + let space = s.space(); + let h = s.doc_mut().unwrap().add(&space, Common::default(), EntityKind::Spline(spline)).unwrap(); + let err = s.execute("area", &json!({ "handle": h.hex() })).unwrap_err(); + assert!(err.to_string().contains("the perimeter is too large"), "{err}"); + lisp(&mut s, &format!("(command \"_.AREA\" \"_O\" (list (handent \"{}\") '(0.0 0.0)))", h.hex())); + assert!(s.log.iter().any(|l| l.contains("The perimeter is too large.")), "{:?}", s.log); + assert!(close(var(&s, "AREA"), 50.0), "{}", var(&s, "AREA")); + assert!(close(var(&s, "PERIMETER"), 20.0 + 200f64.sqrt()), "{}", var(&s, "PERIMETER")); +} + +#[test] +fn dist_sets_the_distance_and_leaves_lastpoint_at_the_first_point() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))"); + assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 0.5) && close(y, 0.25), "{x}, {y}"); +} + +#[test] +fn dist_with_multiple_points_sets_the_total_and_lastpoint_at_the_last() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(40.0 0.0) '(40.0 30.0) '(0.0 30.0) \"\")"); + assert!(close(var(&s, "DISTANCE"), 110.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 0.0) && close(y, 30.0), "{x}, {y}"); +} + +#[test] +fn measuregeom_sets_them_as_dist_and_area_do() { + let mut s = Session::new(); + // (AutoCAD's MEASUREGEOM asks for the next option after each measurement until eXit; here it + // ends after one.) + lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_D\" '(0.0 0.0) '(30.0 40.0))"); + assert!(close(var(&s, "DISTANCE"), 50.0), "{}", var(&s, "DISTANCE")); + lisp(&mut s, "(command \"_.MEASUREGEOM\" \"_AR\" '(0.5 0.25) '(0.54 0.25) '(0.54 0.28) '(0.5 0.28) \"\")"); + assert!(close(var(&s, "AREA"), 0.0012) && close(var(&s, "PERIMETER"), 0.14), "{} {}", var(&s, "AREA"), var(&s, "PERIMETER")); +} + +#[test] +fn the_json_forms_set_them_too_and_they_are_read_only() { + let mut s = Session::new(); + s.execute("area", &json!({ "points": [[0, 0], [40, 0], [40, 30], [0, 30]] })).unwrap(); + s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap(); + assert_eq!((var(&s, "AREA"), var(&s, "PERIMETER"), var(&s, "DISTANCE")), (1200.0, 140.0, 50.0)); + for name in ["AREA", "PERIMETER", "DISTANCE"] { + assert!(s.execute("setvar", &json!({ "name": name, "value": 1 })).is_err(), "{name}"); + } +} + +#[test] +fn lastpoint_after_multiple_points_is_the_last_point_entered() { + // A length typed with Length extends the path, but LASTPOINT stays at the last point picked; + // after Undo it stays at the point undone (AutoCAD 2024 leaves LASTPOINT at 10,0 and 10,10). + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) \"_L\" 5.0 \"\")"); + assert!(close(var(&s, "DISTANCE"), 15.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 10.0) && close(y, 0.0), "{x}, {y}"); + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.0 0.0) \"_M\" '(10.0 0.0) '(10.0 10.0) \"_U\" \"_T\")"); + assert!(close(var(&s, "DISTANCE"), 10.0), "{}", var(&s, "DISTANCE")); + let (x, y) = lastpoint(&s); + assert!(close(x, 10.0) && close(y, 10.0), "{x}, {y}"); +} + +#[test] +fn a_distance_too_large_to_measure_is_refused_and_not_kept() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.DIST\" '(0.5 0.25) '(0.53 0.29))"); + assert!(close(var(&s, "DISTANCE"), 0.05)); + // Finite points whose distance overflows: the JSON form, two picked points, Multiple points. + assert!(s.execute("dist", &json!({ "p1": [-1e308, 0], "p2": [1e308, 0] })).is_err()); + s.cmdline("dist").unwrap(); + s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap(); + s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap(); + assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last()); + // LASTPOINT stays at the first point, as after a measurement. + assert_eq!(lastpoint(&s), (-1e308, 0.0)); + s.cancel(); + s.cmdline("dist").unwrap(); + s.input(Input::Point(Vec2::new(-1e308, 0.0))).unwrap(); + s.cmdline("m").unwrap(); + s.input(Input::Point(Vec2::new(1e308, 0.0))).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.log.last().is_some_and(|l| l.contains("the distance is too large")), "{:?}", s.log.last()); + s.cancel(); + assert!(close(var(&s, "DISTANCE"), 0.05), "{}", var(&s, "DISTANCE")); +} + +#[test] +fn the_variable_list_shows_the_sessions_values() { + // A drawing's header can carry these names; GETVAR reads the session's, and so does the list. + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("AREA", 99.0); + // Every DXF header carries ORTHOMODE; the session's ortho is off. + s.doc_mut().unwrap().header.set_i64("ORTHOMODE", 1); + lisp(&mut s, "(command \"_.AREA\" '(0.0 0.0) '(10.0 0.0) '(10.0 10.0) \"\")"); + let list = s.execute("sysvars", &json!({})).unwrap(); + assert!(close(var(&s, "AREA"), 50.0)); + assert_eq!(list["AREA"].as_f64(), Some(var(&s, "AREA")), "{}", list["AREA"]); + assert_eq!(list["ORTHOMODE"], sysvars::get(&s, "ORTHOMODE").unwrap()); + assert_eq!(list["ORTHOMODE"], 0); +} diff --git a/crates/engine/tests/it/inquiry_z.rs b/crates/engine/tests/it/inquiry_z.rs index 17f44ac03..274eeacd0 100644 --- a/crates/engine/tests/it/inquiry_z.rs +++ b/crates/engine/tests/it/inquiry_z.rs @@ -3,7 +3,7 @@ //! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft. use cadcraft_engine::Session; -use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point}; +use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point, PolyfaceMesh}; use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; use serde_json::json; @@ -56,6 +56,16 @@ fn id_reports_the_z_of_what_is_at_the_point() { assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::>()); } +#[test] +fn id_reports_the_z_on_a_polyface_mesh() { + let mut s = Session::new(); + // A sloped TIN as two triangles: z = 1000 + x / 10 over (0..100, 0..100). + let vertices = vec![Vec3::new(0.0, 0.0, 1000.0), Vec3::new(100.0, 0.0, 1010.0), Vec3::new(100.0, 100.0, 1010.0), Vec3::new(0.0, 100.0, 1000.0)]; + add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]] })); + s.cmdline("zoom e").ok(); + assert!((z(&mut s, [50.0, 0.0]) - 1005.0).abs() < 1e-9, "on the mesh's edge"); +} + fn z_given(s: &mut Session) -> f64 { s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap() } diff --git a/crates/engine/tests/it/insert_attribute_prompts.rs b/crates/engine/tests/it/insert_attribute_prompts.rs new file mode 100644 index 000000000..97df80386 --- /dev/null +++ b/crates/engine/tests/it/insert_attribute_prompts.rs @@ -0,0 +1,82 @@ +//! INSERT's attribute questions as AutoCAD 2024 asks them from AutoLISP's `command` (accoreconsole, +//! the attributes read back with `entget`). + +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, Session}; + +/// A session with block K: a circle and attributes (tag, DXF flags, default value), each with the +/// prompt "P" + tag. +fn with_block(defs: &[(&str, i32, &str)]) -> Session { + // `entmake` goes through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + let mut code = + String::from("(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0)))"); + for (i, (tag, flags, default)) in defs.iter().enumerate() { + let y = 5.0 + 3.0 * i as f64; + code += &format!( + " (entmake '((0 . \"ATTDEF\") (10 0.0 {y:.1} 0.0) (40 . 2.5) (1 . \"{default}\") (3 . \"P{tag}\") (2 . \"{tag}\") (70 . {flags})))" + ); + } + code += " (entmake '((0 . \"ENDBLK\")))"; + s.cmdline(&format!("(progn {code})")).unwrap(); + s.log.clear(); + s +} + +/// The attribute values of the last block reference, by tag. +fn values(s: &Session) -> Vec<(String, String)> { + let d = s.doc().unwrap(); + let Some(EntityKind::Insert(i)) = d.model.iter().last().map(|e| &e.kind) else { panic!("no block reference") }; + i.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect() +} + +fn said(s: &Session, line: &str) -> usize { + s.log.iter().filter(|l| l.as_str() == line).count() +} + +fn pair(tag: &str, value: &str) -> (String, String) { + (tag.to_string(), value.to_string()) +} + +#[test] +fn attreq_0_inserts_the_default_values_without_asking() { + let mut s = with_block(&[("T1", 0, "V1"), ("T2", 0, "V2")]); + s.cmdline("(progn (setvar \"ATTREQ\" 0) (command \"_.-INSERT\" \"K\" '(100.0 0.0) \"\" \"\" 0))").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "V2")]); + assert_eq!(said(&s, "Enter attribute values"), 0, "{:?}", s.log); + s.cmdline("(setvar \"ATTREQ\" 1)").unwrap(); + s.cmdline("(princ (getvar \"ATTREQ\"))").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("1"), "{:?}", s.log); +} + +#[test] +fn a_preset_attribute_is_not_asked() { + let mut s = with_block(&[("T1", 8, "V1"), ("T2", 0, "V2")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"X\")").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "V1"), pair("T2", "X")]); + assert_eq!(said(&s, "Enter attribute values"), 1, "{:?}", s.log); +} + +#[test] +fn a_verify_attribute_is_asked_again_with_the_value_typed() { + let mut s = with_block(&[("T1", 4, "V1")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\")").unwrap(); + assert_eq!(said(&s, "Verify attribute values"), 1, "{:?}", s.log); + assert!(s.prompt_text().contains("PT1 "), "{}", s.prompt_text()); + // A click is no answer. + s.input(Input::Point(Vec2::new(1.0, 1.0))).unwrap(); + assert!(s.prompt_text().contains("PT1 "), "{}", s.prompt_text()); + // Typed at the prompt, the whole line is the value. + s.cmdline("Y").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(values(&s), [pair("T1", "Y")]); + // Enter keeps the value typed. + let mut s = with_block(&[("T1", 4, "V1")]); + s.cmdline("(command \"_.-INSERT\" \"K\" '(300.0 0.0) \"\" \"\" 0 \"X\" \"\")").unwrap(); + assert_eq!(values(&s), [pair("T1", "X")]); +} diff --git a/crates/engine/tests/it/layer_merge_delete.rs b/crates/engine/tests/it/layer_merge_delete.rs index 4778a4c57..bcbc2ed0f 100644 --- a/crates/engine/tests/it/layer_merge_delete.rs +++ b/crates/engine/tests/it/layer_merge_delete.rs @@ -159,3 +159,16 @@ fn layer_delete_clears_viewport_overrides_for_the_layer() { s.execute("layer.new", &json!({ "name": "C" })).unwrap(); assert!(viewport_lists(&s).iter().all(|(f, _)| !f.contains(&"C".to_string()))); } + +#[test] +fn renaming_a_layer_repoints_viewport_lists() { + let mut s = drawing(); + assert_eq!(viewport_lists(&s), vec![(vec!["A".to_string(), "C".to_string()], vec!["B".to_string()])]); + s.execute("rename", &json!({ "table": "layer", "from": "A", "to": "Murs" })).unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])]); + // layer.set's newName form (what the Properties palette sends) goes through the same path. + s.execute("layer.set", &json!({ "name": "B", "newName": "Cloisons" })).unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["Cloisons".to_string()])]); + s.undo().unwrap(); + assert_eq!(viewport_lists(&s), vec![(vec!["Murs".to_string(), "C".to_string()], vec!["B".to_string()])], "undo brings the old name back"); +} diff --git a/crates/engine/tests/it/layout_prompts.rs b/crates/engine/tests/it/layout_prompts.rs index afa784e0b..8b6ea6637 100644 --- a/crates/engine/tests/it/layout_prompts.rs +++ b/crates/engine/tests/it/layout_prompts.rs @@ -17,7 +17,7 @@ fn typed_layout_options_prompt_for_names() { s.cmdline("LAYOUT").unwrap(); let p = s.current_prompt().expect("LAYOUT prompts").display(); - assert_eq!(p, "Enter layout option or [Copy/Delete/New/Template/Rename/SAveas/Set/?] :"); + assert_eq!(p, "Enter layout option [Copy/Delete/New/Template/Rename/SAveas/Set/?] :"); s.cmdline("n").unwrap(); assert_eq!(s.current_prompt().unwrap().display(), "Enter name of new layout :"); s.cmdline("Sheet9").unwrap(); diff --git a/crates/engine/tests/it/leader_command.rs b/crates/engine/tests/it/leader_command.rs new file mode 100644 index 000000000..f71c99462 --- /dev/null +++ b/crates/engine/tests/it/leader_command.rs @@ -0,0 +1,340 @@ +//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole: these scripts, with +//! DIMTAD 1, DIMGAP 0.625, DIMASZ 2.5, DIMSCALE 1 and DIMTXT 2.5 unless a case sets another, and +//! the drawing read back afterwards). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Insert, Leader, MText}; +use serde_json::json; + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) +} + +/// A drawing as the measured scripts start from: the dimension settings, and a text "XYZ" 2.5 +/// high at (100,40) that the Copy cases copy (`(entlast)` after it). +fn session(setup: &str) -> Session { + let mut s = Session::new(); + for (name, v) in [("DIMTAD", 1.0), ("DIMGAP", 0.625), ("DIMASZ", 2.5), ("DIMSCALE", 1.0), ("DIMTXT", 2.5)] { + s.execute("setvar", &json!({ "name": name, "value": v })).unwrap(); + } + s.execute("text", &json!({ "at": [100, 40], "text": "XYZ", "height": 2.5 })).unwrap(); + s.cmdline("(setq hl_T (entlast))").unwrap(); + if !setup.is_empty() { + s.cmdline(setup).unwrap(); + } + s +} + +fn run(setup: &str, script: &str) -> Session { + let mut s = session(setup); + s.cmdline(script).unwrap(); + assert!(s.running.is_none(), "{script}: {}", s.prompt_text()); + s +} + +fn leaders(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Leader(l) = &e.kind { Some(l.clone()) } else { None }).collect() +} + +fn mtexts(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.clone()) } else { None }).collect() +} + +fn inserts(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect() +} + +fn vertices(l: &Leader) -> Vec<[f64; 2]> { + l.vertices.iter().map(|v| [v.x, v.y]).collect() +} + +fn same_points(got: &[[f64; 2]], want: &[[f64; 2]]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(g, w)| near(g[0], w[0]) && near(g[1], w[1])) +} + +/// (case, setup, script, leader vertices, text insertion point, attachment, height, contents) +type Case = (&'static str, &'static str, &'static str, &'static [[f64; 2]], [f64; 2], u8, f64, &'static str); + +#[test] +fn leaders_and_their_text_are_where_autocad_puts_them() { + let cases: [Case; 17] = [ + // A horizontal last segment: no hook line; the text DIMGAP right of and above the end. + ( + "000", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 2.5, + "ABC", + ), + // More than 15° off horizontal: a hook line DIMASZ long into the end. + ( + "001", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]], + [35.625, 30.625], + 7, + 2.5, + "ABC", + ), + ( + "002", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"AB\" \"CD\" \"\")", + &[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]], + [20.625, 15.625], + 7, + 2.5, + "AB\\PCD", + ), + // To the left: the text right-attached, DIMGAP left of the end. + ( + "004", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [-17.5, 15.0], [-20.0, 15.0]], + [-20.625, 15.625], + 9, + 2.5, + "ABC", + ), + // A vertical last segment hooks to the right. + ( + "007-VN", + "", + "(command \"_.LEADER\" '(612345.678 2398765.432) '(612345.678 2398795.432) \"\" \"ABC\" \"\")", + &[[612345.678, 2398765.432], [612343.178, 2398795.432], [612345.678, 2398795.432]], + [612346.303, 2398796.057], + 7, + 2.5, + "ABC", + ), + // 14.9° and 15° keep the end; 15.1° hooks. + ( + "100", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.32158853384029) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 20.32158853384029]], + [40.625, 20.94658853384029], + 7, + 2.5, + "ABC", + ), + ( + "101", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.358983848622454) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 20.358983848622454]], + [40.625, 20.983983848622454], + 7, + 2.5, + "ABC", + ), + ( + "102", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 20.39641415635812) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [37.5, 20.39641415635812], [40.0, 20.39641415635812]], + [40.625, 21.02141415635812], + 7, + 2.5, + "ABC", + ), + // DIMTAD 0: centred on the end, left- or right-attached. + ( + "020", + "(setvar \"DIMTAD\" 0)", + "(command \"_.LEADER\" '(0.5 0.25) '(0.52 0.265) '(0.54 0.265) \"\" \"ABC\" \"\")", + &[[0.5, 0.25], [0.52, 0.265], [0.54, 0.265]], + [1.165, 0.265], + 4, + 2.5, + "ABC", + ), + ( + "103-VN", + "(setvar \"DIMTAD\" 0)", + "(command \"_.LEADER\" '(612345.678 2398765.432) '(612325.678 2398780.432) '(612305.678 2398780.432) \"\" \"ABC\" \"\")", + &[[612345.678, 2398765.432], [612325.678, 2398780.432], [612305.678, 2398780.432]], + [612305.053, 2398780.432], + 6, + 2.5, + "ABC", + ), + // DIMGAP, DIMSCALE (hook, gap and height) and DIMTXT. + ( + "021", + "(setvar \"DIMGAP\" 2.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [42.0, 17.0], + 7, + 2.5, + "ABC", + ), + ( + "022", + "(setvar \"DIMSCALE\" 2.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [30.0, 30.0], [35.0, 30.0]], + [36.25, 31.25], + 7, + 5.0, + "ABC", + ), + ( + "024", + "(setvar \"DIMTXT\" 5.0)", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(40.0 15.0) \"\" \"AB\" \"CD\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 5.0, + "AB\\PCD", + ), + // A spline takes no hook line. + ( + "026", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" '(35.0 30.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [35.0, 30.0]], + [35.625, 30.625], + 7, + 2.5, + "ABC", + ), + // Undo takes the last point back. + ( + "014", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) '(35.0 30.0) \"_U\" '(40.0 15.0) \"\" \"ABC\" \"\")", + &[[0.0, 0.0], [20.0, 15.0], [40.0, 15.0]], + [40.625, 15.625], + 7, + 2.5, + "ABC", + ), + // Annotation options: Mtext, at the command line, comes back to the first line. + ( + "106", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_M\" \"ABC\" \"\")", + &[[0.0, 0.0], [17.5, 15.0], [20.0, 15.0]], + [20.625, 15.625], + 7, + 2.5, + "ABC", + ), + // Copy: centred on its line; with DIMTAD on the leader ends half the text height lower. + ( + "016", + "", + "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" hl_T)", + &[[0.0, 0.0], [17.5, 13.75], [20.0, 13.75]], + [20.625, 15.625], + 4, + 2.5, + "XYZ", + ), + ]; + for (case, setup, script, want_vertices, at, attach, height, contents) in cases { + let s = run(setup, script); + let ls = leaders(&s); + assert_eq!(ls.len(), 1, "{case}"); + assert!(same_points(&vertices(&ls[0]), want_vertices), "{case}: {:?}", vertices(&ls[0])); + let ms = mtexts(&s); + assert_eq!(ms.len(), 1, "{case}"); + let m = &ms[0]; + assert!(near(m.insert.x, at[0]) && near(m.insert.y, at[1]), "{case}: {:?}", m.insert); + assert_eq!((m.attach, m.contents.as_str()), (attach, contents), "{case}"); + assert!(near(m.height, height) && m.width == 0.0, "{case}: {} {}", m.height, m.width); + } +} + +#[test] +fn format_sets_the_path_and_the_arrow() { + // LEADER-011 Format None; LEADER-012 None then Arrow; LEADER-013 Spline then STraight. + for (script, spline, arrow) in [ + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, false), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_N\" \"_F\" \"_A\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"_S\" \"_F\" \"_ST\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"_F\" \"\" '(40.0 15.0) \"\" \"ABC\" \"\")", false, true), + ] { + let l = leaders(&run("", script)).remove(0); + assert_eq!((l.spline, l.arrow), (spline, arrow), "{script}"); + } +} + +#[test] +fn no_annotation_a_block_or_a_copied_multiline_text() { + // LEADER-015: None leaves the leader alone, without a hook line. + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_N\")"); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]])); + assert!(mtexts(&s).is_empty()); + // LEADER-107: a block, scale 2 (Y as X), 30°, where it is placed; no hook line. + let mut s = session(""); + s.execute("line", &json!({ "points": [[100, 0], [110, 0]] })).unwrap(); + s.cmdline("(command \"_.-BLOCK\" \"LB107\" '(100.0 0.0) (entlast) \"\")").unwrap(); + s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_B\" \"LB107\" '(50.0 20.0) 2 \"\" 30)").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0]])); + let i = inserts(&s).remove(0); + assert!(i.block == "LB107" && near(i.insert.x, 50.0) && near(i.insert.y, 20.0), "{i:?}"); + assert!(near(i.scale.x, 2.0) && near(i.scale.y, 2.0) && near(i.rotation, 30f64.to_radians()), "{i:?}"); + // LEADER-105: a copied multiline text keeps its width; LEADER-137 to the left. + let mut s = session(""); + s.cmdline("(command \"_.MTEXT\" '(60.0 40.0) '(90.0 30.0) \"MT\" \"\")").unwrap(); + s.cmdline("(command \"_.LEADER\" '(0.0 0.0) '(20.0 15.0) \"\" \"\" \"_C\" (entlast))").unwrap(); + let copy = mtexts(&s).into_iter().find(|m| near(m.insert.x, 20.625)).unwrap(); + assert!(near(copy.width, 30.0) && copy.attach == 4 && copy.contents == "MT", "{copy:?}"); + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) '(-20.0 15.0) \"\" \"\" \"_C\" hl_T)"); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [-17.5, 13.75], [-20.0, 13.75]])); + let m = mtexts(&s).into_iter().next().unwrap(); + assert!(near(m.insert.x, -20.625) && near(m.insert.y, 15.625) && m.attach == 6, "{m:?}"); +} + +#[test] +fn the_json_form_places_its_text_as_the_command_line_does() { + let mut s = session(""); + let v = s.execute("leader", &json!({ "points": [[0, 0], [20, 15], [35, 30]], "text": "ABC" })).unwrap(); + assert_eq!(v["handles"].as_array().map(Vec::len), Some(2)); + assert!(same_points(&vertices(&leaders(&s)[0]), &[[0.0, 0.0], [20.0, 15.0], [32.5, 30.0], [35.0, 30.0]])); + let m = mtexts(&s).remove(0); + assert!(near(m.insert.x, 35.625) && near(m.insert.y, 30.625) && m.attach == 7 && near(m.height, 2.5), "{m:?}"); +} + +#[test] +fn the_prompts_are_autocads_and_one_point_draws_nothing() { + let mut s = session(""); + s.cmdline("leader").unwrap(); + assert!(s.prompt_text().ends_with("Specify leader start point:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Specify next point:"), "{}", s.prompt_text()); + s.cmdline("20,15").unwrap(); + assert!(s.prompt_text().ends_with("Specify next point or [Annotation/Format/Undo] :"), "{}", s.prompt_text()); + s.cmdline("_F").unwrap(); + assert!(s.prompt_text().ends_with("[Spline/STraight/Arrow/None] :"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("Enter first line of annotation text or :"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.prompt_text().ends_with("[Tolerance/Copy/Block/None/Mtext] :"), "{}", s.prompt_text()); + // LEADER-019: Tolerance, at the command line, comes back to the first line. + s.cmdline("_T").unwrap(); + assert!(s.prompt_text().ends_with("Enter first line of annotation text or :"), "{}", s.prompt_text()); + // An answer that isn't an option is refused; `?` at the block name lists the blocks. + s.cmdline("").unwrap(); + s.cmdline("X").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Invalid option keyword.")); + s.cmdline("_B").unwrap(); + assert!(s.prompt_text().ends_with("Enter block name or [?]:"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Defined blocks: (none)")); + s.cancel(); + // LEADER-139: one point and Enter. + let s = run("", "(command \"_.LEADER\" '(0.0 0.0) \"\")"); + assert!(leaders(&s).is_empty() && mtexts(&s).is_empty()); +} diff --git a/crates/engine/tests/it/lisp_undo_in_routine.rs b/crates/engine/tests/it/lisp_undo_in_routine.rs new file mode 100644 index 000000000..9afa3c9f5 --- /dev/null +++ b/crates/engine/tests/it/lisp_undo_in_routine.rs @@ -0,0 +1,154 @@ +//! U, UNDO and REDO inside a LISP routine, as AutoCAD 2024 runs them (accoreconsole: a line A, a +//! line B, a circle C and a line D, then one routine, then the drawing's `entnext` order read back). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::Handle; +use serde_json::json; + +/// A session with A, B, C and D in draw order, each its own undo step, and their handles. +fn drawing() -> (Session, Vec) { + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap(); + s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let hs = handles(&s); + (s, hs) +} + +fn handles(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle).collect() +} + +/// The drawing's objects in draw order: A to D by their handles, E for any other. +fn order(s: &Session, hs: &[Handle]) -> String { + let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char); + handles(s).iter().map(|h| name(h).to_string()).collect::>().join(" ") +} + +/// Run `parts` as ONE routine, `@A`…`@D` standing for the objects. +fn routine(s: &mut Session, hs: &[Handle], parts: &[&str]) { + let mut code = format!("(progn {})", parts.join(" ")); + for (i, h) in hs.iter().enumerate() { + code = code.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex())); + } + s.cmdline(&code).unwrap(); + assert!(s.running.is_none(), "{code}: {}", s.prompt_text()); +} + +const EB: &str = "(command \"_.ERASE\" @B \"\")"; +const EC: &str = "(command \"_.ERASE\" @C \"\")"; +const U: &str = "(command \"_.U\")"; +const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")"; +const MAKE: &str = "(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 60.0 0.0) (11 10.0 60.0 0.0)))"; + +#[test] +fn u_in_a_routine_takes_back_the_routines_last_command() { + // (case, the routine, the drawing's order afterwards) + let cases: [(&str, &[&str], &str); 9] = [ + ("U-001: ERASE, U", &[EB, U], "A B C D"), + ("U-002: two erases, U", &[EB, EC, U], "A C D"), + ("U-003: two erases, U, U", &[EB, EC, U, U], "A B C D"), + ("U-005: entdel, ERASE, U", &["(entdel @B)", EC, U], "A C D"), + ("U-006: ERASE, entdel, U", &[EB, "(entdel @C)", U], "A B C D"), + ("U-007: LINE, U, REDO", &[LINE, U, "(command \"_.REDO\")"], "A B C D E"), + ("U-008: two erases, UNDO 2", &[EB, EC, "(command \"_.UNDO\" \"2\")"], "A B C D"), + ("U-012: ERASE, entmake, U", &[EB, MAKE, U], "A B C D"), + // OOPS-011 had an OOPS after the U, which brought B back: B was still erased. + ("OOPS-011: ERASE, LINE, U", &[EB, LINE, U], "A C D"), + ]; + for (case, parts, want) in cases { + let (mut s, hs) = drawing(); + routine(&mut s, &hs, parts); + assert_eq!(order(&s, &hs), want, "{case}"); + } +} + +#[test] +fn the_routine_is_still_one_undo_step_after_it() { + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, EC, LINE, U, "(entdel @A)"]); + assert_eq!(order(&s, &hs), "D"); + assert_eq!(s.state().unwrap().undo.len(), depth + 1); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B C D"); + s.cmdline("REDO").unwrap(); + assert_eq!(order(&s, &hs), "D"); +} + +#[test] +fn u_with_nothing_of_the_routine_left_takes_back_the_step_before_it() { + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, U, U]); + // The second U takes back D's line, drawn before the routine. + assert_eq!(order(&s, &hs), "A B C"); + assert_eq!(s.state().unwrap().undo.len(), depth - 1, "the routine changed nothing more of its own"); + // A command after that makes the routine's step, from where the U took the drawing. + routine(&mut s, &hs, &[EB, U, U, "(command \"_.ERASE\" @A \"\")"]); + assert_eq!(order(&s, &hs), "B"); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B"); +} + +#[test] +fn undo_off_still_refuses_inside_a_routine() { + let (mut s, hs) = drawing(); + routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_N\")", EB, U]); + assert_eq!(order(&s, &hs), "A C D"); +} + +#[test] +fn redo_inside_a_routine_brings_back_the_last_u_first() { + // U past the routine's own command, then REDO: the drawing's step comes back, not the erase. + let (mut s, hs) = drawing(); + let depth = s.state().unwrap().undo.len(); + routine(&mut s, &hs, &[EB, U, U, "(command \"_.REDO\")"]); + assert_eq!(order(&s, &hs), "A B C D"); + assert_eq!(s.state().unwrap().undo.len(), depth, "the routine left nothing of its own to undo"); + // A command inside the routine ends what REDO could bring back from before it. + let (mut s, hs) = drawing(); + s.cmdline("U").unwrap(); + assert_eq!(order(&s, &hs), "A B C"); + routine(&mut s, &hs, &[EB, "(command \"_.REDO\")"]); + assert_eq!(order(&s, &hs), "A C"); +} + +#[test] +fn a_command_without_prompts_is_a_step_of_the_routine_too() { + // HATCHTOBACK runs as its JSON form from a routine; U takes it back, not the hatch. + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let rect = handles(&s)[0]; + s.execute("hatch", &json!({ "handles": [rect.hex()], "pattern": "SOLID" })).unwrap(); + let both = handles(&s); + assert_eq!(both.len(), 2); + s.cmdline("(progn (command \"_.HATCHTOBACK\") (command \"_.U\"))").unwrap(); + assert_eq!(handles(&s), both); +} + +#[test] +fn undo_control_one_keeps_only_the_routines_last_command() { + // As UNDO Control One keeps only the drawing's last step: the second U goes on to the step + // before the routine (D's line), the drawing as it was then. + let (mut s, hs) = drawing(); + routine(&mut s, &hs, &["(command \"_.UNDO\" \"_C\" \"_O\")", EB, EC, U, U]); + assert_eq!(order(&s, &hs), "A B C"); +} + +#[test] +fn a_routine_keeps_as_many_steps_as_the_drawing() { + use cadcraft_engine::undoctl::MAX_UNDO; + let (mut s, hs) = drawing(); + // Two lines more than the routine keeps steps for, then a U for each kept step and one more, + // which goes on to the drawing's step before the routine (D's line): the drawing as it was + // then, without the two lines whose steps went. + let code = + format!("(progn (repeat {} (command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")) (repeat {} (command \"_.U\")))", MAX_UNDO + 2, MAX_UNDO + 1); + s.cmdline(&code).unwrap(); + assert!(s.running.is_none()); + assert_eq!(order(&s, &hs), "A B C"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 5c0a37f45..9e014d4b1 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -5,6 +5,7 @@ mod annotative; mod arc_ctrl_direction; mod array_commands; mod assoc_array; +mod attdef_command; mod attdisp_attsync; mod attedit_prompts; mod attrib_modes; @@ -19,13 +20,16 @@ mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod change_command; mod chprop_command; +mod classic_array; mod compare_export; mod constraint_geometry; mod constraint_infer; mod copy_count; mod count; mod dash_block; +mod dash_units; mod data_extraction; mod dim_baseline_spacing; mod dim_chain; @@ -42,6 +46,8 @@ mod draworder; mod dyn_input; mod dynamic_block_data; mod dynamic_grips; +mod explode_stretched; +mod extend_polyline_arc_end; mod fillet_chamfer; mod fix_holds_grips; mod followups_626; @@ -56,7 +62,9 @@ mod hatch_numeric_validation; mod hatch_options; mod helix; mod inquiry_keywords; +mod inquiry_variables; mod inquiry_z; +mod insert_attribute_prompts; mod insert_prompts; mod isometric; mod join_close; @@ -65,12 +73,15 @@ mod layer_cmdline; mod layer_merge_delete; mod layoff_repeat; mod layout_prompts; +mod leader_command; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod lisp_corpus; +mod lisp_undo_in_routine; mod malformed_handles; mod matchprop; mod measure_distance_snapping; mod metric_linetypes; +mod mirror_degenerate_line; mod mleader_content; mod mleader_remember; mod mline; @@ -85,6 +96,7 @@ mod non_finite_edits; mod offset_closed_spline; mod offset_ellipse_side; mod offset_move; +mod oops_command; mod options_profile; mod overkill; mod parallel_display_list; @@ -95,8 +107,10 @@ mod pickdrag; mod pline_arc_xline_options; mod plot_styles; mod point_modifiers; +mod polyface_explode; mod post_command_passes; mod prompt_keywords; +mod prompt_or_once; mod props_bad_lineweight; mod props_insert_scale_attribs; mod purge_mlinestyles; @@ -107,10 +121,12 @@ mod query_entities; mod quickcalc; mod recent_commands; mod rectang_ortho; +mod rectang_polygon_order; mod refedit; mod region; mod rename_cmdline; mod rotate_reference; +mod scale_offset_z; mod select_keywords; mod select_similar; mod select_underscore_keywords; diff --git a/crates/engine/tests/it/mirror_degenerate_line.rs b/crates/engine/tests/it/mirror_degenerate_line.rs new file mode 100644 index 000000000..afa7a32a1 --- /dev/null +++ b/crates/engine/tests/it/mirror_degenerate_line.rs @@ -0,0 +1,17 @@ +//! MIRROR refuses a mirror line whose points are closer than the geometry tolerance. + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn nearly_coincident_mirror_points_are_refused() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 5], [10, 5]] })).unwrap(); + let before = s.doc().unwrap().model.iter().count(); + for erase in [false, true] { + let r = s.execute("mirror", &json!({ "handles": ["100"], "p1": [0, 0], "p2": [5e-10, 0], "erase": erase })); + let err = r.expect_err("mirror line is degenerate").to_string(); + assert!(err.contains("mirror line points coincide"), "{err}"); + } + assert_eq!(s.doc().unwrap().model.iter().count(), before); +} diff --git a/crates/engine/tests/it/oops_command.rs b/crates/engine/tests/it/oops_command.rs new file mode 100644 index 000000000..f42564c6f --- /dev/null +++ b/crates/engine/tests/it/oops_command.rs @@ -0,0 +1,260 @@ +//! OOPS, as AutoCAD 2024 runs it (accoreconsole: a line A, a line B, a circle C and a line D, these +//! scripts, then the drawing's `entnext` order read back). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::{Common, EntityKind, Handle, Line, Space}; +use cadcraft_engine::geom::Vec3; +use serde_json::{Value, json}; + +/// A session that `entmake` works in. +fn session() -> Session { + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + Session::new() +} + +/// A session with A, B, C and D in draw order, and their handles. +fn drawing() -> (Session, Vec) { + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap(); + s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap(); + s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap(); + let hs = handles(&s); + (s, hs) +} + +fn handles(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle).collect() +} + +/// The drawing's objects in draw order: A to D by their handles, E for any other. +fn order(s: &Session, hs: &[Handle]) -> String { + let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char); + handles(s).iter().map(|h| name(h).to_string()).collect::>().join(" ") +} + +/// Run `script` with `@A`…`@D` standing for the objects. +fn run(s: &mut Session, hs: &[Handle], script: &[&str]) { + for line in script { + let mut line = line.to_string(); + for (i, h) in hs.iter().enumerate() { + line = line.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex())); + } + s.cmdline(&line).unwrap(); + assert!(s.running.is_none(), "{line}: {}", s.prompt_text()); + } +} + +fn oops(s: &mut Session) -> Value { + s.execute("oops", &json!({})).unwrap() +} + +#[test] +fn the_last_erase_comes_back_where_it_was() { + const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")"; + const OOPS: &str = "(command \"_.OOPS\")"; + // (case, script, the drawing's order afterwards: E is the line drawn after the erase) + let cases: [(&str, &[&str], &str); 14] = [ + ("OOPS-000: nothing erased yet", &[OOPS], "A B C D"), + ("OOPS-001: two in the middle", &["(command \"_.ERASE\" @B @C \"\")", OOPS], "A B C D"), + ("OOPS-002: picked in reverse", &["(command \"_.ERASE\" @C @B \"\")", OOPS], "A B C D"), + ("OOPS-003: a new object after the erase", &["(command \"_.ERASE\" @B \"\")", LINE, OOPS], "A B C D E"), + ("OOPS-004: the bottom object", &["(command \"_.ERASE\" @A \"\")", LINE, OOPS], "A B C D E"), + ("OOPS-005: twice", &["(command \"_.ERASE\" @B \"\")", OOPS, OOPS], "A B C D"), + ("OOPS-006: after U", &["(command \"_.ERASE\" @B \"\")", "U", OOPS], "A B C D"), + ("OOPS-007: the last erase only", &["(command \"_.ERASE\" @B \"\")", "(command \"_.ERASE\" @C \"\")", OOPS], "A C D"), + ("OOPS-008: U after OOPS", &["(command \"_.ERASE\" @B \"\")", OOPS, "U"], "A C D"), + ("OOPS-009: entdel", &["(entdel @B)", OOPS], "A C D"), + ("OOPS-010: -BLOCK", &["(command \"_.-BLOCK\" \"BKO\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"), + ("OOPS-011: U of another command", &["(command \"_.ERASE\" @B \"\")", LINE, "U", OOPS], "A B C D"), + ("OOPS-012: the object under it erased since", &["(command \"_.ERASE\" @B \"\")", "(entdel @A)", OOPS], "B C D"), + ("OOPS-013: everything", &["(command \"_.ERASE\" \"_ALL\" \"\")", OOPS], "A B C D"), + ]; + for (case, script, want) in cases { + let (mut s, hs) = drawing(); + run(&mut s, &hs, script); + assert_eq!(order(&s, &hs), want, "{case}"); + } +} + +#[test] +fn oops_says_nothing_and_counts_what_came_back() { + let (mut s, hs) = drawing(); + s.cmdline("oops").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS")); + s.execute("erase", &json!({ "handles": [hs[1].hex(), hs[3].hex()] })).unwrap(); + s.cmdline("oops").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS")); + assert_eq!(order(&s, &hs), "A B C D"); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + s.execute("u", &json!({})).unwrap(); + assert_eq!(order(&s, &hs), "A C"); + assert_eq!(oops(&mut s), json!({ "restored": 2 })); + // They come back as they were. + let d = s.doc().unwrap(); + assert!(matches!(d.entity(hs[3]).map(|e| &e.kind), Some(EntityKind::Line(l)) if l.a.y == 40.0 && l.b.x == 10.0)); +} + +#[test] +fn the_delete_key_and_block_feed_oops_too() { + // Edit > Clear, the Delete key. + let (mut s, hs) = drawing(); + s.set_selection(vec![hs[2]]); + s.execute("erase.selection", &json!({})).unwrap(); + assert_eq!(order(&s, &hs), "A B D"); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "A B C D"); + // BLOCK deleting its objects. + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[3].hex()], "keep": "delete" })).unwrap(); + assert_eq!(order(&s, &hs), "B C"); + assert_eq!(oops(&mut s), json!({ "restored": 2 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn an_object_an_undo_took_away_stays_away() { + // U past the line's creation: its handle is free again. + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + let x = handles(&s)[0]; + s.execute("erase", &json!({ "handles": [x.hex()] })).unwrap(); + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + assert!(handles(&s).is_empty()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert!(handles(&s).is_empty()); + // A new object takes the handle. + s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap(); + assert_eq!(handles(&s), [x]); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert!(matches!(s.doc().unwrap().entity(x).map(|e| &e.kind), Some(EntityKind::Circle(_)))); + // A block's object takes the handle: one erased object of two, one of a block's two. + for (erased, in_block) in [(2, 1), (1, 2)] { + let mut s = session(); + for y in 0..erased { + s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap(); + } + let hs = handles(&s); + s.execute("erase", &json!({ "handles": hs.iter().map(|h| h.hex()).collect::>() })).unwrap(); + for _ in 0..=erased { + s.execute("u", &json!({})).unwrap(); + } + assert!(handles(&s).is_empty()); + s.cmdline("(entmake '((0 . \"BLOCK\") (2 . \"BX\") (10 0.0 0.0 0.0) (70 . 0)))").unwrap(); + for _ in 0..in_block { + s.cmdline("(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))").unwrap(); + } + s.cmdline("(entmake '((0 . \"ENDBLK\")))").unwrap(); + assert!(s.doc().unwrap().block("BX").is_some_and(|b| b.entities.contains(hs[0])), "{erased} {in_block}"); + assert_eq!(oops(&mut s), json!({ "restored": 0 }), "{erased} {in_block}"); + assert!(handles(&s).is_empty()); + } +} + +#[test] +fn an_object_whose_layer_or_block_is_gone_stays_away() { + // Its layer purged. + let (mut s, hs) = drawing(); + run(&mut s, &hs, &["(command \"_.-LAYER\" \"_M\" \"L1\" \"\")"]); + s.execute("line", &json!({ "points": [[0, 60], [10, 60]] })).unwrap(); + let on_l1 = *handles(&s).last().unwrap(); + run(&mut s, &hs, &["(setvar \"CLAYER\" \"0\")"]); + s.execute("erase", &json!({ "handles": [on_l1.hex()] })).unwrap(); + s.execute("purge", &json!({ "type": "layers", "names": "L1" })).unwrap(); + assert!(s.doc().unwrap().layer("L1").is_none()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + // Its block purged. + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap(); + s.execute("insert", &json!({ "name": "BK", "at": [50, 0] })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [ins.hex()] })).unwrap(); + s.execute("purge", &json!({ "type": "blocks", "names": "BK" })).unwrap(); + assert!(s.doc().unwrap().block("BK").is_none()); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn the_block_editor_has_an_oops_of_its_own() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap(); + s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap(); + s.execute("bedit", &json!({ "name": "BK" })).unwrap(); + // The drawing's erased line doesn't come back into the block. + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + s.execute("bclose", &json!({ "save": false })).unwrap(); + // Back in the drawing, it does. + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "A B C D"); +} + +#[test] +fn a_layouts_objects_come_back_to_it() { + let mut s = session(); + let paper = Space::Paper("Layout1".into()); + let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) }); + let hs: Vec = (0..3).map(|y| s.doc_mut().unwrap().add(&paper, Common::default(), line(f64::from(y))).unwrap()).collect(); + s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + let d = s.doc().unwrap(); + assert!(d.model.is_empty()); + assert_eq!(d.space(&paper).unwrap().handles(), hs); +} + +#[test] +fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "convert" })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap(); + let r = s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // The drawing's line stays out while the reference is edited... + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + // ...and a working copy comes back. + let copy = r["working"][0].as_str().unwrap().to_string(); + s.execute("erase", &json!({ "handles": [copy] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + // Once the edit is over, its copies don't come back into the drawing. + s.execute("erase", &json!({ "handles": [copy] })).unwrap(); + s.execute("refclose", &json!({ "save": true })).unwrap(); + assert_eq!(s.doc().unwrap().block("BK").unwrap().entities.len(), 1); + assert_eq!(oops(&mut s), json!({ "restored": 0 })); + assert_eq!(order(&s, &hs), "C E"); + // The drawing's own erased line does, after an edit that ends. + s.execute("erase", &json!({ "handles": [hs[2].hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + s.execute("refclose", &json!({ "save": false })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(order(&s, &hs), "C E"); +} + +#[test] +fn a_block_refused_during_refedit_leaves_oops_as_it_was() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "convert" })).unwrap(); + let ins = *handles(&s).last().unwrap(); + s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap(); + s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap(); + // B isn't in the working set: the block is refused and the drawing put back. + let r = s.execute("block", &json!({ "name": "BK2", "base": [0, 0], "handles": [hs[1].hex()], "keep": "delete" })); + assert!(r.is_err(), "{r:?}"); + s.execute("refclose", &json!({ "save": false })).unwrap(); + // OOPS still has D, the last erase that stood. + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert!(s.doc().unwrap().entity(hs[3]).is_some()); +} + +#[test] +fn an_erase_in_the_block_editor_comes_back_there() { + let (mut s, hs) = drawing(); + s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "retain" })).unwrap(); + s.execute("bedit", &json!({ "name": "BK" })).unwrap(); + let inside = handles(&s); + assert_eq!(inside.len(), 2); + s.execute("erase", &json!({ "handles": [inside[0].hex()] })).unwrap(); + assert_eq!(oops(&mut s), json!({ "restored": 1 })); + assert_eq!(handles(&s), inside); +} diff --git a/crates/engine/tests/it/overkill.rs b/crates/engine/tests/it/overkill.rs index 56475ab47..19639458e 100644 --- a/crates/engine/tests/it/overkill.rs +++ b/crates/engine/tests/it/overkill.rs @@ -66,6 +66,48 @@ fn duplicates_tolerance_and_ignored_properties() { assert!(r["message"].as_str().unwrap().contains("duplicate(s) deleted")); } +#[test] +fn objects_within_the_tolerance_are_duplicates_on_either_side_of_a_rounding_step() { + // Each pair is closer than the tolerance, but its numbers round to different multiples of it + // (50.004 → 5000, 50.006 → 5001 at 0.01). + let pairs = [ + (json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50.004, 50], "radius": 3 }), json!({ "center": [50.006, 50], "radius": 3 })), + (json!({ "tolerance": 0.01 }), "circle", json!({ "center": [50, 50], "radius": 3.004 }), json!({ "center": [50, 50], "radius": 3.006 })), + (json!({}), "circle", json!({ "center": [0.0000014, 0], "radius": 3 }), json!({ "center": [0.0000016, 0], "radius": 3 })), + (json!({ "tolerance": 0.01 }), "point", json!({ "at": [10.004, 0] }), json!({ "at": [10.006, 0] })), + ( + json!({ "tolerance": 0.01 }), + "text", + json!({ "at": [0.004, 0], "text": "A", "height": 2.5 }), + json!({ "at": [0.006, 0], "text": "A", "height": 2.5 }), + ), + ]; + for (opts, cmd, a, b) in pairs { + let mut s = Session::new(); + s.execute(cmd, &a).unwrap(); + s.execute(cmd, &b).unwrap(); + let r = ok(&mut s, opts); + assert_eq!(r["deleted"], 1, "{cmd} {a} {b}: {r}"); + assert_eq!(count(&s), 1, "{cmd} {a} {b}"); + } + // Arcs on (about) one circle are combined the same way. + let mut s = Session::new(); + s.execute("arc", &json!({ "center": [0.004, 0], "radius": 5, "start": 0, "end": 90 })).unwrap(); + s.execute("arc", &json!({ "center": [0.006, 0], "radius": 5, "start": 45, "end": 180 })).unwrap(); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["overlapping"], 1, "{r}"); + assert_eq!(count(&s), 1); + // Farther apart than the tolerance, they stay. + let mut s = Session::new(); + s.execute("circle", &json!({ "center": [50.004, 50], "radius": 3 })).unwrap(); + s.execute("circle", &json!({ "center": [50.016, 50], "radius": 3 })).unwrap(); + s.execute("point", &json!({ "at": [10.004, 0] })).unwrap(); + s.execute("point", &json!({ "at": [10.016, 0] })).unwrap(); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 0, "{r}"); + assert_eq!(count(&s), 4); +} + #[test] fn collinear_lines_overlap_and_end_to_end() { let mut s = Session::new(); @@ -230,3 +272,47 @@ fn many_objects_stay_fast() { assert_eq!(count(&s), 4001); assert!(t.elapsed().as_secs() < 20, "{:?}", t.elapsed()); } + +#[test] +fn within_the_tolerance_does_not_chain() { + // At 0.01, the arc is within the tolerance of both circles, but the circles are 0.016 apart: + // the arc goes, both circles stay, whichever comes first in draw order. + let circle = |s: &mut Session, x: f64| s.execute("circle", &json!({ "center": [x, 0], "radius": 3 })).unwrap(); + let arc_at = |s: &mut Session, x: f64, start: f64, end: f64| { + s.execute("arc", &json!({ "center": [x, 0], "radius": 3, "start": start, "end": end })).unwrap() + }; + let kinds = |s: &Session| s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::>(); + let mut s = Session::new(); + circle(&mut s, 50.0); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!((r["duplicates"].as_u64(), r["overlapping"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + let mut s = Session::new(); + arc_at(&mut s, 50.008, 0.0, 90.0); + circle(&mut s, 50.0); + circle(&mut s, 50.016); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + assert_eq!(kinds(&s), ["Circle", "Circle"]); + // Arcs alone: the third is 0.016 from the first, so it is not combined with it. + let mut s = Session::new(); + arc_at(&mut s, 0.0, 0.0, 90.0); + arc_at(&mut s, 0.008, 45.0, 180.0); + arc_at(&mut s, 0.016, 100.0, 200.0); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["overlapping"], 1, "{r}"); + assert_eq!(kinds(&s), ["Arc", "Arc"]); + // A followed line displaces the first one, which a third line matched: the third is 0.016 from + // the followed line, so it stays. + let mut s = Session::new(); + let first = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let third = line(&mut s, [0.0, 0.008], [10.0, 0.008]); + let followed = line(&mut s, [0.0, -0.008], [10.0, -0.008]); + s.execute("dimlinear", &json!({ "object": followed.hex(), "at": [5, 5] })).unwrap(); + let r = ok(&mut s, json!({ "tolerance": 0.01 })); + assert_eq!(r["deleted"], 1, "{r}"); + let d = s.doc().unwrap(); + assert!(d.entity(first).is_none() && d.entity(third).is_some() && d.entity(followed).is_some()); +} diff --git a/crates/engine/tests/it/plot_styles.rs b/crates/engine/tests/it/plot_styles.rs index 06f8cb424..4ad0922c3 100644 --- a/crates/engine/tests/it/plot_styles.rs +++ b/crates/engine/tests/it/plot_styles.rs @@ -104,3 +104,30 @@ fn stylesmanager_creates_edits_saves_and_loads_tables() { // Undo takes a table edit back. s.execute("undo", &json!({})).unwrap(); } + +/// Issue #1928: saving a plot style table to a file goes through the atomic writer: a symlink +/// planted at the old fixed temporary name is left alone, and the file keeps its permissions. +#[cfg(unix)] +#[test] +fn plot_style_save_is_atomic_and_keeps_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cadcraft-pstyle-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("office.stb.json"); + std::fs::write(&path, "old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let victim = dir.join("victim"); + std::fs::write(&victim, "keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("office.stb.json.cadcraft-tmp")).unwrap(); + + let mut s = Session::new(); + s.execute("stylesmanager", &json!({"action": "new", "name": "office.stb"})).unwrap(); + let p = path.to_string_lossy().to_string(); + s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap(); + + assert_ne!(std::fs::read_to_string(&path).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/it/polyface_explode.rs b/crates/engine/tests/it/polyface_explode.rs new file mode 100644 index 000000000..d6d850e88 --- /dev/null +++ b/crates/engine/tests/it/polyface_explode.rs @@ -0,0 +1,32 @@ +//! EXPLODE turns a polyface mesh into its 3D faces, as AutoCAD does; hidden edges stay hidden. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, EntityKind, PolyfaceMesh, Space}; +use cadcraft_geom::Vec3; +use serde_json::json; + +#[test] +fn exploding_a_polyface_mesh_gives_its_3d_faces() { + let mut s = Session::new(); + let mesh = PolyfaceMesh { + vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)], + faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]], + }; + let h = s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::PolyfaceMesh(mesh)).unwrap(); + s.execute("explode", &json!({"handles": [h.hex()]})).unwrap(); + let faces: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Face3d(f) => Some(f.clone()), + _ => None, + }) + .collect(); + assert_eq!(faces.len(), 2); + assert!(s.doc().unwrap().model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_)))); + // The diagonal starts at the third corner of the first triangle: edge 3 (and the closing 4th) hidden. + assert_eq!(faces[0].corners[2], Vec3::new(10.0, 10.0, 2.0)); + assert_eq!(faces[0].hidden_edges, 4 | 8); +} diff --git a/crates/engine/tests/it/prompt_or_once.rs b/crates/engine/tests/it/prompt_or_once.rs new file mode 100644 index 000000000..c84c58b1c --- /dev/null +++ b/crates/engine/tests/it/prompt_or_once.rs @@ -0,0 +1,77 @@ +//! Prompts that offer options say "or" once, as AutoCAD 2024's do: "Enter angle of xline (0) or +//! [Reference]:". The prompt adds "or" before its options, so a message that ended in "or" read +//! "or or". + +use cadcraft_engine::Session; +use serde_json::json; + +fn shows(s: &Session, text: &str) { + let p = s.prompt_text(); + assert!(p.ends_with(text), "{p}"); + assert!(!p.contains(" or or "), "{p}"); +} + +#[test] +fn xline_angle_and_offset() { + let mut s = Session::new(); + s.cmdline("xline").unwrap(); + s.cmdline("a").unwrap(); + shows(&s, "Enter angle of xline (0) or [Reference]:"); + s.cancel(); + s.cmdline("xline").unwrap(); + s.cmdline("o").unwrap(); + shows(&s, "Specify offset distance or [Through] <1.0000>:"); +} + +#[test] +fn helix_radii_and_height() { + let mut s = Session::new(); + s.script("helix\n0,0\n").unwrap(); + shows(&s, "Specify base radius or [Diameter] <1.0000>:"); + s.cmdline("5").unwrap(); + shows(&s, "Specify top radius or [Diameter] <5.0000>:"); + s.cmdline("").unwrap(); + shows(&s, "Specify helix height or [Axis endpoint/Turns/turn Height/tWist] <1.0000>:"); +} + +#[test] +fn blend_first_object() { + let mut s = Session::new(); + s.cmdline("blend").unwrap(); + shows(&s, "Select first object or [CONtinuity]:"); +} + +#[test] +fn lengthen_options() { + let mut s = Session::new(); + s.cmdline("lengthen").unwrap(); + shows(&s, "Select an object to measure or [DElta/Percent/Total/DYnamic] :"); + s.cmdline("de").unwrap(); + shows(&s, "Enter delta length or [Angle] <0.0000>:"); + s.cmdline("2").unwrap(); + shows(&s, "Select an object to change or [Undo]:"); + s.cancel(); + s.cmdline("lengthen").unwrap(); + s.cmdline("t").unwrap(); + shows(&s, "Specify total length or [Angle] <1.0000>:"); +} + +#[test] +fn ncopy_pedit_and_table_break() { + let mut s = Session::new(); + s.script("line 25,-5 25,5\n\nline 0,0 10,0\n\n-block B 0,0 L\n\n").unwrap(); + s.execute("insert", &json!({ "name": "B", "at": [20, 0] })).unwrap(); + s.cmdline("zoom e").unwrap(); + s.cmdline("ncopy").unwrap(); + shows(&s, "Select nested objects to copy or [Settings]:"); + s.script("22,0\n\n").unwrap(); + shows(&s, "Specify base point or [Displacement/Multiple] :"); + s.cancel(); + s.cmdline("pedit").unwrap(); + shows(&s, "Select polyline or [Multiple]:"); + s.cancel(); + s.execute("table", &json!({ "at": [0, 20], "cells": [["1", ""], ["2", ""]], "rowHeight": 1.0, "colWidth": 3.0 })).unwrap(); + s.cmdline("table.break").unwrap(); + s.cmdline("1,19.5").unwrap(); + shows(&s, "Enter maximum part height or [Off]:"); +} diff --git a/crates/engine/tests/it/rectang_polygon_order.rs b/crates/engine/tests/it/rectang_polygon_order.rs new file mode 100644 index 000000000..e58f0a6e9 --- /dev/null +++ b/crates/engine/tests/it/rectang_polygon_order.rs @@ -0,0 +1,215 @@ +//! RECTANG and POLYGON vertices in AutoCAD 2024's order, and the RECTANG modes it remembers from +//! one rectangle to the next. The scripts and numbers were measured with accoreconsole; the +//! RECTANG scripts reset Chamfer, Fillet, Width, Elevation and Thickness first, but not Rotation. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +type Vertex = (f64, f64, f64); + +const RESET: &str = "\"_C\" 0 0 \"_F\" 0 \"_W\" 0 \"_E\" 0 \"_T\" 0"; + +/// The last polyline's vertices (x, y, bulge). +fn last_polyline(s: &Session) -> Vec { + let d = s.doc().unwrap(); + match &d.model.iter().last().unwrap().kind { + EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(), + k => panic!("not a polyline: {k:?}"), + } +} + +fn expect(got: &[Vertex], want: &[Vertex]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + let tol = 1e-9 * (1.0 + w.0.abs().max(w.1.abs())); + assert!((g.0 - w.0).abs() < tol && (g.1 - w.1).abs() < tol && (g.2 - w.2).abs() < 1e-9, "{got:?}\n!= {want:?}"); + } +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +fn rectang(s: &mut Session, rest: &str) { + lisp(s, &format!("(command \"_.RECTANG\" {RESET} {rest})")); +} + +#[test] +fn a_rectangle_starts_at_its_first_corner_and_runs_along_x_first() { + let mut s = Session::new(); + rectang(&mut s, "'(-2.2557479240466947 -9.488221049171962) '(-31.36203661716106 -47.886714708394166)"); + expect( + &last_polyline(&s), + &[ + (-2.255747924046694, -9.488221049171962, 0.0), + (-31.36203661716105, -9.488221049171962, 0.0), + (-31.36203661716105, -47.88671470839417, 0.0), + (-2.255747924046694, -47.88671470839417, 0.0), + ], + ); +} + +#[test] +fn chamfers_cut_the_edge_into_each_corner_by_the_first_distance() { + let mut s = Session::new(); + rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(4.8089419289768 29.66648885915597) '(28.114797694689763 43.85884965263156)"); + expect( + &last_polyline(&s), + &[ + (4.8089419289768, 31.91829932479258, 0.0), + (8.575279497862834, 29.66648885915597, 0.0), + (25.86298722905314, 29.66648885915597, 0.0), + (28.11479769468976, 33.432826428042, 0.0), + (28.11479769468976, 41.60703918699494, 0.0), + (24.34846012580373, 43.85884965263156, 0.0), + (7.060752394613416, 43.85884965263156, 0.0), + (4.8089419289768, 40.09251208374553, 0.0), + ], + ); +} + +#[test] +fn fillet_arcs_start_on_the_edge_into_each_corner() { + let mut s = Session::new(); + rectang(&mut s, "\"_F\" 0.6042729201106594 '(-13.457476758444333 -29.884431038180395) '(14.673991980287038 27.14695046607133)"); + let b = 0.414213562373095; + expect( + &last_polyline(&s), + &[ + (-13.45747675844433, -29.28015811806973, b), + (-12.85320383833367, -29.88443103818039, 0.0), + (14.06971906017638, -29.88443103818039, b), + (14.67399198028703, -29.28015811806973, 0.0), + (14.67399198028703, 26.54267754596067, b), + (14.06971906017638, 27.14695046607133, 0.0), + (-12.85320383833367, 27.14695046607133, b), + (-13.45747675844433, 26.54267754596067, 0.0), + ], + ); +} + +#[test] +fn fillet_arcs_turn_clockwise_when_the_second_corner_is_below_the_first() { + // The second corner down and right: the outline runs clockwise and the bulges are negative + // (AutoCAD gave -0.414213562373095 for such a rectangle, there also rotated). + let mut s = Session::new(); + rectang(&mut s, "\"_F\" 1.0 '(0.0 0.0) '(10.0 -5.0)"); + let b = -0.414213562373095; + expect( + &last_polyline(&s), + &[(0.0, -1.0, b), (1.0, 0.0, 0.0), (9.0, 0.0, b), (10.0, -1.0, 0.0), (10.0, -4.0, b), (9.0, -5.0, 0.0), (1.0, -5.0, b), (0.0, -4.0, 0.0)], + ); +} + +#[test] +fn the_next_rectangle_keeps_the_rotation_and_lists_the_modes() { + let mut s = Session::new(); + rectang(&mut s, "'(27.647422687394844 28.925709913163516) \"_R\" 50.50299224048081 '(46.86449612780332 4.979921576806525)"); + expect( + &last_polyline(&s), + &[ + (27.64742268739484, 28.92570991316351, 0.0), + (23.66889931823719, 24.09886156025833, 0.0), + (46.86449612780331, 4.979921576806525, 0.0), + (50.84301949696097, 9.80676992971171, 0.0), + ], + ); + // No Rotation option this time: the rectangle still turns by 50.50 degrees. + rectang(&mut s, "'(14.3947813348612 23.649728466870016) \"_D\" 19.35761675393126 49.775239210761946 '(-4.9628354190700605 -26.12551074389193)"); + assert!(s.log.iter().any(|l| l == "Current rectangle modes: Rotation=50.50"), "{:?}", s.log); + // The next Rotation prompt offers it as its default, as AutoCAD shows it. + s.cmdline("RECTANG 0,0 R").unwrap(); + assert!(s.prompt_text().ends_with("Specify rotation angle or [Pick points] <50.50>:"), "{}", s.prompt_text()); + s.cancel(); + expect( + &last_polyline(&s), + &[ + (14.3947813348612, 23.64972846687001, 0.0), + (2.082603004810238, 8.712272486646885, 0.0), + (40.4920546468795, -22.94666721518322, 0.0), + (52.80423297693047, -8.009211234960095, 0.0), + ], + ); +} + +#[test] +fn elevation_thickness_and_a_picked_rotation_carry_over_together() { + let mut s = Session::new(); + rectang( + &mut s, + "\"_E\" 7.5 \"_T\" 2.25 '(-18.42481827666694 -20.50343704206707) \"_R\" \"_P\" '(0.0 0.0) '(10.0 6.0) '(10.197290578707722 25.5222006119985)", + ); + expect( + &last_polyline(&s), + &[ + (-18.42481827666694, -20.50343704206706, 0.0), + (22.92627837613747, 4.307220949615576, 0.0), + (10.19729057870772, 25.5222006119985, 0.0), + (-31.15380607409669, 0.7115426203158534, 0.0), + ], + ); + let d = s.doc().unwrap(); + let e = d.model.iter().last().unwrap(); + let EntityKind::LwPolyline(p) = &e.kind else { panic!("not a polyline") }; + assert_eq!((p.elevation, e.common.thickness), (7.5, 2.25)); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Elevation=7.5000 Thickness=2.2500 Rotation=30.96")); + s.cancel(); +} + +#[test] +fn chamfer_and_fillet_are_remembered_one_at_a_time() { + let mut s = Session::new(); + rectang(&mut s, "\"_C\" 2.2518104656366162 3.766337568886034 '(0.0 0.0) '(40.0 30.0)"); + rectang(&mut s, "\"_F\" 0.6042729201106594 '(0.0 0.0) '(40.0 30.0)"); + let logs: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Current rectangle modes:")).collect(); + assert_eq!(logs, ["Current rectangle modes: Chamfer=2.2518 x 3.7663"]); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=0.6043")); + s.cancel(); +} + +#[test] +fn setting_a_fillet_clears_the_chamfer_and_back() { + // A chamfer set after a fillet makes a chamfered rectangle, and the other way round. + let mut s = Session::new(); + s.script("RECTANG F 1 C 2 3 0,0 40,30\n").unwrap(); + assert!(last_polyline(&s).iter().all(|v| v.2 == 0.0), "{:?}", last_polyline(&s)); + assert_eq!(last_polyline(&s).first().map(|v| (v.0, v.1)), Some((0.0, 2.0))); + s.script("RECTANG C 2 3 F 1 0,0 40,30\n").unwrap(); + assert!(s.log.iter().any(|l| l == "Current rectangle modes: Chamfer=2.0000 x 3.0000"), "{:?}", s.log); + expect(&last_polyline(&s)[..1], &[(0.0, 1.0, 0.414213562373095)]); + s.cmdline("RECTANG").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("Current rectangle modes: Fillet=1.0000")); + s.cancel(); +} + +#[test] +fn the_json_form_ignores_the_remembered_modes() { + let mut s = Session::new(); + rectang(&mut s, "'(0.0 0.0) \"_R\" 30 '(10.0 10.0)"); + s.execute("rectang", &json!({ "p1": [100, 0], "p2": [110, 10] })).unwrap(); + expect(&last_polyline(&s), &[(100.0, 0.0, 0.0), (110.0, 0.0, 0.0), (110.0, 10.0, 0.0), (100.0, 10.0, 0.0)]); +} + +#[test] +fn a_circumscribed_polygon_starts_counter_clockwise_from_the_picked_edge() { + let mut s = Session::new(); + lisp(&mut s, "(command \"_.POLYGON\" 4 '(-13.660465775726184 6.051830973104977) \"_C\" '(-33.469717497592136 8.913096552751803))"); + expect( + &last_polyline(&s), + &[ + (-36.33098307723896, -10.89615516911414, 0.0), + (3.287520366492948, -16.61868632840779, 0.0), + (9.01005152578659, 22.9998171153241, 0.0), + (-30.60845191794531, 28.72234827461775, 0.0), + ], + ); + lisp(&mut s, "(command \"_.POLYGON\" 3 '(-4.866064513702604 -0.5675999154110016) \"_C\" '(-33.004110114447 14.119144448101377))"); + expect( + &last_polyline(&s), + &[(-58.44229754982628, -34.61738015807783, 0.0), (51.41002668778618, -29.94108864243579, 0.0), (-7.565922679067647, 62.85566905428061, 0.0)], + ); +} diff --git a/crates/engine/tests/it/scale_offset_z.rs b/crates/engine/tests/it/scale_offset_z.rs new file mode 100644 index 000000000..d82d53ce0 --- /dev/null +++ b/crates/engine/tests/it/scale_offset_z.rs @@ -0,0 +1,195 @@ +//! SCALE scales heights too and OFFSET keeps them, as AutoCAD 2024 does (measured with +//! accoreconsole, running these scripts on these drawings): SCALE scales every Z and elevation +//! about the base point's Z (the current elevation for a point given without one) and every +//! thickness by the factor; OFFSET's copy is at the source's height, and a circle's or arc's copy +//! has no thickness while a polyline's keeps it. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point, PolyfaceMesh}; +use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; +use serde_json::json; + +fn add(s: &mut Session, kind: EntityKind, thickness: f64) -> Handle { + let space = s.space(); + s.doc_mut().unwrap().add(&space, Common { thickness, ..Common::default() }, kind).unwrap() +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +fn get(s: &Session, h: Handle) -> (EntityKind, f64) { + let e = s.doc().unwrap().entity(h).unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn last(s: &Session) -> (EntityKind, f64) { + let e = s.doc().unwrap().model.iter().last().unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * (1.0 + b.abs()) +} + +fn near3(a: Vec3, b: Vec3) -> bool { + close(a.x, b.x) && close(a.y, b.y) && close(a.z, b.z) +} + +fn scale(s: &mut Session, h: Handle, base: &str) { + lisp(s, &format!("(command \"_.SCALE\" (handent \"{}\") \"\" '({base}) 1.7)", h.hex())); +} + +#[test] +fn scale_scales_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 11.9)) && near3(l.b, Vec3::new(66.6, -2.1, 11.9)), "{l:?}"); + // A sloped line at survey coordinates. + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(612345.678, 2398765.432, 3.0), b: Vec3::new(612375.678, 2398775.432, 8.0) }), 0.0); + scale(&mut s, h, "612347.678 2398768.432"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(612344.278, 2398763.332, 5.1)) && near3(l.b, Vec3::new(612395.278, 2398780.332, 13.6)), "{l:?}"); +} + +#[test] +fn scale_scales_the_centre_z_and_thickness_of_circles_and_arcs() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.5, 0.25, 0.005), radius: 0.01 }), 0.002); + scale(&mut s, c, "0.502 0.253"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(0.4986, 0.2479, 0.0085)) && close(k.radius, 0.017) && close(t, 0.0034), "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 1.5); + scale(&mut s, a, "2.0 3.0"); + let (EntityKind::Arc(k), t) = get(&s, a) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 7.65)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); +} + +#[test] +fn scale_scales_a_polylines_elevation_and_thickness_and_a_points_z() { + let mut s = Session::new(); + let vs = [(612345.678, 2398765.432, 0.0), (612365.678, 2398765.432, 0.4), (612375.678, 2398780.432, 0.0), (612350.678, 2398785.432, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "612347.678 2398768.432"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 21.25) && close(t, 1.7), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 612344.278) && close(first.y, 2398763.332), "{first:?}"); + let pt = add(&mut s, EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 6.0), angle: 0.0 }), 0.0); + scale(&mut s, pt, "2.0 3.0"); + let EntityKind::Point(k) = get(&s, pt).0 else { panic!() }; + assert!(near3(k.p, Vec3::new(7.1, 6.4, 10.2)), "{k:?}"); +} + +#[test] +fn scale_scales_heights_about_the_current_elevation() { + // ELEVATION 5: the base point, given without a Z, is at Z = 5. + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 8.4)) && near3(l.b, Vec3::new(66.6, -2.1, 8.4)), "{l:?}"); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0 }), 1.5); + scale(&mut s, c, "2.0 3.0"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 4.15)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "2.0 3.0"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 17.75) && close(t, 1.7), "{} {t}", k.elevation); + let last = k.vertices.last().unwrap().p; + assert!(close(last.x, 49.6) && close(last.y, 23.4), "{last:?}"); +} + +#[test] +fn json_scale_takes_the_base_points_z() { + let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }); + let z = |s: &Session, h: Handle| { + let EntityKind::Line(l) = get(s, h).0 else { panic!() }; + (l.a.z, l.b.z) + }; + let mut s = Session::new(); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, 1.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 11.2) && close(b, 11.2), "{a} {b}"); + // Without one, the current elevation (not measured through JSON: the same base point as the + // command line's). + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 8.4) && close(b, 8.4), "{a} {b}"); + // A Z that isn't a number is refused, the drawing left as it was. + assert!(s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, "1"], "factor": 1.7 })).is_err()); + assert_eq!(z(&s, h), (a, b)); +} + +#[test] +fn move_and_rotate_leave_heights_alone() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 2.0); + lisp(&mut s, &format!("(command \"_.ROTATE\" (handent \"{}\") \"\" '(0.0 0.0) 90)", h.hex())); + lisp(&mut s, &format!("(command \"_.MOVE\" (handent \"{}\") \"\" '(0.0 0.0) '(5.0 5.0))", h.hex())); + let (EntityKind::Line(l), t) = get(&s, h) else { panic!() }; + assert!(close(l.a.z, 7.0) && close(l.b.z, 7.0) && close(t, 2.0), "{l:?} {t}"); +} + +#[test] +fn offset_keeps_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(20.0 0.0)) '(10.0 5.0) \"\")", h.hex())); + let EntityKind::Line(l) = last(&s).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(0.0, 3.0, 7.0)) && near3(l.b, Vec3::new(40.0, 3.0, 7.0)), "{l:?}"); +} + +#[test] +fn offset_keeps_the_centre_z_of_circles_and_arcs_but_not_their_thickness() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(612345.678, 2398765.432, 5.0), radius: 10.0 }), 2.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(612355.678 2398765.432)) '(612345.678 2398780.432) \"\")", c.hex())); + let (EntityKind::Circle(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 5.0) && close(k.radius, 13.0) && t == 0.0, "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(-35000.0, 81000.0, 225.0), radius: 500.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 75.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 150.0 (list (handent \"{}\") '(-35000.0 81500.0)) '(-35000.0 81750.0) \"\")", a.hex())); + let (EntityKind::Arc(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 225.0) && close(k.radius, 650.0) && t == 0.0, "{k:?} {t}"); +} + +#[test] +fn offset_keeps_a_polylines_elevation_and_thickness() { + let mut s = Session::new(); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0), (5.0, 20.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(10.0 0.0)) '(12.0 6.0) \"\")", p.hex())); + let (EntityKind::LwPolyline(k), t) = last(&s) else { panic!() }; + assert!(close(k.elevation, 12.5) && close(t, 1.0), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}"); +} + +#[test] +fn scale_scales_a_polyface_meshs_vertex_heights() { + let mut s = Session::new(); + let vertices = vec![Vec3::new(0.0, 0.0, 2.0), Vec3::new(10.0, 0.0, 2.0), Vec3::new(0.0, 10.0, 6.0)]; + let m = add(&mut s, EntityKind::PolyfaceMesh(PolyfaceMesh { vertices, faces: vec![[1, 2, 3, 0]] }), 0.0); + scale(&mut s, m, "0.0 0.0"); + let EntityKind::PolyfaceMesh(k) = get(&s, m).0 else { panic!() }; + let want = [Vec3::new(0.0, 0.0, 3.4), Vec3::new(17.0, 0.0, 3.4), Vec3::new(0.0, 17.0, 10.2)]; + assert!(k.vertices.iter().zip(want).all(|(a, b)| near3(*a, b)), "{:?}", k.vertices); +} diff --git a/crates/engine/tests/it/vplayer_prompts.rs b/crates/engine/tests/it/vplayer_prompts.rs index f7f7e4a1a..393ad0958 100644 --- a/crates/engine/tests/it/vplayer_prompts.rs +++ b/crates/engine/tests/it/vplayer_prompts.rs @@ -33,9 +33,9 @@ fn typed_vplayer_changes_layers_in_chosen_viewports() { // Freeze in the current viewport (the default inside a viewport). s.cmdline("VPLAYER").unwrap(); - assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:"); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:"); s.script("F\n0\n").unwrap(); - assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [All/Select/Current/Except current] :"); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option [All/Select/Current/Except current] :"); s.script("\n").unwrap(); assert_eq!(frozen(&s, &a), ["0"]); assert!(frozen(&s, &b).is_empty()); diff --git a/crates/engine/tests/layer_name_control_chars.rs b/crates/engine/tests/layer_name_control_chars.rs new file mode 100644 index 000000000..4e00a1454 --- /dev/null +++ b/crates/engine/tests/layer_name_control_chars.rs @@ -0,0 +1,14 @@ +//! `layer.new` refuses control characters in a layer name (the DXF writer would turn them into +//! spaces and the name would change on reopen). + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn layer_new_rejects_control_characters() { + let mut s = Session::new(); + for name in ["A\nB", "A\tB", "A\u{0}B"] { + assert!(s.execute("layer.new", &json!({"name": name})).is_err(), "{name:?} was accepted"); + } + assert!(s.execute("layer.new", &json!({"name": "A B"})).is_ok()); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs new file mode 100644 index 000000000..a42e48278 --- /dev/null +++ b/crates/engine/tests/panic_hunt.rs @@ -0,0 +1,126 @@ +//! Fuzz every registered command with adversarial params: a command must return `Err`, never panic +//! or hang. Each call gets a fresh, populated session on its own thread with a deadline, so one bad +//! command can't change what the next one sees or wedge the run. +//! +//! `Session::execute` contains a command's panic and returns an ordinary `Err`, so looking at the +//! result can't find one, and neither can `catch_unwind` here. A panic hook counts them instead, +//! on whichever thread they happen. `contained_panics_count_and_graceful_errors_do_not` proves it. + +use cadcraft_engine::{Session, command_specs}; +use serde_json::{Value, json}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, Once, PoisonError, mpsc}; +use std::time::Duration; + +/// A drawing with something to act on, so commands reach their real work: with nothing in it most +/// of them stop at "nothing to do", and with no drawing at all they don't run. +fn fresh() -> Session { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0], [10, 10]]})).unwrap(); + s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); + s +} + +/// Wall-clock budget for one command on the small drawing. Real work takes milliseconds, so this +/// only catches hangs and work that grows with a number in the params. +const DEADLINE: Duration = Duration::from_secs(4); + +/// Budget for the self-test, which checks how panics are counted: the hook prints a backtrace +/// under `RUST_BACKTRACE=1`, and the first symbol lookup can be slow on a loaded runner. +const SELF_TEST_DEADLINE: Duration = Duration::from_secs(120); + +/// Panics anywhere in the process, counted by a hook. +static PANICS: AtomicUsize = AtomicUsize::new(0); +/// One run at a time, so a panic is charged to the run that caused it. +static RUN: Mutex<()> = Mutex::new(()); + +/// Run `f` on a worker thread. `None` if it took longer than `deadline`, else whether it finished +/// without panicking, contained or not (returning `Ok` or a graceful `Err` both count). +fn run_watched(deadline: Duration, f: impl FnOnce() + Send + 'static) -> Option { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + PANICS.fetch_add(1, Ordering::SeqCst); + default(info); + })); + }); + let _one = RUN.lock().unwrap_or_else(PoisonError::into_inner); + let before = PANICS.load(Ordering::SeqCst); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let r = catch_unwind(AssertUnwindSafe(f)); + let _ = tx.send(r.is_ok()); + }); + rx.recv_timeout(deadline).ok().map(|ok| ok && PANICS.load(Ordering::SeqCst) == before) +} + +fn run_once(id: &str, p: &Value) -> Option { + let (id, p) = (id.to_string(), p.clone()); + run_watched(DEADLINE, move || { + let mut s = fresh(); + let _ = s.execute(&id, &p); + }) +} + +/// `Err` with a reason on a panic or a hang. A real hang reproduces and one-time setup (the first +/// font scan) doesn't, so a timeout counts only if a rerun on a fresh session times out too. +fn run_guarded(id: &str, p: &Value) -> Result<(), String> { + match run_once(id, p).or_else(|| run_once(id, p)) { + Some(true) => Ok(()), + Some(false) => Err("panicked".into()), + None => Err(format!("hung (>{}s, twice)", DEADLINE.as_secs())), + } +} + +/// Shapes and sizes a hostile script, drawing or agent could send. No param names a real path: +/// the hunt must not write files. +fn adversarial() -> Vec { + let long = "x".repeat(100_000); + vec![ + json!(null), + json!({}), + json!([]), + json!("x"), + json!({"points": [[1e308, -1e308], ["a"]], "handles": ["zz", 99999]}), + json!({"points": [], "handles": [], "center": [], "from": [], "to": [], "name": "", "layer": "", "style": "", "text": ""}), + json!({"center": [1e308, 1e308], "radius": 1e308, "angle": 1e308, "scale": 1e308, "height": 1e308, "width": 1e308, "distance": 1e308}), + json!({"center": [0, 0], "radius": -1, "angle": -1e308, "scale": 0, "height": 0, "width": 0, "distance": -1, "factor": 0}), + json!({"count": 1e9, "rows": 1e9, "columns": 1e9, "levels": 1e9, "items": 1e9, "sides": 1e9, "segments": 1e9, "index": 1e9}), + json!({"count": -1, "rows": -1, "columns": -1, "levels": -1, "items": -1, "sides": -1, "segments": -1, "index": -1}), + json!({"name": long, "layer": long, "text": long, "style": long, "mode": long}), + json!({"name": "\u{0}\u{202e}\u{1f4a5}", "text": r"%%%\P{\H0;", "gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), + ] +} + +#[test] +fn no_command_panics_or_hangs_on_adversarial_params() { + let params = adversarial(); + let mut bad: Vec = Vec::new(); + for spec in command_specs() { + for p in ¶ms { + if let Err(why) = run_guarded(spec.id, p) { + let shown: String = p.to_string().chars().take(120).collect(); + bad.push(format!("{} [{why}] <- {shown}", spec.id)); + break; + } + } + } + assert!(bad.is_empty(), "{} commands panicked or hung:\n{}", bad.len(), bad.join("\n")); +} + +/// A panic the code under test contains still fails the run; a graceful `Err` does not. Without +/// this the hunt could quietly stop detecting anything. +#[test] +fn contained_panics_count_and_graceful_errors_do_not() { + let contained = run_watched(SELF_TEST_DEADLINE, || { + let _ = catch_unwind(|| panic!("contained, as Session::execute does")); + }); + assert_eq!(contained, Some(false)); + let graceful = run_watched(SELF_TEST_DEADLINE, || { + let mut s = fresh(); + assert!(s.execute("no-such-command", &json!({})).is_err()); + }); + assert_eq!(graceful, Some(true)); +} diff --git a/crates/engine/tests/save_backup.rs b/crates/engine/tests/save_backup.rs new file mode 100644 index 000000000..d84aaedd3 --- /dev/null +++ b/crates/engine/tests/save_backup.rs @@ -0,0 +1,306 @@ +//! Saving over a drawing keeps the previous file as `.bak` (ISAVEBAK, #540), and the +//! save itself replaces the file atomically: no temporary files are left behind. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, backup_path, looks_complete, looks_like_drawing, save_keeps_current, set_io}; +use serde_json::json; + +fn hooks() { + set_io(IoHooks { + read: |b, n| cadcraft_io::read(b, n).map_err(|e| e.to_string()), + write: |d, n| cadcraft_io::write(d, n).map_err(|e| e.to_string()), + plot: None, + }); + // Saves at a chosen version (a DWG 2000), as the app's. + cadcraft_engine::cmd::file::set_write_as(|d, n, o| { + cadcraft_io::write_with(d, n, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) + }); +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("cadcraft-bak-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn circles(path: &std::path::Path) -> usize { + let d = cadcraft_io::read(&std::fs::read(path).unwrap(), &*path.to_string_lossy()).unwrap(); + d.model.iter().filter(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Circle(_))).count() +} + +/// Only the drawing and its backup are in the folder: atomic saves leave no staging files. +fn files(dir: &std::path::Path) -> Vec { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v +} + +/// ISAVEBAK is process-wide: the tests that save hold this lock, so one turning it off can't +/// race another's save. Dropping the guard turns it back on, also when a test fails. +static SAVES: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct Saving(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); + +impl Drop for Saving { + fn drop(&mut self) { + cadcraft_engine::autosave::set_isavebak(true); + } +} + +fn saving() -> Saving { + let g = SAVES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + cadcraft_engine::autosave::set_isavebak(true); + Saving(g) +} + +fn add_circle(s: &mut Session, x: f64) { + s.execute("circle", &json!({ "center": [x, 0], "radius": 1 })).unwrap(); +} + +#[test] +fn each_save_keeps_the_previous_file_as_bak() { + let _saving = saving(); + hooks(); + let dir = temp_dir("rotate"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + assert_eq!(files(&dir), ["Plan.dxf"], "a first save has nothing to back up"); + + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]); + assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save"); + + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() { + let _saving = saving(); + hooks(); + let dir = temp_dir("damaged"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1); + + // Another program truncates the drawing; the next save keeps the good backup. + std::fs::write(&path, b"").unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1)); + + // Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup + // stays too. + let whole = std::fs::read(&path).unwrap(); + std::fs::write(&path, &whole[..whole.len() / 2]).unwrap(); + add_circle(&mut s, 11.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "half a DXF"); + std::fs::write(&path, b"AC1032").unwrap(); + add_circle(&mut s, 12.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone"); + + // ISAVEBAK 0: the backup is left as it was. + s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap(); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1)); + assert_eq!(files(&dir), ["Plan.dxf", "Plan.dxf.bak"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn drawings_are_recognised_by_their_start() { + assert!(looks_like_drawing(b"AC1032\0\0\0\0\0")); + assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")); + assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n")); + assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0")); + for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"", b"hello"] { + assert!(!looks_like_drawing(junk), "{junk:?}"); + } +} + +/// A private drawing (0600) keeps a private backup: a new .bak, and a .bak already there that +/// others could read, both take the drawing's mode. +#[cfg(unix)] +#[test] +fn the_backup_is_no_more_readable_than_the_drawing() { + use std::os::unix::fs::PermissionsExt; + let _saving = saving(); + hooks(); + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let dir = temp_dir("mode"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a new .bak"); + + std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o644)).unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a .bak already there"); + assert_eq!(circles(&backup_path(&path)), 2); + + // A .bak kept more private than the drawing is never widened. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + std::fs::set_permissions(backup_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap(); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(mode(&backup_path(&path)), 0o600, "a more private .bak"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A symlink planted at the .bak is replaced by the backup, never written through: the file it +/// pointed at is left as it was. +#[cfg(unix)] +#[test] +fn a_symlink_at_the_backup_is_not_followed() { + let _saving = saving(); + hooks(); + let dir = temp_dir("link"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let other = dir.join("other.txt"); + std::fs::write(&other, b"someone else's file").unwrap(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + std::os::unix::fs::symlink(&other, backup_path(&path)).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(&other).unwrap(), b"someone else's file"); + assert!(std::fs::symlink_metadata(backup_path(&path)).unwrap().is_file(), "the .bak is a file now"); + assert_eq!(circles(&backup_path(&path)), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A drawing looks whole from its end: an ASCII DXF ends with 0/EOF, a binary one with EOF and +/// its NUL, and a DWG is not a few bytes long. +#[test] +fn whole_drawings_are_told_from_cut_off_ones() { + let ascii = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + assert!(looks_complete(ascii, ascii, ascii.len() as u64)); + let cut = &ascii[..30]; + assert!(!looks_complete(cut, cut, cut.len() as u64)); + let mut binary = b"AutoCAD Binary DXF\r\n\x1a\0".to_vec(); + binary.extend_from_slice(b"\0\0SECTION\0\0\0EOF\0"); + assert!(looks_complete(&binary, &binary, binary.len() as u64)); + assert!(!looks_complete(&binary, &binary[..binary.len() - 5], binary.len() as u64 - 5)); + assert!(!looks_complete(b"AC1032", b"AC1032", 6)); + assert!(!looks_complete(b"AC1032", b"", 30_000), "a DWG tag without its header is not whole"); +} + +/// A DWG reaches the end its own header gives: cut off anywhere (not only right after its start), +/// in a 2018 and a 2000 file, it doesn't replace the good backup; whole, it does. +#[test] +fn a_dwg_cut_off_anywhere_keeps_the_good_backup() { + let _saving = saving(); + hooks(); + for version in [2018, 2000] { + let dir = temp_dir(&format!("dwg{version}")); + let path = dir.join("Plan.dwg"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p, "version": version })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "{version}: the first save is backed up"); + let whole = std::fs::read(&path).unwrap(); + let head = &whole[..whole.len().min(4096)]; + assert!(looks_complete(head, &whole[whole.len().saturating_sub(256)..], whole.len() as u64), "{version}: whole"); + for keep in [whole.len() / 2, whole.len() * 9 / 10, whole.len() - 1] { + assert!(!looks_complete(head, &[], keep as u64), "{version}: cut to {keep} of {}", whole.len()); + } + // Another program leaves it cut off at 90%: the next save keeps the good backup. + std::fs::write(&path, &whole[..whole.len() * 9 / 10]).unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "{version}: the cut-off file didn't replace it"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// The checks can be wrong about a real file. With no backup yet, a file that doesn't look whole +/// is still kept, because the save is about to replace the only copy of it. +#[test] +fn a_file_that_looks_damaged_is_kept_when_there_is_no_backup_yet() { + let _saving = saving(); + hooks(); + let dir = temp_dir("first"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + let odd = b"AC1032 but not as this header expects it".to_vec(); + std::fs::write(&path, &odd).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), odd); + let _ = std::fs::remove_dir_all(&dir); +} + +/// What the warning promises and what the save does agree. A 2007 DWG, whose completeness can't +/// be told, replaces an older backup; a file known to be cut off doesn't. +#[test] +fn the_backup_a_save_promises_is_the_one_it_writes() { + let _saving = saving(); + hooks(); + let dir = temp_dir("promise"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + let old = std::fs::read(backup_path(&path)).unwrap(); + + let mut dwg2007 = b"AC1021".to_vec(); + dwg2007.resize(4096, 7); + std::fs::write(&path, &dwg2007).unwrap(); + assert!(save_keeps_current(&path)); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), dwg2007, "the promise was kept"); + + std::fs::write(backup_path(&path), &old).unwrap(); + std::fs::write(&path, b"AC1032").unwrap(); + assert!(!save_keeps_current(&path)); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(std::fs::read(backup_path(&path)).unwrap(), old, "cut off: nothing was promised"); + + cadcraft_engine::autosave::set_isavebak(false); + assert!(!save_keeps_current(&path), "backups off"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `plan.dwg` and `plan.dxf` in one folder keep their own backups, in the DWG's letter case. +#[test] +fn drawings_sharing_a_name_keep_their_own_backups() { + let name = |p: &str| backup_path(std::path::Path::new(p)).to_string_lossy().into_owned(); + assert_eq!(name("x/plan.dwg"), "x/plan.bak"); + assert_eq!(name("x/plan.DWG"), "x/plan.BAK"); + assert_eq!(name("x/plan.dxf"), "x/plan.dxf.bak"); + assert_eq!(name("x/plan.dwt"), "x/plan.dwt.bak"); +} diff --git a/crates/engine/tests/save_creates_missing_folders.rs b/crates/engine/tests/save_creates_missing_folders.rs new file mode 100644 index 000000000..4a44e58c6 --- /dev/null +++ b/crates/engine/tests/save_creates_missing_folders.rs @@ -0,0 +1,62 @@ +//! A save to a folder that doesn't exist writes the file anyway and says what it created, so a +//! mistyped path costs a stray folder, not the drawing (#2215). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; +use std::path::PathBuf; + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-new-folder-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn session() -> Session { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let mut s = Session::empty(); + s.open_drawing(Drawing::new_imperial(), "a.dxf", None); + s +} + +#[test] +fn saveas_and_export_write_the_file_and_report_the_new_folder() { + let dir = scratch("cmds"); + let mut s = session(); + for (cmd, name) in [("saveas", "one"), ("export", "two")] { + let top = dir.join(name); + let path = top.join("deeper").join("a.dxf"); + let r = s.execute(cmd, &json!({ "path": path.to_string_lossy() })).unwrap(); + assert!(path.is_file(), "{cmd} wrote no file"); + assert_eq!(r["createdFolder"], json!(top.to_string_lossy()), "{cmd}"); + let said = top.display().to_string(); + assert!(s.log.iter().any(|l| l.contains(&said) && l.starts_with("Created the folder")), "{cmd}: {:?}", s.log); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_save_into_an_existing_folder_reports_nothing() { + let dir = scratch("quiet"); + let mut s = session(); + let path = dir.join("a.dxf"); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); + assert!(r.get("createdFolder").is_none()); + assert!(!s.log.iter().any(|l| l.starts_with("Created the folder")), "{:?}", s.log); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_folder_that_cannot_be_made_fails_and_says_so() { + let dir = scratch("blocked"); + let blocker = dir.join("a_file"); + std::fs::write(&blocker, b"x").unwrap(); + let mut s = session(); + let path = blocker.join("sub").join("a.dxf"); + let e = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap_err().to_string(); + assert!(e.contains("could not be created"), "{e}"); + assert!(!s.log.iter().any(|l| l.starts_with("Created the folder")), "{:?}", s.log); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/save_portable_fonts.rs b/crates/engine/tests/save_portable_fonts.rs new file mode 100644 index 000000000..a63016d60 --- /dev/null +++ b/crates/engine/tests/save_portable_fonts.rs @@ -0,0 +1,54 @@ +//! Text styles with Linux fonts AutoCAD on Windows doesn't have: a save names them, and SAVEAS +//! with portableFonts writes Windows fonts in that file while the drawing keeps its own. + +use cadcraft_doc::{Drawing, TextStyle}; +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use serde_json::json; + +fn dxf(d: &Drawing, _: &str) -> Result, String> { + Ok(cadcraft_io::write_dxf(d).into_bytes()) +} + +fn session() -> Session { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: dxf, plot: None }); + let mut d = Drawing::new_metric(); + for (name, font) in [("Notes", "DejaVuSans.ttf"), ("Plain", "arial.ttf"), ("Tight", "Liberation Sans Narrow")] { + d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); + } + let mut s = Session::empty(); + s.open_drawing(d, "fonts.dxf", None); + s +} + +#[test] +fn portable_fonts_writes_windows_fonts_in_that_file() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("cadcraft-portable-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_string_lossy().to_string(); + let file = |name: &str| std::fs::read_to_string(dir.join(name)).unwrap(); + let both = json!([{ "style": "Notes", "font": "DejaVuSans.ttf" }, { "style": "Tight", "font": "Liberation Sans Narrow" }]); + + // portableFonts writes Verdana for DejaVu Sans; the narrow style has no Windows font. + let r = s.execute("saveas", &json!({ "path": path("portable.dxf"), "portableFonts": true })).unwrap(); + assert_eq!(r["nonPortableFonts"], json!([{ "style": "Tight", "font": "Liberation Sans Narrow" }])); + assert_eq!(s.log.last().map(String::as_str), Some("Fonts AutoCAD on Windows doesn't have: Tight (Liberation Sans Narrow).")); + assert!(file("portable.dxf").contains("verdana.ttf")); + assert!(s.state().unwrap().drawing().text_styles.iter().any(|t| t.font == "DejaVuSans.ttf"), "the drawing keeps its fonts"); + + // A later save without it writes the drawing's fonts again, and says so. + let r = s.execute("qsave", &json!({})).unwrap(); + assert_eq!(r["nonPortableFonts"], both); + assert!(s.log.last().is_some_and(|l| l.ends_with("SAVEAS with portableFonts writes Windows fonts for those that have one.")), "{:?}", s.log); + assert!(!file("portable.dxf").contains("verdana.ttf")); + + // The same list isn't said again on every save. + let lines = s.log.len(); + let r = s.execute("qsave", &json!({})).unwrap(); + assert_eq!(r["nonPortableFonts"], both); + assert!(s.log.iter().skip(lines).all(|l| !l.starts_with("Fonts AutoCAD")), "{:?}", s.log); + + assert!(s.execute("saveas", &json!({ "path": path("bad.dxf"), "portableFonts": "yes" })).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs index 615028a21..80192bf53 100644 --- a/crates/engine/tests/save_reports_not_saved.rs +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -3,7 +3,7 @@ //! writes them back as read: nothing to report. use cadcraft_engine::Session; -use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::cmd::file::{IoHooks, SaveAs, set_io, set_write_as}; use cadcraft_engine::doc::{Block, Common, Drawing, EntityKind, RawTag, Space, Unknown}; use serde_json::json; @@ -29,7 +29,7 @@ fn save_lists_the_entities_the_file_does_not_keep() { let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); let _ = std::fs::remove_file(&path); assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 })); - let msg = "Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): 1 3DSOLID, 2 MLINE, 1 REGION"; + let msg = "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): 1 3DSOLID, 2 MLINE, 1 REGION"; assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log); // DXF keeps them. @@ -52,6 +52,38 @@ fn save_lists_the_entities_the_file_does_not_keep() { assert!(r.get("notSaved").is_none() && s.log.len() == n); } +#[test] +fn export_and_wblock_say_what_a_dwg_leaves_out() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let mut d = Drawing::new_imperial(); + let h = d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET")).unwrap().hex(); + let mut s = Session::empty(); + s.open_drawing(d, "foreign.dxf", None); + let file = |ext: &str| std::env::temp_dir().join(format!("cadcraft-leaves-out-{}.{ext}", std::process::id())).to_string_lossy().into_owned(); + let said = |l: &String| l.starts_with("Not saved in the DWG file") && l.ends_with(": 1 ACME_WIDGET"); + + let (dwg, dxf) = (file("dwg"), file("dxf")); + for (cmd, p) in [("export", json!({ "path": dwg })), ("wblock", json!({ "path": dwg, "handles": [h] }))] { + s.log.clear(); + let r = s.execute(cmd, &p).unwrap(); + assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 }), "{cmd}"); + assert!(s.log.iter().any(said), "{cmd}: {:?}", s.log); + } + // -WBLOCK takes the objects out of the drawing, so last. + let r = s.execute("-wblock", &json!({ "path": dwg, "handles": [h] })).unwrap(); + assert_eq!(r["notSaved"], json!({ "ACME_WIDGET": 1 })); + let _ = std::fs::remove_file(&dwg); + + // DXF keeps them. + s.execute("undo", &json!({})).unwrap(); + for (cmd, p) in [("export", json!({ "path": dxf })), ("wblock", json!({ "path": dxf, "handles": [h] }))] { + s.log.clear(); + assert!(s.execute(cmd, &p).unwrap().get("notSaved").is_none(), "{cmd}"); + assert!(!s.log.iter().any(said), "{cmd}: {:?}", s.log); + } + let _ = std::fs::remove_file(&dxf); +} + #[test] fn templates_report_what_dwg_does_not_keep() { use cadcraft_engine::cmd::file::drops_unknown; @@ -82,3 +114,130 @@ fn solids_whose_geometry_the_data_store_keeps_are_not_reported_from_2013() { // Before 2013 a file has no data store: every solid loses its geometry. assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 2), ("REGION".to_string(), 1)].into_iter().collect()); } + +/// A DXF of 2013 or later can't hold a solid's SAT-text geometry (it lives in the data store +/// there), so the save leaves it out and says so; 2010 and earlier keep it (#2334). +#[test] +fn saving_sat_text_solids_as_dxf_2013_reports_them() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec())); + let mut d = Drawing::new_imperial(); + let sat = EntityKind::Unknown(Unknown { + dxf_type: "REGION".into(), + tags: vec![ + RawTag { code: 8, value: "0".into() }, + RawTag { code: 1, value: "400 1 1 0".into() }, + RawTag { code: 3, value: "End-of-ACIS-data".into() }, + ], + }); + d.add(&Space::Model, Common::default(), sat).unwrap(); + let mut s = Session::empty(); + s.open_drawing(d, "region.dxf", None); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-note-{}.dxf", std::process::id())); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap(); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file") && l.contains("1 REGION")), "{:?}", s.log); + + // QSAVE takes no version: a `version` passed to it doesn't change what it writes (the + // drawing's own 2018), so the note still comes. + let r = s.execute("qsave", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap(); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + + let n = s.log.len(); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2010 })).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(r.get("notSaved").is_none() && s.log.len() == n); +} + +/// The interactive save asks with the same list, and EXPORT reports it, so overwriting a 2013+ +/// DXF never drops solids on a command-line note alone (#2334). +#[test] +fn a_dxf_2013_loss_is_known_before_saving() { + use cadcraft_engine::cmd::file::loss_on_save; + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let sat = |d: &mut Drawing| { + let tags = vec![RawTag { code: 8, value: "0".into() }, RawTag { code: 1, value: "400 1 1 0".into() }]; + d.add(&Space::Model, Common::default(), EntityKind::Unknown(Unknown { dxf_type: "REGION".into(), tags })).unwrap(); + }; + let mut modern = Drawing::new_imperial(); + modern.header.set_str("ACADVER", "AC1032"); + sat(&mut modern); + let mut s = Session::empty(); + s.open_drawing(modern, "region.dxf", None); + assert_eq!(loss_on_save(&s, "region.dxf"), [("REGION".to_string(), 1)].into_iter().collect()); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-export-{}.dxf", std::process::id())); + let r = s.execute("export", &json!({ "path": path.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); + assert!(s.log.iter().any(|l| l.contains("Not saved in the DXF file")), "{:?}", s.log); + + let mut old = Drawing::new_imperial(); + old.header.set_str("ACADVER", "AC1024"); + sat(&mut old); + s.open_drawing(old, "region.dxf", None); + assert!(loss_on_save(&s, "region.dxf").is_empty(), "2010 keeps SAT text"); +} + +/// In the Block Editor the file still holds the host drawing: its solids and version decide the +/// note, not the edited block's (#2334). +#[test] +fn the_note_counts_the_host_drawing_while_a_block_is_edited() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + set_write_as(|_, _, _: &SaveAs| Ok(b"x".to_vec())); + let mut d = Drawing::new_imperial(); + let sat = EntityKind::Unknown(Unknown { + dxf_type: "REGION".into(), + tags: vec![ + RawTag { code: 8, value: "0".into() }, + RawTag { code: 1, value: "400 1 1 0".into() }, + RawTag { code: 3, value: "End-of-ACIS-data".into() }, + ], + }); + d.add(&Space::Model, Common::default(), sat).unwrap(); + d.blocks.insert("Door".into(), std::sync::Arc::new(Block::new("Door"))); + let mut s = Session::empty(); + s.open_drawing(d, "region.dxf", None); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + + let path = std::env::temp_dir().join(format!("cadcraft-sat-edit-{}.dxf", std::process::id())); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy(), "version": 2018 })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["notSaved"], json!({ "REGION": 1 })); +} + +/// A DWG before 2013 writes a solid's SAT text as it is, so a save there doesn't list a solid that +/// holds it; from 2013 the geometry must be in the data store and SAT text alone is lost, so the +/// save lists it. A solid with neither is always listed (#2334). +#[test] +fn a_dwg_keeps_sat_text_solids_only_before_2013() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let solid = |tags: Vec| EntityKind::Unknown(Unknown { dxf_type: "3DSOLID".into(), tags }); + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(1, "400 1 1 0")])).unwrap(); + d.add(&Space::Model, Common::default(), solid(vec![tag(100, "AcDbModelerGeometry"), tag(70, "1")])).unwrap(); + assert_eq!(not_saved_as(&d, Version::R2010), [("3DSOLID".to_string(), 1)].into_iter().collect(), "2010 keeps the SAT-text solid"); + assert_eq!(not_saved_as(&d, Version::R2018), [("3DSOLID".to_string(), 2)].into_iter().collect(), "2018 loses it"); +} + +/// The DWG bridge writes OLE objects (`ole2frame_roundtrip` in the io tests), so saving one as +/// DWG reports nothing for them, at any version, while other unmodelled types are still listed. +/// The type is matched as stored: the bridge gets it as stored too, and acadrust reads only the +/// upper-case spelling into its OLE entity, so any other spelling is still reported. +#[test] +fn ole_objects_are_not_reported() { + use cadcraft_engine::cmd::file::{Version, not_saved_as}; + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), unknown("OLE2FRAME")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("MLINE")).unwrap(); + d.add(&Space::Model, Common::default(), unknown("ole2frame")).unwrap(); + let mut b = Block::new("Logo"); + let h = d.new_handle(); + b.entities.push(cadcraft_engine::doc::Entity { handle: h, common: Common::default(), kind: unknown("OLE2FRAME") }); + d.blocks.insert("Logo".into(), std::sync::Arc::new(b)); + for v in [Version::R2000, Version::R2010, Version::R2018] { + assert_eq!(not_saved_as(&d, v), [("MLINE".to_string(), 1), ("ole2frame".to_string(), 1)].into_iter().collect(), "{v:?}"); + } +} diff --git a/crates/engine/tests/wblock_purge.rs b/crates/engine/tests/wblock_purge.rs new file mode 100644 index 000000000..3fc3bad5e --- /dev/null +++ b/crates/engine/tests/wblock_purge.rs @@ -0,0 +1,165 @@ +//! WBLOCK writes what the block or the selection uses, not the source drawing's whole tables +//! (#2423): AutoCAD's WBLOCK of a 5-object symbol writes its layer, its style and the defaults, +//! not the other 40 layers and 20 blocks of the drawing it came from. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::{Value, json}; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Writes the written drawing's table names, one table a line: layers, text styles, blocks, +/// dimension styles, linetypes. One hook for every test here: `set_io` is process-wide and the +/// tests run in parallel. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut blocks: Vec<&str> = d.blocks.values().map(|b| b.name.as_str()).collect(); + blocks.sort_unstable(); + let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + let styles: Vec<&str> = d.text_styles.iter().map(|t| t.name.as_str()).collect(); + let dims: Vec<&str> = d.dim_styles.iter().map(|t| t.name.as_str()).collect(); + let linetypes: Vec<&str> = d.linetypes.iter().map(|t| t.name.as_str()).collect(); + Ok(format!("{}\n{}\n{}\n{}\n{}", layers.join(","), styles.join(","), blocks.join(","), dims.join(","), linetypes.join(",")).into_bytes()) +} + +/// The layers, text styles and blocks of the file WBLOCK writes with `p`. +fn written(s: &mut Session, mut p: Value) -> (String, String, String) { + let dir = TempDir::new("wblock-purge"); + let path = dir.path_str("out.dxf"); + p["path"] = json!(path); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let mut lines = text.lines().map(str::to_string); + (lines.next().unwrap_or_default(), lines.next().unwrap_or_default(), lines.next().unwrap_or_default()) +} + +fn line_on(s: &mut Session, layer: &str, y: f64) -> String { + let h = s.execute("line", &json!({ "points": [[0, y], [5, y]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + s.execute("properties.set", &json!({ "handles": [h], "layer": layer })).unwrap(); + h +} + +/// Block SYM: a line on SYM-LAYER and a reference to INNER, whose line is on INNER-LAYER. The +/// drawing also has a layer, a text style and a block that SYM doesn't use. +fn drawing() -> Session { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + for name in ["SYM-LAYER", "INNER-LAYER", "UNUSED-LAYER", "OTHER-LAYER"] { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("style", &json!({ "name": "UNUSED-STYLE" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let inner = line_on(&mut s, "INNER-LAYER", 0.0); + s.execute("block", &json!({ "name": "INNER", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + let r = s.execute("insert", &json!({ "name": "INNER", "at": [0, 5] })).unwrap(); + let inner_ref = r["handle"].as_str().unwrap().to_string(); + let own = line_on(&mut s, "SYM-LAYER", 10.0); + s.execute("block", &json!({ "name": "SYM", "base": [0, 0], "handles": [own, inner_ref], "keep": "delete" })).unwrap(); + let other = line_on(&mut s, "OTHER-LAYER", 20.0); + s.execute("block", &json!({ "name": "OTHER", "base": [0, 0], "handles": [other], "keep": "delete" })).unwrap(); + s +} + +#[test] +fn a_block_is_written_with_what_it_uses() { + let mut s = drawing(); + let (layers, styles, blocks) = written(&mut s, json!({ "name": "SYM" })); + let layers: Vec<&str> = layers.split(',').collect(); + for used in ["0", "SYM-LAYER", "INNER-LAYER"] { + assert!(layers.contains(&used), "{used} in {layers:?}"); + } + for unused in ["UNUSED-LAYER", "OTHER-LAYER"] { + assert!(!layers.contains(&unused), "{unused} in {layers:?}"); + } + assert!(!styles.split(',').any(|n| n == "UNUSED-STYLE"), "{styles}"); + assert!(styles.split(',').any(|n| n == "Standard"), "{styles}"); + // The nested block comes along; the block being written is the file itself, and OTHER stays out. + assert_eq!(blocks, "INNER"); + // The source drawing is untouched. + let d = s.doc().unwrap(); + assert!(d.layer("UNUSED-LAYER").is_some() && d.block("OTHER").is_some()); +} + +#[test] +fn selected_objects_are_written_with_what_they_use() { + let mut s = drawing(); + let r = s.execute("insert", &json!({ "name": "INNER", "at": [50, 50] })).unwrap(); + let h = r["handle"].as_str().unwrap().to_string(); + let (layers, _, blocks) = written(&mut s, json!({ "handles": [h] })); + assert_eq!(blocks, "INNER", "only the block the selection references"); + let layers: Vec<&str> = layers.split(',').collect(); + assert!(layers.contains(&"INNER-LAYER") && !layers.contains(&"SYM-LAYER"), "{layers:?}"); +} + +/// What the block uses through its styles stays too: a leader's dimension style, the linetype a +/// dimension style draws its dimension line with, and the text style of a complex linetype's text. +#[test] +fn styles_used_through_other_styles_stay() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + s.execute("style", &json!({ "name": "LTEXT" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let lin = "*TXT,Text --T--\nA,.5,-.2,[\"T\",LTEXT,S=.1,X=-.1,Y=-.05],-.2\n"; + s.execute("linetype", &json!({ "load": "TXT", "text": lin })).unwrap(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("dimstyle", &json!({ "name": "LDR", "current": true })).unwrap(); + let leader = s.execute("leader", &json!({ "points": [[0, 0], [5, 5]], "text": "NOTE" })).unwrap()["handles"].clone(); + s.execute("dimstyle", &json!({ "name": "DS", "dimLinetype": "DASHED", "current": true })).unwrap(); + let dim = s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, -3] })).unwrap()["handle"].clone(); + s.execute("dimstyle", &json!({ "name": "Standard", "current": true })).unwrap(); + let line = s.execute("line", &json!({ "points": [[0, 8], [9, 8]] })).unwrap()["handles"][0].clone(); + s.execute("properties.set", &json!({ "handles": [line], "linetype": "TXT" })).unwrap(); + let mut handles: Vec = leader.as_array().cloned().unwrap_or_default(); + handles.extend([dim, line]); + s.execute("block", &json!({ "name": "SYM", "base": [0, 0], "handles": handles, "keep": "delete" })).unwrap(); + let dir = TempDir::new("wblock-purge-styles"); + let path = dir.path_str("out.dxf"); + s.execute("wblock", &json!({ "name": "SYM", "path": path })).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let table = |n: usize| text.lines().nth(n).unwrap_or("").split(',').map(str::to_string).collect::>(); + let (styles, dims, linetypes) = (table(1), table(3), table(4)); + for kept in ["LDR", "DS"] { + assert!(dims.iter().any(|n| n == kept), "dimension style {kept} kept: {dims:?}"); + } + for kept in ["DASHED", "TXT"] { + assert!(linetypes.iter().any(|n| n == kept), "linetype {kept} kept: {linetypes:?}"); + } + assert!(styles.iter().any(|n| n == "LTEXT"), "text style LTEXT kept: {styles:?}"); +} + +/// PURGE keeps a dimension style a leader uses (it only counted dimensions and tolerances). +#[test] +fn purge_keeps_a_leaders_dimension_style() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "LDR", "current": true })).unwrap(); + s.execute("leader", &json!({ "points": [[0, 0], [5, 5]], "text": "NOTE" })).unwrap(); + s.execute("dimstyle", &json!({ "name": "Standard", "current": true })).unwrap(); + s.execute("purge", &json!({ "type": "all" })).unwrap(); + assert!(s.doc().unwrap().dim_style("LDR").is_some(), "the leader's style stays"); +} + +/// PURGE keeps a linetype only a multiline style's element uses, and a text style only a table +/// style's cell style uses (from the review of #2621). +#[test] +fn purge_keeps_styles_used_by_multiline_and_table_styles() { + use cadcraft_engine::doc::{CellStyle, MLineElement, MLineStyle, TableStyle}; + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("style", &json!({ "name": "CELLTEXT" })).unwrap(); + s.execute("style.current", &json!({ "name": "Standard" })).unwrap(); + let d = s.doc_mut().unwrap(); + let element = MLineElement { linetype: "DASHED".into(), ..MLineElement::default() }; + d.mline_styles.push(MLineStyle { name: "ML".into(), elements: vec![element], ..MLineStyle::default() }); + let cell = CellStyle { text_style: "CELLTEXT".into(), ..CellStyle::default() }; + d.table_styles.push(TableStyle { name: "TS".into(), cell_styles: vec![cell], ..TableStyle::default() }); + // Both styles are made current, so PURGE keeps them; what they use must stay with them. + d.header.set_str("CMLSTYLE", "ML"); + d.header.set_str("CTABLESTYLE", "TS"); + s.execute("purge", &json!({ "type": "all" })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.linetype("DASHED").is_some(), "the multiline style's linetype stays"); + assert!(d.text_style("CELLTEXT").is_some(), "the table style's text style stays"); +} diff --git a/crates/engine/tests/wblock_units.rs b/crates/engine/tests/wblock_units.rs new file mode 100644 index 000000000..8ba2dffad --- /dev/null +++ b/crates/engine/tests/wblock_units.rs @@ -0,0 +1,87 @@ +//! WBLOCK writes the source drawing's units: `$INSUNITS` and `$MEASUREMENT` of the written file +//! are the source's, for selected objects and for a block, as AutoCAD's `-WBLOCK` does. So are its +//! other settings and styles: a metric drawing's text size, dimension style and scale list. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; + +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Writes "INSUNITS;MEASUREMENT" and, on a second line, the settings a metric drawing changes, so +/// the test can read them from the file that would be saved. +fn write(d: &Drawing, _path: &str) -> Result, String> { + Ok(format!("{};{}\n{}", d.header.i64("INSUNITS", -1), d.header.i64("MEASUREMENT", -1), settings(d)).into_bytes()) +} + +fn settings(d: &Drawing) -> String { + let mleader = d.mleader_styles.first().map(|m| format!("{m:?}")).unwrap_or_default(); + let table = d.table_styles.first().map(|t| format!("{t:?}")).unwrap_or_default(); + let limmax = d.header.point("LIMMAX"); + format!("{} {} {limmax:?} {} {mleader} {table}", d.header.str("DIMSTYLE", ""), d.header.f64("TEXTSIZE", -1.0), d.scales.len()) +} + +/// The file WBLOCK would save: line `n` of what [`write`] wrote. Each call writes into a folder +/// of its own, so tests running in parallel never read or delete each other's file. +fn wblock(s: &mut Session, p: serde_json::Value, n: usize) -> String { + let dir = TempDir::new("wblock-units"); + let path = dir.path_str("out.dxf"); + let mut p = p; + p["path"] = json!(path); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + text.lines().nth(n).unwrap_or("").to_string() +} + +fn written(s: &mut Session, p: serde_json::Value) -> String { + wblock(s, p, 0) +} + +fn written_settings(s: &mut Session, p: serde_json::Value) -> String { + wblock(s, p, 1) +} + +fn line(s: &mut Session) -> String { + s.execute("line", &json!({ "points": [[0, 0], [2, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +#[test] +fn wblock_keeps_the_source_drawing_units() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + // A metric drawing in metres, like the issue's metres.dxf (`--metric` then `insunits 6`). + s.new_drawing(true); + s.execute("units", &json!({ "insunits": 6 })).unwrap(); + let b = line(&mut s); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [b], "keep": "delete" })).unwrap(); + let c = line(&mut s); + + // Selected objects and a block by name both keep the source's units, not inches. + assert_eq!(written(&mut s, json!({ "handles": [c] })), "6;1"); + assert_eq!(written(&mut s, json!({ "name": "B" })), "6;1"); + + // The metric default (millimetres) and an imperial drawing keep their units too. + s.new_drawing(true); + let m = line(&mut s); + assert_eq!(written(&mut s, json!({ "handles": [m] })), "4;1"); + s.new_drawing(false); + let i = line(&mut s); + assert_eq!(written(&mut s, json!({ "handles": [i] })), "1;0"); +} + +#[test] +fn wblock_keeps_the_source_drawing_settings_and_styles() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + // A metric drawing: ISO-25, 2.5 mm text, A3 limits, metric multileader/table styles and scales. + s.new_drawing(true); + let source = settings(s.doc().unwrap()); + let l = line(&mut s); + assert_eq!(written_settings(&mut s, json!({ "handles": [l] })), source); + s.new_drawing(false); + assert_ne!(settings(s.doc().unwrap()), source, "the imperial template differs, so the check means something"); +} diff --git a/crates/engine/tests/wblock_units_handles.rs b/crates/engine/tests/wblock_units_handles.rs new file mode 100644 index 000000000..52a4d6053 --- /dev/null +++ b/crates/engine/tests/wblock_units_handles.rs @@ -0,0 +1,57 @@ +//! WBLOCK writes the source's units, only the blocks the written objects use, and handles that +//! never repeat. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; + +/// Writes "insunits;block names;handles;distinct handles" so the test can read what would be saved. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let mut handles: Vec = d.model.iter().map(|e| e.handle.0).collect(); + let mut names = Vec::new(); + for b in d.blocks.values() { + names.push(b.name.clone()); + handles.extend(b.entities.iter().map(|e| e.handle.0)); + } + let total = handles.len(); + handles.sort_unstable(); + handles.dedup(); + Ok(format!("{};{};{};{}", d.header.i64("INSUNITS", -1), names.join(","), total, handles.len()).into_bytes()) +} + +fn written(s: &mut Session, p: serde_json::Value) -> String { + let path = std::env::temp_dir().join(format!("cadcraft-wblock-units-{}.dxf", std::process::id())); + let mut p = p; + p["path"] = json!(path.to_string_lossy()); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + text +} + +fn line(s: &mut Session, y: f64) -> String { + s.execute("line", &json!({ "points": [[0, y], [1, y]] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +#[test] +fn wblock_keeps_units_drops_unused_blocks_and_repeats_no_handle() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + s.execute("units", &json!({ "insunits": 6 })).unwrap(); + // Block "Part" is made first, so its line has one of the drawing's lowest handles. + let p = line(&mut s, 0.0); + let ins = s.execute("block", &json!({ "name": "Part", "handles": [p], "keep": "convert" })).unwrap()["insert"].as_str().unwrap().to_string(); + let mut picked = vec![ins]; + for i in 1..300 { + picked.push(line(&mut s, f64::from(i) * 0.01)); + } + let u = line(&mut s, 5.0); + s.execute("block", &json!({ "name": "Unused", "handles": [u], "keep": "delete" })).unwrap(); + + // 300 objects (the insert and 299 lines) plus Part's line: the source's units, no Unused, + // and every handle distinct although the new objects are numbered from the start again. + assert_eq!(written(&mut s, json!({ "handles": picked })), "6;Part;301;301"); + // A block written by name: its objects go to model space, its definition stays behind. + assert_eq!(written(&mut s, json!({ "name": "Unused" })), "6;;1;1"); +} diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index c67c627b2..27fa505c4 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -506,3 +506,20 @@ fn saved_xref_paths_are_followed_beside_the_host_or_on_this_computer_only() { s.execute("-xref", &json!({ "option": "path", "names": "unc", "path": far.to_str().unwrap() })).unwrap(); assert_eq!(status(&s, "unc"), Some(XrefStatus::Loaded)); } + +/// A reference saved on Windows with different letter case (`..\XR\PLAN.DWG` for +/// `../xr/plan.dxf`) is still found on case-sensitive file systems. +#[test] +fn saved_xref_paths_ignore_letter_case() { + hooks(); + let base = folder("case"); + std::fs::create_dir_all(base.join("host")).unwrap(); + std::fs::create_dir_all(base.join("Xr")).unwrap(); + save(&site(), &base.join("Xr").join("Plan.dxf")); + let host = base.join("host").join("host.dxf"); + save(&host_with_paths(&[("plan", r"..\XR\PLAN.DXF")]), &host); + let mut s = Session::new(); + open(&mut s, &host); + assert_eq!(status(&s, "plan"), Some(XrefStatus::Loaded), "{:?}", s.log); + assert!(objects(&s, "plan") > 0); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index d2bdddf88..0fc676c7b 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -13,3 +13,4 @@ cadcraft-geom = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } unicode-bidi = { workspace = true } +icu_segmenter = { workspace = true } diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 1fe434272..b807eb45c 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -3,9 +3,10 @@ //! empty, `CRAFT_FONTS` is empty and the build is unchanged. It only reads the local checkout //! (no network). A bad checkout is a warning, or an error with `CRAFT_FONTS_REQUIRED=1`. //! -//! On wasm32 it embeds only BIZ UDPGothic Regular and the `Arab` and `Geor` faces (about 5.7 MB), -//! to keep the web build within hosting limits (Cloudflare Pages: 25 MiB per file). The web build -//! has no system fonts, so these are its only fallback for Japanese, Arabic-script and Georgian text. +//! On wasm32 it embeds only BIZ UDPGothic Regular, the `Arab` and `Geor` faces and the `Thai` +//! Regular faces (about 6.1 MB), to keep the web build within hosting limits (Cloudflare Pages: +//! 25 MiB per file). The web build has no system fonts, so these are its only fallback for +//! Japanese, Arabic-script, Georgian and Thai text. use std::fmt::Write as _; use std::path::PathBuf; @@ -54,8 +55,8 @@ fn craft_fonts(dir: &std::path::Path) -> Result { println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)"); continue; } - let web_script = scripts.split(',').any(|s| matches!(s.trim(), "Arab" | "Geor")); - let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || web_script; + let has = |script: &str| scripts.split(',').any(|s| s.trim() == script); + let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || has("Arab") || has("Geor") || (has("Thai") && *style == "Regular"); if wasm && !web_face { continue; } diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index a8dd367c3..ce2fbcb61 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -1,10 +1,15 @@ -//! Lines with right-to-left text: the line is split into bidi runs in display order +//! Lines with right-to-left text or Thai: the line is split into bidi runs in display order //! ([`crate::bidi::visual_runs`]), each run into pieces by the font that draws them (the text's //! font, a fallback font for characters it lacks, or the stroke font), and each font piece is //! shaped with `harfrust` (OpenType GSUB/GPOS: Arabic contextual forms, lam-alef and other //! ligatures, mark positioning, mirroring) before its glyphs are placed. Shaping happens per //! piece in the font that draws it, so Arabic in a stroke-font style is joined by the fallback //! font. Fonts without OpenType Arabic features get Unicode presentation forms instead. +//! +//! Thai is left to right but needs the shaper too: a consonant carries up to two stacked marks +//! above (a vowel, then a tone mark) and one below, placed by the font's `mark`/`mkmk` +//! positioning; letters with a descender (ฐ, ญ) lose it under a below vowel (`ccmp`); and sara +//! am (ำ) splits into nikhahit and sara aa with the nikhahit moved before any tone mark. use std::ops::Range; use std::panic::{AssertUnwindSafe, catch_unwind}; @@ -17,6 +22,17 @@ use crate::{Shaped, bidi, stroke}; /// Longest line (characters) laid out this way; longer lines keep one glyph per character. const MAX_CHARS: usize = 10_000; +/// Whether a line is laid out here rather than one glyph per character: it has right-to-left +/// text, or Thai (see the module notes). +pub(crate) fn needed(s: &str) -> bool { + s.chars().any(|c| bidi::is_rtl(c) || is_thai(c)) +} + +/// Whether `c` is in the Thai block. +pub(crate) fn is_thai(c: char) -> bool { + matches!(c, '\u{0E00}'..='\u{0E7F}') +} + /// What draws a piece of a run. #[derive(Clone)] enum Src { @@ -48,20 +64,21 @@ impl Src { /// Shape one line containing right-to-left text in `primary` (None: the stroke font), with the /// same conventions as the left-to-right paths: baseline at y = 0, x from 0, text height = cap -/// height, `%%` codes decoded. `None` when the line is too long (the caller then lays it out one -/// glyph per character) or shaping failed. -pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { +/// height, `%%` codes decoded. `base_rtl` is the line's direction (`None`: that of its first +/// strong character). `None` when the line is too long (the caller then lays it out one glyph +/// per character) or shaping failed. +pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Option { let chars = crate::decode_controls(s); if chars.is_empty() || chars.len() > MAX_CHARS { return None; } // A last-resort guard around the bidi and shaping libraries: a panic in them must not take // the drawing down with it (the line then falls back to one glyph per character). - catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok() + catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique, base_rtl))).ok() } /// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs. -fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped { +fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let shear = oblique.tan().clamp(-10.0, 10.0); @@ -74,7 +91,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 let mut x = 0.0; // Whether the rightmost glyph so far is a stroke-font glyph. let mut stroke_last = false; - for (run, rtl) in bidi::visual_runs(&cs, None) { + for (run, rtl) in bidi::visual_runs(&cs, base_rtl) { let mut pieces = itemize(&cs, run, primary); if rtl { pieces.reverse(); @@ -100,7 +117,11 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 } } Src::Face(face) => { - for g in shape_run(face, &cs, r, rtl) { + let glyphs = shape_run(face, &cs, r.clone(), rtl); + // Right-to-left runs stay outlines only: their glyphs come in display order + // and include ligatures (lam-alef) that stand for several characters. + let shows = if rtl { Vec::new() } else { glyph_chars(face, &cs, &glyphs, r.end) }; + for (n, g) in glyphs.iter().enumerate() { let outline = ttf::glyph_by_id(face, g.gid); let k = h / outline.cap * wf; let dy = g.dy * h / outline.cap; @@ -108,7 +129,14 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64 for p in contours.iter_mut().flatten() { *p += Vec2::new(dy * shear, dy); } - out.push_glyph(contours, None); + let origin = Vec2::new(x + g.dx * k + dy * shear, dy); + let text = shows + .get(n) + .copied() + .flatten() + .filter(|c| !c.is_whitespace()) + .and_then(|c| ttf::text_glyph_at(&outline, c, origin, h / outline.cap, wf, shear)); + out.push_glyph(contours, text); } let adv = g.advance * k; let (u, o) = flags(g.cluster); @@ -216,6 +244,44 @@ fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec .collect() } +/// Longest cluster whose glyphs are matched to its characters by glyph id; the glyphs of a +/// longer one (a hostile run of thousands of stacked marks) take its characters in order. +const MAX_MATCHED_CLUSTER: usize = 32; + +/// The character each glyph of a left-to-right run shows, for output that keeps text as text +/// ([`crate::TextGlyph`]). The shaper reorders, splits and substitutes glyphs inside a cluster +/// (a letter with its marks), so within each cluster a glyph that is a character's own glyph +/// takes that character first, then the other glyphs take the cluster's remaining characters in +/// order. Each character is shown once: a glyph left over (the second half of a split Thai sara +/// am) shows none and is drawn as an outline only. +fn glyph_chars(face: &Face, cs: &[char], glyphs: &[Glyph], end: usize) -> Vec> { + let mut out: Vec> = vec![None; glyphs.len()]; + let mut i = 0; + while let Some(cluster) = glyphs.get(i).map(|g| g.cluster) { + let j = glyphs.iter().skip(i).position(|g| g.cluster != cluster).map_or(glyphs.len(), |n| i + n); + // Clusters ascend in a left-to-right run: this one's characters end where the next starts. + let to = glyphs.get(j).map_or(end, |g| g.cluster).clamp(cluster, end.max(cluster)); + let chars = cs.get(cluster..to).unwrap_or_default(); + let mut free: Vec> = chars.iter().copied().map(Some).collect(); + if chars.len() <= MAX_MATCHED_CLUSTER { + let ids = face.glyph_ids(chars); + for n in i..j { + let gid = glyphs.get(n).map(|g| g.gid); + let own = ids.iter().zip(&free).position(|(id, f)| f.is_some() && *id == gid); + if let (Some(p), Some(o)) = (own, out.get_mut(n)) { + *o = free.get_mut(p).and_then(Option::take); + } + } + } + let mut rest = free.into_iter().flatten(); + for o in out.iter_mut().take(j).skip(i).filter(|o| o.is_none()) { + *o = rest.next(); + } + i = j; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -260,13 +326,13 @@ mod tests { #[test] fn empty_complex_glyph_keeps_advance_without_geometry() { let face = empty_glyph_face(); - let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph"); + let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0, None).expect("shapes empty glyph"); assert!(shaped.width > 0.0, "the font's advance is retained"); assert!(shaped.is_empty()); assert!(shaped.glyphs.is_empty()); assert!(shaped.text.is_empty()); - let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph"); + let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0, None).expect("shapes decorated empty glyph"); assert_eq!(underlined.width, shaped.width); assert!(underlined.glyphs.is_empty()); assert!(underlined.text.is_empty()); @@ -279,7 +345,7 @@ mod tests { // missing-glyph boxes, the digits stay stroke glyphs, in the middle of the line (between // the two words), and the measured width matches the shaped one. let s = "قیمت 25 تومان"; - let sh = shape(None, s, 1.0, 1.0, 0.0).unwrap_or_default(); + let sh = shape(None, s, 1.0, 1.0, 0.0, None).unwrap_or_default(); assert!(sh.width > 0.0); assert!((crate::line_width(s, 1.0, 1.0) - sh.width).abs() < 1e-9); let digits: Vec = sh.strokes.iter().filter(|st| !(st.len() == 5 && st.first() == st.last())).flatten().map(|p| p.x).collect(); @@ -372,6 +438,108 @@ mod tests { assert!(matches!((adv, hmtx), (Some(a), Some(h)) if (a - f64::from(h)).abs() < 1e-3), "{adv:?} vs {hmtx:?}"); } + #[test] + fn thai_lines_are_shaped_and_latin_lines_are_not() { + assert!(needed("ระดับดินเดิม") && needed("ขนาด 50x50 mm.") && needed("\u{0E4C}")); + assert!(needed("abc سلام")); + assert!(!needed("50x50 mm.") && !needed("Ünïcödé %%c25") && !needed("")); + } + + fn glyph(cluster: usize) -> Glyph { + Glyph { gid: 0, cluster, advance: 500.0, dx: 0.0, dy: 0.0 } + } + + /// Without installed fonts: a face that maps no character, so glyphs take their cluster's + /// characters in order. + #[test] + fn glyphs_show_their_cluster_characters_once() { + let face = empty_glyph_face(); + let cs: Vec = "xabcd".chars().collect(); + // A run from index 1: cluster 1 has "ab" (two glyphs), cluster 3 has "cd" and a split + // character's extra glyph, which shows nothing. + let gs = Vec::from([1, 1, 3, 3, 3].map(glyph)); + assert_eq!(glyph_chars(&face, &cs, &gs, 5), vec![Some('a'), Some('b'), Some('c'), Some('d'), None]); + // Clusters out of order, past the run or past the line show nothing and don't panic. + let gs = Vec::from([3, 1, usize::MAX, 9].map(glyph)); + assert_eq!(glyph_chars(&face, &cs, &gs, 5), vec![None, Some('a'), None, None]); + assert!(glyph_chars(&face, &cs, &[], 5).is_empty()); + // A cluster longer than the matching limit still shows each character once, in order. + let long: Vec = std::iter::once('ก').chain(std::iter::repeat_n('\u{0E48}', MAX_CHARS)).collect(); + let gs: Vec = (0..long.len()).map(|_| glyph(0)).collect(); + let shows = glyph_chars(&face, &long, &gs, long.len()); + assert_eq!(shows.first(), Some(&Some('ก'))); + assert!(shows.iter().all(Option::is_some)); + } + + /// An installed font with Thai, or `None` (the tests then skip). + fn thai_face() -> Option { + ttf::fallback_for('\u{0E01}').filter(|f| ['\u{0E14}', '\u{0E34}', '\u{0E4C}', '\u{0E33}', '\u{0E10}'].iter().all(|c| f.has_glyph(*c))) + } + + /// Lowest point of the glyph that shows `c`. + fn bottom_of(sh: &Shaped, c: char) -> Option { + let t = sh.text.iter().find(|t| t.ch == c)?; + sh.glyphs.get(t.outline)?.iter().flatten().map(|p| p.y).reduce(f64::min) + } + + #[test] + fn thai_marks_stack_in_an_installed_font() { + let Some(face) = thai_face() else { return }; + let shape = |s: &str| ttf::shape(&face, s, 1.0, 1.0, 0.0).expect("the face parses"); + // Thanthakhat over sara i (ดิ์) sits above the vowel, higher than over the bare consonant + // (ด์): the font's mark-to-mark positioning. One glyph per character drew both marks at + // the same height, on top of each other. + let stacked = shape("ดิ์"); + let alone = shape("ด์"); + let (Some(over_vowel), Some(over_letter)) = (bottom_of(&stacked, '\u{0E4C}'), bottom_of(&alone, '\u{0E4C}')) else { + panic!("thanthakhat drawn as text: {:?}", stacked.text.iter().map(|t| t.ch).collect::()) + }; + assert!(over_vowel > over_letter + 0.05, "stacked {over_vowel} vs alone {over_letter}"); + // Every character is written as text exactly once, however the shaper reorders, splits + // (sara am) or substitutes (ฐ without its tail under sara u) the glyphs. + for s in ["ดิ์", "น้ำ", "ฐุ", "ปั้น", "จีรศักดิ์"] { + let mut shown: Vec = shape(s).text.iter().map(|t| t.ch).collect(); + let mut typed: Vec = s.chars().collect(); + shown.sort_unstable(); + typed.sort_unstable(); + assert_eq!(shown, typed, "{s}"); + } + // Measured and laid-out widths agree, in the face and in a stroke-font style. + let s = "ทาสีขาว 50x50 mm."; + assert!((ttf::width(&face, s, 1.0, 1.0).unwrap_or_default() - shape(s).width).abs() < 1e-9); + assert!((crate::line_width(s, 1.0, 1.0) - crate::shape_stroke(s, 1.0, 1.0, 0.0).width).abs() < 1e-9); + // In a stroke-font style Thai comes from the fallback font as text, without + // missing-glyph boxes, and the Latin stays in the stroke font. + let sh = crate::shape_stroke("ระดับดินเดิม +0.00", 1.0, 1.0, 0.0); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes"); + assert_eq!(sh.text.len(), "ระดับดินเดิม".chars().count()); + assert!(!sh.strokes.is_empty(), "\"+0.00\" in the stroke font"); + } + + #[test] + fn hostile_thai_lines_never_panic() { + for s in [ + "\u{0E33}\u{0E33}\u{0E33}", + "\u{0E4C}\u{0E49}\u{0E34}", + "ก\u{0E49}\u{0E49}\u{0E49}\u{0E33}\u{0E33}", + "%%u\u{0E34}%%o", + "\u{0E01}\u{200D}\u{0E33}\u{10FFFF}", + ] { + let _ = crate::shape_stroke(s, f64::NAN, -1.0, f64::INFINITY); + let _ = crate::line_width(s, 0.0, 0.0); + if let Some(face) = thai_face() { + let _ = ttf::shape(&face, s, f64::NAN, 0.0, f64::NAN); + let _ = ttf::width(&face, s, -1.0, f64::INFINITY); + } + } + // One consonant under thousands of tone marks: a single huge cluster. + let marks = format!("ก{}", "\u{0E48}".repeat(MAX_CHARS - 1)); + assert!(crate::shape_stroke(&marks, 1.0, 1.0, 0.0).width > 0.0); + let long = "ก".repeat(MAX_CHARS + 1); + assert!(shape(None, &long, 1.0, 1.0, 0.0, None).is_none()); + assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); + } + #[test] fn hostile_rtl_lines_never_panic() { for s in ["\u{202E}%%u\u{05D0}%%", "\u{2067}\u{0644}\u{0627}\u{2069}%%o", "\u{064E}\u{064E}\u{05D0}", "\u{05D0}\u{E000}\u{10FFFF}"] { @@ -379,7 +547,7 @@ mod tests { let _ = crate::line_width(s, 0.0, 0.0); } let long = "\u{05D0}b ".repeat(MAX_CHARS); - assert!(shape(None, &long, 1.0, 1.0, 0.0).is_none()); + assert!(shape(None, &long, 1.0, 1.0, 0.0, None).is_none()); assert!(crate::shape_stroke(&long, 1.0, 1.0, 0.0).width > 0.0); } } diff --git a/crates/fonts/src/craft_fonts.rs b/crates/fonts/src/craft_fonts.rs index 713b6632a..3436d1d5f 100644 --- a/crates/fonts/src/craft_fonts.rs +++ b/crates/fonts/src/craft_fonts.rs @@ -1,8 +1,8 @@ //! Fonts from the optional craft-fonts build input (). //! //! Built with `CRAFT_FONTS_DIR=`, `build.rs` embeds the fonts listed in its -//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular and the Arabic and Georgian faces). -//! Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`]) +//! `fonts/manifest.txt` (web builds: only BIZ UDPGothic Regular, the Arabic and Georgian faces and +//! the Thai Regular faces). Without it [`CRAFT_FONTS`] is empty and nothing changes. The font table ([`crate::ttf`]) //! lists the embedded fonts with the installed ones, under [`CraftFont::stem`]. A text style //! can name one (`BIZ UDPGothic`), and they are fallbacks for characters a text's font lacks. //! They are copied out of the binary only when first used. @@ -83,6 +83,7 @@ mod tests { let sample = match f.scripts.first().copied() { Some("Jpan") => '\u{3042}', Some("Arab") => '\u{0628}', + Some("Thai") => '\u{0E01}', Some("Hang" | "Kore") => '가', Some("Geor") => 'ა', _ => 'A', diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 13af05646..983110b4d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,16 +9,19 @@ //! Right-to-left lines (Persian, Arabic, Urdu, Hebrew) go through [`bidi`] (the Unicode //! Bidirectional Algorithm, Arabic joining) and `complex` (OpenType shaping with `harfrust`): //! each run is shaped in the font that draws it before glyphs are placed, so the fallback font -//! drawing Arabic in a stroke-font style joins its letters too. +//! drawing Arabic in a stroke-font style joins its letters too. Thai lines are shaped the same +//! way, which stacks their vowels and tone marks above and below each consonant. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod bidi; mod complex; +mod linebreak; mod mtext; mod stroke; pub mod subset; pub mod ttf; +pub mod windows; use cadcraft_geom::{Bounds2, Vec2}; @@ -144,6 +147,12 @@ fn stroke_advance(c: char, h: f64, wf: f64) -> f64 { /// (radians). Characters the stroke font lacks are drawn from an installed font that has them /// (glyph outlines, sized like TrueType text), else as a missing-glyph box. pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { + shape_stroke_dir(s, height, width_factor, oblique, None) +} + +/// [`shape_stroke`] in the direction `base_rtl` (`None`: that of the line's first strong +/// character). +pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let sc = h / stroke::CAP; @@ -153,8 +162,10 @@ pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Sh let mut under_start: Option = None; let mut over_start: Option = None; let mut gap = stroke::GAP * sc * wf; - if bidi::has_rtl(s) - && let Some(sh) = complex::shape(None, s, h, wf, oblique) + // The simple path lays a line out left to right: one asked to run right to left goes through + // the bidi path even without right-to-left letters. + if (complex::needed(s) || base_rtl == Some(true)) + && let Some(sh) = complex::shape(None, s, h, wf, oblique, base_rtl) { return sh; } @@ -244,10 +255,10 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run /// Width of a line without building strokes. pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { - if bidi::has_rtl(s) { + if complex::needed(s) { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; - if let Some(sh) = complex::shape(None, s, h, wf, 0.0) { + if let Some(sh) = complex::shape(None, s, h, wf, 0.0, None) { return sh.width; } } @@ -414,14 +425,24 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec Shaped { + shape_line_dir(font, s, height, width_factor, oblique, None) +} + +/// [`shape_line`] in the direction `base_rtl` (`None`: that of the line's first strong +/// character). +pub fn shape_line_dir(font: &TextFont, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Shaped { if let TextFont::Outline(face) = font - && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique) + && let Some(sh) = ttf::shape_dir(face, s, height, width_factor, oblique, base_rtl) { return sh; } - shape_stroke(s, height, width_factor, oblique) + shape_stroke_dir(s, height, width_factor, oblique, base_rtl) } +/// Single-line TEXT (and attributes) runs left to right, as AutoCAD lays it out: neutral +/// characters before a right-to-left word ("- " in "- פרוייקט") stay on its left (#2500). +const TEXT_BASE_RTL: Option = Some(false); + /// Width of one line in `font`. pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 { if let TextFont::Outline(face) = font @@ -502,6 +523,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 { origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot) } +/// How far below the baseline a bottom-justified text's alignment point lies: a third of the +/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an +/// outline font (#2229). +fn bottom_depth(font: &TextFont, height: f64) -> f64 { + match font { + TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height), + TextFont::Stroke => height / 3.0, + } +} + +/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline; +/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the +/// string's own extents, descenders included, not at half the height (#2229). +fn ink_middle(sh: &Shaped) -> Option { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite()); + let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))); + (lo <= hi).then_some((lo + hi) / 2.0) +} + +/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`. +fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 { + let dx = match h { + Align::Left | Align::Aligned | Align::Fit => 0.0, + Align::Center | Align::Middle => -sh.width / 2.0, + Align::Right => -sh.width, + }; + let dy = match (h, v) { + (Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0), + (_, VAlign::Baseline) => 0.0, + (_, VAlign::Bottom) => bottom_depth(font, height), + (_, VAlign::Middle) => -height / 2.0, + (_, VAlign::Top) => -height, + }; + Vec2::new(dx, dy) +} + /// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline /// start from that point (along the text), its rotation and its mirroring (backwards, upside /// down). Aligned and fit text are placed by their insertion point. @@ -512,25 +569,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) { let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2)); return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0)); } - let width = shape_line(font, s, height, p.width_factor, p.oblique).width; + let sh = shape_line_dir(font, s, height, p.width_factor, p.oblique, TEXT_BASE_RTL); let origin = match (p.h, p.v) { (Align::Left, VAlign::Baseline) => p.insert, _ => p.align_pt.unwrap_or(p.insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); - (origin, Vec2::new(dx, dy), rot, mirror) + (origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror) } /// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its @@ -545,7 +590,9 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { { let len = insert.dist(p2); rot = insert.angle_to(p2); - let w = text_width(font, s, height, wf); + // Measured as it is placed, left to right: the stroke font's trailing gap follows the + // glyph that ends the line. + let w = shape_line_dir(font, s, height, wf, p.oblique, TEXT_BASE_RTL).width; if w > 1e-12 && len > 1e-12 { let k = len / w; if p.h == Align::Aligned { @@ -555,7 +602,7 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { } } } - let mut sh = shape_line(font, s, height, wf, p.oblique); + let mut sh = shape_line_dir(font, s, height, wf, p.oblique, TEXT_BASE_RTL); let width = sh.width; // Every justification but baseline-left (and aligned/fit, which span both points) is placed by // its alignment point (DXF group 11); the insertion point (group 10) is then a computed point. @@ -563,28 +610,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { (Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert, _ => p.align_pt.unwrap_or(insert), }; - let dx = match p.h { - Align::Left | Align::Aligned | Align::Fit => 0.0, - Align::Center | Align::Middle => -width / 2.0, - Align::Right => -width, - }; - let dy = match (p.h, p.v) { - (Align::Middle, _) => -height / 2.0, - (_, VAlign::Baseline) => 0.0, - (_, VAlign::Bottom) => height / 3.0, - (_, VAlign::Middle) => -height / 2.0, - (_, VAlign::Top) => -height, - }; - let local = Vec2::new(dx, dy); + let local = justify_offset(font, &sh, height, p.h, p.v); let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 }); let xf = |q: Vec2| { let l = q + local; origin + Vec2::new(l.x * mx, l.y * my).rotate(rot) }; sh.map(xf); - let bb = Bounds2::from_points( - [Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf), - ); + let depth = bottom_depth(font, height); + let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf)); (sh, bb) } @@ -611,6 +645,30 @@ pub fn place_text( mod tests { use super::*; + /// A TEXT that starts with neutral characters before a Hebrew word ("- פרוייקט") keeps them + /// on the left, as AutoCAD draws it; the line's own direction (first strong character) would + /// put the dash on the right (#2500). + #[test] + fn text_runs_left_to_right_whatever_its_first_strong_character() { + let s = "- \u{05E4}\u{05E8}\u{05D5}\u{05D9}\u{05E7}\u{05D8}"; + let dash = shape_stroke("-", 1.0, 1.0, 0.0).strokes; + assert!(!dash.is_empty()); + let has_dash_first = |strokes: &[Vec]| dash.iter().all(|d| strokes.iter().any(|st| st == d)); + let (text, _) = place(&TextFont::Stroke, s, &TextParams::new(Vec2::ZERO, 1.0)); + assert!(has_dash_first(&text.strokes), "the dash starts the TEXT"); + // The line on its own (MTEXT pieces) still takes its direction from its first strong + // character: right to left here, the dash after the word. + let line = shape_stroke(s, 1.0, 1.0, 0.0); + assert!(!has_dash_first(&line.strokes), "the dash ends a right-to-left line"); + // A line asked to run right to left does so without right-to-left letters too. + assert!(has_dash_first(&shape_stroke_dir("- abc", 1.0, 1.0, 0.0, None).strokes)); + assert!(!has_dash_first(&shape_stroke_dir("- abc", 1.0, 1.0, 0.0, Some(true)).strokes), "the dash after the word"); + // Fit TEXT is measured as it is placed, so it spans its two points exactly. + let fit = TextParams { h: Align::Fit, align_pt: Some(Vec2::new(10.0, 0.0)), ..TextParams::new(Vec2::ZERO, 1.0) }; + let (text, _) = place(&TextFont::Stroke, s, &fit); + assert!((text.width - 10.0).abs() < 1e-9, "{}", text.width); + } + #[test] fn every_ascii_printable_has_a_glyph() { for c in ' '..='~' { @@ -797,3 +855,92 @@ mod ttf_tests { assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A')); } } + +#[cfg(test)] +mod justify_tests { + use super::*; + + const H: f64 = 2.5; + + /// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`. + fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) { + let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) }; + (place(font, s, &p).0, baseline_start(font, s, &p)) + } + + /// The lowest and highest point of placed text's ink. + fn ink_y(sh: &Shaped) -> (f64, f64) { + let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y); + ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y))) + } + + /// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it, + /// not at half the height: lowercase and descenders move the baseline (#2229). + #[test] + fn a_middle_text_is_centred_on_its_ink() { + let font = TextFont::Stroke; + let mut baselines = Vec::new(); + for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] { + let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point"); + // The insertion point a DWG keeps (group 10) is the baseline start, below the ink's + // middle by as much as the unplaced text's ink middle is above its baseline. + let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0)); + assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}"); + baselines.push((s, start.y)); + } + // Capitals and digits span the height, so they stay at half of it. + assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}"); + // Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher. + for (s, y) in &baselines[1..] { + if *s != "Tg 1234 ABC" { + assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}"); + } + } + } + + /// Text that draws nothing has no ink to centre: it keeps half the height. + #[test] + fn a_middle_text_without_ink_keeps_half_the_height() { + let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline); + assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}"); + } + + /// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is + /// a third of the height above its alignment point, and Middle Center still uses half. + #[test] + fn stroke_font_bottom_and_middle_center_are_unchanged() { + let font = TextFont::Stroke; + for h in [Align::Left, Align::Center, Align::Right] { + let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom); + assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}"); + } + let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle); + assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}"); + } + + /// With a TrueType font, a bottom-justified text's alignment point is at the face's descent, + /// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height). + /// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have + /// one); skips without any. + #[test] + fn a_bottom_justified_truetype_text_sits_on_its_descent() { + let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f))); + let Some((name, face)) = found else { return }; + let ratio = ttf::descent_ratio(&face).unwrap(); + let font = TextFont::Outline(face); + let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom); + assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}"); + if name == "Arial" { + assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}"); + // The descenders reach the alignment point's line: h/3 left them 0.1 above it. + let (lo, _) = ink_y(&sh); + assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point"); + } + // And Middle centres the ink, as with the stroke font. + let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline); + let (lo, hi) = ink_y(&sh); + assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}"); + } +} diff --git a/crates/fonts/src/linebreak.rs b/crates/fonts/src/linebreak.rs new file mode 100644 index 000000000..5669db121 --- /dev/null +++ b/crates/fonts/src/linebreak.rs @@ -0,0 +1,205 @@ +//! Line-break opportunities inside Thai text, for MTEXT word wrap. +//! +//! Thai is written without spaces between words (a space ends a phrase or sentence), so a run of +//! Thai can only wrap where one word ends and the next begins. Those places come from the Unicode +//! line breaking algorithm (UAX #14), which leaves Thai to a word segmenter: `icu_segmenter`'s +//! compiled-in LSTM model for Southeast Asian scripts. Only breaks between two Thai characters +//! are used, so text in other scripts wraps at spaces exactly as before. +//! +//! The model rather than ICU's Thai dictionary: after a word the dictionary lacks (names, +//! loanwords such as เมทัลชีท) the dictionary segmenter loses its place and breaks inside the +//! words that follow, even between a consonant and its tone mark (ทแผ|่|นเร|ียบ), where the model +//! breaks at word boundaries and, when unsure, leaves words together (a line then wraps a little +//! early, never mid-word). The model is also a sixth of the dictionary's size. Any break inside +//! a character cluster is still dropped ([`cluster_boundary`]), so a line never starts with a +//! mark or a following vowel and never ends with a leading vowel. +//! +//! The model takes a few microseconds a character and text is laid out again on every display +//! rebuild, so results are kept in a small cache. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, OnceLock, PoisonError, RwLock}; + +use icu_segmenter::LineSegmenter; +use icu_segmenter::options::LineBreakOptions; + +use crate::complex::is_thai; + +/// Longest text (characters) looked up; longer text (hostile contents) gets no Thai breaks and +/// wraps at spaces only. +const MAX_CHARS: usize = 4_000; + +/// Most texts and bytes of text kept in the cache; it is emptied when either would be passed. +const MAX_CACHED: usize = 8_192; +const MAX_CACHED_BYTES: usize = 4 << 20; + +/// Whether a line may break between Thai characters `prev` and `next` without splitting a +/// character cluster: `next` is no combining mark (vowels above and below, tone marks) and no +/// following vowel or sign (ะ า ำ ๅ, ๆ, ฯ), and `prev` is no leading vowel (เ แ โ ใ ไ), which is +/// written before the consonant it belongs to. +fn cluster_boundary(prev: char, next: char) -> bool { + !crate::bidi::is_mark(next) && !matches!(next, 'ะ' | 'า' | 'ำ' | 'ๅ' | 'ๆ' | 'ฯ') && !matches!(prev, 'เ' | 'แ' | 'โ' | 'ใ' | 'ไ') +} + +/// The line segmenter with the Southeast Asian LSTM models, built once. +fn segmenter() -> &'static icu_segmenter::LineSegmenterBorrowed<'static> { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| LineSegmenter::new_lstm(LineBreakOptions::default())) +} + +#[derive(Default)] +struct Cache { + entries: HashMap>, + bytes: usize, +} + +/// A read-write lock: text is laid out on all cores and nearly every lookup is a hit. +fn cache() -> &'static RwLock { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| RwLock::new(Cache::default())) +} + +/// Character offsets in `text`, ascending, where a line may break between two Thai characters +/// (between two Thai words, at a cluster boundary). Empty when `text` has no Thai, is longer than +/// [`MAX_CHARS`], or the segmenter fails. +pub(crate) fn thai_breaks(text: &str) -> Arc<[usize]> { + if !text.chars().any(is_thai) || text.chars().nth(MAX_CHARS).is_some() { + return Arc::default(); + } + if let Some(found) = cache().read().unwrap_or_else(PoisonError::into_inner).entries.get(text) { + return found.clone(); + } + let breaks: Arc<[usize]> = segment(text).into(); + let mut c = cache().write().unwrap_or_else(PoisonError::into_inner); + let bytes = text.len().saturating_add(breaks.len().saturating_mul(std::mem::size_of::())); + if c.entries.len() >= MAX_CACHED || c.bytes.saturating_add(bytes) > MAX_CACHED_BYTES { + c.entries.clear(); + c.bytes = 0; + } + if c.entries.insert(text.to_string(), breaks.clone()).is_none() { + c.bytes = c.bytes.saturating_add(bytes); + } + breaks +} + +/// [`thai_breaks`] without the cache. +fn segment(text: &str) -> Vec { + let chars: Vec<(usize, char)> = text.char_indices().collect(); + // A last-resort guard around the segmenter: a panic in it must not take the drawing down + // (the text then wraps at spaces only). + let bytes: Vec = catch_unwind(AssertUnwindSafe(|| segmenter().segment_str(text).collect())).unwrap_or_default(); + bytes + .into_iter() + .filter_map(|b| chars.binary_search_by_key(&b, |(at, _)| *at).ok()) + .filter(|&i| { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)).map(|(_, c)| *c); + let next = chars.get(i).map(|(_, c)| *c); + matches!((prev, next), (Some(p), Some(n)) if is_thai(p) && is_thai(n) && cluster_boundary(p, n)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The pieces of `text` between its Thai breaks. + fn words(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut from = 0; + for at in thai_breaks(text).iter().copied().chain(std::iter::once(chars.len())) { + out.push(chars.get(from..at).unwrap_or_default().iter().collect()); + from = at; + } + out + } + + #[test] + fn thai_breaks_between_words() { + assert_eq!(words("ภาษาไทย"), ["ภาษา", "ไทย"]); + assert_eq!(words("เหล็กกล่อง"), ["เหล็ก", "กล่อง"]); + assert_eq!(words("น้ำทำให้ไม่ใช่"), ["น้ำ", "ทำ", "ให้", "ไม่", "ใช่"]); + // The sample drawing's specification text, with a loanword (metal sheet) inside: the + // words after it are still whole. + let spec = words("เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคาเมทัลชีทแผ่นเรียบสีขาว"); + for w in ["เหล็ก", "กล่อง", "ขนาด", "มาตรฐาน", "สำหรับ", "เมทัล", "ชีท", "สี", "ขาว"] + { + assert!(spec.iter().any(|p| p == w), "{w}: {spec:?}"); + } + let wrong = ["ทแผ", "นเร", "ยบ"]; + assert!(spec.iter().all(|p| !wrong.iter().any(|w| p.starts_with(w))), "{spec:?}"); + } + + /// Words no dictionary has (names, loanwords from the sample drawing): no break splits a + /// character cluster there. + #[test] + fn breaks_never_split_a_character_cluster() { + for text in ["จีรศักดิ์สุพรมวัน", "เมทัลชีทแผ่นเรียบสีขาว", "ศุภประดิษฐ์มาสงค์", "น้ำทำให้ไม่ใช่ๆฯ"] + { + let pieces = words(text); + assert_eq!(pieces.concat(), text); + for w in &pieces { + let first = w.chars().next().unwrap_or(' '); + assert!(!crate::bidi::is_mark(first) && !matches!(first, 'ะ' | 'า' | 'ำ' | 'ๅ' | 'ๆ' | 'ฯ'), "{text}: {pieces:?}"); + assert!(!w.ends_with(['เ', 'แ', 'โ', 'ใ', 'ไ']), "{text}: {pieces:?}"); + } + } + } + + #[test] + fn breaks_inside_a_cluster_are_dropped() { + assert!(cluster_boundary('ก', 'ข') && cluster_boundary('า', 'เ') && cluster_boundary('\u{0E48}', 'น')); + for (prev, next) in [ + ('ก', '\u{0E48}'), + ('ด', '\u{0E34}'), + ('\u{0E34}', '\u{0E4C}'), + ('ช', '\u{0E35}'), + ('ก', 'า'), + ('น', 'ำ'), + ('ก', 'ๆ'), + ('เ', 'ม'), + ('ไ', 'ท'), + ] { + assert!(!cluster_boundary(prev, next), "{prev}|{next}"); + } + } + + #[test] + fn other_scripts_and_mixed_text_keep_their_breaks() { + assert!(thai_breaks("hello world").is_empty()); + assert!(thai_breaks("").is_empty()); + // Between Thai and Latin or digits there is no break added (spaces still break). + let text = "ขนาด50x50mm"; + assert!(thai_breaks(text).is_empty(), "{:?}", thai_breaks(text)); + } + + #[test] + fn hostile_text_never_panics_and_stays_bounded() { + for s in ["\u{0E33}\u{0E33}\u{0E33}", "\u{0E4C}\u{0E49}\u{0E34}", "ก\u{0E49}\u{0E49}\u{0E49}", "ก\u{200B}ข\u{FFFF}\u{10FFFF}ค"] { + let n = s.chars().count(); + assert!(thai_breaks(s).iter().all(|&i| i > 0 && i < n)); + } + assert!(thai_breaks(&"ภาษาไทย".repeat(MAX_CHARS)).is_empty(), "too long: no lookup"); + // The limit is on characters, not bytes (a Thai character is three bytes). + assert!(!thai_breaks(&"ภาษาไทย".repeat(MAX_CHARS / 7)).is_empty()); + let long = "ภาษาไทย".repeat(MAX_CHARS / 8); + let n = long.chars().count(); + let b = thai_breaks(&long); + assert!(!b.is_empty() && b.windows(2).all(|w| w[0] < w[1]) && b.iter().all(|&i| i < n)); + } + + #[test] + fn cached_breaks_match_and_the_cache_stays_bounded() { + let text = "เหล็กกล่องขนาดมาตรฐาน"; + assert_eq!(thai_breaks(text), segment(text).into()); + assert_eq!(thai_breaks(text), thai_breaks(text)); + // Many different texts: the cache empties itself rather than growing. + for i in 0..MAX_CACHED + 10 { + let _ = thai_breaks(&format!("ภาษา{i}ไทย")); + } + let c = cache().read().unwrap_or_else(PoisonError::into_inner); + assert!(c.entries.len() <= MAX_CACHED && c.bytes <= MAX_CACHED_BYTES); + } +} diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 23736d017..a8d49fcbd 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -407,6 +407,67 @@ fn parse(contents: &str, base: Fmt) -> (Vec, Vec) { (atoms, fmts) } +/// `word` split where a line may wrap inside it: between two Thai words, as Thai writes no +/// spaces between words ([`crate::linebreak::thai_breaks`]). The pieces keep the word's segments +/// and formats in order and are laid out side by side, so the text looks the same and only gains +/// places to wrap. A word without Thai comes back whole. +/// +/// Lines still wrap at spaces first, as before: only a word too wide for a line of its own (which +/// would run out of the column) is split, so text that fits keeps its line breaks, as its author +/// saw them. +fn split_thai(word: Vec<(Seg, usize)>) -> Vec> { + if !word.iter().any(|(seg, _)| matches!(seg, Seg::Text(t) if t.chars().any(crate::complex::is_thai))) { + return vec![word]; + } + // The word's characters, a stacked fraction standing as one object replacement character + // (never next to a Thai break). + let text: String = word + .iter() + .map(|(seg, _)| match seg { + Seg::Text(t) => t.as_str(), + Seg::Stack(..) => "\u{FFFC}", + }) + .collect(); + let breaks = crate::linebreak::thai_breaks(&text); + if breaks.is_empty() { + return vec![word]; + } + let mut pieces = Vec::with_capacity(breaks.len().saturating_add(1)); + let mut breaks = breaks.iter().copied().peekable(); + let mut piece: Vec<(Seg, usize)> = Vec::new(); + // Character offset of the segment being split. + let mut at = 0usize; + for (seg, format) in word { + let Seg::Text(t) = seg else { + piece.push((seg, format)); + at += 1; + continue; + }; + let n = t.chars().count(); + let (mut rest, mut rest_at) = (t.as_str(), at); + while let Some(b) = breaks.next_if(|&b| b < at + n) { + let k = b.saturating_sub(rest_at); + let byte = rest.char_indices().nth(k).map_or(rest.len(), |(i, _)| i); + let (head, tail) = rest.split_at_checked(byte).unwrap_or((rest, "")); + if !head.is_empty() { + piece.push((Seg::Text(head.to_string()), format)); + } + if !piece.is_empty() { + pieces.push(std::mem::take(&mut piece)); + } + (rest, rest_at) = (tail, b.max(rest_at)); + } + if !rest.is_empty() { + piece.push((Seg::Text(rest.to_string()), format)); + } + at += n; + } + if !piece.is_empty() { + pieces.push(piece); + } + pieces +} + fn seg_width(seg: &Seg, f: &Fmt) -> f64 { match seg { Seg::Text(t) => text_width(&f.font, t, f.height, f.wf), @@ -537,6 +598,8 @@ pub fn layout_mtext(contents: &str, insert: Vec2, height: f64, width: f64, attac layout_mtext_with(contents, &MTextParams { insert, height, width, attach, rotation, line_spacing, ..MTextParams::new(insert, height) }) } +/// The lines of MTEXT without paragraph codes: words wrap at spaces to `width` (0: no wrapping), +/// and a word too wide for a line of its own wraps between its Thai words. fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec { let fmt = |index: usize| fmts.get(index).unwrap_or(base); let mut lines = Vec::new(); @@ -566,32 +629,44 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec { - let word_width: f64 = segments.iter().map(|(segment, format)| seg_width(segment, fmt(*format))).sum(); - let space_width = - pending_space.map(|format| crate::space_width(&fmt(format).font, fmt(format).height, fmt(format).wf)).unwrap_or(0.0); - if width > 0.0 && !line.items.is_empty() && line.width + space_width + word_width > width + 1e-9 { - finish(&mut lines, &mut line, fmt(last_fmt), paragraph); - } else if !line.items.is_empty() || pending_space.is_some() { - if let Some(format) = pending_space { + Atom::Word(word) => { + let word_width = + |segments: &[(Seg, usize)]| -> f64 { segments.iter().map(|(segment, format)| seg_width(segment, fmt(*format))).sum() }; + // Wider than the column: Thai breaks between its words (see `split_thai`). + let split; + let pieces = if width > 0.0 && word_width(word) > width + 1e-9 { + split = split_thai(word.clone()); + split.as_slice() + } else { + std::slice::from_ref(word) + }; + for segments in pieces { + let word_width = word_width(segments); + let space_width = + pending_space.map(|format| crate::space_width(&fmt(format).font, fmt(format).height, fmt(format).wf)).unwrap_or(0.0); + if width > 0.0 && !line.items.is_empty() && line.width + space_width + word_width > width + 1e-9 { + finish(&mut lines, &mut line, fmt(last_fmt), paragraph); + } else if !line.items.is_empty() || pending_space.is_some() { + if let Some(format) = pending_space { + let at = line.chars; + line.items.push(Item { seg: None, fmt: format, x: line.width, w: space_width, at }); + line.text.push(' '); + line.chars += 1; + } + line.width += space_width; + } + pending_space = None; + for (segment, format) in segments { + let segment_width = seg_width(segment, fmt(*format)); + line.max_h = line.max_h.max(fmt(*format).height); let at = line.chars; - line.items.push(Item { seg: None, fmt: format, x: line.width, w: space_width, at }); - line.text.push(' '); - line.chars += 1; + let plain = seg_plain(segment); + line.chars += plain.chars().count(); + line.text.push_str(&plain); + line.items.push(Item { seg: Some(segment.clone()), fmt: *format, x: line.width, w: segment_width, at }); + line.width += segment_width; + last_fmt = *format; } - line.width += space_width; - } - pending_space = None; - for (segment, format) in segments { - let segment_width = seg_width(segment, fmt(*format)); - line.max_h = line.max_h.max(fmt(*format).height); - let at = line.chars; - let plain = seg_plain(segment); - line.chars += plain.chars().count(); - line.text.push_str(&plain); - line.items.push(Item { seg: Some(segment.clone()), fmt: *format, x: line.width, w: segment_width, at }); - line.width += segment_width; - last_fmt = *format; } } } @@ -608,10 +683,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 }; let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 }; let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 }; - let (atoms, fmts) = parse(contents, base.clone()); if para::needed(contents, p) { return para::layout(contents, p, &base); } + let (atoms, fmts) = parse(contents, base.clone()); let fmt = |i: usize| fmts.get(i).unwrap_or(&base); let mut lines = plain_lines(&atoms, &fmts, &base, width); // Right-to-left text: each line in display order, its paragraph's direction from the @@ -875,5 +950,89 @@ mod tests { } let long = "word ".repeat(5000); assert!(lay(&long, 10.0).lines.len() > 100); + // Thai with stacks, format runs, stray marks and unpaired braces in one word. + for s in ["ก\\S1/2;ข{\\C1;ภาษา}ไทย\u{0E4C}\u{0E4C}", "{\\H2x;ภาษา}{\\H3x;ไทย}", "\u{0E33}\u{0E49}\\S/;ไทย}}", "ภาษาไทย\tภาษาไทย"] + { + let _ = lay(s, 0.5); + let _ = lay(&format!("\\pi1,l2,t3;{s}"), 0.5); + } + // Too long to look up: one unbreakable word, like a long Latin word. + assert_eq!(lay(&"ภาษาไทย".repeat(20_000), 3.0).lines.len(), 1); + } + + /// Thai writes no spaces between words: a long line wraps between words, in the plain and the + /// paragraph layout, without fonts installed (the line breaks don't depend on glyph widths). + #[test] + fn thai_wraps_between_words() { + let text = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคาเมทัลชีทแผ่นเรียบสีขาว"; + let one = lay(text, 0.0); + assert_eq!(one.lines, vec![text]); + let wide = one.bounds.width(); + for contents in [text.to_string(), format!("\\pi0,l0;{text}"), format!("{{\\C1;เหล็กกล่อง}}{}", text.trim_start_matches("เหล็กกล่อง"))] + { + let l = lay(&contents, wide / 3.0); + assert!(l.lines.len() >= 3, "{contents}: {:?}", l.lines); + assert_eq!(l.lines.concat(), text, "nothing lost or added: {:?}", l.lines); + for line in &l.lines { + let first = line.chars().next().unwrap_or(' '); + assert!(!crate::bidi::is_mark(first) && !matches!(first, 'ะ' | 'า' | 'ำ'), "{line}"); + assert!(!line.ends_with(['เ', 'แ', 'โ', 'ใ', 'ไ']), "{line}"); + } + } + // The pieces sit side by side with nothing between them: drawn with a Thai font they are + // as wide as the word laid out in one piece (up to kerning). + let pieces = split_thai(vec![(Seg::Text(text.to_string()), 0)]); + if one.pieces.iter().any(|p| !p.shaped.glyphs.is_empty()) { + let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: 1.0, oblique: 0.0, valign: 1 }; + let sum: f64 = pieces.iter().flatten().map(|(seg, _)| seg_width(seg, &base)).sum(); + assert!((sum - wide).abs() < 0.01 * wide, "{sum} {wide}"); + } + assert!(pieces.len() > 3); + // Latin and other words stay whole; stacks keep their place. + assert_eq!(split_thai(vec![(Seg::Text("hello".into()), 0)]).len(), 1); + let mixed = split_thai(vec![(Seg::Text("ภาษา".into()), 0), (Seg::Stack("1".into(), "2".into(), '/'), 0), (Seg::Text("ภาษาไทย".into()), 1)]); + let flat: Vec = mixed.iter().map(|p| p.iter().map(|(s, _)| seg_plain(s)).collect()).collect(); + assert_eq!(flat, ["ภาษา1/2ภาษา", "ไทย"]); + } + + /// A Thai run that would fit a line at the left indent but not after a first-line indent or a + /// leading tab still breaks between its words instead of running out of the column. The indent + /// is well over the slack: with no Thai font the stroke font's pieces are narrower than the run + /// (each loses its trailing letter gap). + #[test] + fn thai_wraps_after_first_line_indent_and_tab() { + let text = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคา"; + let wide = lay(text, 0.0).bounds.width(); + for contents in [format!("\\pi4,l0;{text}"), format!("\\pxt4;\t{text}")] { + let l = lay(&contents, wide + 1.0); + assert!(l.lines.len() >= 2, "{contents}: {:?}", l.lines); + assert_eq!(l.lines.concat().trim_start(), text, "{contents}: {:?}", l.lines); + } + } + + /// Text that fits at its spaces keeps those line breaks: a note from the sample drawing + /// (frame, box section 50x50 mm, painted white) at every column width from one word up never + /// breaks inside ทาสีขาว or any other spaced word, in the plain and the paragraph layout. + #[test] + fn spaced_thai_keeps_its_space_breaks() { + let text = "โครงแนวนอน เหล็กกล่อง ขนาด 50x50 mm. ทาสีขาว"; + let words: Vec<&str> = text.split(' ').collect(); + let widest = words.iter().map(|w| lay(w, 0.0).bounds.width()).fold(0.0, f64::max); + let full = lay(text, 0.0).bounds.width(); + let mut width = widest * 1.001; + while width < full * 1.05 { + for contents in [text.to_string(), format!("\\pxql;\\A1;{text}")] { + let l = lay(&contents, width); + assert_eq!(l.lines.join(" "), text, "{width}: {:?}", l.lines); + assert!(l.lines.iter().flat_map(|line| line.split(' ')).all(|w| words.contains(&w)), "{width}: {:?}", l.lines); + } + width *= 1.05; + } + // After a Latin word, a Thai run too long for any line breaks between its words, filling + // the line it starts on. + let long = "เหล็กกล่องขนาดมาตรฐานสำหรับโครงหลังคา"; + let l = lay(&format!("abc {long}"), lay(long, 0.0).bounds.width() / 2.0); + assert!(l.lines.len() >= 3 && l.lines.first().is_some_and(|f| f.starts_with("abc ") && f.len() > 4), "{:?}", l.lines); + assert_eq!(l.lines.concat(), format!("abc {long}")); } } diff --git a/crates/fonts/src/mtext/para.rs b/crates/fonts/src/mtext/para.rs index 064e62d02..c3b6da3e6 100644 --- a/crates/fonts/src/mtext/para.rs +++ b/crates/fonts/src/mtext/para.rs @@ -7,7 +7,7 @@ use cadcraft_geom::{Bounds2, Vec2}; -use super::{Atom, Fmt, MTextLayout, MTextParams, MTextPiece, Seg, arg, num, parse, seg_plain, seg_shape, seg_width}; +use super::{Atom, Fmt, MTextLayout, MTextParams, MTextPiece, Seg, arg, num, parse, seg_plain, seg_shape, seg_width, split_thai}; /// Columns an MTEXT's text flows through, each as wide as the MTEXT's width. #[derive(Clone, Debug, Default, PartialEq)] @@ -512,8 +512,34 @@ impl<'a> LineBuilder<'a> { } } + /// Place a word (`index` > 0: the part of a word after a tab, with no space before it). A + /// word too wide for a line of its own breaks between its Thai words (see `split_thai`). fn push_text(&mut self, index: usize, segments: &[(Seg, usize)]) { - let word_width: f64 = segments.iter().map(|(segment, format)| seg_width(segment, self.format(*format))).sum(); + // An empty line can't wrap, so the word gets the room from where the line starts (a + // first-line indent or a tab stop); otherwise it can wrap to a line at the left indent. + let room = { + let props = self.paragraphs.props(self.paragraph); + let start = if self.line.items.is_empty() { self.line.width } else { props.left.max(0.0) }; + self.available_width(props) - start + }; + if self.width > 0.0 && self.word_width(segments) > room + 1e-9 { + for (k, piece) in split_thai(segments.to_vec()).iter().enumerate() { + self.place(if k == 0 { index } else { index.max(1) }, piece); + } + } else { + self.place(index, segments); + } + } + + /// Width of a word's segments, each in its format. + fn word_width(&self, segments: &[(Seg, usize)]) -> f64 { + segments.iter().map(|(segment, format)| seg_width(segment, self.format(*format))).sum() + } + + /// Put a word, or a piece of one, on the current line after any pending space, or start a + /// new line when it doesn't fit (`index` as in [`Self::push_text`]). + fn place(&mut self, index: usize, segments: &[(Seg, usize)]) { + let word_width = self.word_width(segments); let space_width = if index == 0 { self.pending_space .map(|format| crate::space_width(&self.format(format).font, self.format(format).height, self.format(format).wf)) diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 6b4c5ebdd..bcf610e0a 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -5,6 +5,11 @@ //! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections //! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face. //! +//! System fonts are searched in the platform's own directories: `/System/Library/Fonts` and +//! friends on macOS, `C:\Windows\Fonts` on Windows, `/system/fonts` and the product/vendor +//! partitions on Android and HarmonyOS, and the XDG directories elsewhere. Web builds have no +//! system fonts. +//! //! Two process-wide settings (like AutoCAD's profile variables) control substitution: //! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX //! file we don't read, a missing TTF). Empty = the built-in stroke font. @@ -74,6 +79,12 @@ impl Face { pub fn has_glyph(&self, c: char) -> bool { self.font().is_some_and(|f| f.charmap().map(c).is_some()) } + /// The glyph ids the face's character map gives `chars` (`None` for an unmapped one). + pub(crate) fn glyph_ids(&self, chars: &[char]) -> Vec> { + let Some(f) = self.font() else { return vec![None; chars.len()] }; + let map = f.charmap(); + chars.iter().map(|c| map.map(*c).map(|g| g.to_u32())).collect() + } fn names(&self) -> Vec { let Some(f) = self.font() else { return Vec::new() }; [skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME] @@ -155,14 +166,58 @@ const ARABIC_FALLBACK: &[&str] = &[ "dejavusans", ]; const HEBREW_FALLBACK: &[&str] = &["notosanshebrew", "notoserifhebrew", "arial", "tahoma", "segoeui", "dejavusans"]; +/// Fonts known to cover Thai, with the OpenType mark positioning its stacked vowels and tone +/// marks need. First the Windows faces Thai drawings are laid out with (Cordia New, Angsana New, +/// Browallia New: Thai SHX fonts such as `cordia2.shx` copy their design and widths, so text +/// keeps its fit in title blocks and tables), then IBM Plex Sans Thai (installed, or embedded +/// from craft-fonts, which makes it the web build's Thai font), then Noto and the fonts each OS +/// ships (macOS Thonburi and Sukhumvit, Windows Leelawadee and Tahoma, the Linux TLWG fonts). +/// Matched as parts of file names: `angsa` and `browa` find both today's Windows collections +/// (`angsana.ttc`, `browalia.ttc`) and the older single faces (`angsa.ttf`, `browab.ttf`). +const THAI_FALLBACK: &[&str] = &[ + "cordia", + "angsa", + "browa", + "ibmplexsansthai", + "notosansthai", + "notoserifthai", + "thonburi", + "sukhumvit", + "leelawui", + "leelawadeeui", + "leelawad", + "tahoma", + "kinnari", + "norasi", + "garuda", + "loma", + "waree", + "sarabun", +]; /// Script-specific fallback files loaded per character, at most. const MAX_SCRIPT_FILES: usize = 4; +/// The font file stems matching a script's fallback `names`, best first: regular weights before +/// bold, light and the like, then in the order of `names`, then by name. +fn script_candidates(stems: &[String], names: &[&str]) -> Vec { + let mut v: Vec<(usize, usize, String)> = stems + .iter() + .filter_map(|s| { + let n = norm(s); + names.iter().position(|p| n.contains(p)).map(|pref| (weight_penalty(s, ""), pref, s.clone())) + }) + .collect(); + v.sort(); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|(_, _, s)| s).collect() +} + /// Script-specific fallback font names for `c`, if any. fn script_fallback(c: char) -> &'static [&'static str] { match c { '\u{0600}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}' => ARABIC_FALLBACK, '\u{0590}'..='\u{05FF}' | '\u{FB1D}'..='\u{FB4F}' => HEBREW_FALLBACK, + '\u{0E00}'..='\u{0E7F}' => THAI_FALLBACK, _ => &[], } } @@ -220,15 +275,20 @@ fn lock() -> MutexGuard<'static, Db> { g } +/// The platform's system font directories, in search order. #[cfg(not(target_arch = "wasm32"))] -fn scan() -> HashMap { - let mut out = HashMap::new(); +fn font_dirs() -> Vec { let mut dirs: Vec = Vec::new(); if cfg!(target_os = "macos") { dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); if let Some(h) = std::env::var_os("HOME") { dirs.push(PathBuf::from(h).join("Library/Fonts")); } + } else if cfg!(any(target_os = "android", target_env = "ohos")) { + // Android and HarmonyOS keep system fonts under /system/fonts; Android devices also use + // the product and vendor partitions for faces of their own. An app may read all of them, + // and directories a device lacks are skipped. + dirs.extend(["/system/fonts", "/product/fonts", "/system_ext/fonts", "/vendor/fonts"].map(Into::into)); } else if cfg!(windows) { dirs.push("C:\\Windows\\Fonts".into()); if let Some(l) = std::env::var_os("LOCALAPPDATA") { @@ -241,7 +301,14 @@ fn scan() -> HashMap { dirs.push(PathBuf::from(h).join(".local/share/fonts")); } } - let mut stack = dirs; + dirs +} + +/// Every font file under [`font_dirs`], by lower-case file stem. +#[cfg(not(target_arch = "wasm32"))] +fn scan() -> HashMap { + let mut out = HashMap::new(); + let mut stack = font_dirs(); let mut seen = 0usize; while let Some(d) = stack.pop() { let Ok(rd) = std::fs::read_dir(&d) else { continue }; @@ -362,18 +429,8 @@ impl Db { if names.is_empty() { return None; } - let mut stems: Vec<(usize, usize, String)> = self - .paths - .keys() - .chain(self.files.keys()) - .filter_map(|s| { - let n = norm(s); - names.iter().position(|p| n.contains(p)).map(|pref| (weight_penalty(s, ""), pref, s.clone())) - }) - .collect(); - stems.sort(); - stems.dedup_by(|a, b| a.2 == b.2); - for (_, _, stem) in stems.into_iter().take(MAX_SCRIPT_FILES) { + let stems: Vec = self.paths.keys().chain(self.files.keys()).cloned().collect(); + for stem in script_candidates(&stems, names).into_iter().take(MAX_SCRIPT_FILES) { let Some(bytes) = self.file(&stem) else { continue }; let Some(face) = faces(&bytes).into_iter().find(|f| f.has_glyph(c)) else { continue }; if let Some(i) = self.fallback.faces.iter().position(|f| f.same(&face)) { @@ -709,6 +766,16 @@ impl OutlinePen for Pen { } } +/// How far below the baseline a face reaches, as a fraction of the text height (which is the +/// face's cap height): its descent over its cap height. A bottom-justified text's alignment +/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent. +pub fn descent_ratio(face: &Face) -> Option { + let f = face.font()?; + let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent); + let r = descent / cap_height(&f); + (r.is_finite() && r > 0.0 && r < 2.0).then_some(r) +} + /// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height /// of its "H", else an estimate from the ascent. fn cap_height(f: &FontRef) -> f64 { @@ -844,9 +911,15 @@ pub(crate) fn place_glyph_at(g: &GlyphOutline, origin: Vec2, scale: f64, wf: f64 /// y = 0, x from 0) plus `%%u`/`%%o` decorations as strokes. Text height = cap height. /// Characters the face lacks come from a fallback font, scaled to its own cap height. pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + shape_dir(face, s, height, width_factor, oblique, None) +} + +/// [`shape`] in the direction `base_rtl` (`None`: that of the line's first strong character). +pub fn shape_dir(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64, base_rtl: Option) -> Option { face.font()?; - if crate::bidi::has_rtl(s) - && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique) + // As in [`crate::shape_stroke_dir`]: a line asked to run right to left takes the bidi path. + if (crate::complex::needed(s) || base_rtl == Some(true)) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, oblique, base_rtl) { return Some(sh); } @@ -883,8 +956,8 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) /// Width of one line set in a TrueType face. pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { face.font()?; - if crate::bidi::has_rtl(s) - && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0) + if crate::complex::needed(s) + && let Some(sh) = crate::complex::shape(Some(face), s, height, width_factor, 0.0, None) { return Some(sh.width); } @@ -913,6 +986,40 @@ pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique mod family_tests { use super::*; + #[test] + fn thai_looks_in_thai_fonts_before_the_general_search() { + // Letters, vowels, tone marks and digits: the general search loads a bounded number of + // files with the CJK fonts first, none of which has Thai. + for c in ['\u{0E01}', '\u{0E33}', '\u{0E34}', '\u{0E4C}', '\u{0E50}'] { + assert!(script_fallback(c).contains(&"notosansthai"), "{c}"); + } + assert!(script_fallback('a').is_empty() && script_fallback('\u{0E80}').is_empty()); + // Windows, macOS and Linux each ship one of them under these file names. + let stems: Vec = ["LeelawUI", "Thonburi", "Loma", "msyh"].map(norm).to_vec(); + assert!(stems.iter().filter(|s| THAI_FALLBACK.iter().any(|p| s.contains(p))).count() == 3); + // The faces Thai drawings are laid out with come first, then IBM Plex Sans Thai (an + // installed file or the craft-fonts face, both under this stem), then the others; regular + // weights before bold. + let stems: Vec = + ["notosansthai-regular", "ibmplexsansthai-bold", "ibmplexsansthai-regular", "msyh", "cordiab", "cordia", "ibmplexsansthailooped-regular"] + .map(String::from) + .to_vec(); + assert_eq!( + script_candidates(&stems, THAI_FALLBACK), + ["cordia", "cordiab", "ibmplexsansthai-regular", "ibmplexsansthailooped-regular", "notosansthai-regular", "ibmplexsansthai-bold"] + ); + // A text style naming the family looks in its regular file first, not the Latin Plex + // Sans or the looped or bold variants. + let stems: Vec = ["ibmplexsans-regular", "ibmplexsansthai-bold", "ibmplexsansthailooped-regular", "ibmplexsansthai-regular", "arial"] + .map(String::from) + .to_vec(); + let found = family_candidates(&stems, &norm("IBM Plex Sans Thai"), "ibm"); + assert_eq!(found.first().map(String::as_str), Some("ibmplexsansthai-regular"), "{found:?}"); + // Angsana New and Browallia New under today's Windows file names and the older ones. + let stems: Vec = ["browa", "notosansthai-regular", "angsana", "browalia", "angsa", "arial"].map(String::from).to_vec(); + assert_eq!(script_candidates(&stems, THAI_FALLBACK), ["angsa", "angsana", "browa", "browalia", "notosansthai-regular"]); + } + #[test] fn a_family_is_looked_for_in_files_starting_with_its_first_word() { let stems: Vec = ["gost", "gost2304a", "gost common", "arial", "isocpeur"].map(String::from).to_vec(); @@ -990,3 +1097,26 @@ mod geometry_budget_tests { assert!(shaped.text.is_empty()); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod scan_tests { + use super::*; + + /// Each platform searches its own system font directories. A branch that is missing or + /// misnamed leaves a device with no fonts at all, so the platform's main location is checked + /// here rather than discovered by a drawing full of missing-glyph boxes. + #[test] + fn the_platforms_system_font_directory_is_searched() { + let dirs = font_dirs(); + #[cfg(target_os = "macos")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/System/Library/Fonts")), "{dirs:?}"); + #[cfg(target_os = "android")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}"); + #[cfg(target_env = "ohos")] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/system/fonts")), "{dirs:?}"); + #[cfg(windows)] + assert!(dirs.iter().any(|d| d == std::path::Path::new("C:\\Windows\\Fonts")), "{dirs:?}"); + #[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_env = "ohos"))))] + assert!(dirs.iter().any(|d| d == std::path::Path::new("/usr/share/fonts")), "{dirs:?}"); + } +} diff --git a/crates/fonts/src/windows.rs b/crates/fonts/src/windows.rs new file mode 100644 index 000000000..729a2eea0 --- /dev/null +++ b/crates/fonts/src/windows.rs @@ -0,0 +1,82 @@ +//! Linux font families that AutoCAD on Windows doesn't have, and the Windows fonts a drawing +//! saved with portable fonts gets instead. + +/// Family key (lower case, without spaces, hyphens or underscores; a longer key before a shorter +/// one it starts with) and the Windows fonts for its regular, bold, italic and bold italic styles. +const FAMILIES: [(&str, [&str; 4]); 7] = [ + ("liberationsans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]), + ("liberationserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]), + ("liberationmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]), + ("opensans", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]), + ("dejavusansmono", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]), + ("dejavusans", ["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"]), + ("dejavuserif", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]), +]; + +/// The family of a TrueType `font` (file or family name) from [`FAMILIES`], with the rest of its +/// key (the style: `bold`, `italic`, …). +fn family(font: &str) -> Option<(&'static [&'static str; 4], String)> { + let name = font.trim().rsplit(['/', '\\']).next().unwrap_or_default().to_lowercase(); + let stem = name.strip_suffix(".ttf").or_else(|| name.strip_suffix(".otf")).unwrap_or(&name); + if stem.contains('.') { + return None; // another kind of file, such as an SHX font + } + let key: String = stem.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).collect(); + let (family, fonts) = FAMILIES.iter().find(|(f, _)| key.starts_with(f))?; + Some((fonts, key.get(family.len()..).unwrap_or_default().to_string())) +} + +/// Whether `font` is a Linux font AutoCAD on Windows doesn't have. +pub fn missing_on_windows(font: &str) -> bool { + family(font).is_some() +} + +/// The Windows font for a Linux `font`. Narrow and condensed styles have none (Arial Narrow comes +/// with Office, not with Windows). +pub fn windows_font(font: &str) -> Option<&'static str> { + let (fonts, style) = family(font)?; + if style.contains("narrow") || style.contains("condensed") { + return None; + } + let bold = style.contains("bold"); + let italic = style.contains("italic") || style.contains("oblique"); + fonts.get(usize::from(bold) + 2 * usize::from(italic)).copied() +} + +/// The font a drawing file gets for a text style's `font`: with `portable`, the Windows font for +/// a Linux one. +pub fn written_font(font: &str, portable: bool) -> &str { + if portable { windows_font(font).unwrap_or(font) } else { font } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_fonts_by_file_or_family_name() { + assert_eq!(windows_font("DejaVuSans.ttf"), Some("verdana.ttf")); + assert_eq!(windows_font("/usr/share/fonts/truetype/LiberationSerif-Bold.ttf"), Some("timesbd.ttf")); + assert_eq!(windows_font("Liberation Mono"), Some("cour.ttf")); + assert_eq!(windows_font("LiberationMono-BoldItalic.ttf"), Some("courbi.ttf")); + assert_eq!(windows_font("OpenSans-SemiBold.ttf"), Some("arialbd.ttf")); + assert_eq!(windows_font("Open Sans"), Some("arial.ttf")); + assert_eq!(windows_font("DejaVuSansMono-BoldOblique.ttf"), Some("courbi.ttf")); + assert_eq!(windows_font("DejaVuSerif-Italic.ttf"), Some("timesi.ttf")); + // Narrow and condensed styles are Linux fonts with no Windows one. + for font in ["Liberation Sans Narrow", "LiberationSansNarrow-Bold.ttf", "OpenSans-CondensedBold.ttf", "DejaVuSansCondensed.ttf"] { + assert!(missing_on_windows(font) && windows_font(font).is_none(), "{font}"); + } + // Fonts AutoCAD has, other kinds of file, or names that aren't fonts. + for font in ["arial.ttf", "txt", "dejavusans.shx", "", "/", "Liberation"] { + assert!(!missing_on_windows(font) && windows_font(font).is_none(), "{font}"); + } + } + + #[test] + fn written_font_only_changes_with_portable_fonts() { + assert_eq!(written_font("DejaVuSans.ttf", true), "verdana.ttf"); + assert_eq!(written_font("DejaVuSans.ttf", false), "DejaVuSans.ttf"); + assert_eq!(written_font("Liberation Sans Narrow", true), "Liberation Sans Narrow"); + } +} diff --git a/crates/geom/src/bounds.rs b/crates/geom/src/bounds.rs index c52efdef6..ba5b7175d 100644 --- a/crates/geom/src/bounds.rs +++ b/crates/geom/src/bounds.rs @@ -9,6 +9,12 @@ pub struct Bounds2 { pub max: Vec2, } +/// `max - min`, clamped to `f64::MAX` when finite endpoints make it overflow. +fn span(min: f64, max: f64) -> f64 { + let d = max - min; + if d.is_infinite() && min.is_finite() && max.is_finite() { f64::MAX } else { d } +} + impl Default for Bounds2 { fn default() -> Self { Bounds2::EMPTY @@ -47,10 +53,10 @@ impl Bounds2 { Bounds2 { min: self.min.min(o.min), max: self.max.max(o.max) } } pub fn width(&self) -> f64 { - if self.is_empty() { 0.0 } else { self.max.x - self.min.x } + if self.is_empty() { 0.0 } else { span(self.min.x, self.max.x) } } pub fn height(&self) -> f64 { - if self.is_empty() { 0.0 } else { self.max.y - self.min.y } + if self.is_empty() { 0.0 } else { span(self.min.y, self.max.y) } } pub fn center(&self) -> Vec2 { if self.is_empty() { @@ -98,4 +104,14 @@ mod tests { assert_eq!(b.center().y, 5.0); assert_eq!(Bounds2::EMPTY.center(), Vec2::ZERO); } + + #[test] + fn width_and_height_stay_finite_for_opposite_large_coordinates() { + let b = Bounds2::new(Vec2::new(-1e308, -1e308), Vec2::new(1e308, 1e308)); + assert_eq!(b.width(), f64::MAX); + assert_eq!(b.height(), f64::MAX); + let b = Bounds2::new(Vec2::new(1e308, 0.0), Vec2::new(1.6e308, 10.0)); + assert!((b.width() / 1e308 - 0.6).abs() < 1e-12); + assert_eq!(b.height(), 10.0); + } } diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 2b799e433..04f665f88 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{Bounds2, EPS, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle}; +use crate::{Bounds2, EPS, Mat3, PI, TAU, Vec2, angle_in_sweep, arc_segments, ccw_sweep, norm_angle}; /// A straight segment. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] @@ -247,6 +247,23 @@ impl Ellipse { pub fn sweep(&self) -> f64 { ccw_sweep(self.start, self.end) } + /// The image under the affine transform `m`, exactly: the conjugate semi-diameters + /// `A·major`, `A·minor` give the new principal axes; parameters shift with them (and + /// reverse under a mirroring transform). + pub fn transformed(&self, m: &Mat3) -> Ellipse { + let center = m.apply(self.center); + let (am, an) = (m.apply_vec(self.major), m.apply_vec(self.minor())); + // |am·cos t + an·sin t| is largest at t0: the new major axis. + let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); + let (s0, c0) = t0.sin_cos(); + let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); + let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; + if self.is_full() { + return Ellipse::full(center, u, ratio); + } + let (start, end) = if u.cross(v) >= 0.0 { (self.start - t0, self.end - t0) } else { (t0 - self.end, t0 - self.start) }; + Ellipse { center, major: u, ratio, start: norm_angle(start), end: norm_angle(end) } + } pub fn tessellate(&self, tol: f64, out: &mut Vec) { let r = self.major.len(); let sweep = self.sweep(); diff --git a/crates/geom/src/mat.rs b/crates/geom/src/mat.rs index 5f2613540..5f28b068d 100644 --- a/crates/geom/src/mat.rs +++ b/crates/geom/src/mat.rs @@ -98,6 +98,14 @@ impl Mat3 { pub fn is_mirroring(&self) -> bool { self.det() < 0.0 } + /// The rotation-and-uniform-scale factor of the linear part (mirroring allowed), or `None` + /// when it distorts shapes (non-uniform scale or shear). + pub fn similarity_scale(&self) -> Option { + let (u, v) = (Vec2::new(self.a, self.b), Vec2::new(self.c, self.d)); + let (lu, lv) = (u.len(), v.len()); + let tol = 1e-9 * lu.max(lv); + ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) + } } /// A 4x4 transform, row-major, for 3D views and block transforms with elevation. diff --git a/crates/geom/src/nurbs.rs b/crates/geom/src/nurbs.rs index 104ba8d3c..e07852340 100644 --- a/crates/geom/src/nurbs.rs +++ b/crates/geom/src/nurbs.rs @@ -131,6 +131,12 @@ impl Spline { /// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the /// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407). fn derivative_unchecked(&self, t: f64) -> Vec2 { + let d = self.velocity(t); + if d.is_finite() { d } else { Vec2::ZERO } + } + + /// [`Spline::derivative_unchecked`] before a value past f64's range is read as zero. + fn velocity(&self, t: f64) -> Vec2 { let p = self.degree; let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO }; let (lo, hi) = self.domain(); @@ -158,8 +164,7 @@ impl Spline { if w.abs() < 1e-300 { return Vec2::ZERO; } - let d = (da - a / w * dw) / w; - if d.is_finite() { d } else { Vec2::ZERO } + (da - a / w * dw) / w } /// Unit tangent at `t` in the direction of increasing parameter; a central difference stands @@ -477,22 +482,18 @@ impl Spline { out } - /// Arc length of one integration panel. The spline is validated once by the public callers - /// ([`Spline::length_between`]/[`Spline::length`]) before any panel is measured, so the speed - /// closure uses the unchecked derivative to avoid re-scanning the knots per sample. - fn panel_length(&self, a: f64, b: f64) -> f64 { - let speed = |t: f64| self.derivative_unchecked(t).len(); + /// Arc length of one integration panel, at the speed of `derivative`. The spline is validated + /// once by the callers ([`Spline::integrated_length`]/[`Spline::param_at_length`]) before any + /// panel is measured, so the derivative is an unchecked one, to avoid re-scanning the knots per + /// sample. + fn panel_length(&self, a: f64, b: f64, derivative: fn(&Spline, f64) -> Vec2) -> f64 { + let speed = |t: f64| derivative(self, t).len(); adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH) } /// Arc length between parameters `a` and `b` (adaptive Gauss–Legendre over each knot span). pub fn length_between(&self, a: f64, b: f64) -> f64 { - if !(self.is_valid() && a.is_finite() && b.is_finite()) { - return 0.0; - } - let (lo, hi) = self.domain(); - let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi)); - let l: f64 = self.length_intervals(a, b).into_iter().map(|(x, y)| self.panel_length(x, y)).sum(); + let l = self.integrated_length(a, b); if l.is_finite() { l } else { 0.0 } } @@ -502,6 +503,25 @@ impl Spline { self.length_between(lo, hi) } + /// Arc length of the whole spline, or `None` when it is past f64's range, where + /// [`Spline::length`] reads 0. An invalid spline's is 0. + pub fn checked_length(&self) -> Option { + let (lo, hi) = self.domain(); + let l = self.integrated_length(lo, hi); + l.is_finite().then_some(l) + } + + /// Arc length between `a` and `b`, infinite or NaN when it overflows (the length, or the speed + /// along the way); 0 for an invalid spline or a non-finite parameter. + fn integrated_length(&self, a: f64, b: f64) -> f64 { + if !(self.is_valid() && a.is_finite() && b.is_finite()) { + return 0.0; + } + let (lo, hi) = self.domain(); + let (a, b) = (a.min(b).clamp(lo, hi), a.max(b).clamp(lo, hi)); + self.length_intervals(a, b).into_iter().map(|(x, y)| self.panel_length(x, y, Spline::velocity)).sum() + } + /// The parameter `len` along the curve from parameter `from` (forward for positive `len`, /// backward for negative), clamped to the domain. pub fn param_at_length(&self, from: f64, len: f64) -> f64 { @@ -521,7 +541,7 @@ impl Spline { } let mut acc = 0.0; for (x0, x1) in ivs { - let l = self.panel_length(x0, x1); + let l = self.panel_length(x0, x1, Spline::derivative_unchecked); if acc + l >= target && l > 0.0 { // Bisect inside this interval, measuring from its near end. let rest = target - acc; @@ -529,7 +549,7 @@ impl Spline { let (mut s0, mut s1) = (near, far); for _ in 0..60 { let m = (s0 + s1) / 2.0; - let lm = self.panel_length(near.min(m), near.max(m)); + let lm = self.panel_length(near.min(m), near.max(m), Spline::derivative_unchecked); if lm < rest { s0 = m; } else { @@ -735,6 +755,24 @@ mod tests { assert_eq!(q.param_at_length(lo, 1e9), hi); } + #[test] + fn checked_length_tells_an_overflow_from_a_zero_length() { + let l = Spline::from_line(Vec2::ZERO, Vec2::new(3.0, 4.0), 3); + assert_eq!(l.checked_length(), Some(l.length())); + let dot = Spline::from_line(Vec2::new(1.0, 1.0), Vec2::new(1.0, 1.0), 1); + assert_eq!((dot.length(), dot.checked_length()), (0.0, Some(0.0))); + // Finite ends 2e308 apart: over a knot span of 4 the speed is in f64's range and the length + // past it; over a span of 1 the speed is past it too. `length` reads both as 0. + let ends = vec![Vec2::new(-1e308, 0.0), Vec2::new(1e308, 0.0)]; + for span in [4.0, 1.0] { + let huge = Spline { degree: 1, knots: vec![0.0, 0.0, span, span], control: ends.clone(), ..Spline::default() }; + assert_eq!((huge.length(), huge.checked_length()), (0.0, None), "{span}"); + } + let mut invalid = l.clone(); + invalid.knots.reverse(); + assert_eq!(invalid.checked_length(), Some(0.0)); + } + #[test] fn spline_arc_length_on_a_tight_bend() { // One Bezier span that turns sharply: four fixed Gauss panels come out 0.3% short, and diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index d954e66b1..1d5922d37 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -205,10 +205,69 @@ impl Spline { out } + /// Points along the curve for measuring it (AREA). Each knot span has a segment, so a narrow + /// span, which one grid over the whole domain can step over entirely, is measured too. The + /// count [`Spline::tessellate`] takes at `tol` is then shared out among the spans that bend + /// ([`Spline::span_bend`]), by the cube root of their bend: a span's chord error falls with + /// the square of its segments, so that share keeps the total error least. A straight span + /// keeps its one segment, which measures it exactly. The points number one per span plus + /// that count, past `MAX_SAMPLES` when the spans are that many, as [`Spline::length`] + /// integrates every span. An invalid spline gives its control points, as `tessellate` does. + pub fn tessellate_spans(&self, tol: f64) -> Vec { + if !self.is_valid() { + return self.control.clone(); + } + let (lo, hi) = self.domain(); + let n = self.sample_count(tol); + let spans: Vec<(f64, f64, f64)> = self.indexed_knot_spans().map(|(j, a, b)| (a, b, self.span_bend(j).cbrt())).collect(); + if spans.is_empty() { + return self.samples(n).collect(); + } + let total: f64 = spans.iter().map(|s| s.2).sum(); + let shared = total.is_finite() && total > 0.0; + let mut out = Vec::with_capacity(spans.len() + n + 1); + let (mut acc, mut used) = (0.0, 0); + for (a, b, share) in spans { + // Rounded on the running total, so the extra samples add up to `n` however thinly the + // shares are spread; the fraction first, at most 1, so a huge bend can't overflow. + acc += share; + let upto = if shared { (n as f64 * (acc / total).min(1.0)) as usize } else { 0 }; + let seg = 1 + upto.saturating_sub(used); + used = used.max(upto); + out.extend((0..seg).map(|i| self.eval_valid(a + (b - a) * i as f64 / seg as f64, lo, hi))); + } + out.push(self.eval_valid(hi, lo, hi)); + out + } + + /// How far knot span `j` (as [`Spline::indexed_knot_spans`] numbers it) can stray from its + /// chord, as an area: twice the largest triangle its first control point, the one farthest + /// from it and another make. That is between a quarter of the area of their convex hull and + /// twice it. With positive weights the span and its chord lie in that hull, so the area + /// between them is no more; it is zero when the points are collinear, and then the span is + /// straight. A weight that isn't positive lets the span leave the hull, so the square of the + /// points' polygon length counts instead. + fn span_bend(&self, j: usize) -> f64 { + let Some(pts) = self.control.get(j..=j + self.degree) else { return 0.0 }; + let Some(&a) = pts.first() else { return 0.0 }; + if !self.weights.get(j..=j + self.degree).is_none_or(|w| w.iter().all(|w| *w > 0.0)) { + let len: f64 = pts.windows(2).filter_map(|w| w.first().zip(w.get(1)).map(|(p, q)| p.dist(*q))).sum(); + return len * len; + } + let far = pts.iter().copied().max_by(|p, q| a.dist(*p).total_cmp(&a.dist(*q))).unwrap_or(a); + pts.iter().map(|p| (*p - a).cross(far - a).abs()).fold(0.0, f64::max) + } + /// The non-empty knot spans the curve is defined over, in parameter order. fn knot_spans(&self) -> impl Iterator + '_ { + self.indexed_knot_spans().map(|(_, a, b)| (a, b)) + } + + /// The non-empty knot spans with the index of the first of the `degree + 1` control points + /// that shape each. + fn indexed_knot_spans(&self) -> impl Iterator + '_ { let knots = self.knots.get(self.degree..=self.control.len()).unwrap_or(&[]); - knots.windows(2).filter_map(|w| w.first().zip(w.get(1)).map(|(a, b)| (*a, *b)).filter(|(a, b)| b > a)) + knots.windows(2).enumerate().filter_map(|(j, w)| w.first().zip(w.get(1)).map(|(a, b)| (j, *a, *b)).filter(|(_, a, b)| b > a)) } } diff --git a/crates/geom/src/spline/tests.rs b/crates/geom/src/spline/tests.rs index d953cfabd..820490d89 100644 --- a/crates/geom/src/spline/tests.rs +++ b/crates/geom/src/spline/tests.rs @@ -202,6 +202,83 @@ fn bounds_keep_their_cap_over_huge_knot_spans() { } } +#[test] +fn tessellate_spans_measures_narrow_knot_spans() { + // A degree-1 spline is its control polygon: the triangle (0,0) (1,0) (0,1), though two of its + // three spans are a billionth of the domain. One grid over the domain steps over them and + // sees no area; spread over the spans, the corners are there. + let control = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::ZERO]; + let s = Spline { degree: 1, knots: vec![0.0, 0.0, 1e-9, 2e-9, 1.0, 1.0], control, ..Spline::default() }; + assert!(s.is_valid()); + assert!(crate::shoelace(&s.tessellate(1e-3)).abs() < 1e-6); + let pts = s.tessellate_spans(1e-3); + assert!(pts.iter().any(|p| p.near(Vec2::new(1.0, 0.0), 1e-12)), "{pts:?}"); + assert!((crate::shoelace(&pts).abs() - 0.5).abs() < 1e-12); + // An ordinary spline measures as `tessellate` does, within the sampling's sag. + let w = wiggly(false); + let (a, b) = (crate::shoelace(&w.tessellate(1e-4)), crate::shoelace(&w.tessellate_spans(1e-4))); + assert!((a - b).abs() <= 1e-4 * a.abs().max(1.0), "{a} {b}"); +} + +/// The square (0,0) (100,0) (100,100) (0,100) as a degree-1 spline: `edge` spans of width 1 +/// along the bottom, then two spans a millionth wide up the right side and across the top. +fn square_with_narrow_spans(edge: usize) -> Spline { + let mut control: Vec = (0..=edge).map(|i| Vec2::new(100.0 * i as f64 / edge as f64, 0.0)).collect(); + control.extend([Vec2::new(100.0, 100.0), Vec2::new(0.0, 100.0)]); + let end = edge as f64; + let mut knots: Vec = (0..=edge).map(|i| i as f64).collect(); + knots.insert(0, 0.0); + knots.extend([end + 1e-6, end + 2e-6, end + 2e-6]); + Spline { degree: 1, knots, control, ..Spline::default() } +} + +#[test] +fn tessellate_spans_covers_every_span_however_many() { + // A degree-1 spline's spans are straight: one segment each, its control points, however many + // there are. The narrow spans are measured, not stepped over by a grid. + for edge in [3000, MAX_SAMPLES + 500] { + let s = square_with_narrow_spans(edge); + assert!(s.is_valid()); + let pts = s.tessellate_spans(1e-9); + assert_eq!(pts.len(), s.control.len()); + assert!(pts.iter().zip(&s.control).all(|(p, c)| p.near(*c, 1e-9))); + assert!((crate::shoelace(&pts).abs() - 1e4).abs() < 1e-6, "{}", crate::shoelace(&pts)); + } + let s = square_with_narrow_spans(MAX_SAMPLES + 500); + assert!(crate::shoelace(&s.samples(MAX_SAMPLES).collect::>()).abs() < 0.6e4); +} + +#[test] +fn tessellate_spans_samples_where_the_curve_bends() { + // A quadratic spline along the x-axis but for one control point 1000 above it: three spans + // bend, and the area under them is 1750/3 (a fine grid gives 583.3333334). Their chords alone + // would give 750. Below the sample cap and past it, the straight spans keep one segment each + // (their samples are the only ones on the axis but the curve's end) and the bent spans take + // all the rest. + for edge in [40, MAX_SAMPLES + 500] { + let mut control: Vec = (0..=edge).map(|i| Vec2::new(i as f64, 0.0)).collect(); + control.extend([Vec2::new(edge as f64 + 0.5, 1000.0), Vec2::new(edge as f64 + 1.0, 0.0), Vec2::new(edge as f64 + 2.0, 0.0)]); + let s = Spline::from_control(control, 2); + let pts = s.tessellate_spans(1e-9); + let area = crate::shoelace(&pts).abs(); + assert!((area - 1750.0 / 3.0).abs() < 1e-6 * area, "{edge}: {area}"); + assert_eq!(pts.iter().filter(|p| p.y == 0.0).count(), edge + 1, "{edge}"); + assert_eq!(pts.len(), s.control.len() - 2 + s.sample_count(1e-9) + 1, "{edge}"); + } +} + +#[test] +fn span_bend_bounds_the_area_off_the_chord() { + let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 2.0)], 2); + // Collinear control points: a straight span. + assert_eq!(s.span_bend(0), 0.0); + // (1,0) (3,0) (4,2): twice their triangle's area. + assert!((s.span_bend(1) - 4.0).abs() < 1e-12, "{}", s.span_bend(1)); + // A weight that isn't positive: the polygon's length squared. + s.weights = vec![1.0, -0.5, 1.0, 1.0]; + assert!((s.span_bend(0) - 9.0).abs() < 1e-12, "{}", s.span_bend(0)); +} + fn scaled(s: &Spline, k: f64) -> Spline { Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() } } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 2e8b3cc80..49464e06c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -25,7 +25,7 @@ quick-xml = { workspace = true } [dev-dependencies] # Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's). -acadrust = "0.6.5" +acadrust = "0.6.6" tiny-skia = { workspace = true } # Reads plotted PDFs back. craft-pdf = { workspace = true, features = ["read"] } diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index 9b755f80f..1a2c8696b 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -502,6 +502,10 @@ fn retained_objects( let modelled_entry = |tags: &[Tag], handle: &str| { entries(tags).iter().any(|(key, entry_handle)| entry_handle == handle && MODELLED_XDICT_KEYS.contains(&up(key).as_str())) }; + // is_xdict reads the whole record; a dictionary that owns many objects (ACAD_GROUP with + // hundreds of thousands of groups) would be read once per child (#862). + let mut xdict_memo: HashMap = HashMap::new(); + let mut is_xdict = |index: usize| *xdict_memo.entry(index).or_insert_with(|| is_xdict(index)); let mut retained = HashMap::new(); for index in 0..objects.len() { let mut path = Vec::new(); @@ -599,17 +603,13 @@ pub(crate) fn data_store(tags: &mut Vec, d: &Drawing, modern: bool) { let first = ds.iter().position(|t| t.code == 0).unwrap_or(ds.len()); out.extend(ds.iter().take(first).map(tag)); let mut records = 0usize; - let mut i = first; - while i < ds.len() { - let end = ds.iter().skip(i + 1).position(|t| t.code == 0).map_or(ds.len(), |k| i + 1 + k); - let rec = ds.get(i..end).unwrap_or(&[]); + for rec in cadcraft_doc::foreign::data_store_records(ds) { let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD")); - let owner = rec.iter().find(|t| t.code == 320).map(|t| norm(&t.value)); + let owner = cadcraft_doc::foreign::data_store_owner(rec).map(|t| norm(&t.value)); if !is_record || owner.as_ref().is_some_and(|h| written.contains(h)) { records += usize::from(is_record); out.extend(rec.iter().map(tag)); } - i = end; } if records == 0 { return; @@ -846,7 +846,7 @@ fn unknown_handles(d: &Drawing) -> HashSet { } /// Put the drawing's foreign data back into a written file's tags (see the module comment). -pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { +pub(crate) fn finish(tags: &mut Vec, d: &Drawing, reactors: &HashMap>, modern: bool) { let f = d.foreign.as_ref(); let unknown = unknown_handles(d); let xd: XdataOf = all_entities(d).filter(|e| !e.common.xdata.is_empty()).map(|e| (e.handle, e.common.xdata.as_slice())).collect(); @@ -875,7 +875,7 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { } let renumber = |old: Option| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied()); // Entities of types CADCraft doesn't model, placed among the written ones. - let mut planned = plan_unknown(d, &recs, tags, &roles); + let mut planned = plan_unknown(d, &recs, tags, &roles, modern); for list in planned.values_mut() { list.retain_mut(|(_, t)| { if !t.first().is_some_and(|z| is_proxy(&z.str())) { @@ -993,8 +993,10 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { let (Some(zero), Some(ub)) = (t.first(), t.get(1..)) else { continue }; let ur = Rec { sec: sec.clone(), kind: zero.str(), start: 0, end: 0 }; let x = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| entity_extra(f, &xd, h)); + // Reactors the writer planned for this source (a DIMASSOC, an associative hatch). + let extra = head(ub).handle.and_then(|h| handle(&h)).and_then(|h| reactors.get(&h)).map_or(&[][..], Vec::as_slice); out.push(zero.clone()); - record(&map, &ur, ub, x.as_deref(), true, false, &mut out); + record(&map, &ur, ub, x.as_deref(), Some(extra), false, &mut out); } out.extend(tags.get(r.start).cloned()); let x = extra_for(f, &xd, r, b, &block); @@ -1002,7 +1004,7 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { out.extend(b.iter().cloned()); continue; } - record(&map, r, b, x.as_deref(), false, Some(r.start) == root, &mut out); + record(&map, r, b, x.as_deref(), None, Some(r.start) == root, &mut out); } add_appids(&mut out); let top = max_handle(&out).saturating_add(1); @@ -1017,7 +1019,14 @@ pub(crate) fn finish(tags: &mut Vec, d: &Drawing) { /// Where the entities of types CADCraft doesn't model go: each before the next written entity of /// its space or block (else at the end of it), as `(section, record)` lists by the index of the /// record they precede. Owners come from the written block records. -fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap) -> HashMap)>> { +fn plan_unknown( + d: &Drawing, + recs: &[Rec], + tags: &[Tag], + roles: &HashMap, + modern: bool, +) -> HashMap)>> { + let stored = d.foreign.data_store_entities(); let mut out: HashMap)>> = HashMap::new(); let body = |r: &Rec| tags.get(r.start + 1..r.end).unwrap_or(&[]); let mut at: HashMap = HashMap::new(); @@ -1055,6 +1064,9 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap { + if modern && cadcraft_doc::foreign::sat_text_acis(u, e.handle, &stored) { + continue; + } let t = unknown_entity(u, e, &owner, paper); if !t.is_empty() { pending.push((sec.to_string(), t)); @@ -1076,10 +1088,23 @@ fn plan_unknown(d: &Drawing, recs: &[Rec], tags: &[Tag], roles: &HashMap, unknown: Option<&[String]>) -> Vec { + let extra = unknown.unwrap_or_default(); + let mut have: HashSet = h.reactors.iter().cloned().collect(); + let kept = x.filter(|_| !(unknown.is_some() && h.has_reactors)).into_iter().flat_map(|x| x.reactors.iter()).filter_map(|r| map.get(&r.hex())); + kept.chain(extra.iter().cloned()).filter(|r| r != "0" && have.insert(norm(r))).collect() +} + /// A written record with its kept extras, remapped references (unknown entities) and kept /// dictionary entries (the named object dictionary). -fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool, root: bool, out: &mut Vec) { +fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: Option<&[String]>, root: bool, out: &mut Vec) { let h = head(b); + let adds = reactors_to_add(map, &h, x, unknown); + let mut in_reactors = false; let entity = matches!(r.sec.as_str(), "ENTITIES" | "BLOCKS") && r.kind != "BLOCK"; let xdata_at = b.iter().position(|t| t.code == 1001).unwrap_or(b.len()); let own_apps: HashSet = b.iter().filter(|t| t.code == 1001).map(|t| up(&t.str())).collect(); @@ -1096,6 +1121,12 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool let rgb = b.iter().take(xdata_at).find(|t| t.code == 420).map(Tag::str); let has_book = b.iter().take(xdata_at).any(|t| t.code == 430); let handle_at = b.iter().position(|t| matches!(t.code, 5 | 105)); + // A kept extension dictionary follows the reactors group, as the DXF reference orders them. + let reactors_end = b.iter().position(|t| t.code == 102 && t.str().trim() == "{ACAD_REACTORS").and_then(|s| { + let rest = b.get(s + 1..)?; + rest.iter().position(|t| t.code == 102 && t.str().trim() == "}").map(|e| s + 1 + e) + }); + let xdict_at = if h.has_reactors { reactors_end.or(handle_at) } else { handle_at }; let mut seen_owner = false; for (i, t) in b.iter().enumerate() { if i == props_at @@ -1114,8 +1145,18 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool { xdata(map, x, &own_apps, out); } + if t.code == 102 { + match t.str().trim() { + "{ACAD_REACTORS" => in_reactors = true, + "}" if in_reactors => { + in_reactors = false; + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + } + _ => {} + } + } let mut t = t.clone(); - if unknown && i < xdata_at && is_pointer(t.code) && t.code != 102 { + if unknown.is_some() && i < xdata_at && is_pointer(t.code) && t.code != 102 { if t.code == 330 && !seen_owner { seen_owner = true; } else { @@ -1123,18 +1164,17 @@ fn record(map: &Map, r: &Rec, b: &[Tag], x: Option<&ForeignExtra>, unknown: bool } } out.push(t); - if Some(i) == handle_at + if Some(i) == handle_at && !h.has_reactors && !adds.is_empty() { + out.push(Tag::s(102, "{ACAD_REACTORS")); + out.extend(adds.iter().map(|a| Tag::s(330, a.clone()))); + out.push(Tag::s(102, "}")); + } + if Some(i) == xdict_at && let Some(x) = x + && h.xdict.is_none() + && let Some(xd) = x.xdict { - if !h.has_reactors { - let rs: Vec = x.reactors.iter().map(|r| r.hex()).collect(); - map.handle_group(out, "ACAD_REACTORS", 330, &rs); - } - if h.xdict.is_none() - && let Some(xd) = x.xdict - { - map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); - } + map.handle_group(out, "ACAD_XDICTIONARY", 360, &[xd.hex()]); } } if let Some(x) = x { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1489f4def..2dcb14fc0 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -59,7 +59,7 @@ struct Rx { pstyles: HashMap, /// Upper-case block name → associative array parameters from its BLOCK_RECORD. block_arrays: HashMap, - /// LAYER record handle (upper case) → layer name (VIEWPORT group 341 frozen layers). + /// LAYER record handle (upper case) → layer name (VIEWPORT group 331/341 frozen layers). layers: HashMap, } @@ -73,7 +73,8 @@ pub fn read(bytes: &[u8]) -> Result { type BlockDef = (String, Vec3, String, Vec<(Option, bool, Entity)>, i64); /// A layout read from OBJECTS: name, tab order, paper block handle and page setup. -type LayoutDef = (String, u32, String, PageSetup); +/// A LAYOUT object: name, tab order, block record, page setup and its limits (groups 10/11). +type LayoutDef = (String, u32, String, PageSetup, Option<(Vec2, Vec2)>); /// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections. fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { @@ -93,7 +94,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { let mut objs = Objects::default(); // Every handle another program's file already uses is taken before anything gets a new one: // its $HANDSEED can be lower than its handles, and entities made while reading (the faces of - // a polyface mesh) would otherwise get handles that records further on still have. A file + // a polygon mesh) would otherwise get handles that records further on still have. A file // CADCraft wrote names its own next handle, above which its rebuilt records are numbered // (#495), so it is left to that. let ours = secs.iter().any(|s| s.name == "OBJECTS" && s.tags.iter().any(|t| t.code == 3 && t.str() == crate::dxf_ext::HANDSEED_KEY)); @@ -143,9 +144,13 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { } /// An entity from the groups of a DXF record of type `kind` (without its `0` group), as the -/// reader makes it. AutoLISP's `entmake` and `entmod` are built on it. +/// reader makes it, with plain strings as [`crate::entity_groups`] gives them (not the file's +/// `^ ` escapes). AutoLISP's `entmake` and `entmod` are built on it. pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { - entity(&kind.to_ascii_uppercase(), tags) + // AutoLISP's strings are plain: escape them as the file has them (see `entity_groups`). + let mut tags = tags.to_vec(); + mtext_dxf::map_text_groups(kind, &mut tags, mtext_dxf::encode_line); + entity(&kind.to_ascii_uppercase(), &tags) } #[cfg(test)] diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index 9cba18171..1dbabf6e9 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -19,6 +19,15 @@ impl T<'_> { pub(super) fn p(&self, code: i32) -> Vec3 { Vec3::new(self.fd(code, 0.0), self.fd(code + 10, 0.0), self.fd(code + 20, 0.0)) } + /// A view's centre in the drawing. The DXF stores it in display coordinates (`center`), whose + /// origin is the view target (`target`), so the point in the middle of a plan view is their + /// sum: a file whose view centre is 0,0 keeps the location in the target (#2492). A sum that + /// overflows leaves the stored centre. + pub(super) fn view_center(&self, center: i32, target: i32) -> Vec2 { + let c = self.p(center).xy(); + let sum = c + self.p(target).xy(); + if sum.is_finite() { sum } else { c } + } pub(super) fn all_f(&self, code: i32) -> Vec { self.0.iter().filter(|t| t.code == code).map(Tag::f64).collect() } @@ -43,28 +52,53 @@ fn transparency_440(v: i64) -> Transparency { } } +/// The tags outside application-defined groups (`102 {` to `102 }`, whose codes are the +/// application's), with their positions in `tags`. +pub(super) fn outside_app(tags: &[Tag]) -> impl Iterator { + let mut in_app = false; + tags.iter().enumerate().filter(move |(_, t)| { + if t.code == 102 { + in_app = t.str().starts_with('{'); + } + !in_app && t.code != 102 + }) +} + +/// The groups of the AcDbEntity subclass: the record up to the entity's own subclass marker (all +/// of it in files without markers, and in lists with no AcDbEntity marker, whose layer and colour +/// may follow the entity's marker), or up to an embedded object (101). Later groups reuse the +/// codes: an MTEXT's column width is 48. Markers inside an application-defined group (`102 {` +/// to `102 }`, whose codes are the application's) do not count. +fn entity_groups(tags: &[Tag]) -> &[Tag] { + let outside_app = || outside_app(tags); + let marked = outside_app().any(|(_, t)| t.code == 100 && t.str() == "AcDbEntity"); + let end = outside_app().find(|(_, t)| (marked && t.code == 100 && t.str() != "AcDbEntity") || t.code == 101).map_or(tags.len(), |(i, _)| i); + tags.get(..end).unwrap_or(tags) +} + pub(super) fn common(t: &T) -> Common { - let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; - if let Some(v) = t.i(62) { + let e = T(entity_groups(t.0)); + let mut c = Common { layer: e.s(8).unwrap_or_else(|| "0".into()), linetype: e.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; + if let Some(v) = e.i(62) { c.color = Color::from_aci(v as i16); if v < 0 { c.color = Color::ByLayer; c.visible = true; } } - if let Some(tc) = t.i(420) { + if let Some(tc) = e.i(420) { c.color = Color::True(Rgb::from_u32(tc as u32)); } - if let Some(lw) = t.i(370) { + if let Some(lw) = e.i(370) { c.lineweight = Lineweight::from_dxf(lw as i16); } - if let Some(s) = t.f(48) { + if let Some(s) = e.f(48) { c.ltscale = if s > 0.0 { s } else { 1.0 }; } - if t.i(60) == Some(1) { + if e.i(60) == Some(1) { c.visible = false; } - if let Some(v) = t.i(440) { + if let Some(v) = e.i(440) { c.transparency = transparency_440(v); } c.thickness = t.fd(39, 0.0); diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index d732f4450..6f61e3415 100644 --- a/crates/io/src/dxf_read/document.rs +++ b/crates/io/src/dxf_read/document.rs @@ -1,7 +1,7 @@ use super::common::T; use super::entities::{entity_extras, entity_plot_style, entity_with_table_budget, insert_attribs, polyline_entity}; use super::objects::{MAX_OBJECTS, Objects, page_setup}; -use super::{Block, BlockDef, Drawing, Entity, EntityKind, Handle, HashMap, Layout, LayoutDef, Rx, Tag, records}; +use super::{Block, BlockDef, Drawing, Entity, EntityKind, Handle, HashMap, Layout, LayoutDef, Rx, Tag, Vec2, records}; pub(super) fn parse_entities( recs: &[(String, Vec)], @@ -23,7 +23,7 @@ pub(super) fn parse_entities( { c.plot_style = n; } - // More entities from this record (the faces of a polyface or polygon mesh). + // More entities from this record (the faces of a polygon mesh). let mut extra: Vec = Vec::new(); i += 1; match kind.as_str() { @@ -138,21 +138,29 @@ pub(super) fn read_objects(tags: &[Tag], objs: &mut Objects, layout_objs: &mut V // LAYOUT has two 330s: owner dict and the block record; take the last. let br = tg.iter().rfind(|x| x.code == 330).map(Tag::str).unwrap_or_default(); let plot = T(tg.get(..start).unwrap_or(&[])); - layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot))); + layout_objs.push((t.s(1).unwrap_or_default(), t.i(71).unwrap_or(0) as u32, br.to_ascii_uppercase(), page_setup(&plot), limits(&t))); } } } +/// A LAYOUT's limits (groups 10/20 and 11/21 after the AcDbLayout marker), when both corners +/// are there and usable, so a save writes back the file's own (#2109). The 0,0 to 12,9 a +/// layout without its own limits writes reads back as none. +fn limits(t: &T) -> Option<(Vec2, Vec2)> { + let corner = |x: i32, y: i32| Some(Vec2::new(t.f(x)?, t.f(y)?)).filter(|p| p.is_finite() && p.x.abs() < 1e12 && p.y.abs() < 1e12); + corner(10, 20).zip(corner(11, 21)).filter(|l| *l != (Vec2::ZERO, Vec2::new(12.0, 9.0))) +} + /// Add the drawing's layouts, and return the layout each paper-space block belongs to, and the /// layout that takes paper-space entities without an owner. pub(super) fn setup_layouts(d: &mut Drawing, layout_objs: &mut [LayoutDef], rx: &Rx) -> (HashMap, String) { let mut paper_block_for: HashMap = HashMap::new(); layout_objs.sort_by_key(|l| l.1); - for (name, order, br, page) in layout_objs.iter() { + for (name, order, br, page, limits) in layout_objs.iter() { if name.eq_ignore_ascii_case("Model") { continue; } - d.layouts.push(Layout { page: page.clone(), ..Layout::new(name, *order) }); + d.layouts.push(Layout { page: page.clone(), limits: *limits, ..Layout::new(name, *order) }); if let Some(bname) = rx.brs.get(br) { paper_block_for.insert(bname.to_ascii_uppercase(), name.clone()); } diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index aba1bdc4e..6ca0f30d1 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -1,11 +1,13 @@ use super::common::{T, common, ocs}; use super::hatch::hatch; +use super::mtext_dxf; use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; use super::{ Arc, Attrib, Circle, Common, DimKind, Dimension, Ellipse, EntityKind, Face3d, Handle, HashMap, Helix, HelixConstrain, Insert, Leader, Line, LwPolyline, MAX_FRAME_CHARS, Point, PolyVertex, Polyline3d, RawTag, RayLine, Rx, Solid, Spline, Table, TableCell, Tag, Tolerance, Unknown, Vec2, Vec3, Viewport, Wipeout, records, }; +use cadcraft_doc::PolyfaceMesh; /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. pub(super) fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { @@ -189,19 +191,30 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef p14: t.p(14), p15: t.p(15), p16: t.p(16), - text: t.s(1).unwrap_or_default(), + text: mtext_dxf::decode_line(&t.s(1).unwrap_or_default()), style: t.s(3).unwrap_or_else(|| "Standard".into()), measurement: t.fd(42, 0.0), text_rotation: t.fd(53, 0.0).to_radians(), user_text_pos: t.i(70).unwrap_or(0) & 128 != 0, block: t.s(2).filter(|s| !s.is_empty()), + insertion_point: t.p(12), overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), }) } "LEADER" => EntityKind::Leader(Leader { - vertices: t.pts(10).into_iter().map(|p| p.to3(0.0)).collect(), + // Each group 10 starts a vertex. Scope Y/Z to it so an omitted Z cannot + // shift a later vertex's elevation onto this one. + vertices: t + .all_f(10) + .into_iter() + .zip(tags.split(|tag| tag.code == 10).skip(1)) + .map(|(x, vertex)| { + let v = T(vertex); + Vec3::new(x, v.fd(20, 0.0), v.fd(30, 0.0)) + }) + .collect(), arrow: t.i(71).unwrap_or(1) != 0, spline: t.i(72).unwrap_or(0) != 0, style: t.s(3).unwrap_or_else(|| "Standard".into()), @@ -234,7 +247,8 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef center: t.p(10), width: t.fd(40, 1.0), height: t.fd(41, 1.0), - view_center: Vec2::new(t.fd(12, 0.0), t.fd(22, 0.0)), + // 12/22 is in display coordinates, whose origin is the view target 17/27/37. + view_center: t.view_center(12, 17), view_height: t.fd(45, 1.0), id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0, @@ -398,20 +412,26 @@ pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap Option { let mut in_entity = false; + let mut by_block = false; for t in tags { if t.code == 100 { if in_entity { - return None; + break; } in_entity = t.str() == "AcDbEntity"; - } else if t.code == 390 && in_entity { - return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(); + } else if in_entity { + match t.code { + 390 => return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned(), + 380 => by_block = t.i64() == 1, + _ => {} + } } } - None + by_block.then(|| "ByBlock".to_string()) } /// Dimension overrides and associativity, table references: data of an entity record that @@ -456,14 +476,20 @@ pub(super) fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityK } rx.mleader_fix.push((h, pending)); } - // CADCraft's VPFROZEN xdata wins; otherwise AutoCAD's group 341 layer handles. - ("VIEWPORT", EntityKind::Viewport(v)) if v.frozen_layers.is_empty() => { - v.frozen_layers = tags - .iter() - .take_while(|t| t.code < 1000) - .filter(|t| t.code == 341) - .filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase()).cloned()) - .collect(); + // The frozen layers: the LAYER handles of group 331 (341 in DXF 2000), outside `102 {…}` + // application groups, then the names in CADCraft's VPFROZEN xdata, which holds those + // without one; each layer once. + ("VIEWPORT", EntityKind::Viewport(v)) => { + let mut app_group = false; + let handles = tags.iter().take_while(|t| t.code < 1000).filter(|t| { + if t.code == 102 { + app_group = t.str().starts_with('{'); + } + !app_group && (t.code == 331 || t.code == 341) + }); + let named = handles.filter_map(|t| rx.layers.get(&t.str().trim().to_ascii_uppercase())); + let mut seen = std::collections::HashSet::new(); + v.frozen_layers = named.chain(&v.frozen_layers).filter(|n| seen.insert(n.to_ascii_uppercase())).cloned().collect(); } _ => {} } @@ -497,11 +523,20 @@ pub(super) fn polyline_entity(recs: &[(String, Vec)], i: &mut usize, t: &T) } let c = common(t); let mut extra: Vec = Vec::new(); - let k = if flags & (16 | 64) != 0 { - // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the - // polyline's place. + let k = if flags & 64 != 0 { + // Face records that name a missing vertex (or fewer than three) are dropped: they draw + // nothing and would be written back broken. A mesh left with no face is skipped. + let mut mesh = PolyfaceMesh { vertices: pts.iter().map(|p| p.0).collect(), faces: Vec::new() }; + let kept = faces.iter().map(|f| f.map(|ix| i32::try_from(ix).unwrap_or(i32::MAX))).filter(|f| mesh.face3d(f).is_some()).collect(); + mesh.faces = kept; + if mesh.faces.is_empty() { + return None; + } + EntityKind::PolyfaceMesh(mesh) + } else if flags & 16 != 0 { + // Polygon meshes become 3D faces; the first takes the polyline's place. let verts: Vec = pts.iter().map(|p| p.0).collect(); - let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, t, flags) }.into_iter().map(EntityKind::Face3d); + let mut fs = mesh_faces(&verts, t, flags).into_iter().map(EntityKind::Face3d); let first = fs.next()?; extra.extend(fs); first @@ -539,7 +574,7 @@ pub(super) fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_lay tag: own.s(2).unwrap_or_default(), text, invisible: own.i(70).unwrap_or(0) & 1 != 0, - constant: false, + constant: own.i(70).unwrap_or(0) & 2 != 0, prompt: String::new(), props: attrib_props(&tt, ins_layer), multiline, @@ -556,36 +591,6 @@ pub(super) fn insert_attribs(recs: &[(String, Vec)], i: &mut usize, ins_lay attribs } -/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; -/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or -/// an index outside the vertex list are skipped. -fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { - let mut out = Vec::new(); - for f in faces { - let mut corners: Vec = Vec::with_capacity(4); - let mut hidden = 0u8; - let mut ok = true; - for &ix in f.iter().filter(|&&ix| ix != 0) { - let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); - let Some(p) = at.and_then(|n| verts.get(n)) else { - ok = false; - break; - }; - if ix < 0 { - hidden |= 1 << corners.len(); - } - corners.push(*p); - } - match (ok, corners.as_slice()) { - // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). - (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), - (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), - _ => {} - } - } - out -} - /// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches /// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index f955603e6..822384f73 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -1,7 +1,9 @@ //! MTEXT specifics of DXF (DXF Reference, MTEXT): control characters in the contents written //! as a caret and a letter (`^I` a tab, `^J` a line feed, `^ ` a caret), and the column groups //! 75 (type), 76 (count), 78 (flow reversed), 79 (auto height), 48 (width), 49 (gutter) and -//! 50 (column heights, after the column type; before it, 50 is the rotation). +//! 50 (column heights, after the column type; before it, 50 is the rotation). TEXT, ATTDEF, +//! ATTRIB and DIMENSION strings use the same encoding ([`encode_line`]): AutoCAD reads a bare +//! `^2` in `10^2` as a control character. use cadcraft_doc::{MText, MTextColumnType, MTextColumns}; use cadcraft_dxf::Tag; @@ -12,6 +14,15 @@ const MAX_COLUMNS: usize = 1_000; /// Decode the contents' `^I` (tab), `^J` (line feed) and `^ ` (caret). Other carets stay as /// they are (older files write stacked tolerances `\S+0.1^-0.1;` with a bare caret). pub(crate) fn decode_contents(s: &str) -> String { + decode(s, true) +} + +/// A single-line string as read: [`decode_contents`] without line feeds (`^J` stays as it is). +pub(crate) fn decode_line(s: &str) -> String { + decode(s, false) +} + +fn decode(s: &str, line_feeds: bool) -> String { if !s.contains('^') { return s.to_string(); } @@ -25,7 +36,7 @@ pub(crate) fn decode_contents(s: &str) -> String { match it.peek().copied() { Some(' ') => out.push('^'), Some('I') => out.push('\t'), - Some('J') => out.push('\n'), + Some('J') if line_feeds => out.push('\n'), _ => { out.push('^'); continue; @@ -36,6 +47,28 @@ pub(crate) fn decode_contents(s: &str) -> String { out } +/// A single-line string (TEXT, ATTDEF/ATTRIB, DIMENSION) for a DXF file: a caret as `^ ` and a +/// tab as `^I`, read back by [`decode_line`]. It holds no line break (the writer makes it a +/// space). +pub(crate) fn encode_line(s: &str) -> String { + s.replace('^', "^ ").replace('\t', "^I") +} + +/// Apply `f` ([`encode_line`] or [`decode_line`]) to the groups of a `kind` record that hold +/// an encoded string: the text (1), and an ATTDEF's prompt (3). An embedded MTEXT (from group 101) +/// has its own and is left alone. AutoLISP's entget and entmake/entmod use it. +pub(crate) fn map_text_groups(kind: &str, tags: &mut [Tag], f: fn(&str) -> String) { + let kind = kind.to_ascii_uppercase(); + if !matches!(kind.as_str(), "TEXT" | "ATTRIB" | "ATTDEF" | "DIMENSION") { + return; + } + for t in tags.iter_mut().take_while(|t| t.code != 101) { + if t.code == 1 || (t.code == 3 && kind == "ATTDEF") { + *t = Tag::s(t.code, f(&t.str())); + } + } +} + /// Encode the contents for a DXF string: a tab as `^I`, a line feed as `^J`, a caret as `^ `; /// other control characters (which draw nothing) are dropped. pub(crate) fn encode_contents(s: &str) -> String { @@ -56,7 +89,7 @@ pub(crate) fn encode_contents(s: &str) -> String { } /// The MTEXT's own tags: up to an embedded object (group 101), whose groups reuse MTEXT's codes. -fn own(tags: &[Tag]) -> &[Tag] { +pub(crate) fn own(tags: &[Tag]) -> &[Tag] { let end = tags.iter().position(|t| t.code == 101).unwrap_or(tags.len()); tags.get(..end).unwrap_or(tags) } @@ -74,16 +107,22 @@ fn finite(v: f64) -> f64 { /// The columns of an MTEXT, and the column width (group 48) when there are columns. pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { - let tags = own(tags); - let start = tags.iter().position(|t| t.code == 75)?; - let kind = match tags.get(start).map(Tag::i64) { + // An application-defined group in the record may hold any code, 50 included. + let own_tags: Vec<&Tag> = super::common::outside_app(own(tags)).map(|(_, t)| t).collect(); + let Some(start) = own_tags.iter().position(|t| t.code == 75) else { return read_embedded_columns(tags) }; + let tags = own_tags; + let kind = match tags.get(start).map(|t| t.i64()) { Some(1) => MTextColumnType::Static, Some(2) => MTextColumnType::Dynamic, _ => return None, }; let cols = tags.get(start..).unwrap_or_default(); - let first = |code: i32| cols.iter().find(|t| t.code == code); - let heights: Vec = cols.iter().filter(|t| t.code == 50).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); + let first = |code: i32| cols.iter().find(|t| t.code == code).copied(); + // The first 50 is the number of heights that follow, unless the file predates the count + // (CADCraft up to f536bf4 wrote the n heights of group 76 alone). + let fifties = cols.iter().filter(|t| t.code == 50); + let counted = first(76).is_some_and(|t| fifties.clone().count() as i64 - 1 == t.i64()); + let heights: Vec = fifties.skip(usize::from(counted)).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); let count = first(76).map(Tag::i64).unwrap_or(heights.len() as i64).clamp(1, MAX_COLUMNS as i64); let auto_height = first(79).map(Tag::i64).unwrap_or(0) != 0; let defined = tags.iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0); @@ -101,12 +140,81 @@ pub(crate) fn read_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { Some((c, first(48).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0))) } -/// The column groups of an MTEXT with columns. -pub(crate) fn column_tags(t: &MText) -> Vec { +/// The columns of an R2018 MTEXT, from its embedded object (group 101), whose groups reuse +/// MTEXT's codes: 71 type, 72 count, 44 column width, 45 gutter, 73 auto height, 74 flow +/// reversed, 46 heights. The column width is also the MTEXT's width. +fn read_embedded_columns(tags: &[Tag]) -> Option<(MTextColumns, f64)> { + let obj = tags.get(tags.iter().position(|t| t.code == 101)? + 1..)?; + let first = |code: i32| obj.iter().find(|t| t.code == code); + let kind = match first(71).map(Tag::i64) { + Some(1) => MTextColumnType::Static, + Some(2) => MTextColumnType::Dynamic, + _ => return None, + }; + let heights: Vec = obj.iter().filter(|t| t.code == 46).take(MAX_COLUMNS).map(|t| finite(t.f64()).max(0.0)).collect(); + let auto_height = first(73).map(Tag::i64).unwrap_or(0) != 0; + let manual = kind == MTextColumnType::Dynamic && !auto_height; + // The column height: the entity's own 46 (defined height), else the object's 41 or first 46. + let own_46 = own(tags).iter().find(|t| t.code == 46).map(|t| finite(t.f64()).max(0.0)); + let defined = [own_46, first(41).map(|t| finite(t.f64())), heights.first().copied()].into_iter().flatten().find(|h| *h > 0.0).unwrap_or(0.0); + // A dynamic auto-height object stores 0 columns in 72: the count follows from the total width + // (42 = n * 44 + (n - 1) * 45). + let [width, gutter, total] = [44, 45, 42].map(|g| first(g).map_or(0.0, |t| finite(t.f64()))); + let count = match first(72).map(Tag::i64) { + Some(n) if n > 0 => n, + Some(_) if width + gutter > 0.0 && total > 0.0 => ((total + gutter) / (width + gutter)).round() as i64, + _ => 1, + }; + let c = MTextColumns { + kind, + count: u16::try_from(count.clamp(1, MAX_COLUMNS as i64)).unwrap_or(1), + gutter: first(45).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0), + height: if manual { heights.iter().copied().fold(0.0, f64::max) } else { defined }, + auto_height, + heights: if manual { heights } else { Vec::new() }, + flow_reversed: first(74).map(Tag::i64).unwrap_or(0) != 0, + }; + Some((c, first(44).map(|t| finite(t.f64()).max(0.0)).unwrap_or(0.0))) +} + +/// The column groups of an MTEXT with columns: the legacy groups 75/76/78/79/48/49/50 before +/// R2018, an embedded object (group 101) from R2018 on, which reads those as the entity's own. +pub(crate) fn column_tags(t: &MText, embedded: bool) -> Vec { let Some(c) = &t.columns else { return Vec::new() }; let manual = c.kind == MTextColumnType::Dynamic && !c.auto_height; let heights: Vec = if manual { c.heights.clone() } else { vec![c.height; usize::from(c.count.max(1))] }; let count = if manual { heights.len().max(1) } else { usize::from(c.count.max(1)) }; + if embedded { + let (sin, cos) = t.rotation.sin_cos(); + let total_width = t.width * count as f64 + c.gutter * (count as f64 - 1.0); + // The column height (static, and dynamic with auto height) is the entity's group 46. + let height = if manual { heights.iter().copied().fold(0.0, f64::max) } else { c.height }; + let mut v = vec![ + Tag::f(46, height), + Tag::s(101, "Embedded Object"), + Tag::i(70, 1), + Tag::f(10, cos), + Tag::f(20, sin), + Tag::f(30, 0.0), + Tag::f(11, t.insert.x), + Tag::f(21, t.insert.y), + Tag::f(31, t.insert.z), + Tag::f(40, t.width), + Tag::f(41, height), + Tag::f(42, total_width), + Tag::f(43, height), + Tag::i(71, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), + Tag::i(72, count as i64), + Tag::f(44, t.width), + Tag::f(45, c.gutter), + Tag::i(73, i64::from(c.auto_height)), + Tag::i(74, i64::from(c.flow_reversed)), + ]; + if manual { + v.extend(heights.into_iter().map(|h| Tag::f(46, h))); + } + return v; + } let mut v = vec![ Tag::i(75, if c.kind == MTextColumnType::Dynamic { 2 } else { 1 }), Tag::i(76, count as i64), @@ -115,6 +223,10 @@ pub(crate) fn column_tags(t: &MText) -> Vec { Tag::f(48, t.width), Tag::f(49, c.gutter), ]; + // No count for an empty list: a lone 50 = 0 would read back as one height of 0. + if !heights.is_empty() { + v.push(Tag::i(50, heights.len() as i64)); + } v.extend(heights.into_iter().map(|h| Tag::f(50, h))); v } diff --git a/crates/io/src/dxf_read/objects.rs b/crates/io/src/dxf_read/objects.rs index 4d692ccee..374313d48 100644 --- a/crates/io/src/dxf_read/objects.rs +++ b/crates/io/src/dxf_read/objects.rs @@ -153,8 +153,16 @@ impl Objects { } } + /// Add the groups named by the ACAD_GROUP dictionary entries, with the members that exist. + /// A name already present (compared case-insensitively) is replaced by the later definition. fn apply_groups(&self, d: &mut Drawing) { - // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + // Names are matched through an index: a drawing can carry hundreds of thousands of + // (often empty) anonymous groups, and a linear search per group made loading quadratic + // (#862). + let mut by_name: HashMap = HashMap::new(); + for (i, g) in d.groups.iter().enumerate() { + by_name.entry(g.name.to_ascii_lowercase()).or_insert(i); + } for (h, tags) in &self.groups { let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; let t = T(tags); @@ -166,9 +174,12 @@ impl Objects { .take(MAX_OBJECTS) .collect(); let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; - match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + match by_name.get(&name.to_ascii_lowercase()).and_then(|&i| d.groups.get_mut(i)) { Some(x) => *x = g, - None => d.groups.push(g), + None => { + by_name.insert(name.to_ascii_lowercase(), d.groups.len()); + d.groups.push(g); + } } } } @@ -274,6 +285,10 @@ pub(super) fn page_setup(t: &T) -> PageSetup { .into(); p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 }); p.plot_style_table = t.s(7).unwrap_or_default(); + // Paper image origin: kept as the file has it, so a save puts the sheet back where it was (#2109). + // 0,0 is what a page without one writes, so it reads back as none. + p.image_origin = + t.f(148).zip(t.f(149)).map(|(x, y)| Vec2::new(x, y)).filter(|o| o.is_finite() && o.x.abs() < 1e12 && o.y.abs() < 1e12 && *o != Vec2::ZERO); p } diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index 8a57f7f9e..c914ea9f5 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -4,6 +4,7 @@ use super::{ Color, DashElement, DimStyle, Drawing, HVal, HashMap, Layer, Linetype, Lineweight, NamedView, Rgb, Rx, Tag, TextStyle, Ucs, Vec2, Vec3, VportConfig, VportTile, records, }; +use cadcraft_fonts::windows::windows_font; pub(super) fn header(tags: &[Tag], d: &mut Drawing) { let mut i = 0; @@ -66,9 +67,9 @@ fn annotative(tags: &[Tag]) -> bool { } /// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) -/// while the font file is still the `txt` written for other readers; the TrueType family of the -/// style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD stores TrueType -/// styles); otherwise the file. +/// while the font file is still the `txt` or Windows font written for other readers; the TrueType +/// family of the style's `ACAD` xdata (`1000` typeface) when it has no font file (how AutoCAD +/// stores TrueType styles); otherwise the file. fn style_font(tags: &[Tag], file: String) -> String { if file.trim().is_empty() && let Some(face) = @@ -82,8 +83,9 @@ fn style_font(tags: &[Tag], file: String) -> String { _ => None, }); let stem = file.trim().to_ascii_lowercase(); + let base = stem.rsplit(['/', '\\']).next().unwrap_or_default(); match own { - Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, + Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx" || windows_font(&name) == Some(base)) => name, _ => file, } } @@ -215,7 +217,8 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { continue; } - let center = t.p(10).xy(); + // The view centre 10/20 is in display coordinates, whose origin is the target 12/22/32. + let center = t.view_center(10, 12); if !(center.x.is_finite() && center.y.is_finite()) { continue; } @@ -314,7 +317,8 @@ const MAX_VPORT_TILES: usize = 64; /// are fractions of the drawing area; unusable tiles are skipped. fn vport_record(d: &mut Drawing, t: &T) { let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); - let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + // The view centre 12/22 is in display coordinates, whose origin is the view target 17/27/37. + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.view_center(12, 17)); let height = t.fd(40, 0.0); let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs index 10d770c0e..df73b878c 100644 --- a/crates/io/src/dxf_read/text.rs +++ b/crates/io/src/dxf_read/text.rs @@ -42,6 +42,10 @@ pub(super) fn multiline_attrib(embedded: Option<&[Tag]>, c: &Common) -> Option MText { + // The MTEXT's own groups: an embedded object (group 101) reuses their codes (44 is its + // column width, not the line spacing factor). + let columns = mtext_dxf::read_columns(tags); + let tags = mtext_dxf::own(tags); let t = T(tags); // Contents: code 3 chunks followed by code 1. let mut contents = String::new(); @@ -50,7 +54,6 @@ pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { } contents.push_str(&t.s(1).unwrap_or_default()); let contents = mtext_dxf::decode_contents(&contents); - let columns = mtext_dxf::read_columns(tags); // The direction comes from the MTEXT's own groups only: in an R2018+ embedded object // (group 101), 11 is the insertion point, not the X-axis direction (#667, as in #834). let own = T(mtext_dxf::before_columns(tags)); @@ -73,8 +76,10 @@ pub(super) fn mtext_from(tags: &[Tag], c: &Common) -> MText { MText { insert: t.p(10), height: t.fd(40, 0.2).max(1e-9), + // An MTEXT's width is one column's: with columns, the column width (48, or 44 of the + // embedded object) rather than 41, which ZWCAD and AutoCAD write as the total width. width: match &columns { - Some((_, w)) if t.fd(41, 0.0) <= 0.0 => *w, + Some((_, w)) if *w > 0.0 => *w, _ => t.fd(41, 0.0).max(0.0), }, attach, @@ -107,7 +112,7 @@ pub(super) fn text_from(t: &T) -> Text { insert: t.p(10), align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, height: t.fd(40, 0.2).max(1e-9), - value: t.s(1).unwrap_or_default(), + value: mtext_dxf::decode_line(&t.s(1).unwrap_or_default()), rotation: t.fd(50, 0.0).to_radians(), width_factor: t.fd(41, 1.0), oblique: t.fd(51, 0.0).to_radians(), @@ -167,7 +172,7 @@ pub(super) fn attdef(tags: &[Tag], c: &Common) -> Attrib { text, invisible: own.i(70).unwrap_or(0) & 1 != 0, constant: own.i(70).unwrap_or(0) & 2 != 0, - prompt: own.s(3).unwrap_or_default(), + prompt: mtext_dxf::decode_line(&own.s(3).unwrap_or_default()), props: Default::default(), multiline, modes: attrib_modes(own.0), diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 4ec9fc636..411f1c4b2 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1,7 +1,7 @@ //! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later -//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in -//! `$ACADVER`, from 2004 in the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller -//! picks the text encoding) and MULTILEADER entities (older files get LEADER + MTEXT). +//! versions keep that structure and differ in `$ACADVER`, from 2004 in CLASS instance counts and +//! the newer DIMSTYLE groups, and from 2007 in UTF-8 text (the caller picks the text encoding) and +//! MULTILEADER entities (older files get LEADER + MTEXT). use std::collections::HashMap; @@ -33,6 +33,7 @@ pub(crate) use blocks::is_generated_arrow; pub(crate) use blocks::table_block_entities; use ctx::Ctx; use entity::entity; +pub(crate) use header::code_page; use sections::tags_for; struct W { @@ -40,10 +41,10 @@ struct W { next: u64, /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects). pstyles: HashMap, - /// Upper-case layer name → its LAYER table record handle (group 341 of viewports). + /// Upper-case layer name → its LAYER table record handle (group 331/341 of viewports). layers: HashMap, - /// Persistent reactors of boundary objects: each source object of an associative hatch → - /// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`]. + /// Persistent reactors of source geometry: the associative hatches and DIMASSOC objects + /// that follow it (groups `{ACAD_REACTORS 330 …}`). reactors: HashMap>, } @@ -67,7 +68,11 @@ impl W { self.f(c + 10, p.y); } fn h(&mut self) -> String { - let h = format!("{:X}", self.next); + format!("{:X}", self.next_handle()) + } + /// A new handle for a record the drawing doesn't hold (as [`W::h`]). + fn next_handle(&mut self) -> u64 { + let h = self.next; self.next = self.next.saturating_add(1); h } @@ -111,13 +116,13 @@ pub fn write(d: &Drawing) -> String { /// The drawing's DXF tags, declaring `version` in `$ACADVER`. pub fn tags(d: &Drawing, version: Version) -> Vec { - tags_for(d, version, version) + tags_for(d, version, version, version < Version::R2004) } /// The R2000 DXF the DWG bridge converts to a `dwg` version DWG: its entity types are the DWG /// version's (MULTILEADER from 2007). pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String { - let mut tags = tags_for(d, Version::R2000, dwg); + let mut tags = tags_for(d, Version::R2000, dwg, false); bridge_markers(&mut tags); cadcraft_dxf::write_ascii(&tags) } @@ -146,10 +151,13 @@ pub fn entity_groups(d: &Drawing, e: &Entity) -> (String, Vec) { layers: HashMap::new(), reactors: HashMap::new(), }; - let cx = Ctx { target: Version::R2018, ..Ctx::default() }; + let cx = Ctx { target: Version::R2018, legacy_columns: true, ..Ctx::default() }; let paper = d.space_of(e.handle).is_some_and(|s| !matches!(s, cadcraft_doc::Space::Model)); entity(&mut w, d, e, "0", paper, &cx); let mut tags = w.t.into_iter(); let kind = tags.next().filter(|t| t.code == 0).map(|t| t.value.as_str()).unwrap_or_default(); - (kind, tags.take_while(|t| t.code != 0).filter(|t| t.code != 330).collect()) + let mut tags: Vec = tags.take_while(|t| t.code != 0).filter(|t| t.code != 330).collect(); + // Strings as AutoLISP sees them: `10^2`, not the file's `10^ 2`. + crate::dxf_read::mtext_dxf::map_text_groups(&kind, &mut tags, crate::dxf_read::mtext_dxf::decode_line); + (kind, tags) } diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index 6ba3ce8ef..ef2632665 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -101,6 +101,10 @@ pub(super) fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass if let Some(v) = transparency_440(e.common.transparency) { w.i(440, v); } + // ByBlock is the plot style name type 1 (ByLayer, 0, is the default and left out). + if e.common.plot_style.trim().eq_ignore_ascii_case("byblock") { + w.i(380, 1); + } // A named plot style (not ByLayer/ByBlock): its placeholder object. if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() { w.s(390, h); @@ -241,10 +245,21 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { if q.is_finite() { Vec3::new(q.x, q.y, t.insert.z) } else { t.insert } } +/// `attdef_prompt` is `Some` for an ATTDEF (its prompt, which may be empty) and `None` for an ATTRIB. pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { - w.p(10, text_insert(d, t)); + let attdef = attdef_prompt.is_some(); + // An attribute definition with no default value is drawn as its tag, whatever its prompt: the + // baseline start AutoCAD stored is judged against the tag's width too. A stored point that is + // close to the empty text's own start is kept as it is, so the font's width of the tag never + // moves it. + let own = text_insert(d, t); + let insert = match attrib { + Some((a, _)) if attdef && t.value.is_empty() && own != t.insert => text_insert(d, &Text { value: a.tag.clone(), ..t.clone() }), + _ => own, + }; + w.p(10, insert); w.f(40, t.height); - w.s(1, &t.value); + w.s(1, crate::dxf_read::mtext_dxf::encode_line(&t.value)); if t.rotation != 0.0 { w.f(50, t.rotation.to_degrees()); } @@ -277,12 +292,12 @@ pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attri } match attrib { Some((a, target)) => { - w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); + w.s(100, if attdef { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); if target >= Version::R2010 { w.i(280, 0); } if let Some(prompt) = attdef_prompt { - w.s(3, prompt); + w.s(3, crate::dxf_read::mtext_dxf::encode_line(prompt)); } w.s(2, &a.tag); w.i(70, attrib_flags(a)); diff --git a/crates/io/src/dxf_write/ctx.rs b/crates/io/src/dxf_write/ctx.rs index ce9c38b7a..25194bb46 100644 --- a/crates/io/src/dxf_write/ctx.rs +++ b/crates/io/src/dxf_write/ctx.rs @@ -53,6 +53,11 @@ pub(super) struct Ctx { /// The version the file ends up in (the DWG version for a DWG save): it decides which /// entity types exist (MULTILEADER from 2007). pub(super) target: Version, + /// A VIEWPORT's frozen layers in group 341, as an R2000 DXF has them; otherwise 331 (DXF 2004 + /// and later, and the DWG bridge, which reads only 331). + pub(super) frozen_341: bool, + /// MTEXT columns as the legacy groups 75..79 even from R2018 (the groups AutoLISP lists). + pub(super) legacy_columns: bool, } impl Ctx { diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index 9c2f12896..6e1d84270 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -32,6 +32,64 @@ pub(super) fn hatch_reactors(d: &Drawing) -> HashMap> { out } +/// A polyface mesh as one POLYLINE record: the vertices (flags 64 | 128), then one face record +/// (128) per face that names them (the others draw nothing and are dropped, as on read). Counts +/// and indices are 16-bit groups (71–74), and no index is above the vertex count: a mesh too large +/// for them is written as its 3D faces rather than with numbers cut short. +fn polyface(w: &mut W, d: &Drawing, e: &Entity, m: &cadcraft_doc::PolyfaceMesh, owner: &str, paper: bool, cx: &Ctx) { + let faces: Vec<&[i32; 4]> = m.faces.iter().filter(|f| m.face3d(f).is_some()).collect(); + let max = usize::try_from(i16::MAX).unwrap_or(usize::MAX); + if m.vertices.len() > max || faces.len() > max { + for (i, f) in m.faces3d().into_iter().enumerate() { + let handle = if i == 0 { e.handle } else { Handle(w.next_handle()) }; + let face = Entity { handle, common: e.common.clone(), kind: EntityKind::Face3d(f) }; + entity(w, d, &face, owner, paper, cx); + } + return; + } + w.s(0, "POLYLINE"); + common(w, e, owner, paper, "AcDbPolyFaceMesh"); + w.i(66, 1); + w.p(10, Vec3::ZERO); + w.i(70, 64); + w.i(71, m.vertices.len() as i64); + w.i(72, faces.len() as i64); + for q in &m.vertices { + let vh = w.h(); + w.s(0, "VERTEX"); + w.s(5, vh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + w.s(100, "AcDbVertex"); + w.s(100, "AcDbPolyFaceMeshVertex"); + w.p(10, *q); + w.i(70, 192); + } + for f in faces { + let vh = w.h(); + w.s(0, "VERTEX"); + w.s(5, vh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); + w.s(100, "AcDbFaceRecord"); + w.p(10, Vec3::ZERO); + w.i(70, 128); + for (code, ix) in [71, 72, 73, 74].into_iter().zip(f) { + if *ix != 0 { + w.i(code, i64::from(*ix)); + } + } + } + let sh = w.h(); + w.s(0, "SEQEND"); + w.s(5, sh); + w.s(330, e.handle.hex()); + w.s(100, "AcDbEntity"); + w.s(8, &e.common.layer); +} + pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { write_basic_entity(w, d, e, owner, paper, cx); write_text_entity(w, d, e, owner, paper, cx); @@ -45,7 +103,7 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo write_other_entity(w, d, e, owner, paper, cx); } -fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { +fn write_basic_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { match &e.kind { EntityKind::Line(l) => { w.s(0, "LINE"); @@ -53,6 +111,7 @@ fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: b thickness(w, e); w.p(10, l.a); w.p(11, l.b); + extrusion(w, e); } EntityKind::Point(p) => { w.s(0, "POINT"); @@ -143,6 +202,7 @@ fn write_basic_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: b w.s(100, "AcDbEntity"); w.s(8, &e.common.layer); } + EntityKind::PolyfaceMesh(m) => polyface(w, d, e, m, owner, paper, cx), EntityKind::Spline(s) => { w.s(0, "SPLINE"); common(w, e, owner, paper, "AcDbSpline"); @@ -256,7 +316,8 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo w.f(40, t.height); w.f(41, t.width); w.i(71, i64::from(t.attach)); - w.i(72, 1); + // Drawing direction: 5, by style. + w.i(72, 5); // Long contents go in 250-char 3-chunks, the remainder in 1. let chars: Vec = crate::dxf_read::mtext_dxf::encode_contents(&t.contents).chars().collect(); let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); @@ -274,7 +335,7 @@ fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo // Line spacing style: 1 = at least, 2 = exactly. w.i(73, if t.line_spacing_exact { 2 } else { 1 }); w.f(44, t.line_spacing); - w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); + w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t, cx.target >= Version::R2018 && !cx.legacy_columns)); } _ => {} } @@ -338,6 +399,12 @@ fn write_dimension_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper 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)); w.p(11, if dm.user_text_pos { dm.text_mid } else { g.text_pos.to3(0.0) }); + // Group 12: a block read from a file keeps the dimension's moved-by offset (AutoCAD + // draws the block translated by it); regenerated geometry already sits where the + // definition points place it, so it writes no offset. + if dm.block.is_some() && dm.insertion_point != Vec3::ZERO { + w.p(12, dm.insertion_point); + } let ty: i64 = match dm.kind { DimKind::Linear { .. } => 0, DimKind::Aligned => 1, @@ -353,7 +420,7 @@ fn write_dimension_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper // The actual measurement (read-only in DXF): AutoLISP's entget shows it. w.f(42, g.measurement); if !dm.text.is_empty() { - w.s(1, &dm.text); + w.s(1, crate::dxf_read::mtext_dxf::encode_line(&dm.text)); } w.s(3, &dm.style); if dm.text_rotation != 0.0 { @@ -533,6 +600,9 @@ fn write_annotations_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, pap } } +/// Writes a HATCH: its pattern name, solid and associative flags, its boundary loops, and the +/// pattern angle, scale and double flag (written for solid hatches too). Pattern lines are written +/// only for non-solid hatches. fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) { if let EntityKind::Hatch(h) = &e.kind { w.s(0, "HATCH"); @@ -567,10 +637,12 @@ fn write_hatch_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bo } w.i(75, i64::from(h.style)); w.i(76, 1); + // Angle, scale and double flag are written for solid fills too, as read (AutoCAD and + // other writers keep them on SOLID hatches; readers ignore them there). + w.f(52, h.angle.to_degrees()); + w.f(41, h.scale); + w.i(77, 0); if !h.solid { - w.f(52, h.angle.to_degrees()); - w.f(41, h.scale); - w.i(77, 0); // The lines read from the file, else the library's pattern of that name. let lines = if h.pattern_lines.is_empty() { cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default() @@ -618,22 +690,24 @@ fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: // 340 entity, 131072 = off. let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some()); let flags = 32768 | if v.locked { 16384 } else { 0 } | if clip.is_some() { 65536 } else { 0 } | if v.on { 0 } else { 131072 }; + // AutoCAD's frozen-layer list, before group 90 as in the DXF Reference: one pointer per + // layer, to its LAYER record. A name with no record goes in CADCraft's xdata below. + let mut unrecorded: Vec = Vec::new(); + for name in &v.frozen_layers { + match w.layers.get(&name.to_ascii_uppercase()).cloned() { + Some(h) => w.s(if cx.frozen_341 { 341 } else { 331 }, h), + None => unrecorded.push(name.clone()), + } + } w.i(90, flags); if let Some(h) = clip { w.s(340, h.hex()); } - // AutoCAD's frozen-layer list: one soft pointer per layer, to its LAYER record. Other - // programs read only this; CADCraft prefers its own xdata below when present. - for name in &v.frozen_layers { - if let Some(h) = w.layers.get(&name.to_ascii_uppercase()).cloned() { - w.s(341, h); - } - } // One CADCraft xdata group holds both lists: readers stop at the first group of an app. - if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { + if !unrecorded.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); - if !v.frozen_layers.is_empty() { - w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + if !unrecorded.is_empty() { + w.xdata(dxf_ext::frozen_xdata(&unrecorded)); } if !v.layer_colors.is_empty() { w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); @@ -752,6 +826,24 @@ fn thickness(w: &mut W, e: &Entity) { } } +/// The extrusion (210) of an entity whose points are world coordinates, when it is not the Z axis. +fn extrusion(w: &mut W, e: &Entity) { + // Zero and non-finite vectors are not a direction: nothing is written. `Vec3::normalized` would + // also drop a tiny one (its zero threshold is the drawing tolerance) and turn infinity into NaN. + let v = e.common.extrusion; + let max = v.x.abs().max(v.y.abs()).max(v.z.abs()); + if !v.is_finite() || max == 0.0 { + return; + } + // Scaled by the largest component first: the length of a huge one would overflow to infinity. + let v = Vec3::new(v.x / max, v.y / max, v.z / max); + let len = v.len(); + let n = Vec3::new(v.x / len, v.y / len, v.z / len); + if n != Vec3::Z { + w.p(210, n); + } +} + /// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a /// world position (`1011`, moved with the hatch by other programs) and the background colour /// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other diff --git a/crates/io/src/dxf_write/header.rs b/crates/io/src/dxf_write/header.rs index b628a5c3d..9b8c1f800 100644 --- a/crates/io/src/dxf_write/header.rs +++ b/crates/io/src/dxf_write/header.rs @@ -19,6 +19,7 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("CMLJUST", 70), ("CMLSCALE", 40), ("TEXTSTYLE", 7), + ("CTABLESTYLE", 7), ("CLAYER", 8), ("CELTYPE", 6), ("CECOLOR", 62), @@ -41,6 +42,7 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("PLINEWID", 40), ("CELWEIGHT", 370), ("LWDISPLAY", 290), + ("PSTYLEMODE", 290), ("INSUNITS", 70), ("MEASUREMENT", 70), ("PSLTSCALE", 70), @@ -133,10 +135,10 @@ pub(super) fn header_vars(w: &mut W, d: &Drawing) { dim_header_vars(w, d); } -/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it (the text -/// is ASCII with `\U+XXXX` escapes, right in any code page, but the file keeps its declared one); -/// otherwise, and from 2007 (UTF-8 text), `ANSI_1252`. -pub(super) fn code_page(d: &Drawing, version: Version) -> String { +/// `$DWGCODEPAGE`: before 2007 the code page read from the file when CADCraft knows it, in which +/// a saved file's text is written (characters outside it as `\U+XXXX` escapes); otherwise, and +/// from 2007 (UTF-8 text), `ANSI_1252`. +pub(crate) fn code_page(d: &Drawing, version: Version) -> String { let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default(); let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some(); if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() } diff --git a/crates/io/src/dxf_write/layout.rs b/crates/io/src/dxf_write/layout.rs index 4ad19ffbb..364648daf 100644 --- a/crates/io/src/dxf_write/layout.rs +++ b/crates/io/src/dxf_write/layout.rs @@ -33,12 +33,21 @@ pub(super) fn sheet_first(l: &Layout) -> Vec<&Entity> { all().filter(sheet).chain(all().filter(|e| !sheet(e))).collect() } +/// Paper units per paper-space unit of layout `l`: its custom plot scale (groups 142/143), so +/// the sheet is as large in paper space as it plots (a 420 mm sheet at 49.6 mm per unit is 8.47 +/// units wide, #2108). 1 when it is scaled to fit or the scale is unusable. +fn sheet_scale(l: &Layout) -> f64 { + let s = l.page.scale; + if l.page.scale_to_fit || !(s.is_finite() && (1e-6..=1e6).contains(&s)) { 1.0 } else { s } +} + /// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one /// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader /// leaves it implicit again. pub(super) fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; - let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let paper = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let size = paper / sheet_scale(l); let center = size * 0.5; let h = w.h(); w.s(0, "VIEWPORT"); diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index fe865515b..68cf61b21 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -90,9 +90,10 @@ fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap w.f(50, s.oblique.to_degrees()); w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); w.f(42, 0.2); - // The built-in font goes out as `txt`, which every reader has; CADCraft's own name - // travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it. - let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }; + // The built-in font goes out as `txt`, which every reader has, and a Linux font as its + // Windows one with portable fonts; CADCraft's own name travels in `CADCRAFT` xdata + // (`1000 FONT`, name) so the style reopens with it. + let file = if s.font == cadcraft_fonts_name() { "txt" } else { cadcraft_fonts::windows::written_font(&s.font, d.portable_fonts) }; w.s(3, file); w.s(4, &s.big_font); if s.annotative { @@ -383,6 +384,8 @@ struct LayoutWriteData<'a> { flags: i64, page: &'a PageSetup, view: Option<(Vec2, f64)>, + /// The layout's limits as read (`None`: the default 0,0 to 12,9). + limits: Option<(Vec2, Vec2)>, } fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData<'_>) { @@ -447,14 +450,18 @@ fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData< w.s(7, &page.plot_style_table); w.i(75, if page.scale_to_fit { 0 } else { 16 }); w.f(147, scale); - w.f(148, 0.0); - w.f(149, 0.0); + // The paper image origin the file had (#2109), else 0,0. + let origin = page.image_origin.filter(|o| o.is_finite()).unwrap_or(Vec2::ZERO); + w.f(148, origin.x); + w.f(149, origin.y); w.s(100, "AcDbLayout"); w.s(1, name); w.i(70, flags); w.i(71, tab); - w.p2(10, Vec2::ZERO); - w.p2(11, Vec2::new(12.0, 9.0)); + // The layout's limits as the file had them (#2109), else 0,0 to 12,9. + let (lo, hi) = data.limits.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((Vec2::ZERO, Vec2::new(12.0, 9.0))); + w.p2(10, lo); + w.p2(11, hi); w.p(12, Vec3::ZERO); w.p(14, Vec3::ZERO); w.p(15, Vec3::ZERO); @@ -523,7 +530,7 @@ fn write_layout_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { layout_obj( w, LayoutWriteContext { drawing: d, dictionary: layout_dict }, - LayoutWriteData { handle: model_layout, name: "Model", tab: 0, block_record: ms_br, flags: 1, page: &model_page, view: None }, + LayoutWriteData { handle: model_layout, name: "Model", tab: 0, block_record: ms_br, flags: 1, page: &model_page, view: None, limits: None }, ); for (i, (_, brh, l)) in ps_brs.iter().enumerate() { let h = layout_handles.get(i).cloned().unwrap_or_default(); @@ -538,6 +545,7 @@ fn write_layout_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { flags: 1, page: &l.page, view: l.view, + limits: l.limits, }, ); } @@ -873,7 +881,20 @@ fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut if let EntityKind::Dimension(dm) = &e.kind { let (flags, refs) = std_assoc_refs(d, dm); if !refs.is_empty() { - cx.assoc.insert(e.handle, AssocObj { xdict: w.h(), handle: w.h(), flags, refs }); + let assoc = AssocObj { xdict: w.h(), handle: w.h(), flags, refs }; + // Source geometry must name the DIMASSOC as well, or other readers do not + // follow later edits. Both objects of an intersection need the reactor. + for source in assoc.refs.iter().flat_map(|r| std::iter::once(r.main).chain(r.other)) { + // common_x already gives the dimension its own DIMASSOC reactor. + if source == e.handle { + continue; + } + let reactors = w.reactors.entry(source).or_default(); + if !reactors.contains(&assoc.handle) { + reactors.push(assoc.handle.clone()); + } + } + cx.assoc.insert(e.handle, assoc); } } } @@ -882,7 +903,8 @@ fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut /// The drawing's DXF tags, declaring `version` in `$ACADVER`, with what `target` (the version the /// file ends up in: `version`, or the DWG version the bridge writes) has. -pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { +/// `frozen_341`: see [`Ctx::frozen_341`]. +pub(super) fn tags_for(d: &Drawing, version: Version, target: Version, frozen_341: bool) -> Vec { // An in-place block edit in progress is not written: the drawing as before it. let committed = d.committed(); let d: &Drawing = &committed; @@ -915,7 +937,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); - let mut cx = Ctx { paper_views: paper_views(d), target, ..Ctx::default() }; + let mut cx = Ctx { paper_views: paper_views(d), target, frozen_341, ..Ctx::default() }; cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect(); let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); let mut dim_defs = dimension_block_definitions(d, &all_spaces, &dim_plan, &mut w, &mut cx); @@ -1115,16 +1137,31 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec= Version::R2013); crate::dxf_raw::data_store(&mut w.t, d, target >= Version::R2013); - add_class_instance_counts(&mut w.t); + add_class_instance_counts(&mut w.t, target); mark_xrefs(&mut w.t, d); w.t } -/// Write DXF Reference group 91 on every CLASS record. Count records after `dxf_raw::finish` -/// restores foreign classes and objects so every class gets its actual instance count. -fn add_class_instance_counts(tags: &mut Vec) { +/// DXF Reference group 91 of CLASS records, the instance count, which came with the 2004 format. +/// From 2004 every CLASS record gets its actual count, counted after `dxf_raw::finish` restores +/// foreign classes and objects (AutoCAD rejects a 2004+ file without it, #635). An R2000 file has +/// none, not even on a class kept from a newer file: AutoCAD LT 2026 stops responding when an +/// R2000 file's CLASS records carry a 91. +fn add_class_instance_counts(tags: &mut Vec, target: Version) { + if target < Version::R2004 { + let mut in_class = false; + tags.retain(|t| { + if t.code == 0 { + in_class = t.str() == "CLASS"; + } + !(in_class && t.code == 91) + }); + return; + } let mut counts: HashMap = HashMap::new(); for t in tags.iter().filter(|t| t.code == 0) { let kind = t.str(); diff --git a/crates/io/src/dxf_xref.rs b/crates/io/src/dxf_xref.rs index f98a044f7..9ea7904cd 100644 --- a/crates/io/src/dxf_xref.rs +++ b/crates/io/src/dxf_xref.rs @@ -13,7 +13,7 @@ use cadcraft_doc::Drawing; /// Whether a symbol name belongs to an attached xref (`xref|name`). pub(crate) fn dependent(name: &str) -> bool { - name.contains('|') + cadcraft_doc::foreign::xref_dependent(name) } /// The drawing as it is saved: xref blocks emptied, xref-dependent symbols other than layers and diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 8cad3f24f..4863ddb34 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -55,6 +55,13 @@ pub enum IoError { pub type Result = std::result::Result; +/// How a program runs the DWG reader and writer in a child process (`cadcraft_dwg::worker`). +pub mod dwg_worker { + pub use cadcraft_dwg::{WORKER_ARG, serve_if_asked, use_own_worker}; + #[cfg(all(not(target_arch = "wasm32"), not(all(target_os = "freebsd", target_arch = "aarch64"))))] + pub use cadcraft_dwg::{crash_worker_for_test, use_worker}; +} + fn ext(name: impl AsRef) -> String { name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } @@ -64,7 +71,7 @@ fn ext(name: impl AsRef) -> String { pub fn read(bytes: &[u8], name: impl AsRef) -> Result { let name = name.as_ref(); if cadcraft_dwg::is_dwg(bytes) { - let (dxf, notes) = cadcraft_dwg::dwg_to_dxf_noted(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?; + let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?; let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?; d.read_notes = notes; return Ok(d); @@ -92,10 +99,13 @@ pub fn write_with(d: &Drawing, name: impl AsRef, opts: &SaveOptions) -> Re match Format::from_path(name) { Some(Format::Dxf) => { let tags = dxf_write::tags(d, version); - Ok(if opts.binary { - cadcraft_dxf::write_binary(&tags, version.utf8()) - } else { - cadcraft_dxf::write_ascii_as(&tags, version.utf8()).into_bytes() + // Before 2007 the text is in the code page the header declares. + let code_page = dxf_write::code_page(d, version); + Ok(match (opts.binary, version.utf8()) { + (true, true) => cadcraft_dxf::write_binary(&tags, true), + (true, false) => cadcraft_dxf::write_binary_in(&tags, &code_page), + (false, true) => cadcraft_dxf::write_ascii_as(&tags, true).into_bytes(), + (false, false) => cadcraft_dxf::write_ascii_in(&tags, &code_page), }) } // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without @@ -103,7 +113,7 @@ pub fn write_with(d: &Drawing, name: impl AsRef, opts: &SaveOptions) -> Re // gradients, so a drawing from an R2000 DXF doesn't lose them by default. Some(Format::Dwg) if !opts.binary => { let version = opts.version.unwrap_or_else(|| version.max(Version::R2004)); - cadcraft_dwg::dxf_to_dwg_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) + cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format) } _ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))), _ => write_framed(d, name, None), diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 3f35233a8..ba9d65867 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -508,8 +508,37 @@ struct SheetPage { label: String, } +/// `o` with a layout's saved page setup filled in where the options don't say: its plot window, +/// scale to fit or saved scale, and centring (#2493). Explicit options win; model space has no +/// saved setup. +pub(crate) fn with_saved_setup(d: &Drawing, space: &Space, o: &PdfOptions) -> PdfOptions { + let mut o = o.clone(); + let Some(page) = space.layout().and_then(|n| d.layout(n)).map(|l| &l.page) else { return o }; + // An explicit `fit: false` without a `scale` asks for the plain 1:1 plot of the sheet. + if o.fit == Some(false) && o.scale.is_none() { + o.window = None; + return o; + } + let saved = paper::saved_plot(page); + // A layout is plotted over its own sheet: only its saved window applies, not one in the options. + o.window = saved.window; + // An explicit scale beats a saved scale to fit (the scale is ignored while fitting). + if o.fit.is_none() && o.scale.is_none() && saved.fit { + o.fit = Some(true); + } + if o.scale.is_none() { + o.scale = saved.scale; + } + if o.center.is_none() && saved.window.is_some() { + o.center = Some(saved.center); + } + o +} + /// Plot one space. fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result { + let saved = with_saved_setup(d, space, o); + let o = &saved; let page = page_for(d, space, o)?; let unit_mm = paper::page_unit_mm(d, &page); let sheet = Sheet::from_page(&page, unit_mm); @@ -520,6 +549,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String tolerance: 0.001, min_dash: 0.0, text: true, + linetypes: true, fill: true, lineweights, view_height: 0.0, @@ -529,8 +559,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String cancel: None, }; let k = unit_mm * PT_PER_MM; - let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); - let fit = o.fit.unwrap_or(window.is_some() || matches!(space, Space::Model)); + let window = o.window.filter(|w| !w.is_empty()); + let fit = o.fit.unwrap_or(matches!(space, Space::Model)); // Chord tolerance: about 0.05 mm on paper. let est = plot_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, fit, o.scale); let tol = 0.05 / unit_mm / est.max(1e-300); @@ -540,8 +570,11 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, view_height, ..ropts }); let b = window.unwrap_or(list.bounds); let s = plot_scale(&b, &sheet, fit, o.scale); - let map = if fit || matches!(space, Space::Model) { - let (from, to) = placement(space, &b, fit, &sheet, o); + // A layout plotted over a window is placed like model space: the window on the printable area. + let placed = fit || window.is_some() || matches!(space, Space::Model); + let map = if placed { + let by = if window.is_some() { &Space::Model } else { space }; + let (from, to) = placement(by, &b, fit, &sheet, o); Map { from, s, to, k } } else { Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k } @@ -550,11 +583,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let mut list = list; cadcraft_render::pen::apply(&mut list, map.s * unit_mm); let media = sheet.size * k; - let clip_pt = if fit || matches!(space, Space::Model) { - Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) - } else { - Bounds2::new(Vec2::ZERO, media) - }; + let clip_pt = if placed { Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) } else { Bounds2::new(Vec2::ZERO, media) }; // The layers drawn on, in order of first use. let mut layers: Vec = Vec::new(); if o.layers { @@ -988,3 +1017,86 @@ fn uri(url: &str) -> Option { } Some(out) } + +#[cfg(test)] +mod saved_setup_tests { + use super::*; + use cadcraft_doc::{Common, EntityKind, Layout, Line}; + use cadcraft_geom::Vec3; + use serde_json::json; + + /// A layout drawn in mm at A1 size (frame 20,5 - 836,589) whose page setup is A4 landscape + /// plotting that window, as in #2493. + fn drawing(scale_to_fit: bool, scale: f64) -> Drawing { + let mut d = Drawing::new_metric(); + let mut l = Layout::new("L1", 3); + l.page = PageSetup { + width_mm: 297.0, + height_mm: 210.0, + landscape: true, + margins_mm: [0.0; 4], + plot_area: "window".into(), + plot_window: [20.0, 5.0, 836.0, 589.0], + scale_to_fit, + scale, + center: true, + ..PageSetup::default() + }; + d.layouts.push(l); + let line = EntityKind::Line(Line { a: Vec3::new(20.0, 5.0, 0.0), b: Vec3::new(836.0, 589.0, 0.0) }); + d.add(&Space::Paper("L1".into()), Common::default(), line).unwrap(); + d + } + + fn map(d: &Drawing, opts: serde_json::Value) -> Map { + sheet(d, &Space::Paper("L1".into()), &PdfOptions::from_json(&opts), &[]).unwrap().map + } + + /// A layout plotted with no options uses its saved scale to fit and plot window, not 1:1. + #[test] + fn a_layout_plots_its_saved_window_scaled_to_fit_by_default() { + let d = drawing(true, 1.0); + let m = map(&d, json!({})); + let want = (297.0_f64 / 816.0).min(210.0 / 584.0); + assert!((m.s - want).abs() < 1e-9, "scale {} not {want}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9, "window centre, got {:?}", m.from); + assert!(m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "page centre, got {:?}", m.to); + } + + /// The options still win: `fit: false` is the plain 1:1 plot, and a `scale` is used as given. + #[test] + fn explicit_options_override_the_saved_setup() { + let d = drawing(true, 1.0); + let m = map(&d, json!({"fit": false})); + assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO)); + let m = map(&d, json!({"fit": false, "scale": 0.25})); + assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s); + } + + /// A saved scale (not scale to fit) is used by default, with the window centred on the page. + #[test] + fn a_layout_plots_at_its_saved_scale_by_default() { + let d = drawing(false, 0.3394); + let m = map(&d, json!({})); + assert!((m.s - 0.3394).abs() < 1e-12, "{}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "{:?} {:?}", m.from, m.to); + } + + /// A page setup with nothing saved to fit plots as it always did. + #[test] + fn a_layout_without_a_saved_window_or_scale_is_unchanged() { + let mut d = drawing(false, 1.0); + d.layouts.iter_mut().find(|l| l.name == "L1").unwrap().page.plot_area = "layout".into(); + let m = map(&d, json!({})); + assert_eq!((m.s, m.from, m.to), (1.0, Vec2::ZERO, Vec2::ZERO)); + } + + /// An explicit `scale` beats a saved scale to fit, in the PDF as in the preview. + #[test] + fn an_explicit_scale_overrides_the_saved_scale_to_fit() { + let d = drawing(true, 1.0); + let m = map(&d, json!({"scale": 0.25})); + assert!((m.s - 0.25).abs() < 1e-12, "{}", m.s); + assert!(m.from.dist(Vec2::new(428.0, 297.0)) < 1e-9 && m.to.dist(Vec2::new(148.5, 105.0)) < 1e-9, "still placed: {:?} {:?}", m.from, m.to); + } +} diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index 12da96c3b..f0a174ea1 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -63,7 +63,7 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 { } fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { - let o = PdfOptions::from_json(opts); + let o = pdf::with_saved_setup(d, space, &PdfOptions::from_json(opts)); let (sheet, page) = sheet(d, space, &o)?; let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0); // Pixels per paper unit, lowered so the image stays within the caps. @@ -78,10 +78,17 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let (width, height) = (px(sheet.size.x), px(sheet.size.y)); let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true }); let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new); - // Paper units per drawing unit: 1 for a layout at 1:1, the fitted scale for model space. + // Paper units per drawing unit: the fitted scale for model space or a fitted layout, else the + // `scale` option or 1:1, as PDF plots it. let fit = matches!(space, Space::Model) || o.fit == Some(true); - let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); - let est = if fit { fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) } else { 1.0 }; + let window = o.window.filter(|w| !w.is_empty()); + // A layout over a saved window is placed like model space, the window on the printable area. + let placed = fit || window.is_some(); + let est = if fit { + fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) + } else { + pdf::plot_scale(&Bounds2::EMPTY, &sheet, false, o.scale) + }; // Chord tolerance: a fifth of a pixel. let tolerance = (0.2 / (k * est)).max(1e-9); let transparency = o.plot_transparency.unwrap_or(page.plot_transparency); @@ -89,21 +96,27 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let mut list = cadcraft_render::build_plot(d, space, &ropts); let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k); let b = window.unwrap_or(list.bounds); - let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 }; + let s = if fit { fit_scale(&b, &sheet, &o) } else { est }; // Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's // are already drawn). cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); - let view = if fit { - let (from, to) = pdf::placement(space, &b, true, &sheet, &o); - // The world point at the image centre, mapped through the same paper origin as PDF. - let center = from + (half - to) / s; - raster::View { center, scale: k * s, width, height } + let (from, to) = if placed { + // A window is placed like model space, whatever the space (as in PDF). + pdf::placement(if window.is_some() { &Space::Model } else { space }, &b, fit, &sheet, &o) } else { - raster::View { center: half, scale: k, width, height } + (Vec2::ZERO, Vec2::ZERO) }; + // The world point at the image centre, mapped through the same paper origin as PDF. + let center = from + (half - to) / s; + let view = raster::View { center, scale: k * s, width, height }; let px_per_mm = if lineweights { (k / sheet.unit_mm) as f32 } else { 0.0 }; let ro = raster::RasterOptions { background: Rgb(255, 255, 255), hairline: 1.0, px_per_mm, antialias: true }; - raster::render_png(&list, &view, &ro).ok_or_else(|| IoError::Format("render failed".into())) + let mut pm = raster::render(&list, &view, &ro).ok_or_else(|| IoError::Format("render failed".into()))?; + if placed { + // PDF clips a placed plot to the printable area: blank the rest of the sheet. + raster::blank_outside_printable(&mut pm, &sheet.printable, k); + } + pm.encode_png().map_err(|_| IoError::Format("render failed".into())) } fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { @@ -237,4 +250,125 @@ mod tests { assert!(err.to_string().contains("not supported for SVG")); assert!(plot(&d, &model, &json!({"output": "svg", "center": true})).unwrap().starts_with(b" Drawing { + let mut d = Drawing::new_metric(); + let mut l = cadcraft_doc::Layout::new("L1", 3); + l.page = cadcraft_doc::PageSetup { + width_mm: 297.0, + height_mm: 210.0, + landscape: true, + margins_mm: [10.0; 4], + plot_area: "window".into(), + plot_window: [0.0, 0.0, 100.0, 60.0], + scale_to_fit, + scale: 1.0, + center: true, + ..cadcraft_doc::PageSetup::default() + }; + d.layouts.push(l); + let sp = Space::Paper("L1".into()); + let mut line = |a: (f64, f64), b: (f64, f64)| { + d.add(&sp, Common::default(), EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })).unwrap(); + }; + line((0.0, 30.0), (100.0, 30.0)); + line((-3.0, 0.0), (-3.0, 60.0)); + d + } + + /// Whether any pixel of the PNG in the column range `x0..=x1` (mm from the sheet's left edge) + /// at the row of `y_mm` (from the sheet's bottom edge, give or take 2 px) is not white. + fn inked(png: &[u8], x0_mm: f64, x1_mm: f64, y_mm: f64) -> bool { + let pm = tiny_skia::Pixmap::decode_png(png).unwrap(); + let px_per_mm = f64::from(pm.width()) / 297.0; + let row = (f64::from(pm.height()) - y_mm * px_per_mm).round() as i64; + let (c0, c1) = ((x0_mm * px_per_mm).round() as i64, (x1_mm * px_per_mm).round() as i64); + (row - 2..=row + 2).any(|r| (c0..=c1).any(|c| pm.pixel(c as u32, r as u32).is_some_and(|p| p.red() < 200))) + } + + const LAYOUT_PNG: &str = r#"{"output": "png", "resolution": 100}"#; + + /// Without scale to fit, a saved window is still placed (centred on the printable area, 1:1) as PDF + /// places it, not left at the sheet origin. + #[test] + fn png_places_the_saved_window_when_fit_is_off() { + let d = layout_drawing(false); + let png = plot(&d, &Space::Paper("L1".into()), &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + // The 100 mm line is centred on the sheet: 98.5 to 198.5 mm. At the origin it would end at 100 mm. + assert!(inked(&png, 140.0, 160.0, 105.0), "the window's line crosses the sheet centre"); + assert!(!inked(&png, 10.0, 90.0, 105.0), "nothing at the sheet origin"); + } + + /// An explicit scale overrides a saved scale to fit, and the PNG uses it. + #[test] + fn png_explicit_scale_beats_saved_fit() { + let d = layout_drawing(true); + let sp = Space::Paper("L1".into()); + let fitted = plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + assert!(inked(&fitted, 200.0, 280.0, 105.0), "fitted, the 100 unit line is 277 mm wide"); + let half = plot(&d, &sp, &json!({"output": "png", "resolution": 100, "scale": 0.5})).unwrap(); + assert!(inked(&half, 130.0, 170.0, 105.0)); + assert!(!inked(&half, 200.0, 280.0, 105.0), "at 0.5 the line is 50 mm wide, centred"); + } + + /// A placed window shows only inside the printable area, as PDF clips it. + #[test] + fn png_clips_a_placed_window_to_the_printable_area() { + let d = layout_drawing(true); + let png = plot(&d, &Space::Paper("L1".into()), &serde_json::from_str(LAYOUT_PNG).unwrap()).unwrap(); + // The line 3 units left of the window lands about 1.7 mm from the sheet edge, in the margin. + assert!(!inked(&png, 0.0, 9.0, 105.0), "the margin stays blank"); + } + + /// A layout with `window`/`scale` saved (any values, hostile ones included). + fn saved_drawing(window: [f64; 4], scale: f64, fit: bool) -> Drawing { + let mut d = layout_drawing(fit); + let page = &mut d.layouts.iter_mut().find(|l| l.name == "L1").unwrap().page; + page.plot_window = window; + page.scale = scale; + d + } + + /// Degenerate windows, a window off the drawing, and a saved scale of 0 or not finite fall back + /// to the plain plot: the setup resolves to no window or scale, and PDF and PNG still plot. + #[test] + fn hostile_saved_setups_fall_back_and_never_panic() { + let sp = Space::Paper("L1".into()); + let nan = f64::NAN; + let inf = 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]] { + let d = saved_drawing(window, 1.0, true); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert!(o.window.is_none(), "{window:?}: a degenerate window is dropped, got {:?}", o.window); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF"), "{window:?}"); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok(), "{window:?}"); + } + for scale in [0.0, -2.0, nan, inf] { + let d = saved_drawing([0.0, 0.0, 100.0, 60.0], scale, false); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert!(o.scale.is_none(), "{scale}: an unusable saved scale is ignored"); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF"), "{scale}"); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok(), "{scale}"); + } + // A window far from everything drawn is plotted (an empty sheet), not an error. + let d = saved_drawing([1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true); + assert!(plot(&d, &sp, &json!({})).unwrap().starts_with(b"%PDF")); + assert!(plot(&d, &sp, &serde_json::from_str(LAYOUT_PNG).unwrap()).is_ok()); + } + + /// A layout with no saved setup (`PageSetup::default()`) plots as the options say: nothing is + /// filled in from it. + #[test] + fn a_layout_without_saved_setup_changes_nothing() { + let mut d = Drawing::new_metric(); + d.layouts.push(cadcraft_doc::Layout::new("L1", 3)); + let sp = Space::Paper("L1".into()); + let given = PdfOptions::from_json(&json!({"scale": 0.5})); + let o = pdf::with_saved_setup(&d, &sp, &given); + assert_eq!((o.window, o.fit, o.scale, o.center), (None, None, Some(0.5), None)); + let o = pdf::with_saved_setup(&d, &sp, &PdfOptions::from_json(&json!({}))); + assert_eq!((o.window, o.fit, o.scale, o.center), (None, None, None, None)); + } } diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs index 21508fe7b..54ef9d846 100644 --- a/crates/io/src/svg_read.rs +++ b/crates/io/src/svg_read.rs @@ -1,8 +1,8 @@ //! SVG → Drawing: the static W3C SVG subset that drawing tools emit. //! //! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path` -//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform` -//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers +//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping, `transform` on groups +//! and shapes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers //! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with //! the page's top-left corner at the origin and Y pointing up — where a laser or pen job //! wants it. @@ -262,6 +262,19 @@ impl Parser { } return Ok(()); } + // A shape's own `transform` (SVG 1.1 §7.5) maps it after its groups', for this element only. + let local = attr(e, b"transform").as_deref().and_then(parse_transform); + if let Some(local) = local { + self.stack.push(self.cur().then_before(local)); + } + let read = self.shape(name, e); + if local.is_some() { + self.stack.pop(); + } + read + } + + fn shape(&mut self, name: &[u8], e: &BytesStart) -> Result<()> { match name { b"use" => { self.left_out(LeftOut::Use); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 977a7c184..46536f24e 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -98,6 +98,7 @@ fn sample() -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }), ) .unwrap(); @@ -558,6 +559,7 @@ fn dim(kind: DimKind, p13: Vec3, p14: Vec3) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } @@ -604,6 +606,12 @@ fn viewport_frozen_layers_roundtrip() { assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}"); } +/// `d` as an R2018 DXF (frozen viewport layers in group 331). +fn dxf_2018(d: &Drawing) -> String { + let opts = SaveOptions { version: Some(Version::R2018), binary: false }; + String::from_utf8(write_with(d, "t.dxf", &opts).unwrap()).unwrap() +} + /// A layout with viewport 2 frozen on `Walls` and `A B` (both real layers), and its written DXF as /// (code, value) pairs. fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { @@ -624,10 +632,14 @@ fn frozen_vp_dxf() -> (Drawing, Vec<(String, String)>) { on: true, }); d.add(&paper, Common::default(), vp).unwrap(); - let text = write_dxf(&d); + let text = dxf_2018(&d); + (d, pairs(&text)) +} + +/// The (code, value) pairs of DXF text. +fn pairs(text: &str) -> Vec<(String, String)> { let lines: Vec<&str> = text.lines().collect(); - let pairs = lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect(); - (d, pairs) + lines.chunks(2).map(|c| (c[0].trim().to_string(), c.get(1).copied().unwrap_or("").to_string())).collect() } /// The pair range of the VIEWPORT record whose group 69 is `id`. @@ -676,47 +688,60 @@ fn join(p: &[(String, String)]) -> String { .collect() } -#[test] -fn viewport_frozen_layers_written_as_341_handles() { - let (_, p) = frozen_vp_dxf(); - let r = vp_record(&p, "2"); - let rec = &p[r]; - let h341: Vec<&str> = rec.iter().filter(|(c, _)| c == "341").map(|(_, v)| v.as_str()).collect(); - assert_eq!(h341, vec![layer_handle(&p, "Walls").as_str(), layer_handle(&p, "A B").as_str()]); - let last341 = rec.iter().rposition(|(c, _)| c == "341").unwrap(); - let x = rec.iter().position(|(c, _)| c == "1001").expect("xdata"); - assert!(last341 < x, "341 must precede the xdata"); +/// The group 331 values of viewport 2's record. +fn frozen_331(p: &[(String, String)]) -> Vec { + p[vp_record(p, "2")].iter().filter(|(c, _)| c == "331").map(|(_, v)| v.trim().to_string()).collect() } #[test] -fn viewport_frozen_layers_read_from_341_without_xdata() { +fn viewport_frozen_layers_written_as_331_handles() { + // Real layers go out as handles only, before group 90. let (_, p) = frozen_vp_dxf(); - let r = vp_record(&p, "2"); - // Strip the CADCraft xdata, and add a 341 to a handle that names no layer. - let x = p[r.clone()].iter().position(|(c, _)| c == "1001").unwrap() + r.start; - let mut q: Vec<(String, String)> = p[..r.start].to_vec(); - let mut head = p[r.start..x].to_vec(); - head.push(("341".into(), "FFFFF".into())); - q.extend(head); - q.extend(p[r.end..].iter().cloned()); - let back = read_dxf(join(&q).as_bytes()).unwrap(); - assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); + assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); + let rec = &p[vp_record(&p, "2")]; + assert!(!rec.iter().any(|(c, v)| c == "1000" && v == "VPFROZEN"), "no xdata for layers with a handle"); + assert!(rec.iter().position(|(c, _)| c == "331") < rec.iter().position(|(c, _)| c == "90")); + // An R2000 DXF holds them in group 341, as the DXF 2000 Reference has them. + let (d, _) = frozen_vp_dxf(); + let p = pairs(&write_dxf(&d)); + let h341: Vec = p[vp_record(&p, "2")].iter().filter(|(c, _)| c == "341").map(|(_, v)| v.clone()).collect(); + assert_eq!(h341, [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); + assert!(frozen_331(&p).is_empty()); } #[test] -fn viewport_frozen_xdata_wins_over_341() { +fn viewport_frozen_layers_read_from_331_or_341_once_each() { let (_, p) = frozen_vp_dxf(); let r = vp_record(&p, "2"); - // Point every 341 at layer 0: the xdata list must still be what is read. - let zero = layer_handle(&p, "0"); + // 341 instead of 331, then Walls again in 331 and a handle that names no layer. let mut q = p.clone(); - for t in &mut q[r] { - if t.0 == "341" { - t.1 = zero.clone(); + for t in &mut q[r.clone()] { + if t.0 == "331" { + t.0 = "341".into(); } } + q.splice(r.end..r.end, [("331".into(), "FFFFF".into()), ("331".into(), layer_handle(&p, "Walls"))]); + // A 331 inside another application's `102 {…}` group isn't a frozen layer. + let app = [("102".into(), "{ACAD_OTHER".into()), ("331".into(), layer_handle(&p, "0")), ("102".into(), "}".into())]; + q.splice(r.start + 2..r.start + 2, app); let back = read_dxf(join(&q).as_bytes()).unwrap(); - assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); + assert_eq!(viewport_frozen(&back, 2), ["Walls", "A B"]); +} + +#[test] +fn viewport_freeze_edited_in_autocad_is_read_as_edited() { + // Every layer thawed in AutoCAD (no 331 left) stays thawed; a layer it froze instead is read. + let (_, p) = frozen_vp_dxf(); + let r = vp_record(&p, "2"); + let thawed: Vec<(String, String)> = p.iter().enumerate().filter(|(i, (c, _))| !(r.contains(i) && c == "331")).map(|(_, t)| t.clone()).collect(); + assert!(viewport_frozen(&read_dxf(join(&thawed).as_bytes()).unwrap(), 2).is_empty()); + let mut zero = p.clone(); + for t in &mut zero[r] { + if t.0 == "331" { + t.1 = layer_handle(&p, "0"); + } + } + assert_eq!(viewport_frozen(&read_dxf(join(&zero).as_bytes()).unwrap(), 2), ["0"]); } #[test] @@ -725,7 +750,9 @@ fn viewport_frozen_layers_survive_dwg() { let dwg = write(&d, "t.dwg").unwrap(); let back = read(&dwg, "t.dwg").unwrap(); assert_eq!(viewport_frozen(&back, 2), vec!["Walls".to_string(), "A B".to_string()]); - // Survives via the CADCraft xdata: the DWG converter does not yet carry group 341 itself. + // The DWG holds them in group 331, what AutoCAD reads. + let p = pairs(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(frozen_331(&p), [layer_handle(&p, "Walls"), layer_handle(&p, "A B")]); } #[test] @@ -1012,7 +1039,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() { // Lines that aren't even group codes: the size is reported before anything is parsed. let text = "not a group code\nx\n".repeat(60); let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err(); - assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads"); + assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)"); } /// Issue #800: one unreadable object record doesn't fail the whole DWG. The reader skips it, the @@ -2274,6 +2301,48 @@ fn svg_group_transforms_apply() { assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}"); } +/// The geometry of everything read, for comparing two readings of one drawing. +fn geometry(d: &Drawing) -> String { + format!("{:?}", d.model.iter().map(|e| &e.kind).collect::>()) +} + +/// `transform` on a shape element (SVG 1.1 §7.5, every graphics element takes one) maps it as +/// the same transform on a group around it would: for every shape kind, composed after the +/// parent group's, and not passed on to the next sibling. +#[test] +fn svg_element_transforms_apply_like_a_group_around_the_element() { + let read_svg = |body: &str| read(format!("{SVG_PAGE}{body}").as_bytes(), "t.svg").unwrap(); + let shapes = [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ]; + for t in ["translate(5,2)", "rotate(30 5 5)", "scale(2,1)", "skewX(20)", "matrix(1 0.2 0 1 3 -1)"] { + for shape in shapes { + let on_element = shape.replacen("/>", &format!(r#" transform="{t}"/>"#), 1); + let in_group = format!(r#"{shape}"#); + assert_eq!(geometry(&read_svg(&on_element)), geometry(&read_svg(&in_group)), "{t} on {shape}"); + } + } + // A rotated circle stays a circle; a skewed one is an ellipse. + assert_eq!(kinds(&read_svg(r#""#)), ["Circle"]); + assert_eq!(kinds(&read_svg(r#""#)), ["Ellipse"]); + // Inside a group: the group's transform, then the element's. + let nested = read_svg(r#""#); + let both = read_svg(r#""#); + assert_eq!(geometry(&nested), geometry(&both)); + // Written with an end tag too, and the next sibling keeps its own place. + let d = read_svg(r#""#); + let lines: Vec = d.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect(); + assert_eq!(lines.len(), 2); + assert!((lines[0].a - Vec3::new(5.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[0]); + assert!((lines[1].a - Vec3::new(0.0, 10.0, 0.0)).len() < 1e-9, "{:?}", lines[1]); +} + #[test] fn svg_viewbox_scaling_and_mm_units() { let svg = r#""#; @@ -2827,3 +2896,19 @@ fn non_unicode_names_pick_the_format_by_extension() { assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG")); assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG"); } + +/// A SOLID hatch keeps its angle (52), scale (41) and double flag (77) through a save: the writer +/// wrote them for pattern fills only. +#[test] +fn solid_hatch_keeps_angle_scale_and_double_flag() { + let src = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n5\n40\n8\n0\n100\nAcDbHatch\n10\n0\n20\n0\n30\n0\n2\nSOLID\n70\n1\n71\n0\n\ +91\n1\n92\n2\n72\n0\n73\n1\n93\n4\n10\n0\n20\n0\n10\n40\n20\n0\n10\n40\n20\n40\n10\n0\n20\n40\n97\n0\n\ +75\n0\n76\n1\n52\n30\n41\n2\n77\n0\n98\n0\n0\nENDSEC\n0\nEOF\n"; + let out = write_dxf(&read_dxf(src.as_bytes()).unwrap()); + let pairs = dxf_pairs(&out); + let h = dxf_records(&pairs, "HATCH")[0]; + let f = |code: &str| group(h, code).and_then(|v| v.parse::().ok()); + assert!(f("52").is_some_and(|a| (a - 30.0).abs() < 1e-9), "{h:?}"); + assert!(f("41").is_some_and(|s| (s - 2.0).abs() < 1e-12), "{h:?}"); + assert_eq!(group(h, "77"), Some("0"), "{h:?}"); +} diff --git a/crates/io/tests/dimassoc_angular_radial.rs b/crates/io/tests/dimassoc_angular_radial.rs index ec1a11f69..1df635320 100644 --- a/crates/io/tests/dimassoc_angular_radial.rs +++ b/crates/io/tests/dimassoc_angular_radial.rs @@ -27,6 +27,7 @@ fn dim(kind: DimKind, defpt: Vec3, p13: Vec3, p14: Vec3, p15: Vec3, p16: Vec3, a overrides: Default::default(), assoc, marks: Default::default(), + insertion_point: Vec3::ZERO, }) } @@ -288,3 +289,50 @@ fn standard_dimassoc_associates_in_dxf_and_dwg() { let back = read(&dwg, "case.dwg").unwrap(); assert_standard_assoc(&c, &back, "DWG"); } + +/// Every source object names each DIMASSOC that follows it once, before its owner group. +#[test] +fn dimension_sources_carry_their_persistent_reactors() { + let c = case(); + let text = write_dxf(&c.d); + let all = records(&text); + for dimension in [c.hd, c.hr, c.ha, c.hl] { + let assoc = dimassoc(&text, dimension).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + for reference in point_refs(&assoc) { + // Both objects of an intersection need the link back. Repeated snaps to one + // object must not write the same DIMASSOC reactor twice. + for source in [string(&reference, 331), string(&reference, 332)].into_iter().flatten() { + let geometry = all.iter().find(|r| string(r, 5) == Some(source)).expect("referenced geometry"); + let start = geometry.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("source reactor group"); + let end = + start + 1 + geometry.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(geometry[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); + assert_eq!(start, 2, "reactors immediately follow the entity handle"); + assert_eq!(geometry.get(end + 1).map(|(code, _)| *code), Some(330), "entity owner follows reactors"); + assert_eq!(geometry.iter().filter(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").count(), 1); + } + } + } +} + +/// Malformed self-links must not duplicate the reactor the dimension already carries. +#[test] +fn a_self_referential_dimension_writes_its_reactor_once() { + let mut c = case(); + assert!(c.d.model.modify(c.hl, |e| { + let EntityKind::Dimension(dm) = &mut e.kind else { panic!("dimension") }; + for reference in &mut dm.assoc { + reference.handle = c.hl; + } + })); + let text = write_dxf(&c.d); + let assoc = dimassoc(&text, c.hl).expect("DIMASSOC"); + let reactor = string(&assoc, 5).expect("DIMASSOC handle"); + let all = records(&text); + let handle = c.hl.hex(); + let dimension = all.iter().find(|r| string(r, 5) == Some(&handle)).expect("dimension record"); + let start = dimension.iter().position(|(code, value)| *code == 102 && value == "{ACAD_REACTORS").expect("reactor group"); + let end = start + 1 + dimension.iter().skip(start + 1).position(|(code, value)| *code == 102 && value == "}").expect("reactor group end"); + assert_eq!(dimension[start + 1..end].iter().filter(|(code, value)| *code == 330 && value == reactor).count(), 1); +} diff --git a/crates/io/tests/dwg_table_cell_height.rs b/crates/io/tests/dwg_table_cell_height.rs new file mode 100644 index 000000000..11ee98c9d --- /dev/null +++ b/crates/io/tests/dwg_table_cell_height.rs @@ -0,0 +1,173 @@ +//! TABLE heights inherit from the table style unless the file records a cell override. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_doc::{Common, Drawing, EntityKind, Space, Table, TableCell, TableStyle}; + +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + d.table_styles.push(TableStyle { name: "Small".into(), text_height: 0.0625, title: false, header: false, ..TableStyle::default() }); + let table = Table { + style: "Small".into(), + text_height: 0.0625, + title: false, + header: false, + row_heights: vec![0.2], + col_widths: vec![2.0; 3], + cells: vec![vec![ + TableCell { text: "plain".into(), ..TableCell::default() }, + TableCell { text: "{\\C256;formatted}".into(), ..TableCell::default() }, + TableCell { text: "explicit".into(), text_height: Some(0.125), ..TableCell::default() }, + ]], + ..Table::default() + }; + d.add(&Space::Model, Common::default(), EntityKind::Table(table)).unwrap(); + d +} + +fn check(d: &Drawing) { + let t = d + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + assert_eq!(t.style, "Small"); + assert_eq!(t.text_height, 0.0625); + for cell in &t.cells[0][..2] { + assert!(cell.text_height.is_none(), "unexpected override: {:?}", cell.text_height); + } + assert_eq!(t.cells[0][2].text_height, Some(0.125)); +} + +#[test] +fn table_cell_heights_keep_inheritance_through_dwg() { + let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap(); + // A style record can be present for metadata without overriding its text height. + // Write that synthetic representation directly so no earlier DXF conversion can + // turn the unused default into an explicit override before this import. + let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap(); + let table = doc + .entities_mut() + .find_map(|e| match e { + acadrust::entities::EntityType::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + table.rows[0].cells[0].style = Some(acadrust::entities::table::CellStyle::new()); + let bytes = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + check(&cadcraft_io::read(&bytes, "height.dwg").unwrap()); +} + +#[test] +fn table_cell_heights_keep_inheritance_through_dxf() { + let bytes = cadcraft_io::write(&drawing(), "height.dxf").unwrap(); + check(&cadcraft_io::read(&bytes, "height.dxf").unwrap()); +} + +// ---- cells with their own style record (review on #2588) ---- + +use acadrust::entities::table::{CellContent, CellStyle, CellStylePropertyFlags}; + +/// Write the drawing as a DWG, edit its first cell through acadrust (`edit`), write it again as a +/// DWG (or, with `as_dxf`, as a DXF by acadrust's writer) and read it with CADCraft. Returns the first cell's +/// text height override. +fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::table::TableCell)) -> Option { + let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap(); + let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap(); + let table = doc + .entities_mut() + .find_map(|e| match e { + acadrust::entities::EntityType::Table(t) => Some(t), + _ => None, + }) + .unwrap(); + edit(&mut table.rows[0].cells[0]); + let d = if as_dxf { + let bytes = acadrust::DxfWriter::new(&doc).write_to_vec().unwrap(); + cadcraft_io::read(&bytes, "height.dxf").unwrap() + } else { + let bytes = acadrust::DwgWriter::write_to_vec(&doc).unwrap(); + cadcraft_io::read(&bytes, "height.dwg").unwrap() + }; + d.model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Table(t) => Some(t.cells[0][0].text_height), + _ => None, + }) + .unwrap() +} + +fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle { + let mut s = CellStyle::new(); + s.text_height = height; + s.property_flags = flags; + s +} + +#[test] +fn a_cell_style_with_the_text_height_flag_keeps_its_height() { + let h = first_cell_height(false, |c| c.style = Some(styled(0.5, CellStylePropertyFlags::TEXT_HEIGHT))); + assert_eq!(h, Some(0.5)); +} + +#[test] +fn content_with_format_override_bit_128_keeps_its_own_height() { + // acadrust writes the content's height as group 144 and the style's as 140; they differ here + // so the result shows which one CADCraft honoured. + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.5, CellStylePropertyFlags::NONE)); + if c.contents.is_empty() { + c.contents.push(CellContent::new()); + } + c.contents[0].format_override_flags |= 128; + c.contents[0].text_height = 0.3125; + }); + assert_eq!(h, Some(0.3125)); +} + +/// Only the first content's override stands for the cell: a later content's height must not +/// replace the first content's inherited one (review on #2588). +#[test] +fn a_later_contents_height_does_not_replace_the_first_contents() { + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.18, CellStylePropertyFlags::NONE)); + c.contents.clear(); + c.contents.push(CellContent::new()); + let mut later = CellContent::new(); + later.format_override_flags |= 128; + later.text_height = 0.3125; + c.contents.push(later); + }); + assert_eq!(h, None); +} + +/// Bit 128 with an unusable content height copies nothing, so the writer's default style height +/// is still cleared (review on #2588). +#[test] +fn bit_128_with_an_invalid_height_still_clears_the_default() { + for bad in [0.0, -1.0, f64::NAN] { + let h = first_cell_height(false, |c| { + c.style = Some(styled(0.18, CellStylePropertyFlags::NONE)); + if c.contents.is_empty() { + c.contents.push(CellContent::new()); + } + c.contents[0].format_override_flags |= 128; + c.contents[0].text_height = bad; + }); + assert_eq!(h, None, "content height {bad}"); + } +} + +#[test] +fn a_cell_with_no_style_record_inherits_the_table_style() { + assert_eq!(first_cell_height(false, |c| c.style = None), None); +} + +/// A style record with a height and neither override is not an override, through DWG. +#[test] +fn a_style_height_without_an_override_is_cleared() { + assert_eq!(first_cell_height(false, |c| c.style = Some(styled(0.18, CellStylePropertyFlags::NONE))), None); +} diff --git a/crates/io/tests/it/acad_interop.rs b/crates/io/tests/it/acad_interop.rs index 387f23334..bc9cc90e1 100644 --- a/crates/io/tests/it/acad_interop.rs +++ b/crates/io/tests/it/acad_interop.rs @@ -98,6 +98,7 @@ fn s25(kind: DimKind, p13: Vec3, p14: Vec3, defpt: Vec3) -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); d diff --git a/crates/io/tests/it/acis_data_store.rs b/crates/io/tests/it/acis_data_store.rs index 74b9809ce..f0ad2039c 100644 --- a/crates/io/tests/it/acis_data_store.rs +++ b/crates/io/tests/it/acis_data_store.rs @@ -101,3 +101,119 @@ fn dwg_keeps_the_solids_geometry() { let again = cadcraft_io::read(&dwg, "x.dwg").unwrap(); assert_eq!(again.foreign.data_store_entities().len(), 2); } + +/// A solid with SAT-text ACIS data (an R2000-style file) can't be written to a DXF of 2013 or +/// later, whose files keep that data in the data store: it is left out there and counted, while +/// 2010 and earlier keep it, and solids with a data store record are kept everywhere (#2334). +#[test] +fn sat_text_solids_are_left_out_of_dxf_2013_and_later() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n1\n16 ZWCAD\n3\nEnd-of-ACIS-data\n\ + 0\n3DSOLID\n5\n2C\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n\ + 0\nLINE\n5\n2D\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let count = |v: cadcraft_io::Version, ty: &str| { + let out = cadcraft_io::write_with(&d, "x.dxf", &cadcraft_io::SaveOptions { version: Some(v), binary: false }).unwrap(); + let t = String::from_utf8(out).unwrap().replace("\r\n", "\n"); + t.matches(&format!("\n0\n{ty}\n")).count() + t.matches(&format!("\n 0\n{ty}\n")).count() + }; + assert_eq!(cadcraft_doc::foreign::sat_text_dropped(&d), [("3DSOLID".to_string(), 1), ("REGION".to_string(), 1)].into()); + for v in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] { + assert_eq!((count(v, "REGION"), count(v, "3DSOLID")), (1, 1), "{v:?} keeps them"); + } + for v in [cadcraft_io::Version::R2013, cadcraft_io::Version::R2018] { + assert_eq!((count(v, "REGION"), count(v, "3DSOLID")), (0, 0), "{v:?} leaves them out"); + assert_eq!(count(v, "LINE"), 1, "{v:?} keeps the rest"); + } + // Solids with a data store record keep their geometry in 2013 and later. + let stored = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + assert!(cadcraft_doc::foreign::sat_text_dropped(&stored).is_empty()); +} + +/// A DWG keeps SAT-text solids at any version (its writer handles them), and a DXF's note leaves +/// out the contents of xref blocks, which the DXF doesn't hold anyway (#2334). +#[test] +fn sat_text_solids_stay_in_dwg_2018_and_xref_contents_are_not_counted() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n\ + 0\nLINE\n5\n2D\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2018), binary: false }; + let dwg = cadcraft_io::write_with(&d, "x.dwg", &opts).unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let unknown = |d: &cadcraft_doc::Drawing| d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Unknown(u) if u.dxf_type == "REGION")).count(); + assert_eq!(unknown(&back), 1, "the DWG 2018 keeps the region"); + + // Xref blocks (and `xref|` ones) hold no entities in the file: not reported as left out. + let mut x = d.clone(); + let region = d.model.iter().find(|e| matches!(&e.kind, EntityKind::Unknown(_))).cloned().unwrap(); + let mut host = cadcraft_doc::Block::new("REF"); + host.xref_path = Some("other.dwg".into()); + host.entities.push((*region).clone()); + x.blocks.insert("REF".into(), std::sync::Arc::new(host)); + let mut dep = cadcraft_doc::Block::new("REF|PART"); + dep.entities.push((*region).clone()); + x.blocks.insert("REF|PART".into(), std::sync::Arc::new(dep)); + assert_eq!(cadcraft_doc::foreign::sat_text_dropped(&x), [("REGION".to_string(), 1)].into(), "only the model-space region"); +} + +/// Only groups 1 and 3 of the modeler geometry subclass are SAT text, and only a record holding +/// the entity's ASM_Data keeps its geometry in the data store (#2334). +#[test] +fn sat_text_is_told_by_its_subclass_and_a_geometry_record() { + use cadcraft_doc::foreign::{Foreign, sat_text_acis}; + use cadcraft_doc::{Handle, RawTag, Unknown}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let solid = |tags: Vec| Unknown { dxf_type: "3DSOLID".into(), tags }; + let none = std::collections::BTreeSet::new(); + let h = Handle(0x2B); + // A stray 1 or 3 outside AcDbModelerGeometry is not SAT text. + let stray = + solid(vec![tag(100, "AcDbEntity"), tag(1, "name"), tag(100, "AcDbModelerGeometry"), tag(70, "1"), tag(100, "AcDb3dSolid"), tag(3, "x")]); + assert!(!sat_text_acis(&stray, h, &none)); + let real = solid(vec![tag(100, "AcDbEntity"), tag(100, "AcDbModelerGeometry"), tag(70, "1"), tag(1, "400 1 1 0"), tag(100, "AcDb3dSolid")]); + assert!(sat_text_acis(&real, h, &none)); + // Without subclass markers (an old file) the groups are the entity's own. + assert!(sat_text_acis(&solid(vec![tag(8, "0"), tag(1, "400 1 1 0")]), h, &none)); + + // A 320 in the data store without an ASM_Data record doesn't keep the geometry. + let store = |asm: bool| { + let mut t = vec![tag(0, "ACDSRECORD"), tag(90, "1"), tag(320, "2B")]; + t.push(tag(2, if asm { "ASM_Data" } else { "Other" })); + Foreign { data_store: t, ..Foreign::default() } + }; + assert!(store(true).data_store_entities().contains(&h)); + assert!(store(false).data_store_entities().is_empty()); +} + +/// The writer keeps a data store record by its first 320 only, so only that entity counts as +/// stored (#2334). +#[test] +fn a_data_store_record_stores_only_its_first_entity() { + use cadcraft_doc::foreign::Foreign; + use cadcraft_doc::{Handle, RawTag}; + let tag = |code: i32, value: &str| RawTag { code, value: value.into() }; + let store = Foreign { data_store: vec![tag(0, "ACDSRECORD"), tag(320, "2B"), tag(320, "2C"), tag(2, "ASM_Data")], ..Foreign::default() }; + assert_eq!(store.data_store_entities(), [Handle(0x2B)].into()); +} + +/// A real DWG round trip of a SAT-text REGION: the DWG writer keeps the SAT groups up to 2010, and +/// from 2013 they come back empty (the solid's geometry is not in the file), which the DWG save +/// note has to say (#2334). +#[test] +fn a_dwg_keeps_a_sat_text_region_only_before_2013() { + let sat = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n\ + 0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\n400 1 1 0\n3\nEnd-of-ACIS-data\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(sat.as_bytes()).unwrap(); + let holds_sat = |v: cadcraft_io::Version| { + let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(v), binary: false }).unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let region = back.model.iter().find_map(|e| match &e.kind { + EntityKind::Unknown(u) if u.dxf_type == "REGION" => Some(u.clone()), + _ => None, + }); + region.is_some_and(|u| cadcraft_doc::foreign::has_sat_text(&u.tags)) + }; + assert!(holds_sat(cadcraft_io::Version::R2010), "2010 keeps the SAT text"); + assert!(!holds_sat(cadcraft_io::Version::R2018), "2018 comes back without it"); +} diff --git a/crates/io/tests/it/attdef_insert_dwg.rs b/crates/io/tests/it/attdef_insert_dwg.rs new file mode 100644 index 000000000..9fd1a1a3a --- /dev/null +++ b/crates/io/tests/it/attdef_insert_dwg.rs @@ -0,0 +1,49 @@ +//! A centre-justified ATTDEF keeps its insertion point in a DWG (#2464): AutoCAD puts it where the +//! baseline of the displayed tag starts, which for an attribute with an empty default value is +//! further from the alignment point than half a height. + +use cadcraft_doc::EntityKind; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +/// The `insert` x of the only ATTDEF in `d`. +fn attdef_x(d: &cadcraft_doc::Drawing) -> f64 { + d.model.iter().find_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.text.insert.x) } else { None }).expect("an ATTDEF") +} + +#[test] +fn centred_attdef_with_an_empty_value_keeps_its_insertion_point() { + // The tag "LONGTAG" at height 2.5, centred on x = 100: its baseline starts half a width left. + // The stored point is 1 off that start (within the tolerance), so a write that recomputed + // it from the tag would not pass for keeping it. + let width = cadcraft_fonts::line_width("LONGTAG", 2.5, 1.0); + let start = 100.0 - width / 2.0 - 1.0; + assert!(100.0 - start > 3.0, "the point is well over a height from the alignment point"); + assert_kept(start); +} + +/// A stored point that a tag-width baseline would move is kept too (review of #2466): the check +/// does not rely on the font's width of the tag, so this one is only 0.5 from the alignment point. +#[test] +fn centred_attdef_point_near_the_alignment_point_is_kept() { + assert_kept(99.5); +} + +/// A centred ATTDEF with an empty default value and group 10 at `start`, group 11 at x = 100 is +/// read from DXF and kept by a write as DXF and as DWG (both versions). The displayed text is the +/// tag whatever the prompt: with a prompt, an empty one (group 3 empty) and none (no group 3). +fn assert_kept(start: f64) { + for prompt in ["3\nprompt\n", "3\n\n", ""] { + let dxf = format!( + "0\nSECTION\n2\nENTITIES\n0\nATTDEF\n8\n0\n10\n{start}\n20\n5.0\n40\n2.5\n1\n\n7\nSTANDARD\n72\n1\n11\n100.0\n21\n5.0\n{prompt}2\nLONGTAG\n70\n8\n0\nENDSEC\n0\nEOF\n" + ); + let d = read(dxf.as_bytes(), "t.dxf").unwrap(); + assert!((attdef_x(&d) - start).abs() < 1e-6, "read from DXF"); + let as_dxf = cadcraft_io::write(&d, "t.dxf").unwrap(); + assert!((attdef_x(&read(&as_dxf, "t.dxf").unwrap()) - start).abs() < 1e-6, "DXF, prompt {prompt:?}: insertion point changed"); + for version in [Version::R2000, Version::R2018] { + let dwg = write_with(&d, "t.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let back = read(&dwg, "t.dwg").unwrap(); + assert!((attdef_x(&back) - start).abs() < 1e-6, "{version:?}, prompt {prompt:?}: insertion point {} instead of {start}", attdef_x(&back)); + } + } +} diff --git a/crates/io/tests/it/attrib_modes.rs b/crates/io/tests/it/attrib_modes.rs index 625ead939..3dfa8e321 100644 --- a/crates/io/tests/it/attrib_modes.rs +++ b/crates/io/tests/it/attrib_modes.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cadcraft_doc::*; use cadcraft_geom::Vec3; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with}; fn attrib(tag: &str, modes: AttribModes) -> Attrib { let text = Text { @@ -28,6 +28,10 @@ const LOCKED: AttribModes = AttribModes { verify: false, preset: false, lock_pos /// Block TAG with definitions A (every mode) and B (none), and a reference whose A is locked. fn drawing() -> Drawing { + drawing_c(false) +} + +fn drawing_c(constant: bool) -> Drawing { let mut d = Drawing::new_metric(); let mut b = Block::new("TAG"); for a in [attrib("A", ALL), attrib("B", AttribModes::default())] { @@ -40,7 +44,7 @@ fn drawing() -> Drawing { insert: Vec3::ZERO, scale: Vec3::new(1.0, 1.0, 1.0), rotation: 0.0, - attribs: vec![attrib("A", LOCKED), attrib("B", AttribModes::default())], + attribs: vec![Attrib { constant, ..attrib("A", LOCKED) }, attrib("B", AttribModes::default())], cols: 1, rows: 1, col_spacing: 0.0, @@ -113,3 +117,27 @@ fn a_280_before_the_tag_is_the_version_not_lock_position() { .collect(); assert!(!modes(&read_dxf(text.as_bytes()).expect("read")).0[0].lock_position); } + +/// The Constant mode (flag 2) of an ATTRIB attached to an INSERT, not only of an ATTDEF (#2246). +#[test] +fn constant_attached_attrib_survives_dxf_and_dwg() { + let d = drawing_c(true); + let constants = |d: &Drawing| -> Vec { + d.model + .iter() + .find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.attribs.iter().map(|a| a.constant).collect()) } else { None }) + .unwrap_or_default() + }; + assert_eq!(constants(&d), [true, false]); + let dxf = cadcraft_io::write(&d, "x.dxf").expect("dxf"); + assert_eq!(constants(&cadcraft_io::read(&dxf, "x.dxf").expect("read dxf")), [true, false], "dxf"); + let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg"); + assert_eq!(constants(&cadcraft_io::read(&dwg, "x.dwg").expect("read dwg")), [true, false], "dwg"); + // Every save version, in both DXF encodings and in DWG. + for version in Version::ALL { + for (name, binary) in [("x.dxf", false), ("x.dxf", true), ("x.dwg", false)] { + let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary }).expect("write"); + assert_eq!(constants(&cadcraft_io::read(&bytes, name).expect("read")), [true, false], "{version:?} {name} binary={binary}"); + } + } +} diff --git a/crates/io/tests/it/block_expansion.rs b/crates/io/tests/it/block_expansion.rs index b190156f8..64fca189e 100644 --- a/crates/io/tests/it/block_expansion.rs +++ b/crates/io/tests/it/block_expansion.rs @@ -1,7 +1,10 @@ //! Nested, self-referencing and arrayed block references in a hostile DXF must not hang drawing //! or extents (issue #255): the expansion is bounded, not only its depth. -use cadcraft_doc::{Drawing, EntityKind, Space}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; use cadcraft_render::{MAX_BLOCK_EXPANSION, Options, build}; fn read(text: &str) -> Drawing { @@ -63,3 +66,63 @@ fn ordinary_nested_and_arrayed_blocks_are_drawn_whole() { assert!(matches!(d.model.iter().next().map(|e| &e.kind), Some(EntityKind::Insert(_)))); assert_eq!(build(&d, &Space::Model, &Options::default()).prims.len(), cadcraft_doc::MAX_BLOCK_DEPTH); } + +#[test] +fn a_build_that_spends_the_expansion_budget_says_so() { + // 10,000 x 10,000 copies of a one-line block: far past the budget. The list stops at the + // budget and says so, so a screen, plot or export missing contents is not silent (#1070). + let d = read(&minsert(10_000, 10_000)); + let list = build(&d, &Space::Model, &Options::default()); + assert!(list.truncated, "block contents were left out without a word"); + assert!(list.prims.len() <= MAX_BLOCK_EXPANSION, "{}", list.prims.len()); + // A drawing within the budget is whole and says nothing. + let d = read(&minsert(3, 2)); + let list = build(&d, &Space::Model, &Options::default()); + assert!(!list.truncated); + assert_eq!(list.prims.len(), 6); +} + +#[test] +fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() { + let mut d = Drawing::new_imperial(); + d.ensure_layer("FROZEN"); + let mut block = Block::new("A"); + block.entities.push(Entity { + handle: Handle(0x200), + common: Common { layer: "FROZEN".into(), ..Common::default() }, + kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) }), + }); + d.blocks.insert(block.name.clone(), Arc::new(block)); + let insert = Insert { + block: "A".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1_000, + rows: 1_000, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); + let viewport = |center: Vec3, view_center: Vec2, frozen_layers| Viewport { + center, + width: 100.0, + height: 100.0, + view_center, + view_height: 100.0, + id: 2, + locked: false, + frozen_layers, + layer_colors: Vec::new(), + clip: None, + on: true, + }; + let paper = Space::Paper("Layout1".into()); + d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(50.0, 50.0, 0.0), Vec2::ZERO, vec!["FROZEN".into()]))).unwrap(); + d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(200.0, 50.0, 0.0), Vec2::new(10_000.0, 10_000.0), Vec::new()))).unwrap(); + + let list = build(&d, &paper, &Options::default()); + assert_eq!(list.expanded, MAX_BLOCK_EXPANSION); + assert!(!list.truncated, "the first viewport consumed the budget exactly and the second saw no model content"); +} diff --git a/crates/io/tests/it/codepages.rs b/crates/io/tests/it/codepages.rs index 40d85b3f4..56f9af80f 100644 --- a/crates/io/tests/it/codepages.rs +++ b/crates/io/tests/it/codepages.rs @@ -116,6 +116,55 @@ fn code_page_text_survives_a_round_trip() { assert_eq!(names(&back), ("Слой".into(), "Блок".into(), "Привет".into())); } +/// Saving a pre-2007 DXF writes the text in the file's code page, as the DXF Reference +/// describes: a Portuguese drawing keeps `Elétrica`, `Ação` and `Função` as ANSI_1252 bytes +/// instead of `\U+00E9` escapes, a Cyrillic one its ANSI_1251 bytes, and characters the code +/// page lacks are still escaped. ASCII and binary, R2000 and R2004, read back the same. +#[test] +fn saving_writes_text_in_the_code_page() { + use cadcraft_io::{SaveOptions, Version}; + let pt = ascii_dxf("AC1015", "ANSI_1252", b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"); + let ru = ascii_dxf("AC1015", "ANSI_1251", b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"); + let cases: [(&[u8], (&str, &str, &str), &[&[u8]]); 2] = [ + (&pt, ("Elétrica", "Ação", "Função 图"), &[b"El\xE9trica", b"A\xE7\xE3o", b"Fun\xE7\xE3o \\U+56FE"]), + (&ru, ("Слой", "Блок", "Привет é"), &[b"\xD1\xEB\xEE\xE9", b"\xC1\xEB\xEE\xEA", b"\xCF\xF0\xE8\xE2\xE5\xF2 \\U+00E9"]), + ]; + for (file, want, raw) in cases { + let d = cadcraft_io::read_dxf(file).unwrap(); + assert_eq!(names(&d), (want.0.into(), want.1.into(), want.2.into())); + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let out = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + for r in raw { + assert!(out.windows(r.len()).any(|w| w == *r), "{version} binary={binary}: {}", String::from_utf8_lossy(r)); + } + assert!(!out.windows(7).any(|w| w == b"\\U+00E7"), "{version} binary={binary}: ç is written as its byte"); + let back = cadcraft_io::read_dxf(&out).unwrap(); + assert_eq!(names(&back), (want.0.into(), want.1.into(), want.2.into()), "{version} binary={binary}"); + } + } + } +} + +/// A file whose header declares `ASCII` keeps that code page and stays ASCII when saved: its +/// non-ASCII text is written as `\U+` escapes, not as Windows-1252 bytes. +#[test] +fn saving_an_ascii_file_keeps_it_ascii() { + use cadcraft_io::{SaveOptions, Version}; + let d = cadcraft_io::read_dxf(&ascii_dxf("AC1015", "ASCII", b"El\\U+00E9trica", b"B", b"Eletrica \\U+00E9")).unwrap(); + assert_eq!(names(&d), ("Elétrica".into(), "B".into(), "Eletrica é".into())); + for version in [Version::R2000, Version::R2004] { + let ascii = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); + assert!(ascii.is_ascii(), "{version}"); + let binary = cadcraft_io::write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).unwrap(); + for out in [&ascii, &binary] { + assert!(out.windows(14).any(|w| w == b"El\\U+00E9trica"), "{version}"); + assert!(out.windows(5).any(|w| w == b"ASCII"), "{version}: the header keeps its code page"); + assert_eq!(names(&cadcraft_io::read_dxf(out).unwrap()), ("Elétrica".into(), "B".into(), "Eletrica é".into()), "{version}"); + } + } +} + /// A binary DXF: R2000 strings are in the code page, R2007+ strings UTF-8. #[test] fn binary_dxf_honours_the_code_page() { diff --git a/crates/io/tests/it/dim_block_resave_roundtrip.rs b/crates/io/tests/it/dim_block_resave_roundtrip.rs index f0705d6fd..195706157 100644 --- a/crates/io/tests/it/dim_block_resave_roundtrip.rs +++ b/crates/io/tests/it/dim_block_resave_roundtrip.rs @@ -27,6 +27,7 @@ fn linear(a: [f64; 2], b: [f64; 2], at: [f64; 2]) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } @@ -158,6 +159,47 @@ fn reopened_dimension_reuses_its_block_name_and_handles() { assert_eq!(again.handseed, back.handseed); } +#[test] +fn a_moved_dimension_keeps_its_group_12_offset() { + let mut d = Drawing::new_metric(); + // What an older AutoCAD left: the block contents where the dimension was measured, the + // move kept in group 12 (insertion point), as #2634's drawing has it. + let mut b = Block::new("*D3"); + let h = d.new_handle(); + b.entities.push(Entity::new(h, EntityKind::Line(Line { a: Vec3::new(150.0, 80.0, 0.0), b: Vec3::new(160.0, 80.0, 0.0) }))); + d.blocks.insert("*D3".into(), Arc::new(b)); + let mut dm = linear([0.0, 0.0], [10.0, 0.0], [5.0, 3.0]); + dm.block = Some("*D3".into()); + dm.insertion_point = Vec3::new(-150.0, -80.0, 0.0); + let h = d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + + // The block is written as read and the offset round-trips as group 12. + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let Some(EntityKind::Dimension(dm)) = back.entity(h).map(|e| &e.kind) else { panic!("dimension lost") }; + assert_eq!(dm.insertion_point, Vec3::new(-150.0, -80.0, 0.0)); + assert_eq!(dm.block.as_deref(), Some("*D3")); + let p = pairs(&cadcraft_io::write_dxf(&d)); + let at = p.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + let own = &p[at..p[at + 1..].iter().position(|(c, _)| *c == 0).map(|k| at + 1 + k).unwrap()]; + assert!(own.iter().any(|(c, v)| *c == 12 && v.parse::() == Ok(-150.0)), "no group 12 x"); + assert!(own.iter().any(|(c, v)| *c == 22 && v.parse::() == Ok(-80.0)), "no group 12 y"); + + // Edited (edits drop the block link): geometry regenerated from the definition points, + // so the save writes no offset. + let mut edited = back.clone(); + edited + .modify_entity(h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.block = None; + } + }) + .unwrap(); + let q = pairs(&cadcraft_io::write_dxf(&edited)); + let at = q.iter().position(|(c, v)| *c == 0 && v == "DIMENSION").unwrap(); + let own = &q[at..q[at + 1..].iter().position(|(c, _)| *c == 0).map(|k| at + 1 + k).unwrap()]; + assert!(!own.iter().any(|(c, _)| *c == 12), "regenerated geometry keeps a stale offset"); +} + #[test] fn hostile_handle_seed_does_not_panic() { let mut d = Drawing::new_metric(); diff --git a/crates/io/tests/it/dim_block_text.rs b/crates/io/tests/it/dim_block_text.rs index 0c5fa0cab..244e9401c 100644 --- a/crates/io/tests/it/dim_block_text.rs +++ b/crates/io/tests/it/dim_block_text.rs @@ -34,6 +34,7 @@ fn height_written(style_scale: f64, overrides: serde_json::Value) -> f64 { overrides: overrides.as_object().cloned().unwrap_or_default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); block_mtext_height(&cadcraft_io::write_dxf(&d)) @@ -73,6 +74,7 @@ fn angular_drawing(separator_override: Option<&str>) -> Drawing { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; if let Some(separator) = separator_override { dm.overrides.insert("DIMDSEP".into(), serde_json::json!(separator)); diff --git a/crates/io/tests/it/dim_marks_roundtrip.rs b/crates/io/tests/it/dim_marks_roundtrip.rs index 40a85d89a..c442376de 100644 --- a/crates/io/tests/it/dim_marks_roundtrip.rs +++ b/crates/io/tests/it/dim_marks_roundtrip.rs @@ -24,6 +24,7 @@ fn dim(kind: DimKind, marks: DimMarks) -> EntityKind { overrides: Default::default(), assoc: Vec::new(), marks, + insertion_point: Vec3::ZERO, }) } diff --git a/crates/io/tests/it/dimstyle_vars_roundtrip.rs b/crates/io/tests/it/dimstyle_vars_roundtrip.rs index 061054bbc..b2beac3e2 100644 --- a/crates/io/tests/it/dimstyle_vars_roundtrip.rs +++ b/crates/io/tests/it/dimstyle_vars_roundtrip.rs @@ -60,6 +60,7 @@ fn dimension(overrides: serde_json::Value) -> EntityKind { overrides: overrides.as_object().cloned().unwrap_or_default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }) } diff --git a/crates/io/tests/it/dimtfill_roundtrip.rs b/crates/io/tests/it/dimtfill_roundtrip.rs index 5e1b4f1bf..48f7d9e32 100644 --- a/crates/io/tests/it/dimtfill_roundtrip.rs +++ b/crates/io/tests/it/dimtfill_roundtrip.rs @@ -174,6 +174,7 @@ fn linear() -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/io/tests/it/dwg_header_vars.rs b/crates/io/tests/it/dwg_header_vars.rs index 1bebee75e..644fe375c 100644 --- a/crates/io/tests/it/dwg_header_vars.rs +++ b/crates/io/tests/it/dwg_header_vars.rs @@ -60,6 +60,21 @@ fn header_settings_round_trip_through_dwg() { check("DWG", &r); } +/// `$PSTYLEMODE` (0 = named plot styles, 1 = colour-dependent) is written as read, as DXF and +/// as DWG: left out, readers default to colour-dependent and the drawing plots with the wrong +/// table (#2332). +#[test] +fn plot_style_mode_round_trips() { + let dxf = "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$PSTYLEMODE\n290\n0\n0\nENDSEC\n0\nEOF\n"; + let d = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap(); + assert_eq!(d.header.i64("PSTYLEMODE", -1), 0, "read"); + let as_dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(as_dxf.header.i64("PSTYLEMODE", -1), 0, "DXF"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let as_dwg = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(as_dwg.header.i64("PSTYLEMODE", -1), 0, "DWG"); +} + /// The current layer (`$CLAYER`) survives a DWG save and reopen, ASCII or not. A DWG stores a /// handle to the layer, and the writer used the null handle, so the drawing came back on "0" (#2252). #[test] diff --git a/crates/io/tests/it/dwg_roundtrip_losses.rs b/crates/io/tests/it/dwg_roundtrip_losses.rs index ec9c2b93d..aa00458a6 100644 --- a/crates/io/tests/it/dwg_roundtrip_losses.rs +++ b/crates/io/tests/it/dwg_roundtrip_losses.rs @@ -159,3 +159,26 @@ fn a_paper_viewport_behind_a_model_viewport_stays_id_1_through_dwg() { assert_eq!((id(model), id(sheet)), (Some(2), Some(1)), "layout {name}"); } } + +/// The paper-space viewport a save adds is the sheet as large as it plots: a layout with a custom +/// plot scale (here 49.6 mm of paper per unit, as in #2108's file) has an A3 sheet of 8.47 × 5.99 +/// units in paper space, not 420 × 297. A layout at 1:1 or scaled to fit keeps the page size. +#[test] +fn the_added_paper_viewport_follows_the_plot_scale() { + let sheet_width = |scale: f64, fit: bool| { + let mut d = Drawing::new_metric(); + let page = &mut d.layouts[0].page; + (page.width_mm, page.height_mm, page.landscape, page.paper_mm) = (297.0, 420.0, true, true); + (page.scale, page.scale_to_fit) = (scale, fit); + let first = Space::Paper(d.layouts[0].name.clone()); + d.add(&first, Common::default(), viewport(2)).unwrap(); + let text = cadcraft_io::write_dxf(&d); + let vps = records(&text, "VIEWPORT"); + assert_eq!(group(&vps[0], "1000"), Some("PAPERVIEW"), "{text}"); + group(&vps[0], "40").unwrap().parse::().unwrap() + }; + assert!((sheet_width(49.6, false) - 420.0 / 49.6).abs() < 1e-9, "{}", sheet_width(49.6, false)); + assert!((sheet_width(1.0, false) - 420.0).abs() < 1e-9); + assert!((sheet_width(49.6, true) - 420.0).abs() < 1e-9, "scaled to fit: the scale means nothing"); + assert!((sheet_width(f64::NAN, false) - 420.0).abs() < 1e-9, "an unusable scale is 1"); +} diff --git a/crates/io/tests/it/dxf_versions.rs b/crates/io/tests/it/dxf_versions.rs index 3fc2f1c73..390c94703 100644 --- a/crates/io/tests/it/dxf_versions.rs +++ b/crates/io/tests/it/dxf_versions.rs @@ -87,9 +87,11 @@ fn dxf_at_every_version_reads_back_the_same() { assert_eq!(acadver(&bytes), v.code(), "{what}"); assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}"); let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes()); - assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, \\U+ escapes before"); - if !v.utf8() && !binary { - assert!(bytes.is_ascii(), "{what}"); + assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page before"); + if !v.utf8() { + // ANSI_1252 has the Latin characters; the others are escaped. + let has = |b: &[u8]| bytes.windows(b.len()).any(|w| w == b); + assert!(has(b"\\U+56FE\\U+53F7 Caf\xe9 \xd8 \\U+D83D\\U+DE00"), "{what}"); } let back = read(&bytes, "x.dxf").unwrap(); assert_eq!(first_text(&back).as_deref(), Some(INTL), "{what}"); @@ -99,11 +101,40 @@ fn dxf_at_every_version_reads_back_the_same() { } } -/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote. +/// CLASS records (DXF Reference, CLASSES): group 91, the instance count, came with the 2004 +/// format and follows the proxy flags. AutoCAD LT 2026 stops responding on an R2000 file whose +/// CLASS records carry a 91, and AutoCAD rejects a 2004+ file without it (#635). +#[test] +fn class_records_follow_the_version() { + let d = sample(); + for v in Version::ALL { + for binary in [false, true] { + let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap(); + let classes: Vec> = cadcraft_dxf::sections(&tags) + .into_iter() + .filter(|s| s.name == "CLASSES") + .flat_map(|s| cadcraft_dxf::records(&s.tags)) + .filter(|(kind, _)| kind == "CLASS") + .map(|(_, t)| t.iter().map(|x| x.code).collect()) + .collect(); + assert!(!classes.is_empty(), "{v} binary={binary}"); + let want: &[i32] = if v >= Version::R2004 { &[1, 2, 3, 90, 91, 280, 281] } else { &[1, 2, 3, 90, 280, 281] }; + for codes in classes { + assert_eq!(codes, want, "{v} binary={binary}"); + } + } + } +} + +/// R2000 ASCII is the R2000 DXF CADCraft always wrote, tag for tag; only the text is in the +/// code page instead of all `\U+` escapes. #[test] fn r2000_ascii_is_unchanged() { let d = sample(); - assert_eq!(write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&d).into_bytes()); + let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(); + let escaped = write_dxf(&d); + assert_ne!(saved, escaped.as_bytes(), "Café is written in ANSI_1252"); + assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap()); } /// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index d265f9e70..189a2305b 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -86,6 +86,48 @@ fn a_saved_drawing_keeps_the_dynamic_block_as_read() { assert_eq!(by_node(dynamic_block(&back, "Door").unwrap()), by_node(before)); } +/// The Door fixture with a scale action on the Width parameter: graph node 6, object 68. +fn with_scale_action() -> String { + let node = "91 6\n93 32\n95 7\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n"; + let action = "0 BLOCKSCALEACTION\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n\ + 100 AcDbBlockElement\n300 Scale\n98 33\n99 232\n1071 0\n\ + 100 AcDbBlockAction\n70 0\n71 1\n330 30\n1010 0\n1020 0\n1030 0\n\ + 100 AcDbBlockActionWithBasePt\n92 2\n93 2\n301 UpdatedBaseX\n302 UpdatedBaseY\n\ + 1011 0\n1021 0\n1031 0\n280 1\n1012 0\n1022 0\n1032 0\n\ + 100 AcDbBlockScaleAction\n94 2\n95 2\n96 2\n303 Scale\n304 XScale\n305 YScale\n"; + fixture() + .replacen("96 6\n97 6\n", "96 7\n97 7\n", 1) + .replacen("0 BLOCKVISIBILITYPARAMETER\n", &format!("{node}0 BLOCKVISIBILITYPARAMETER\n"), 1) + .replacen("0 BLOCKLOOKUPPARAMETER\n", &format!("{action}0 BLOCKLOOKUPPARAMETER\n"), 1) +} + +/// A scale action goes through CADCraft's own DWG save (the bridge DXF, then acadrust) whole: +/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused +/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's +/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte). +#[test] +fn a_scale_action_survives_a_dwg_save() { + use acadrust::objects::{DynamicBlockData, DynamicBlockObject, ObjectType}; + let d = read_dxf(dxf(&with_scale_action()).as_bytes()).unwrap(); + let before = dynamic_block(&d, "Door").unwrap(); + assert!(before.actions.iter().any(|a| a.node == 7 && matches!(a.kind, ActionKind::Scale)), "{:?}", before.actions); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + + let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap(); + let scale = doc.objects.values().find_map(|o| match o { + ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()), + _ => None, + }); + let scale = scale.expect("the DWG has no scale action"); + let names = |c: &[acadrust::objects::BlockConnection]| c.iter().map(|c| (c.code, c.name.clone())).collect::>(); + assert_eq!(names(&scale.connections), [(2, "Scale".to_string()), (2, "XScale".into()), (2, "YScale".into())]); + assert_eq!(names(&scale.action.connections), [(2, "UpdatedBaseX".to_string()), (2, "UpdatedBaseY".into())]); + assert_eq!(scale.dwg_tail_byte, 0); + + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(by_node(dynamic_block(&back, "Door").unwrap()), by_node(before)); +} + /// The model with each parameter and action object named by its graph id instead of its handle. fn by_node(mut m: DynamicBlock) -> DynamicBlock { let ids: std::collections::HashMap = m @@ -406,6 +448,194 @@ fn parameters_in_a_stretch_selection_are_carried_along() { assert_eq!(tags[grip + 1..grip + 3], [(300, "GRIPLOC".to_string()), (11, "-15".to_string())]); } +/// A reference to Door at the origin with Door's own values, in a drawing read from `src`. +fn door_reference(src: &str) -> (cadcraft_doc::Drawing, Handle) { + let mut d = read_dxf(dxf(src).as_bytes()).unwrap(); + let ins = cadcraft_doc::Insert { + block: "Door".into(), + insert: cadcraft_doc::geom::Vec3::new(0.0, 0.0, 0.0), + scale: cadcraft_doc::geom::Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); + (d, r) +} + +/// The points of the lines and polylines of reference `r`'s block, in its order. +fn shapes(d: &cadcraft_doc::Drawing, r: Handle) -> Vec> { + let b = d.block(&block_of(d, &r.hex())).unwrap(); + b.entities + .iter() + .filter_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Line(l) => Some(vec![(l.a.x, l.a.y), (l.b.x, l.b.y)]), + cadcraft_doc::EntityKind::LwPolyline(p) => Some(p.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()), + _ => None, + }) + .collect() +} + +#[test] +fn a_stretch_moves_the_points_inside_its_frame_not_the_listed_ones() { + // The stretch (frame (34,-2)-(38,2)) also acts on line 32, which is all outside the frame, and + // on a polyline whose last two vertices are inside it; and the vertex lists it stores are + // wrong for all three. AutoCAD 2024 moves the points inside the frame, measured on a dynamic + // block from another program whose lists didn't match its objects: every point of its lines and + // polylines moved exactly when it was inside the frame, whatever the lists said. A selected + // line or polyline all outside the frame moves whole, as in AutoCAD's own stretched copies. + let polyline = "0 LWPOLYLINE\n5 33\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 3\n70 0\n10 20\n20 1\n10 36\n20 1\n10 36\n20 -1\n"; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 32\n330 33\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 0\n331 32\n74 1\n94 1\n331 33\n74 1\n94 0\n75 0") + .replace("21 25\n31 0\n0 ENDBLK\n", &format!("21 25\n31 0\n{polyline}0 ENDBLK\n")); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { vertices, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(vertices, &[(h("30"), vec![0]), (h("32"), vec![1]), (h("33"), vec![0])]); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let want: Vec> = vec![ + vec![(0.0, 0.0), (30.0, 0.0)], + vec![(0.0, 0.0), (0.0, 36.0)], + vec![(-6.0, 0.0), (19.0, 25.0)], + vec![(20.0, 1.0), (30.0, 1.0), (30.0, -1.0)], + ]; + assert_eq!(shapes(&d, r), want); +} + +#[test] +fn a_zero_distance_multiplier_moves_as_one_without_its_angle_offset() { + // A stretch written with multiplier 0 and angle offset 1 (radian), and group 280 = 1. AutoCAD + // 2024 moved such an action by the parameter's change, straight along it. + let src = fixture().replace("140 1\n141 0\n280 0", "140 0\n141 1\n280 1"); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { multiplier, angle_offset, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { + panic!("a stretch action") + }; + assert_eq!((*multiplier, *angle_offset), (0.0, 1.0)); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_on_the_edge_of_a_polygon_frame_is_inside_it() { + // Line 30 ends at (36, 0), and its start is outside every frame here. On the edge of the frame + // (34,-2)-(36,2) given as two corners, the end is inside and moves alone. The same frame as a + // polygon, triangles whose slanted edge passes through (36, 0), exactly or up to rounding, and a + // square 5e-10 across with (36, 0) halfway along its right edge, take it the same way. + let frames = [ + "72 2\n1011 34\n1021 -2\n1011 36\n1021 2\n", + "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 34\n1021 -2\n1011 38\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 35.7\n1021 -0.3\n1011 36.7\n1021 0.7\n1011 30\n1021 10\n", + "72 4\n1011 35.9999999995\n1021 -2.5e-10\n1011 36\n1021 -2.5e-10\n1011 36\n1021 2.5e-10\n1011 35.9999999995\n1021 2.5e-10\n", + ]; + for frame in frames { + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)], "{frame}"); + } +} + +#[test] +fn a_point_just_outside_a_tiny_frame_is_outside_it() { + // A frame 1e-8 across whose right edge is 5e-10 short of line 30's end (36, 0): the end is + // outside, so the line, with no point inside, moves whole. An edge's tolerance is a billionth of + // its length, not of a drawing unit. + let frame = "72 4\n1011 35.99999999\n1021 -1e-8\n1011 35.9999999995\n1021 -1e-8\n1011 35.9999999995\n1021 1e-8\n1011 35.99999999\n1021 1e-8\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_less_than_a_rounding_off_a_short_edge_is_outside_it() { + // A triangle frame whose right edge runs from (35.99999999999999, -2e-10) to + // (36.00000000000001, 3e-10): line 30's end (36, 0) is 1.4e-15 off it, past a billionth of the + // edge's length (5e-19), and outside the frame, so the line moves whole. Measured from the + // edge's start, the distance keeps digits that rounding near x = 36 would drop. + let frame = "72 3\n1011 35.99999999999999\n1021 -2e-10\n1011 36.00000000000001\n1021 3e-10\n1011 35.999999999\n1021 0\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_past_the_end_of_a_frame_edge_is_outside_it() { + // The frame's right edge runs along x = 36 from y = -2 to -1: line 30's end (36, 0) is on that + // edge's line but past its end, outside the frame, so the line moves whole. + let frame = "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 -1\n1011 34\n1021 -1\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_tilted_polyline_keeps_the_stored_vertex_list() { + // Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): its vertices are in + // that plane's coordinates, not the block's, so the frame isn't tested against them and the + // stored list (vertex 0) is used, as before; so is polyline 36's, tilted by only 5e-10 (at an + // elevation of 2e9 that moves it about a unit). Polyline 35 is mirrored (extrusion (0,0,-1)): it + // is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested. + let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [f64; 3], elevation: f64| { + format!( + "0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n38 {elevation}\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n" + ) + }; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 4\n330 30\n330 34\n330 35\n330 36\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 4\n331 30\n74 1\n94 1\n331 34\n74 1\n94 0\n331 35\n74 1\n94 0\n331 36\n74 1\n94 0\n75 0") + .replace( + "21 25\n31 0\n0 ENDBLK\n", + &format!( + "21 25\n31 0\n{}{}{}0 ENDBLK\n", + polyline("34", [20, 36], [1.0, 0.0, 0.0], 0.0), + polyline("35", [-20, -36], [0.0, 0.0, -1.0], 0.0), + polyline("36", [20, 36], [5e-10, 0.0, 1.0], 2e9) + ), + ); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let s = shapes(&d, r); + assert_eq!(s[3], [(14.0, 1.0), (36.0, 1.0)]); + assert_eq!(s[4], [(20.0, 1.0), (30.0, 1.0)]); + assert_eq!(s[5], [(14.0, 1.0), (36.0, 1.0)]); +} + +#[test] +fn a_frame_with_too_many_points_to_test_one_by_one_keeps_the_stored_lists() { + // The same frame as a polygon of `n` points (its four corners, the last repeated). Up to 1000 + // points each point of the objects is tested against it; past that the stored list (here the + // line's start, outside the frame) is used, so a huge frame doesn't make every point test every + // edge. + let frame = |n: usize| { + let mut f = format!("72 {n}\n1011 34\n1021 -2\n1011 38\n1021 -2\n1011 38\n1021 2\n"); + f.push_str(&"1011 34\n1021 2\n".repeat(n - 3)); + f + }; + for (n, want) in [(1000, [(0.0, 0.0), (30.0, 0.0)]), (1001, [(-6.0, 0.0), (36.0, 0.0)])] { + let src = fixture() + .replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", &frame(n)) + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 1\n331 30\n74 1\n94 0\n75 0"); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { frame, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(frame.len(), n); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], want, "{n} frame points"); + } +} + /// The object entry `key` of dictionary `dict` refers to. fn dict_entry(d: &cadcraft_doc::Drawing, dict: Handle, key: &str) -> Option { let o = d.foreign.objects.iter().find(|o| o.handle == dict)?; diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index d86d0b4b8..a5467d7cc 100644 --- a/crates/io/tests/it/entity_props_roundtrip.rs +++ b/crates/io/tests/it/entity_props_roundtrip.rs @@ -24,6 +24,49 @@ fn square(size: f64) -> Vec { [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() } +#[test] +fn line_extrusion_roundtrips_with_world_endpoints() { + // A LINE's endpoints are world coordinates whatever its extrusion, which only directs its + // thickness; a mirrored normal (0, 0, -1) must come back from DXF and DWG (#2241). + let mut d = Drawing::new_metric(); + let common = Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }; + let line = Line { a: Vec3::new(1.0, 2.0, 0.0), b: Vec3::new(11.0, 7.0, 0.0) }; + let h = d.add(&Space::Model, common, EntityKind::Line(line.clone())).expect("add"); + let dxf = write_dxf(&d); + assert!(dxf.contains("230\r\n-1.0\r\n"), "the extrusion is written"); + let back = roundtrip(&d); + let e = get(&back, h); + assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0)); + assert_eq!(e.kind, EntityKind::Line(line.clone())); + let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg"); + let back = cadcraft_io::read(&dwg, "x.dwg").expect("reopen dwg"); + let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))) else { panic!("no line") }; + assert_eq!(e.common.extrusion, Vec3::new(0.0, 0.0, -1.0), "through DWG"); + assert_eq!(e.kind, EntityKind::Line(line)); +} + +#[test] +fn line_extrusion_is_written_as_a_unit_vector_or_not_at_all() { + // A normal that is not a direction (zero, NaN) is dropped and one that is not unit length is + // normalized, so a bad 210/220/230 read from a file is not passed on as it is (#2241). + let line = Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }; + let dxf = |extrusion| { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common { extrusion, ..Common::default() }, EntityKind::Line(line.clone())).expect("add"); + write_dxf(&d) + }; + let default = dxf(Vec3::Z); + for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(f64::INFINITY, 0.0, 0.0), Vec3::new(0.0, 0.0, 5.0)] { + assert_eq!(dxf(bad), default, "{bad:?} writes nothing, like the Z axis"); + } + assert!(dxf(Vec3::new(0.0, 0.0, -5.0)).contains("230\r\n-1.0\r\n")); + // A tiny normal is still a direction: it must not read back as +Z (it would flip a thickness). + assert!(dxf(Vec3::new(0.0, 0.0, -1e-10)).contains("230\r\n-1.0\r\n"), "a tiny normal keeps its direction"); + // A huge finite normal's length overflows to infinity: it is a direction all the same. + let huge = dxf(Vec3::new(0.0, -1.5e308, 1.5e308)); + assert!(huge.contains("220\r\n-0.7071067811865475\r\n230\r\n0.7071067811865475\r\n"), "a huge normal keeps its direction:\n{huge}"); +} + #[test] fn thickness_roundtrips_on_every_entity_that_has_one() { let mut d = Drawing::new_metric(); @@ -214,8 +257,16 @@ fn faces(d: &Drawing) -> Vec { .collect() } +/// The faces of the one polyface mesh in `d`, as 3D faces. +fn polyface_faces(d: &Drawing) -> Vec { + match d.model.iter().map(|e| &e.kind).collect::>().as_slice() { + [EntityKind::PolyfaceMesh(m)] => m.faces3d(), + ks => panic!("expected one polyface mesh, got {ks:?}"), + } +} + #[test] -fn polyface_and_polygon_mesh_polylines_become_faces_not_bogus_vertices() { +fn polyface_and_polygon_mesh_polylines_read_as_meshes_and_faces_not_bogus_vertices() { let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); // Polyface: four vertices, a quad, a triangle whose third edge is hidden, and faces with an // out-of-range index or only two vertices (skipped). @@ -231,14 +282,14 @@ fn polyface_and_polygon_mesh_polylines_become_faces_not_bogus_vertices() { recs.push(vec![(0, "SEQEND"), (8, "0")]); let refs: Vec<&[(i32, &str)]> = recs.iter().map(Vec::as_slice).collect(); let d = read_dxf(dxf(&refs).as_bytes()).expect("read polyface"); - let fs = faces(&d); + let fs = polyface_faces(&d); assert_eq!(fs.len(), 2, "{fs:?}"); assert_eq!(fs[0].corners, [p(0.0, 0.0, 2.0), p(1.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); assert_eq!(fs[0].hidden_edges, 0); assert_eq!(fs[1].corners, [p(0.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); assert_eq!(fs[1].hidden_edges, 4 | 8, "the triangle's closing edge is hidden"); - // The faces survive a save and reopen as 3DFACEs. - assert_eq!(faces(&roundtrip(&d)), fs); + // The mesh survives a save and reopen. + assert_eq!(polyface_faces(&roundtrip(&d)), fs); // 3×2 polygon mesh: two quads. let mut recs: Vec> = diff --git a/crates/io/tests/it/followups_626.rs b/crates/io/tests/it/followups_626.rs index a44a27eb4..10e355cd3 100644 --- a/crates/io/tests/it/followups_626.rs +++ b/crates/io/tests/it/followups_626.rs @@ -177,10 +177,9 @@ fn code_page_is_kept_before_2007() { .unwrap(); for v in [Version::R2000, Version::R2004] { let bytes = save(&d, "x.dxf", v); - assert!(bytes.is_ascii(), "{v:?}: ASCII with escapes"); - let text = String::from_utf8(bytes).unwrap(); - assert_eq!(code_page(&text), "ANSI_1251", "{v:?}"); - let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert!(bytes.windows(6).any(|w| w == b"\xCF\xF0\xE8\xE2\xE5\xF2"), "{v:?}: the text in ANSI_1251"); + assert_eq!(code_page(&String::from_utf8_lossy(&bytes)), "ANSI_1251", "{v:?}"); + let back = cadcraft_io::read_dxf(&bytes).unwrap(); assert_eq!(back.header.str("DWGCODEPAGE", ""), "ANSI_1251"); assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.value == "Привет"))); } diff --git a/crates/io/tests/it/groups_roundtrip.rs b/crates/io/tests/it/groups_roundtrip.rs index 394b3d482..8614dc757 100644 --- a/crates/io/tests/it/groups_roundtrip.rs +++ b/crates/io/tests/it/groups_roundtrip.rs @@ -29,3 +29,65 @@ fn groups_survive_dwg_round_trip() { .collect(); assert_eq!(got, vec![("Doors", "front", false, vec![5.0, 0.0]), ("*A1", "", true, vec![0.0])]); } + +/// A DXF with one LINE (handle 2A) and the given groups in ACAD_GROUP: `n` empty anonymous +/// groups `*A1`..`*An`, then the `named` ones `(name, handle, description)`, each holding the line. +fn groups_dxf(n: usize, named: &[(&str, &str, &str)]) -> String { + let mut dict = String::from("0\nDICTIONARY\n5\nC\n3\nACAD_GROUP\n350\nD\n0\nDICTIONARY\n5\nD\n330\nC\n"); + let mut groups = String::new(); + for i in 1..=n { + let h = format!("{:X}", 0x1000 + i); + dict.push_str(&format!("3\n*A{i}\n350\n{h}\n")); + groups.push_str(&format!("0\nGROUP\n5\n{h}\n330\nD\n300\n\n70\n1\n71\n1\n")); + } + for (name, h, text) in named { + dict.push_str(&format!("3\n{name}\n350\n{h}\n")); + groups.push_str(&format!("0\nGROUP\n5\n{h}\n330\nD\n300\n{text}\n70\n0\n71\n1\n340\n2A\n")); + } + format!( + "0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n2A\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n\ + 0\nSECTION\n2\nOBJECTS\n{dict}{groups}0\nENDSEC\n0\nEOF\n" + ) +} + +/// Drawings can carry hundreds of thousands of empty anonymous groups (#862). Every group +/// arrives, and a name repeated in another case still keeps one group, the later definition. +#[test] +fn many_anonymous_groups_read_and_names_merge_case_insensitively() { + const N: usize = 20_000; + let dxf = groups_dxf(N, &[("Doors", "100", "first"), ("DOORS", "101", "second")]); + let d = cadcraft_io::read_dxf(dxf.as_bytes()).expect("read"); + assert_eq!(d.groups.len(), N + 1); + assert!(d.groups.iter().take(N).enumerate().all(|(i, g)| g.name == format!("*A{}", i + 1) && g.members.is_empty())); + let doors: Vec<(&str, &str, usize)> = d + .groups + .iter() + .filter(|g| g.name.eq_ignore_ascii_case("doors")) + .map(|g| (g.name.as_str(), g.description.as_str(), g.members.len())) + .collect(); + assert_eq!(doors, [("DOORS", "second", 1)]); +} + +/// Reading groups scales linearly (#862): 16 times the groups take about 16 to 20 times as long, +/// where the former quadratic lookups took about 256 times as long. The bound compares the two +/// sizes with each other, not with a wall-clock limit, and leaves room for timing noise; each +/// size is timed as the best of three reads. +#[test] +fn reading_groups_scales_linearly() { + let best_of_three = |n: usize| { + let dxf = groups_dxf(n, &[]); + (0..3) + .map(|_| { + let t = std::time::Instant::now(); + let d = cadcraft_io::read_dxf(dxf.as_bytes()).expect("read"); + assert_eq!(d.groups.len(), n); + t.elapsed() + }) + .min() + .unwrap_or_default() + }; + let small = best_of_three(2_500); + let large = best_of_three(40_000); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 64.0, "16x the groups took {ratio:.1}x as long ({small:?} -> {large:?})"); +} diff --git a/crates/io/tests/it/layout_limits_roundtrip.rs b/crates/io/tests/it/layout_limits_roundtrip.rs new file mode 100644 index 000000000..31baea67d --- /dev/null +++ b/crates/io/tests/it/layout_limits_roundtrip.rs @@ -0,0 +1,64 @@ +//! A layout keeps its limits (LAYOUT groups 10/11) and its page's paper image origin +//! (groups 148/149) through a DXF and a DWG save, as the file had them, so another program puts +//! the sheet back where it was (#2109). A layout CADCraft made writes the defaults as before. + +use cadcraft_doc::geom::Vec2; +use cadcraft_doc::{Drawing, Layout}; +use cadcraft_io::{SaveOptions, Version, read, read_dxf, write_dxf, write_with}; + +/// A drawing whose layout has the limits and image origin of the ZWCAD file in #2109. +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + let mut layout = Layout::new("Sheet", 1); + layout.limits = Some((Vec2::new(1.038, 0.694), Vec2::new(9.506, 6.681))); + layout.page.image_origin = Some(Vec2::new(-1.054, -0.71)); + d.layouts = vec![layout]; + d +} + +fn sheet(d: &Drawing) -> &Layout { + d.layouts.iter().find(|l| l.name == "Sheet").expect("the layout") +} + +fn near(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-9 +} + +fn assert_kept(d: &Drawing, how: &str) { + let l = sheet(d); + let (lo, hi) = l.limits.unwrap_or_else(|| panic!("{how}: the limits are kept")); + assert!(near(lo, Vec2::new(1.038, 0.694)) && near(hi, Vec2::new(9.506, 6.681)), "{how}: {lo:?} {hi:?}"); + let origin = l.page.image_origin.unwrap_or_else(|| panic!("{how}: the image origin is kept")); + assert!(near(origin, Vec2::new(-1.054, -0.71)), "{how}: {origin:?}"); +} + +#[test] +fn a_layout_keeps_its_limits_and_image_origin_through_dxf() { + let back = read_dxf(write_dxf(&drawing()).as_bytes()).expect("reopen"); + assert_kept(&back, "DXF"); + // And again: a second save writes what the first read. + let again = read_dxf(write_dxf(&back).as_bytes()).expect("reopen"); + assert_kept(&again, "DXF twice"); +} + +#[test] +fn a_layout_keeps_its_limits_and_image_origin_through_dwg() { + let dwg = write_with(&drawing(), "t.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("DWG"); + let back = read(&dwg, "t.dwg").expect("reopen"); + assert_kept(&back, "DWG"); +} + +#[test] +fn a_new_layout_writes_the_defaults() { + let text = write_dxf(&Drawing::new_imperial()); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + // The AcDbLayout limits after the layout's name, and the image origin before it. + let after = |code: &str| lines.windows(2).filter(|w| w[0] == code).map(|w| w[1]).collect::>(); + assert!(after("148").iter().all(|v| v.parse::() == Ok(0.0)), "{:?}", after("148")); + let back = read_dxf(text.as_bytes()).expect("reopen"); + let l = back.layouts.first().expect("a layout"); + // The defaults read back as none, so a drawing CADCraft made round-trips unchanged. + assert_eq!((l.limits, l.page.image_origin), (None, None)); + let limits = (after("10"), after("11")); + assert!(limits.0.iter().any(|v| v.parse::() == Ok(0.0)) && limits.1.iter().any(|v| v.parse::() == Ok(12.0)), "{limits:?}"); +} diff --git a/crates/io/tests/it/leader_vertex_elevation.rs b/crates/io/tests/it/leader_vertex_elevation.rs new file mode 100644 index 000000000..2566a861b --- /dev/null +++ b/crates/io/tests/it/leader_vertex_elevation.rs @@ -0,0 +1,106 @@ +//! LEADER vertex elevations survive the shared DXF read path and DWG bridge (#2245). + +use cadcraft_color::Color; +use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space}; +use cadcraft_dxf::Tag; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +type TestResult = Result>; + +fn drawing() -> TestResult { + let mut d = Drawing::new_metric(); + d.dim_styles.push(DimStyle { name: "Elevation".into(), ..DimStyle::default() }); + let common = Common { color: Color::Index(3), lineweight: Lineweight::Mm100(25), ltscale: 2.5, visible: false, ..Common::default() }; + for vertices in [ + vec![Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)], + vec![Vec3::new(1.0, 2.0, 0.0), Vec3::new(5.0, 6.0, 0.0), Vec3::new(9.0, 10.0, 0.0)], + ] { + for arrow in [false, true] { + for spline in [false, true] { + let leader = Leader { vertices: vertices.clone(), arrow, spline, style: "Elevation".into() }; + d.add(&Space::Model, common.clone(), EntityKind::Leader(leader))?; + } + } + } + Ok(d) +} + +fn check_round_trips(name: &str, binary: bool) -> TestResult { + let original = drawing()?; + for version in Version::ALL { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, name, &SaveOptions { version: Some(version), binary })?; + d = read(&bytes, name)?; + assert_eq!(d.model.len(), original.model.len(), "{name} {version:?}, pass={pass}"); + for expected in original.model.iter() { + let actual = d.entity(expected.handle).ok_or("LEADER handle lost")?; + let (EntityKind::Leader(want), EntityKind::Leader(got)) = (&expected.kind, &actual.kind) else { + return Err("LEADER entity type changed".into()); + }; + assert_eq!(got.vertices, want.vertices, "XYZ/order: {name} {version:?}, pass={pass}, {:?}", expected.handle); + assert_eq!((got.arrow, got.spline, &got.style), (want.arrow, want.spline, &want.style), "LEADER properties"); + assert_eq!(actual.common, expected.common, "Common properties: {name} {version:?}, pass={pass}"); + } + } + } + Ok(()) +} + +#[test] +fn dxf_preserves_leader_vertex_elevations() -> TestResult { + for binary in [false, true] { + check_round_trips("leader.dxf", binary)?; + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_preserves_leader_vertex_elevations() -> TestResult { + check_round_trips("leader.dwg", false) +} + +#[test] +fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult { + let vertices = [Vec3::new(1.0, 2.0, 4.0), Vec3::new(5.0, 6.0, 7.0), Vec3::new(9.0, 10.0, -3.0)]; + // All eight presence patterns include wholly 2D input and omitted first/middle/last Zs. + // Author the groups directly, independently of CADCraft's entity writer. + for z_mask in 0..8 { + let mut tags = vec![ + Tag::s(0, "SECTION"), + Tag::s(2, "ENTITIES"), + Tag::s(0, "LEADER"), + Tag::s(5, "10"), + Tag::s(100, "AcDbEntity"), + Tag::s(8, "0"), + Tag::s(100, "AcDbLeader"), + Tag::s(3, "Standard"), + Tag::i(71, 0), + Tag::i(72, 1), + Tag::i(76, 3), + ]; + let mut expected = Vec::new(); + for (i, p) in vertices.iter().enumerate() { + tags.push(Tag::f(10, p.x)); + let has_z = z_mask & (1 << i) != 0; + if has_z { + // Y and Z can appear in either order within this vertex. + tags.push(Tag::f(30, p.z)); + } + tags.push(Tag::f(20, p.y)); + expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 })); + } + tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]); + let dxf = cadcraft_dxf::write_ascii(&tags); + let d = read(dxf.as_bytes(), "missing-z.dxf")?; + let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?; + let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) }; + assert_eq!(l.vertices, expected, "Z presence mask {z_mask:03b}"); + assert!(!l.arrow && l.spline); + assert_eq!(l.style, "Standard"); + assert_eq!(e.common, Common::default()); + } + Ok(()) +} diff --git a/crates/io/tests/it/low_handseed.rs b/crates/io/tests/it/low_handseed.rs index fbc1a77b6..26273f778 100644 --- a/crates/io/tests/it/low_handseed.rs +++ b/crates/io/tests/it/low_handseed.rs @@ -1,32 +1,32 @@ //! Another program's file can name a $HANDSEED below handles it uses. Entities CADCraft makes -//! while reading (the faces a polyface mesh becomes) must not take handles that records further +//! while reading (the faces a polygon mesh becomes) must not take handles that records further //! on still have: a DWG keeps one object per handle, so the duplicates were lost on save. use std::collections::HashSet; use cadcraft_doc::{Drawing, EntityKind, Handle}; -/// A block holding a polyface mesh of three triangles (handle 2000) and, in model space after it, +/// A block holding a 2×4 polygon mesh, three quads (handle 2000) and, in model space after it, /// lines with handles 2001–2003 (the next ones after the mesh's), under a $HANDSEED of 10. The /// collision is across separately read lists (a block and model space), as in the files it came /// from: within one list duplicates were already renumbered. fn dxf() -> String { - let vertex = |h: &str, x: f64, y: f64| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n{x}\n20\n{y}\n30\n0.0\n70\n192\n"); - let face = |h: &str, a: i32, b: i32, c: i32| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n0.0\n20\n0.0\n30\n0.0\n70\n128\n71\n{a}\n72\n{b}\n73\n{c}\n"); + let vertex = |h: &str, x: f64, y: f64| format!("0\nVERTEX\n5\n{h}\n8\n0\n10\n{x}\n20\n{y}\n30\n0.0\n70\n64\n"); let line = |h: &str| format!("0\nLINE\n5\n{h}\n8\n0\n10\n0.0\n20\n0.0\n30\n0.0\n11\n1.0\n21\n1.0\n31\n0.0\n"); format!( "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$HANDSEED\n5\n10\n0\nENDSEC\n\ 0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n5\n30\n8\n0\n2\nMESH\n70\n0\n10\n0.0\n20\n0.0\n30\n0.0\n\ - 0\nPOLYLINE\n5\n2000\n8\n0\n66\n1\n10\n0.0\n20\n0.0\n30\n0.0\n70\n64\n71\n4\n72\n3\n\ - {}{}{}{}{}{}{}0\nSEQEND\n5\n2010\n8\n0\n0\nENDBLK\n5\n31\n8\n0\n0\nENDSEC\n\ + 0\nPOLYLINE\n5\n2000\n8\n0\n66\n1\n10\n0.0\n20\n0.0\n30\n0.0\n70\n16\n71\n2\n72\n4\n\ + {}{}{}{}{}{}{}{}0\nSEQEND\n5\n2010\n8\n0\n0\nENDBLK\n5\n31\n8\n0\n0\nENDSEC\n\ 0\nSECTION\n2\nENTITIES\n{}{}{}0\nINSERT\n5\n60\n8\n0\n2\nMESH\n10\n0.0\n20\n0.0\n30\n0.0\n0\nENDSEC\n0\nEOF\n", vertex("2004", 0.0, 0.0), - vertex("2005", 1.0, 0.0), - vertex("2006", 1.0, 1.0), - vertex("2007", 0.0, 1.0), - face("2008", 1, 2, 3), - face("2009", 1, 3, 4), - face("200A", 2, 3, 4), + vertex("2005", 0.0, 1.0), + vertex("2006", 0.0, 2.0), + vertex("2007", 0.0, 3.0), + vertex("2008", 1.0, 0.0), + vertex("2009", 1.0, 1.0), + vertex("200A", 1.0, 2.0), + vertex("200B", 1.0, 3.0), line("2001"), line("2002"), line("2003"), diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 07b1e8c2e..289e87806 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -5,6 +5,7 @@ mod angle_header; mod annotative_orientation; mod annotative_roundtrip; mod assoc_array_roundtrip; +mod attdef_insert_dwg; mod attrib_modes; mod attrib_props; mod block_expansion; @@ -30,8 +31,10 @@ mod groups_roundtrip; mod hatch_assoc_roundtrip; mod hatch_pattern_def; mod helix_roundtrip; +mod layout_limits_roundtrip; mod layout_paper_units; mod layout_plot_window; +mod leader_vertex_elevation; #[cfg(not(target_arch = "wasm32"))] mod low_handseed; mod mleader_content; @@ -39,10 +42,13 @@ mod mleader_roundtrip; mod mline_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod model_layout_plot_type; +mod mtext_caret_spaces; mod mtext_columns_roundtrip; mod mtext_embedded_object; mod mtext_line_spacing; mod multiline_attributes; +#[cfg(not(target_arch = "wasm32"))] +mod ole2frame_roundtrip; mod paperview_marker; mod passthrough_image_roundtrip; mod pdf_followups; @@ -52,7 +58,10 @@ mod pdf_read; mod plot_layout_scale; mod plot_model_png_scale; mod plot_styles; +mod polyface_roundtrip; +mod portable_fonts; mod region_roundtrip; +mod roundtrip; mod roundtrip_audit; mod skipped_records; mod sortents; @@ -63,9 +72,11 @@ mod svg_arc_budget; mod svg_tiny_extents; mod table_cellstyle_roundtrip; mod tables_objects_roundtrip; +mod text_carets_roundtrip; mod text_left_justify; mod tolerance_roundtrip; mod user_lin_pat_roundtrip; mod view_tables_roundtrip; +mod view_target; mod viewport_clip_roundtrip; mod xref_roundtrip; diff --git a/crates/io/tests/it/mtext_caret_spaces.rs b/crates/io/tests/it/mtext_caret_spaces.rs new file mode 100644 index 000000000..8bb1ed071 --- /dev/null +++ b/crates/io/tests/it/mtext_caret_spaces.rs @@ -0,0 +1,118 @@ +//! Literal MTEXT carets and their adjacent whitespace survive DXF and DWG saves (#2250). + +use cadcraft_doc::{Common, Drawing, EntityKind, Handle, MText, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +type TestResult = Result<(), Box>; + +fn drawing() -> Result<(Drawing, Vec<(Handle, String)>), Box> { + let mut d = Drawing::new_metric(); + let mut expected = Vec::new(); + let mut cases: Vec = [ + "ordinary text", + "^ C", + "A ^ C B", + "^ C", + "A ^ C B", + "^ ", + "^^ C ^ ^ D^", + "^I ^J ^ C", + "\t^ C\nD\tE", + "^\t ^\n C", + r"A\P^ C\P^I ^J", + r"\S+0.1^-0.1; ^ C", + ] + .into_iter() + .map(str::to_string) + .collect(); + // Exercise multiple group-3 chunks and a caret at the output chunk boundary. + cases.push(format!("{}^ C", "A".repeat(124))); + cases.push(format!("{}^ C B", "A".repeat(260))); + for contents in cases { + let kind = EntityKind::MText(MText { + insert: Vec3::new(1.0, 2.0, 0.0), + height: 0.5, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Standard".into(), + contents: contents.clone(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + }); + let handle = d.add(&Space::Model, Common::default(), kind)?; + expected.push((handle, contents)); + } + Ok((d, expected)) +} + +fn assert_contents(d: &Drawing, expected: &[(Handle, String)], context: &str) -> TestResult { + assert_eq!(d.model.len(), expected.len(), "{context}: entity count"); + for (handle, contents) in expected { + let e = d.entity(*handle).ok_or("MTEXT handle lost")?; + let EntityKind::MText(t) = &e.kind else { return Err("MTEXT entity type changed".into()) }; + assert_eq!(&t.contents, contents, "{context}: {handle:?}"); + } + Ok(()) +} + +#[test] +fn dxf_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + for binary in [false, true] { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dxf", &SaveOptions { version: Some(version), binary })?; + d = read(&bytes, "carets.dxf")?; + assert_contents(&d, &expected, &format!("{version:?}, binary={binary}, pass={pass}"))?; + } + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dwg_mtext_caret_spaces_round_trip() -> TestResult { + let (original, expected) = drawing()?; + for version in Version::ALL { + let mut d = original.clone(); + for pass in 1..=2 { + let bytes = write_with(&d, "carets.dwg", &SaveOptions { version: Some(version), binary: false })?; + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code())); + // Inspect the stored DWG text before its DXF rendition: a compensating reader + // must not hide corruption on save. This uses the same codec, not an external oracle. + let native = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read()?; + for (handle, contents) in &expected { + let text = native.entities().find_map(|e| match e { + acadrust::EntityType::MText(t) if t.common.handle.value() == handle.0 => Some(&t.value), + _ => None, + }); + assert_eq!(text, Some(contents), "native DWG {version:?}, pass={pass}, {handle:?}"); + } + let dxf = cadcraft_dwg::dwg_to_dxf(&bytes)?; + let converted = read(&dxf, "bridge.dxf")?; + assert_contents(&converted, &expected, &format!("DWG bridge DXF {version:?}, pass={pass}"))?; + d = read(&bytes, "carets.dwg")?; + assert_contents(&d, &expected, &format!("DWG {version:?}, pass={pass}"))?; + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult { + let (d, _) = drawing()?; + let dxf = cadcraft_io::write_dxf(&d); + assert!(dxf.contains(" 1\r\n^ C\r\n"), "literal caret plus authored space needs two wire spaces"); + let native = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf.into_bytes()))?.read()?; + assert!(native.entities().any(|e| matches!(e, acadrust::EntityType::MText(t) if t.value == "^ C"))); + let dwg = write_with(&d, "carets.dwg", &SaveOptions { version: Some(Version::R2018), binary: false })?; + let returned = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg)?)?; + assert!(returned.contains(" 1\r\n^ C\r\n"), "the return bridge must encode the separator independently of authored whitespace"); + Ok(()) +} diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 7db9ed685..dbe7a5185 100644 --- a/crates/io/tests/it/mtext_columns_roundtrip.rs +++ b/crates/io/tests/it/mtext_columns_roundtrip.rs @@ -2,8 +2,9 @@ //! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `. use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space}; +use cadcraft_dxf::Tag; use cadcraft_geom::Vec3; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, entity_from_groups, read_dxf, write_dxf, write_with}; fn mtext(contents: &str, rotation: f64, columns: Option) -> MText { MText { @@ -76,6 +77,115 @@ fn paragraph_codes_tabs_and_carets_round_trip() { assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::MText(m) if m.contents == contents))); } +#[test] +fn legacy_column_groups_stay_in_the_mtext_subclass() { + // Columns as other programs write them (#2247): the column width (48) and the height count + // (the first 50) are groups of the AcDbMText subclass, not the entity's linetype scale and a + // column height. + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap(); + let legacy = + " 75\r\n1\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n3\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n 50\r\n20.0\r\n"; + let dxf = write_dxf(&d).replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{legacy}"), 1); + assert!(dxf.contains(" 75\r\n"), "legacy groups inserted"); + let back = read_dxf(dxf.as_bytes()).unwrap(); + let Some(e) = back.model.iter().find(|e| matches!(e.kind, EntityKind::MText(_))) else { panic!("no mtext") }; + assert_eq!(e.common.ltscale, 1.0, "48 is the column width, not the linetype scale"); + let EntityKind::MText(t) = &e.kind else { panic!("no mtext") }; + let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 2.5, height: 20.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); + // Written back as read: the height count before the heights. + let out = write_dxf(&back); + assert!(out.contains(" 50\r\n3\r\n 50\r\n20.0\r\n"), "height count before the heights"); +} + +#[test] +fn heights_written_without_a_count_are_all_kept() { + // Files written before the height count (CADCraft up to f536bf4) have group 76 = n and n + // heights, no count: the first 50 is a height there (#2247 review). + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(mtext("x", 0.0, None))).unwrap(); + let dxf = write_dxf(&d); + for (heights, old) in [ + (vec![7.0], " 75\r\n2\r\n 76\r\n1\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n7.0\r\n"), + ( + vec![5.0, 3.0, 4.0], + " 75\r\n2\r\n 76\r\n3\r\n 78\r\n0\r\n 79\r\n0\r\n 48\r\n12.5\r\n 49\r\n2.5\r\n 50\r\n5.0\r\n 50\r\n3.0\r\n 50\r\n4.0\r\n", + ), + ] { + let back = read_dxf(dxf.replacen(" 44\r\n1.0\r\n", &format!(" 44\r\n1.0\r\n{old}"), 1).as_bytes()).unwrap(); + let Some(EntityKind::MText(t)) = back.model.iter().map(|e| &e.kind).find(|k| matches!(k, EntityKind::MText(_))) else { panic!("no mtext") }; + let n = heights.len(); + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: n as u16, + gutter: 2.5, + height: heights.iter().copied().fold(0.0, f64::max), + auto_height: false, + heights: heights.clone(), + flow_reversed: false, + }; + assert_eq!(t.columns, Some(c), "{n} heights without a count"); + // Written back with the count, and still n columns. + let out = write_dxf(&back); + assert!(out.contains(&format!(" 76\r\n{n}\r\n 78\r\n")), "76 stays {n}: {out}"); + assert!(out.contains(&format!(" 50\r\n{n}\r\n 50\r\n{:.1}\r\n", heights[0])), "count then the first height: {out}"); + } +} + +#[test] +fn heights_inside_an_application_group_are_not_column_heights() { + // An application-defined group (102 {...}) in the record may hold a 50 of its own: it is + // neither a height nor the count that tells a legacy record apart (#2247 review). + let tags = [ + Tag::s(8, "0"), + Tag::f(40, 0.5), + Tag::f(41, 12.5), + Tag::s(1, "x"), + Tag::i(75, 2), + Tag::i(76, 3), + Tag::i(78, 0), + Tag::i(79, 0), + Tag::f(48, 12.5), + Tag::f(49, 2.5), + Tag::f(50, 5.0), + Tag::f(50, 3.0), + Tag::f(50, 4.0), + Tag::s(102, "{ACME_APP"), + Tag::f(50, 8.0), + Tag::s(102, "}"), + ]; + let Some((_, EntityKind::MText(t))) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") }; + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 2.5, + height: 5.0, + auto_height: false, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: false, + }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn empty_manual_heights_round_trip() { + // A dynamic manual column with no heights stays without heights: no count is written for an + // empty list (a lone 50 = 0 would read back as one height of 0) (#2247 review). + let c = MTextColumns { + kind: MTextColumnType::Dynamic, + count: 1, + gutter: 0.5, + height: 0.0, + auto_height: false, + heights: Vec::new(), + flow_reversed: false, + }; + let (t, dxf) = round_trip(mtext("x", 0.0, Some(c.clone()))); + assert_eq!(t.columns, Some(c)); + assert!(!dxf.contains(" 50\r\n0\r\n"), "no height count for an empty list: {dxf}"); +} + #[test] fn hostile_column_groups_do_not_panic() { let mut d = Drawing::new_metric(); @@ -91,3 +201,205 @@ fn hostile_column_groups_do_not_panic() { let _ = read_dxf(bad.as_bytes()); } } + +#[test] +fn embedded_groups_do_not_supply_mtext_common_ltscale() { + // Without subclass markers, an embedded object (101) reuses 48 for its own data (#2247, test from #2276). + let tags = [Tag::s(8, "0"), Tag::f(40, 0.5), Tag::f(41, 12.5), Tag::s(1, "own text"), Tag::s(101, "Embedded Object"), Tag::f(48, 99.0)]; + let Some((common, _)) = entity_from_groups("MTEXT", &tags) else { panic!("MTEXT was not read") }; + assert_eq!(common.ltscale, 1.0, "48 after 101 belongs to the embedded object"); +} + +#[test] +fn layer_after_an_entmake_subclass_marker_is_kept() { + // An entity list built by hand may put a subclass marker before the layer and have no AcDbEntity + // marker at all: the whole list is then the entity's (#2247 review). + let tags = [Tag::s(100, "AcDbLine"), Tag::s(8, "Walls"), Tag::i(62, 3), Tag::f(10, 0.0), Tag::f(20, 0.0), Tag::f(11, 1.0), Tag::f(21, 0.0)]; + let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") }; + assert_eq!(common.layer, "Walls"); + assert_eq!(common.color, cadcraft_color::Color::from_aci(3)); +} + +#[test] +fn markers_inside_an_application_group_do_not_end_the_entity_groups() { + // An application-defined group (102 {...} before AcDbEntity) may hold any code, 100 and 101 + // included: the entity's layer and colour come after it (#2247 review). + let tags = [ + Tag::s(5, "A1"), + Tag::s(102, "{ACME_APP"), + Tag::s(100, "AcmeMarker"), + Tag::s(101, "AcmeObject"), + Tag::s(102, "}"), + Tag::s(100, "AcDbEntity"), + Tag::s(8, "Walls"), + Tag::i(62, 3), + Tag::s(100, "AcDbLine"), + Tag::f(10, 0.0), + Tag::f(20, 0.0), + Tag::f(11, 1.0), + Tag::f(21, 0.0), + ]; + let Some((common, _)) = entity_from_groups("LINE", &tags) else { panic!("LINE was not read") }; + assert_eq!(common.layer, "Walls"); + assert_eq!(common.color, cadcraft_color::Color::from_aci(3)); +} + +// ---- #2248: R2018 embedded column object ---- + +fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec { + use cadcraft_dxf::Tag; + let mut v = vec![ + Tag::s(8, "0"), + Tag::f(10, 10.0), + Tag::f(20, 10.0), + Tag::f(30, 0.0), + Tag::f(40, 2.5), + Tag::f(41, width), + Tag::i(71, 5), + Tag::i(72, 5), + Tag::s(1, "Hello"), + Tag::s(101, "Embedded Object"), + Tag::i(70, 1), + Tag::f(10, 1.0), + Tag::f(20, 0.0), + Tag::f(30, 0.0), + Tag::f(11, 10.0), + Tag::f(21, 10.0), + Tag::f(31, 0.0), + Tag::f(40, width), + Tag::f(41, 0.0), + Tag::f(42, width), + Tag::f(43, 0.0), + Tag::i(71, 1), + Tag::i(72, count), + Tag::f(44, width), + Tag::f(45, gutter), + Tag::i(73, 0), + Tag::i(74, 0), + ]; + v.extend(heights.iter().map(|h| Tag::f(46, *h))); + v +} + +fn mtext_from_groups(tags: &[cadcraft_dxf::Tag]) -> (Common, MText) { + let Some((common, EntityKind::MText(t))) = entity_from_groups("MTEXT", tags) else { panic!("MTEXT was not read") }; + (common, t) +} + +/// The DXF of `t` written as `version` and its MTEXT record only (other sections reuse the codes). +fn dxf_as(t: &MText, version: Version) -> (String, String) { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::MText(t.clone())).unwrap(); + let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + let from = text.find(" 0\r\nMTEXT\r\n").expect("an MTEXT"); + let len = text[from + 5..].find(" 0\r\n").map_or(text.len() - from, |n| n + 5); + let record = text[from..from + len].to_string(); + (text, record) +} + +#[test] +fn embedded_object_does_not_leak_into_the_entity() { + // A single column (the usual R2018 block of a plain MTEXT): 44 is the column width, not the + // line spacing factor, and the columns are kept. + let (_, t) = mtext_from_groups(&embedded_tags(1, 38.8, 12.5, &[0.0])); + assert_eq!(t.line_spacing, 1.0, "44 of the embedded object is the column width"); + assert_eq!(t.attach, 5); + assert_eq!(t.width, 38.8); + let c = MTextColumns { kind: MTextColumnType::Static, count: 1, gutter: 12.5, height: 0.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn embedded_static_columns_are_read() { + let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); + let c = MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 2.5, height: 20.0, ..Default::default() }; + assert_eq!(t.columns, Some(c)); +} + +#[test] +fn embedded_column_height_is_the_entitys_defined_height() { + // As ezdxf and AutoCAD write static columns: the height in the entity's 46, none in the object. + let mut tags = embedded_tags(3, 12.5, 2.5, &[]); + let at = tags.iter().position(|t| t.code == 101).unwrap(); + tags.insert(at, cadcraft_dxf::Tag::f(46, 20.0)); + let (_, t) = mtext_from_groups(&tags); + assert_eq!(t.columns.map(|c| c.height), Some(20.0)); +} + +#[test] +fn r2018_writes_the_embedded_object_and_older_versions_the_legacy_groups() { + let (_, t) = mtext_from_groups(&embedded_tags(3, 12.5, 2.5, &[20.0, 20.0, 20.0])); + let (full, r2018) = dxf_as(&t, Version::R2018); + assert!(r2018.contains("101\r\nEmbedded Object\r\n"), "R2018 keeps the embedded object"); + assert!(!r2018.contains(" 75\r\n"), "no legacy column groups in R2018"); + assert!(r2018.contains(" 72\r\n5\r\n"), "the flow direction (72) is kept"); + assert!(r2018.contains(" 44\r\n12.5\r\n 45\r\n2.5\r\n"), "column width and gutter"); + let own = r2018.split("Embedded Object").next().unwrap_or_default(); + assert!(own.contains(" 44\r\n1.0\r\n") && !own.contains(" 44\r\n12.5\r\n"), "the entity's line spacing is not the column width"); + let back = read_dxf(full.as_bytes()).unwrap(); + let Some(EntityKind::MText(b)) = back.model.iter().map(|e| e.kind.clone()).find(|k| matches!(k, EntityKind::MText(_))) else { + panic!("no mtext") + }; + assert_eq!(b.columns, t.columns); + let (_, r2000) = dxf_as(&t, Version::R2000); + assert!(!r2000.contains("Embedded Object"), "R2000 has no embedded object"); + assert!(r2000.contains(" 75\r\n1\r\n"), "R2000 keeps the legacy column groups"); +} + +#[test] +fn dynamic_auto_height_embedded_count_comes_from_the_total_width() { + // A dynamic auto-height object stores no column count (72 = 0): the count is what 42 (total + // width), 44 (column width) and 45 (gutter) hold, 3 * 50 + 2 * 12.5 = 175. + let mut tags = embedded_tags(1, 50.0, 12.5, &[]); + let at = tags.iter().position(|t| t.code == 101).unwrap(); + for t in &mut tags[at..] { + *t = match t.code { + 42 => cadcraft_dxf::Tag::f(42, 175.0), + 71 => cadcraft_dxf::Tag::i(71, 2), + 72 => cadcraft_dxf::Tag::i(72, 0), + 73 => cadcraft_dxf::Tag::i(73, 1), + _ => t.clone(), + }; + } + let (_, t) = mtext_from_groups(&tags); + let c = t.columns.expect("columns"); + assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3)); + assert_eq!(c.gutter, 12.5); +} + +#[test] +fn dynamic_auto_height_count_comes_from_the_widths_when_72_is_zero() { + // A dynamic auto-height object stores 0 in 72; the count is implied by the total width + // (42 = n * 44 + (n - 1) * 45): (175 + 12.5) / (50 + 12.5) = 3. + let mut tags = embedded_tags(0, 50.0, 12.5, &[]); + let mut set = |code: i32, v: cadcraft_dxf::Tag| { + let at = tags.iter().rposition(|t| t.code == code).unwrap(); + tags[at] = v; + }; + set(71, cadcraft_dxf::Tag::i(71, 2)); + set(73, cadcraft_dxf::Tag::i(73, 1)); + set(42, cadcraft_dxf::Tag::f(42, 175.0)); + let (_, t) = mtext_from_groups(&tags); + let c = t.columns.expect("columns"); + assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3)); +} + +#[test] +fn embedded_column_width_wins_over_the_entitys_width() { + // ZWCAD (and AutoCAD) write the total width of the columns in the entity's 41 and the column + // width in the object's 44: an MTEXT's `width` is one column's, so 44 is what is read, and + // what the R2018 object gets back (#2467 review). + let mut tags = embedded_tags(3, 50.0, 12.5, &[20.0, 20.0, 20.0]); + let own_41 = tags.iter().position(|t| t.code == 41).unwrap(); + tags[own_41] = cadcraft_dxf::Tag::f(41, 175.0); + for code in [40, 42] { + let at = tags.iter().rposition(|t| t.code == code).unwrap(); + tags[at] = cadcraft_dxf::Tag::f(code, 175.0); + } + let (_, t) = mtext_from_groups(&tags); + assert_eq!(t.width, 50.0, "the column width, not the total"); + let (_, r2018) = dxf_as(&t, Version::R2018); + assert!(r2018.contains(" 42\r\n175.0\r\n"), "total width kept:\n{r2018}"); + assert!(r2018.contains(" 44\r\n50.0\r\n 45\r\n12.5\r\n"), "column width and gutter kept:\n{r2018}"); +} diff --git a/crates/io/tests/it/multiline_attributes.rs b/crates/io/tests/it/multiline_attributes.rs index 625c38a30..428dc7db5 100644 --- a/crates/io/tests/it/multiline_attributes.rs +++ b/crates/io/tests/it/multiline_attributes.rs @@ -335,3 +335,18 @@ fn damaged_embedded_text_does_not_panic() { let _ = read_dxf(save(&d).as_bytes()).unwrap(); } } + +#[test] +fn entmod_leaves_the_embedded_text_alone() { + // entget and entmod convert the carets of the attribute's own groups, not the embedded MTEXT's, + // which has its own encoding: `^ J` there is a caret and a J, not a line feed. + let d = read_dxf(dxf(FIXTURE).replace("SITE\\PNAME", "SITE^ JNAME").as_bytes()).unwrap(); + let Some(e) = d.block("TAG").and_then(|b| b.entities.iter().find(|e| matches!(e.kind, EntityKind::AttDef(_)))) else { + panic!("no definition"); + }; + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + let embedded: Vec = groups.iter().skip_while(|t| t.code != 101).filter(|t| t.code == 1).map(|t| t.str()).collect(); + assert_eq!(embedded, ["SITE^ JNAME"]); + let Some((_, EntityKind::AttDef(a))) = cadcraft_io::entity_from_groups(&kind, &groups) else { panic!("no ATTDEF") }; + assert_eq!(a.multiline.map(|m| m.mtext.contents), Some("SITE^JNAME".to_string())); +} diff --git a/crates/io/tests/it/ole2frame_roundtrip.rs b/crates/io/tests/it/ole2frame_roundtrip.rs new file mode 100644 index 000000000..2491d30d4 --- /dev/null +++ b/crates/io/tests/it/ole2frame_roundtrip.rs @@ -0,0 +1,113 @@ +//! OLE objects (OLE2FRAME: a picture, a spreadsheet or a document embedded in the drawing) +//! survive a DWG save. CADCraft doesn't model them and keeps them as read; the DWG bridge reads +//! them from our DXF into acadrust's own entity and writes them to DWG with their frame and the +//! embedded object's bytes, so a drawing saved as DWG and reopened still has them. + +use cadcraft_doc::EntityKind; + +/// Stand-in OLE data as a DWG carries it: a two-byte marker, the frame's four corners (upper +/// left, upper right, lower right, lower left; x, y, z doubles), then the object's bytes, here +/// opaque text long enough to take several 310 groups. +fn payload() -> Vec { + let mut data = vec![0x80, 0x00]; + for [x, y] in [[10.0f64, 40.0], [70.0, 40.0], [70.0, 5.0], [10.0, 5.0]] { + for v in [x, y, 0.0] { + data.extend_from_slice(&v.to_le_bytes()); + } + } + for i in 0..12u8 { + data.extend_from_slice(b"synthetic embedded object "); + data.push(i); + } + data +} + +fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02X}")).collect() +} + +/// A 2018 DXF with one embedded OLE object in model space on layer `Pictures`. +fn dxf() -> String { + let data = payload(); + let chunks: String = data.chunks(127).map(|c| format!("310\n{}\n", hex(c))).collect(); + format!( + "0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n9\n$HANDSEED\n5\n100\n0\nENDSEC\n\ + 0\nSECTION\n2\nENTITIES\n\ + 0\nOLE2FRAME\n5\n2A\n330\n1F\n100\nAcDbEntity\n8\nPictures\n100\nAcDbOle2Frame\n70\n2\n\ + 10\n10\n20\n40\n30\n0\n11\n70\n21\n5\n31\n0\n71\n2\n72\n0\n73\n2\n90\n{}\n{chunks}1\nOLE\n\ + 0\nENDSEC\n0\nEOF\n", + data.len() + ) +} + +/// Each OLE2FRAME of DXF text as its (code, value) pairs. +fn frames(text: &str) -> Vec> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out: Vec> = Vec::new(); + let mut inside = false; + for pair in lines.chunks(2) { + let [code, value] = pair else { break }; + if *code == "0" { + inside = *value == "OLE2FRAME"; + if inside { + out.push(Vec::new()); + } + } else if inside && let Some(rec) = out.last_mut() { + rec.push((code.to_string(), value.to_string())); + } + } + out +} + +fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> { + rec.iter().find(|(c, _)| c == code).map(|(_, v)| v.as_str()) +} + +fn number(rec: &[(String, String)], code: &str) -> Option { + group(rec, code).and_then(|v| v.parse().ok()) +} + +/// The embedded object's bytes: the 310 groups joined. +fn data(rec: &[(String, String)]) -> String { + rec.iter().filter(|(c, _)| c == "310").map(|(_, v)| v.as_str()).collect() +} + +/// The frame, its object's bytes, type and layer as the DWG keeps them. +fn assert_kept(rec: &[(String, String)]) { + assert_eq!(data(rec), hex(&payload()), "the embedded object's bytes changed"); + assert_eq!(number(rec, "90"), Some(payload().len() as f64)); + let corners: Vec> = ["10", "20", "11", "21"].iter().map(|c| number(rec, c)).collect(); + assert_eq!(corners, [Some(10.0), Some(40.0), Some(70.0), Some(5.0)]); + assert_eq!(group(rec, "71"), Some("2"), "an embedded object"); + assert_eq!(group(rec, "73"), Some("2")); + assert_eq!(group(rec, "8"), Some("Pictures")); +} + +#[test] +fn dwg_keeps_ole_objects() { + let d = cadcraft_io::read_dxf(dxf().as_bytes()).unwrap(); + let ole = |d: &cadcraft_doc::Drawing| d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Unknown(u) if u.dxf_type == "OLE2FRAME")).count(); + assert_eq!(ole(&d), 1); + + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let back = String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + let got = frames(&back); + assert_eq!(got.len(), 1, "{got:?}"); + assert_kept(&got[0]); + + // Reopened in CADCraft it is in model space again, and a second DWG save keeps it too. + let again = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(ole(&again), 1); + let dwg = cadcraft_io::write(&again, "x.dwg").unwrap(); + let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(got.len(), 1, "{got:?}"); + assert_kept(&got[0]); + + // Older DWG versions keep it the same way. + for version in [cadcraft_io::Version::R2000, cadcraft_io::Version::R2010] { + let dwg = cadcraft_io::write_with(&d, "x.dwg", &cadcraft_io::SaveOptions { version: Some(version), binary: false }).unwrap(); + let got = frames(&String::from_utf8(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap()); + assert_eq!(got.len(), 1, "{version:?}: {got:?}"); + assert_kept(&got[0]); + } +} diff --git a/crates/io/tests/it/plot_styles.rs b/crates/io/tests/it/plot_styles.rs index 679c167b9..73a5c8e5b 100644 --- a/crates/io/tests/it/plot_styles.rs +++ b/crates/io/tests/it/plot_styles.rs @@ -86,6 +86,19 @@ fn named_plot_styles_round_trip_through_the_plot_style_dictionary() { assert!(plain.contains("ACAD_PLOTSTYLENAME") && !plain.contains("CADCRAFT_PLOTSTYLES")); } +/// #2242: a ByBlock plot style is group 380 = 1 (AcDb::PlotStyleNameType); it was written like +/// ByLayer (no group at all) and read back as ByLayer. +#[test] +fn by_block_plot_style_round_trips() { + let mut d = Drawing::new_metric(); + let line = EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(1.0, 0.0, 0.0) }); + let h = d.add(&Space::Model, Common { plot_style: "ByBlock".into(), ..Default::default() }, line).unwrap(); + let text = write_dxf(&d); + let lines: Vec<&str> = text.lines().collect(); + assert!(lines.windows(2).any(|w| w[0].trim() == "380" && w[1].trim() == "1"), "group 380 = 1 written"); + assert_eq!(read_dxf(text.as_bytes()).unwrap().entity(h).unwrap().common.plot_style, "ByBlock"); +} + #[test] fn pdf_applies_a_named_table_from_the_drawing() { use cadcraft_doc::{PlotStyle, PlotStyleTable}; diff --git a/crates/io/tests/it/polyface_roundtrip.rs b/crates/io/tests/it/polyface_roundtrip.rs new file mode 100644 index 000000000..0ad05518d --- /dev/null +++ b/crates/io/tests/it/polyface_roundtrip.rs @@ -0,0 +1,121 @@ +//! A polyface mesh stays one object through DXF and DWG, instead of becoming loose 3D faces; +//! its hidden edges stay hidden. + +use cadcraft_doc::{Drawing, EntityKind, PolyfaceMesh, Prim}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +/// A 10 × 10 square as two triangles; the diagonal (from vertex 3) is hidden in both. +const POLYFACE_DXF: &str = "0\nSECTION\n2\nENTITIES\n\ +0\nPOLYLINE\n5\n30\n100\nAcDbEntity\n8\n0\n100\nAcDbPolyFaceMesh\n66\n1\n10\n0\n20\n0\n30\n0\n70\n64\n71\n4\n72\n2\n\ +0\nVERTEX\n5\n31\n8\n0\n10\n0\n20\n0\n30\n0\n70\n192\n\ +0\nVERTEX\n5\n32\n8\n0\n10\n10\n20\n0\n30\n0\n70\n192\n\ +0\nVERTEX\n5\n33\n8\n0\n10\n10\n20\n10\n30\n2\n70\n192\n\ +0\nVERTEX\n5\n34\n8\n0\n10\n0\n20\n10\n30\n2\n70\n192\n\ +0\nVERTEX\n5\n35\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n73\n-3\n\ +0\nVERTEX\n5\n36\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n3\n72\n4\n73\n-1\n\ +0\nSEQEND\n5\n37\n8\n0\n0\nENDSEC\n0\nEOF\n"; + +fn mesh(d: &Drawing) -> PolyfaceMesh { + let meshes: Vec<&PolyfaceMesh> = d + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::PolyfaceMesh(m) => Some(m), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "one polyface mesh"); + assert!(d.model.iter().all(|e| !matches!(e.kind, EntityKind::Face3d(_))), "no loose 3D faces"); + meshes[0].clone() +} + +fn expected() -> PolyfaceMesh { + PolyfaceMesh { + vertices: vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 2.0), Vec3::new(0.0, 10.0, 2.0)], + faces: vec![[1, 2, -3, 0], [3, 4, -1, 0]], + } +} + +#[test] +fn polyface_mesh_is_read_as_one_object_with_its_hidden_edges() { + let m = mesh(&read_dxf(POLYFACE_DXF.as_bytes()).unwrap()); + assert_eq!(m, expected()); + assert_eq!(m.faces3d().len(), 2); + // The square's four sides; the hidden diagonal isn't drawn. + let edges = EntityKind::PolyfaceMesh(m).prims().into_iter().filter(|p| matches!(p, Prim::Seg(_))).count(); + assert_eq!(edges, 4); +} + +#[test] +fn polyface_mesh_round_trips_through_dxf_and_dwg() { + let d = read_dxf(POLYFACE_DXF.as_bytes()).unwrap(); + let text = write_dxf(&d); + assert!(text.contains("AcDbPolyFaceMesh") && text.contains("AcDbFaceRecord")); + assert_eq!(mesh(&read_dxf(text.as_bytes()).unwrap()), expected()); + #[cfg(not(target_arch = "wasm32"))] + { + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + assert_eq!(mesh(&cadcraft_io::read(&dwg, "x.dwg").unwrap()), expected()); + } +} + +#[test] +fn a_polyface_mesh_too_large_for_its_record_is_saved_as_3d_faces() { + use cadcraft_doc::{Common, Space}; + use cadcraft_io::{SaveOptions, read, write_with}; + // 40,000 vertices: counts and indices past 32,767 don't fit the 16-bit groups 71–74. + let vertices: Vec = (0..40_000).map(|i| Vec3::new(f64::from(i), 0.0, f64::from(i % 7))).collect(); + let mesh = PolyfaceMesh { vertices, faces: vec![[1, 2, 39_999, 0], [39_998, -39_999, 40_000, 3]] }; + let mut d = Drawing::new_metric(); + let common = Common { layer: "0".into(), ..Common::default() }; + d.add(&Space::Model, common, EntityKind::PolyfaceMesh(mesh.clone())).unwrap(); + let want = mesh.faces3d(); + for binary in [false, true] { + let bytes = write_with(&d, "big.dxf", &SaveOptions { version: None, binary }).unwrap(); + let back = read(&bytes, "big.dxf").unwrap(); + let faces: Vec<_> = back + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Face3d(f) => Some(f.clone()), + _ => None, + }) + .collect(); + // Every face keeps its corners and hidden edges: no index was cut to 32,767. + assert_eq!(faces, want, "binary: {binary}"); + assert!(back.model.iter().all(|e| !matches!(e.kind, EntityKind::PolyfaceMesh(_))), "binary: {binary}"); + let mut handles: Vec<_> = back.model.iter().map(|e| e.handle).collect(); + handles.sort(); + handles.dedup(); + assert_eq!(handles.len(), back.model.iter().count(), "each face has its own handle"); + } +} + +#[test] +fn faces_naming_missing_vertices_are_dropped_on_read() { + // Added faces: one names vertex 9 of a 4-vertex mesh, one has only two corners, one has two + // before its first 0 (which ends the face). + let text = POLYFACE_DXF.replace( + "0\nSEQEND\n", + "0\nVERTEX\n5\n38\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n73\n9\n\ +0\nVERTEX\n5\n39\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n\ +0\nVERTEX\n5\n3A\n8\n0\n10\n0\n20\n0\n30\n0\n70\n128\n71\n1\n72\n2\n74\n3\n0\nSEQEND\n", + ); + let d = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(mesh(&d), expected(), "only the faces that name its vertices"); + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(mesh(&back), expected()); +} + +#[test] +fn faces_naming_missing_vertices_are_not_written() { + use cadcraft_doc::{Common, Space}; + let mut m = expected(); + m.faces.push([1, 2, 9, 0]); + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common { layer: "0".into(), ..Common::default() }, EntityKind::PolyfaceMesh(m)).unwrap(); + let text = write_dxf(&d); + assert_eq!(text.matches("AcDbFaceRecord").count(), 2, "only the faces that name its vertices"); + assert_eq!(mesh(&read_dxf(text.as_bytes()).unwrap()), expected()); +} diff --git a/crates/io/tests/it/portable_fonts.rs b/crates/io/tests/it/portable_fonts.rs new file mode 100644 index 000000000..070c376e8 --- /dev/null +++ b/crates/io/tests/it/portable_fonts.rs @@ -0,0 +1,59 @@ +//! Portable fonts: a drawing saved with them gets the Windows font for each Linux font in its +//! STYLE table, keeps its own in CADCraft xdata, and reopens with it, from DXF and DWG. + +use cadcraft_doc::{Drawing, TextStyle}; +use cadcraft_io::{read, read_dxf, write, write_dxf}; + +fn drawing(portable_fonts: bool) -> Drawing { + let mut d = Drawing { portable_fonts, ..Drawing::new_metric() }; + // A Linux font, and one with no Windows font. + for (name, font) in [("Notes", "DejaVuSans-Bold.ttf"), ("Tight", "Liberation Sans Narrow")] { + d.text_styles.push(TextStyle { name: name.into(), font: font.into(), ..TextStyle::default() }); + } + d +} + +/// (style, font) of the drawing's text styles. +fn fonts(d: &Drawing) -> Vec<(String, String)> { + d.text_styles.iter().map(|t| (t.name.clone(), t.font.clone())).collect() +} + +/// (style, font file) of the STYLE records of DXF text. +fn style_files(dxf: &str) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut style = false; + for t in cadcraft_dxf::parse(dxf.as_bytes()).unwrap() { + match t.code { + 0 => style = t.str() == "STYLE", + 2 if style => out.push((t.str(), String::new())), + 3 if style => out.last_mut().unwrap().1 = t.str(), + _ => {} + } + } + out +} + +#[test] +fn styles_get_windows_fonts_and_reopen_with_their_own() { + let d = drawing(true); + let dxf = write_dxf(&d); + let files = style_files(&dxf); + for (style, file) in [("Notes", "verdanab.ttf"), ("Tight", "Liberation Sans Narrow")] { + assert!(files.contains(&(style.into(), file.into())), "{style}: {files:?}"); + } + for back in [read_dxf(dxf.as_bytes()).unwrap(), read(&write(&d, "t.dwg").unwrap(), "t.dwg").unwrap()] { + assert_eq!(fonts(&back), fonts(&drawing(false))); + } + // The Windows font with a path is still the one written for the style's own. + let path = read_dxf(dxf.replace("verdanab.ttf", "C:\\Windows\\Fonts\\verdanab.ttf").as_bytes()).unwrap(); + assert_eq!(fonts(&path), fonts(&drawing(false))); + // A font changed in AutoCAD since (the xdata left as it was) is the font read. + let edited = read_dxf(dxf.replace("verdanab.ttf", "times.ttf").as_bytes()).unwrap(); + assert!(fonts(&edited).contains(&("Notes".into(), "times.ttf".into()))); +} + +#[test] +fn without_portable_fonts_the_file_has_the_drawings_fonts() { + let files = style_files(&write_dxf(&drawing(false))); + assert!(files.contains(&("Notes".into(), "DejaVuSans-Bold.ttf".into())), "{files:?}"); +} diff --git a/crates/io/tests/it/roundtrip/cases.rs b/crates/io/tests/it/roundtrip/cases.rs new file mode 100644 index 000000000..a6031ec8f --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases.rs @@ -0,0 +1,308 @@ +//! Deterministic drawing inputs shared by DXF and DWG tests, independent of either writer. +//! Each family isolates a property so a known failure need not disable unrelated coverage. +//! Format/version limits and pending failures belong to the callers, not these generators. + +pub(super) mod blocks; +pub(super) mod complex; +pub(super) mod geometry; +pub(super) mod properties; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{ + AnnoScale, Circle, Common, Drawing, EntityKind, HAlign, Layer, Line, Linetype, Lineweight, RawTag, Space, Text, TextStyle, Transparency, VAlign, +}; +use cadcraft_geom::Vec3; + +pub(super) struct Case { + pub name: String, + pub drawing: Drawing, +} + +pub(super) fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Survey_Ä".into(), color: Color::Index(3), ..Layer::default() }); + d.linetypes.push(Linetype::simple("Dash_test", "Original synthetic dash pattern", &[2.0, -0.75, 0.0, -0.25])); + d.text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() }); + d +} + +pub(super) fn text() -> Text { + Text { + insert: Vec3::new(2.5, -3.75, 4.125), + align_pt: None, + height: 2.25, + value: "CAD Ø".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +pub(super) fn entity_case(name: &str, common: Common, kind: EntityKind) -> Case { + let mut drawing = drawing(); + drawing.add(&Space::Model, common, kind).unwrap(); + Case { name: name.into(), drawing } +} + +pub(super) fn common_case(name: &str, common: Common) -> Case { + let mut drawing = drawing(); + for kind in [ + EntityKind::Line(Line { a: Vec3::new(-1.25, 2.5, 3.75), b: Vec3::new(8.125, -13.5, 21.25) }), + EntityKind::Circle(Circle { center: Vec3::new(-7.25, 11.5, 4.75), radius: 3.125 }), + EntityKind::Text(text()), + ] { + drawing.add(&Space::Model, common.clone(), kind).unwrap(); + } + Case { name: name.into(), drawing } +} + +pub(super) fn empty_and_defaults() -> Vec { + vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())] +} + +pub(super) fn geometry() -> Vec { + let mut cases = Vec::new(); + for (name, a, b) in [ + ("zero-start", Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)), + ("zero-end", Vec3::new(-3.0, -2.0, -1.0), Vec3::ZERO), + ("small", Vec3::new(1e-9, -2e-9, 3e-9), Vec3::new(-4e-9, 5e-9, -6e-9)), + ("survey", Vec3::new(1e7 + 0.125, -2e7 - 0.25, 123.75), Vec3::new(1e7 + 0.5, -2e7 + 0.75, -42.5)), + ] { + for (direction, start, end) in [("forward", a, b), ("reverse", b, a)] { + cases.push(entity_case(&format!("line/{name}/{direction}"), Common::default(), EntityKind::Line(Line { a: start, b: end }))); + } + for radius in [1e-9, 0.125, 3.25, 1e8] { + cases.push(entity_case(&format!("circle/{name}/radius={radius}"), Common::default(), EntityKind::Circle(Circle { center: a, radius }))); + } + } + cases +} + +pub(super) fn layers() -> Vec { + ["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into() +} + +pub(super) fn layer_linetypes() -> Vec { + ["0", "Survey_Ä"] + .map(|layer| { + let mut case = common_case(&format!("common/layer={layer}/inherited-linetype"), Common { layer: layer.into(), ..Common::default() }); + case.drawing.layer_mut(layer).unwrap().linetype = "Dash_test".into(); + case + }) + .into() +} + +pub(super) fn colors() -> Vec { + [Color::ByLayer, Color::ByBlock] + .into_iter() + .chain((1..=255).map(Color::Index)) + .chain([Rgb(0, 0, 0), Rgb(255, 255, 255), Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb(17, 93, 241)].map(Color::True)) + .map(|color| common_case(&format!("common/color={color:?}"), Common { color, ..Common::default() })) + .collect() +} + +pub(super) fn linetypes() -> Vec { + ["ByLayer", "ByBlock", "Continuous", "Dash_test"] + .map(|linetype| common_case(&format!("common/linetype={linetype}"), Common { linetype: linetype.into(), ..Common::default() })) + .into() +} + +pub(super) fn lineweights() -> Vec { + [Lineweight::ByLayer, Lineweight::ByBlock, Lineweight::Default] + .into_iter() + .chain(Lineweight::STANDARD.map(Lineweight::Mm100)) + .map(|lineweight| common_case(&format!("common/lineweight={lineweight:?}"), Common { lineweight, ..Common::default() })) + .collect() +} + +pub(super) fn linetype_scales() -> Vec { + [0.125, 1.0, 3.25, 1e6].map(|ltscale| common_case(&format!("common/ltscale={ltscale}"), Common { ltscale, ..Common::default() })).into() +} + +pub(super) fn transparencies() -> Vec { + [Transparency::ByLayer, Transparency::ByBlock] + .into_iter() + .chain((0..=90).map(Transparency::Percent)) + .map(|transparency| common_case(&format!("common/transparency={transparency:?}"), Common { transparency, ..Common::default() })) + .collect() +} + +pub(super) fn visibility() -> Vec { + [false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into() +} + +pub(super) fn thicknesses() -> Vec { + [-2.5, 0.0, 3.75].map(|thickness| common_case(&format!("common/thickness={thickness}"), Common { thickness, ..Common::default() })).into() +} + +pub(super) fn plot_styles() -> Vec { + ["ByLayer", "Review"] + .map(|plot_style| common_case(&format!("common/plot_style={plot_style}"), Common { plot_style: plot_style.into(), ..Common::default() })) + .into() +} + +pub(super) fn byblock_plot_style() -> Vec { + vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })] +} + +pub(super) fn extrusions() -> Vec { + [Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 1.0, 0.0)] + .map(|extrusion| common_case(&format!("common/extrusion={extrusion:?}"), Common { extrusion, ..Common::default() })) + .into() +} + +pub(super) fn foreign_xdata() -> Vec { + let xdata = vec![ + RawTag { code: 1001, value: "ROUNDTRIP_TEST".into() }, + RawTag { code: 1000, value: "foreign text Ø 😀".into() }, + RawTag { code: 1070, value: "-17".into() }, + RawTag { code: 1040, value: "3.25".into() }, + RawTag { code: 1004, value: "00FF80".into() }, + ]; + vec![common_case("common/foreign-xdata", Common { xdata, ..Common::default() })] +} + +pub(super) fn common_interactions(plot_style: &str) -> Vec { + // Full local product; retain the original DXF case with a named plot style as well as + // an inherited-style counterpart, so a named-style loss does not hide other interactions. + let mut cases = Vec::new(); + for layer in ["0", "Survey_Ä"] { + for color in [Color::ByLayer, Color::ByBlock, Color::True(Rgb(21, 89, 233))] { + for visible in [false, true] { + let common = Common { + layer: layer.into(), + color, + visible, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + ltscale: 2.75, + transparency: Transparency::Percent(37), + thickness: -1.25, + plot_style: plot_style.into(), + ..Common::default() + }; + cases.push(common_case(&format!("common/product/{layer}/{color:?}/visible={visible}/plot_style={plot_style}"), common)); + } + } + } + cases +} + +pub(super) fn text_values() -> Vec { + [("empty", ""), ("whitespace", " a\tb "), ("unicode", "Café 图号 😀 e\u{301}")] + .map(|(name, value)| entity_case(&format!("text/value/{name}"), Common::default(), EntityKind::Text(Text { value: value.into(), ..text() }))) + .into() +} + +pub(super) fn text_literal_escapes() -> Vec { + vec![entity_case("text/value/literal-escapes", Common::default(), EntityKind::Text(Text { value: r"\U+0041 \M+183A4 \P %%c".into(), ..text() }))] +} + +pub(super) fn text_heights() -> Vec { + [1e-9, 0.125, 2.25, 1e5] + .map(|height| entity_case(&format!("text/height={height}"), Common::default(), EntityKind::Text(Text { height, ..text() }))) + .into() +} + +pub(super) fn text_rotations() -> Vec { + [0.0, -0.7, std::f64::consts::FRAC_PI_2, 5.9] + .map(|rotation| entity_case(&format!("text/rotation={rotation}"), Common::default(), EntityKind::Text(Text { rotation, ..text() }))) + .into() +} + +pub(super) fn text_widths() -> Vec { + [0.125, 1.0, 2.75] + .map(|width_factor| { + entity_case(&format!("text/width_factor={width_factor}"), Common::default(), EntityKind::Text(Text { width_factor, ..text() })) + }) + .into() +} + +pub(super) fn text_obliques() -> Vec { + [-0.3, 0.0, 0.4] + .map(|oblique| entity_case(&format!("text/oblique={oblique}"), Common::default(), EntityKind::Text(Text { oblique, ..text() }))) + .into() +} + +pub(super) fn text_styles() -> Vec { + ["Standard", "Notes"] + .map(|style| entity_case(&format!("text/style={style}"), Common::default(), EntityKind::Text(Text { style: style.into(), ..text() }))) + .into() +} + +pub(super) fn annotation_scales() -> Vec { + [vec![], vec![AnnoScale::unit()], vec![AnnoScale::unit(), AnnoScale::new("1:50", 1.0, 50.0)]] + .map(|scales| { + entity_case(&format!("text/anno_scales={scales:?}"), Common { anno_scales: scales, ..Common::default() }, EntityKind::Text(text())) + }) + .into() +} + +pub(super) fn coplanar_text_alignment() -> Vec { + // Construct additional valid single-plane inputs before serialization. Keep the original + // differing-elevation cases separately: DXF can store both XYZ points, DWG TEXT has one Z. + let mut cases = text_alignment(true); + for case in &mut cases { + case.name.push_str("/coplanar"); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + if let EntityKind::Text(t) = &mut e.kind { + t.align_pt = t.align_pt.map(|p| Vec3::new(p.x, p.y, t.insert.z)); + } + }); + } + } + cases +} + +pub(super) fn noncoplanar_text_alignment() -> Vec { + text_alignment(true) + .into_iter() + .filter(|case| case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z)))) + .collect() +} + +pub(super) fn text_alignment(with_point: bool) -> Vec { + let mut cases = Vec::new(); + let points = if with_point { vec![Some(Vec3::ZERO), Some(Vec3::new(17.25, -23.5, 6.75))] } else { vec![None] }; + for halign in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Aligned, HAlign::Middle, HAlign::Fit] { + for valign in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] { + if matches!(halign, HAlign::Aligned | HAlign::Middle | HAlign::Fit) && valign != VAlign::Baseline { + continue; + } + for &align_pt in &points { + // The unused second point of left-baseline TEXT has a separate DXF read fixture. + if halign == HAlign::Left && valign == VAlign::Baseline && align_pt.is_some() { + continue; + } + let mut t = Text { halign, valign, align_pt, rotation: 0.7, oblique: -0.2, width_factor: 1.25, style: "Notes".into(), ..text() }; + // Build a consistent baseline before serialization. No writer-derived expected + // data: the font layout API determines a valid input; both points compare exactly. + if let Some(anchor) = align_pt + && !matches!(halign, HAlign::Aligned | HAlign::Fit) + { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(anchor.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: false, + upside_down: false, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(anchor.z); + } + cases.push(entity_case(&format!("text/alignment/{halign:?}/{valign:?}/point={align_pt:?}"), Common::default(), EntityKind::Text(t))); + } + } + } + cases +} diff --git a/crates/io/tests/it/roundtrip/cases/blocks.rs b/crates/io/tests/it/roundtrip/cases/blocks.rs new file mode 100644 index 000000000..a1af6218a --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/blocks.rs @@ -0,0 +1,234 @@ +//! Stored block graphs and references, never expanded geometry. Definitions have independent +//! handles; forward, repeated and cyclic references are small bounded synthetic graphs. + +use std::sync::Arc; + +use cadcraft_color::Color; +use cadcraft_doc::{Attrib, AttribModes, AttribProps, Block, Common, Entity, EntityKind, Insert, Line, Lineweight, Space, Text}; +use cadcraft_geom::Vec3; + +use super::{Case, drawing, text}; + +pub(crate) fn insert(block: &str) -> Insert { + Insert { + block: block.into(), + insert: Vec3::new(1.25, -2.5, 3.75), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + } +} + +fn base(name: &str, reference: Insert) -> Case { + let mut drawing = drawing(); + let mut leaf = Block::new("Z_Leaf"); + leaf.entities.push(Entity::new(drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) }))); + drawing.blocks.insert(leaf.name.clone(), Arc::new(leaf)); + drawing.add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + Case { name: name.into(), drawing } +} + +pub(crate) fn transformations() -> Vec { + let mut cases = Vec::new(); + for scale in [Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 0.5, 3.0), Vec3::new(-1.0, 2.0, 1.0)] { + for rotation in [0.0, 0.7, 5.9] { + cases.push(base(&format!("insert/scale={scale:?}/rotation={rotation}"), Insert { scale, rotation, ..insert("Z_Leaf") })); + } + } + cases +} + +pub(crate) fn arrays() -> Vec { + [(1, 3), (4, 1), (3, 2)] + .map(|(cols, rows)| { + base(&format!("minsert/{cols}x{rows}"), Insert { cols, rows, col_spacing: 5.25, row_spacing: -3.75, rotation: 0.7, ..insert("Z_Leaf") }) + }) + .into() +} + +pub(crate) fn metadata() -> Vec { + let mut case = base("block/metadata", insert("Z_Leaf")); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.base = Vec3::new(4.0, -3.0, 2.0); + block.description = "Synthetic Ø 图".into(); + block.explodable = false; + block.units = 4; + vec![case] +} + +pub(crate) fn nested(cyclic: bool) -> Vec { + let mut case = base(&format!("block/nested/cyclic={cyclic}"), insert("A_Parent")); + let mut parent = Block::new("A_Parent"); + for x in [2.0, 5.0] { + parent + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(Insert { insert: Vec3::new(x, 0.0, 0.0), ..insert("Z_Leaf") }))); + } + if cyclic { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(insert("A_Parent")))); + } + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + vec![case] +} + +fn attribute(tag: &str) -> Attrib { + Attrib { + tag: tag.into(), + text: Text { insert: Vec3::ZERO, height: 1.0, value: tag.into(), ..text() }, + invisible: false, + constant: false, + prompt: String::new(), + props: AttribProps::default(), + multiline: None, + modes: AttribModes::default(), + } +} + +pub(crate) fn attributes(family: &str) -> Vec { + let mut cases = Vec::new(); + let variants = match family { + "flags" => 0..2, + "constant" => 2..4, + _ => 0..4, + }; + for variant in variants { + let mut definition = attribute("ID"); + definition.prompt = "Synthetic identifier".into(); + let mut value = attribute("ID"); + value.text.value = "Part Ø 图".into(); + match family { + "flags" | "constant" => { + definition.invisible = variant & 1 != 0; + definition.constant = variant & 2 != 0; + value.invisible = definition.invisible; + value.constant = definition.constant; + } + "modes" => { + definition.modes.verify = variant & 1 != 0; + definition.modes.preset = variant & 2 != 0; + } + "lock" => { + definition.modes.lock_position = variant & 1 != 0; + value.modes.lock_position = definition.modes.lock_position; + } + "overrides" => { + value.props = match variant { + 0 => AttribProps { layer: Some("Survey_Ä".into()), ..Default::default() }, + 1 => AttribProps { color: Some(Color::Index(3)), ..Default::default() }, + 2 => AttribProps { linetype: Some("Dash_test".into()), ..Default::default() }, + _ => AttribProps { lineweight: Some(Lineweight::Mm100(53)), ..Default::default() }, + }; + } + _ => panic!("unknown attribute family"), + } + let mut case = base(&format!("attributes/{family}/{variant}"), Insert { attribs: vec![value, attribute("SECOND")], ..insert("Z_Leaf") }); + let handle = case.drawing.new_handle(); + let second = case.drawing.new_handle(); + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + block.entities.push(Entity::new(handle, EntityKind::AttDef(definition))); + block.entities.push(Entity::new(second, EntityKind::AttDef(attribute("SECOND")))); + cases.push(case); + } + cases +} + +/// Repeated nested instances must retain their own ordered values, including empty text and +/// explicit zero overrides. Depth changes the stored owner chain, not attribute identity. +pub(crate) fn nested_attributes() -> Vec { + [1, 3] + .map(|depth| { + let mut case = attributes("flags").remove(0); + case.name = format!("block/nested-attributes/depth={depth}"); + let mut child = "Z_Leaf".to_owned(); + for level in 0..depth { + let mut parent = Block::new(&format!("Parent_{level}")); + for instance in 0..2 { + let mut value = attribute("ID"); + value.text.value = if instance == 0 { String::new() } else { format!("level={level}/instance={instance} Ø") }; + value.props = if instance == 0 { + AttribProps::default() + } else { + AttribProps { + layer: Some("Survey_Ä".into()), + color: Some(Color::ByBlock), + linetype: Some("Continuous".into()), + lineweight: Some(Lineweight::Mm100(0)), + } + }; + parent.entities.push(Entity::new( + case.drawing.new_handle(), + EntityKind::Insert(Insert { + insert: Vec3::new(f64::from(instance) * 10.0, f64::from(level), 0.0), + attribs: vec![value, attribute("SECOND")], + ..insert(&child) + }), + )); + } + // Each definition owns its own ATTDEF handles, even when its tags are repeated. + for tag in ["ID", "SECOND"] { + parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::AttDef(attribute(tag)))); + } + child.clone_from(&parent.name); + case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent)); + } + let handle = case.drawing.model.iter().next().unwrap().handle; + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.block = child; + }); + case + }) + .into() +} + +pub(crate) fn multiline_attributes() -> Vec { + // Keep the original origin witness and distinguish the fallback TEXT's point from MTEXT's. + [(Vec3::ZERO, Vec3::ZERO), (Vec3::new(12.0, 7.0, 0.0), Vec3::new(20.0, 8.0, 0.0))] + .into_iter() + .map(|(text_point, mtext_point)| { + let mut case = attributes("flags").remove(0); + case.name = format!("attributes/multiline/text-point={text_point:?}"); + let decorate = |a: &mut Attrib| { + a.text.insert = text_point; + a.text.value = "first\nsecond".into(); + a.multiline = Some(cadcraft_doc::MultilineAttrib { + mtext: cadcraft_doc::MText { + insert: mtext_point, + height: 1.0, + width: 6.0, + contents: "first\\Psecond".into(), + ..super::geometry::mtext() + }, + read_as: "first\nsecond".into(), + }); + }; + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + case.drawing.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + decorate(i.attribs.first_mut().unwrap()); + }); + } + let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap()); + let handle = block.entities.iter().find(|e| matches!(&e.kind, EntityKind::AttDef(a) if a.tag == "ID")).unwrap().handle; + block.entities.modify(handle, |e| { + let EntityKind::AttDef(a) = &mut e.kind else { panic!("expected ATTDEF") }; + decorate(a); + }); + case + }) + .collect() +} + +pub(crate) fn unreferenced() -> Vec { + let mut case = base("block/unreferenced", insert("Z_Leaf")); + case.drawing.blocks.insert("Empty".into(), Arc::new(Block::new("Empty"))); + let mut unused = Block::new("Unused"); + unused.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + case.drawing.blocks.insert(unused.name.clone(), Arc::new(unused)); + vec![case] +} diff --git a/crates/io/tests/it/roundtrip/cases/complex.rs b/crates/io/tests/it/roundtrip/cases/complex.rs new file mode 100644 index 000000000..163effa49 --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/complex.rs @@ -0,0 +1,132 @@ +//! Compound and reference-bearing entities. Geometry and cached values are authored explicitly; +//! unsupported versions are selected by callers rather than normalized after reading. + +use super::{Case, entity_case, geometry}; +use cadcraft_doc::{ + Block, Common, DimKind, DimMarks, Dimension, Entity, EntityKind, Helix, HelixConstrain, Line, MLeader, MLine, MLineJust, MLineStyle, Region, + Tolerance, +}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use std::sync::Arc; + +pub(crate) fn helices() -> Vec { + [false, true] + .into_iter() + .flat_map(|ccw| { + [HelixConstrain::Height, HelixConstrain::Turns, HelixConstrain::TurnHeight].map(|constrain| { + entity_case( + &format!("helix/{ccw}/{constrain:?}"), + Common::default(), + EntityKind::Helix(Helix { + center: Vec3::new(1.25, -2.5, 3.75), + base_radius: 3.0, + top_radius: 1.5, + height: -7.5, + turns: 2.5, + start_angle: 0.0, + ccw, + constrain, + }), + ) + }) + }) + .collect() +} + +pub(crate) fn mlines() -> Vec { + [false, true] + .into_iter() + .flat_map(|closed| { + [MLineJust::Top, MLineJust::Zero, MLineJust::Bottom].map(|just| { + entity_case( + &format!("mline/{closed}/{just:?}"), + Common::default(), + EntityKind::MLine(MLine::new(&MLineStyle::default(), &[Vec2::ZERO, Vec2::new(5.0, 0.0), Vec2::new(5.0, 4.0)], closed, just, 2.5)), + ) + }) + }) + .collect() +} + +pub(crate) fn mleaders() -> Vec { + [false, true] + .map(|spline| { + entity_case( + &format!("mleader/spline={spline}"), + Common::default(), + EntityKind::MLeader(MLeader { + leaders: vec![vec![Vec3::ZERO, Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]], + landing: Vec3::new(5.0, 4.0, 0.0), + dogleg: 0.75, + text: Some(cadcraft_doc::MText { insert: Vec3::new(5.9, 4.0, 0.0), rotation: 0.0, ..geometry::mtext() }), + style: "Standard".into(), + arrow_size: 0.3, + spline, + block: None, + }), + ) + }) + .into() +} + +pub(crate) fn regions() -> Vec { + vec![entity_case( + "region/bulged-loop", + Common::default(), + EntityKind::Region(Region::from_loop(vec![ + PolyVertex::with_bulge(Vec2::ZERO, 0.5), + PolyVertex::new(Vec2::new(5.0, 0.0)), + PolyVertex::new(Vec2::new(5.0, 4.0)), + PolyVertex::new(Vec2::new(0.0, 4.0)), + ])), + )] +} + +pub(crate) fn tolerances() -> Vec { + vec![entity_case( + "tolerance/frame", + Common::default(), + EntityKind::Tolerance(Tolerance { + insert: Vec3::new(1.25, -2.5, 3.75), + dir: Vec3::new(0.0, 1.0, 0.0), + style: "ISO-25".into(), + text: "0.125%%vA%%vB".into(), + }), + )] +} + +pub(crate) fn dimensions() -> Vec { + [DimKind::Linear { rotation: 0.0 }, DimKind::Aligned] + .map(|kind| { + let mut case = entity_case( + &format!("dimension/{kind:?}"), + Common::default(), + EntityKind::Dimension(Dimension { + kind, + defpt: Vec3::new(0.0, 3.0, 0.0), + text_mid: Vec3::new(5.0, 4.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: "<> mm".into(), + style: "ISO-25".into(), + measurement: 10.0, + text_rotation: 0.0, + user_text_pos: true, + block: Some("*D42".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: DimMarks::default(), + insertion_point: Vec3::ZERO, + }), + ); + let mut block = Block::new("*D42"); + block + .entities + .push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, 3.0, 0.0), b: Vec3::new(10.0, 3.0, 0.0) }))); + case.drawing.blocks.insert(block.name.clone(), Arc::new(block)); + case + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/cases/geometry.rs b/crates/io/tests/it/roundtrip/cases/geometry.rs new file mode 100644 index 000000000..35a2cc9dc --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/geometry.rs @@ -0,0 +1,301 @@ +//! Additional entity families with authored geometry and optional fields. No serializer output +//! is used to build an input. New failures are isolated by the format-specific test registrations. + +use cadcraft_doc::{ + Arc, Common, Ellipse, EntityKind, Face3d, Hatch, HatchLoop, Image, Leader, LwPolyline, MText, MTextColumnType, MTextColumns, Point, Polyline3d, + RayLine, Solid, Table, TableCell, Wipeout, +}; +use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; + +use super::{Case, entity_case}; + +fn case(name: &str, kind: EntityKind) -> Case { + entity_case(name, Common::default(), kind) +} + +pub(crate) fn arcs() -> Vec { + let mut cases = Vec::new(); + for radius in [1e-9, 0.125, 3.25, 1e8] { + for (start, end) in [(0.0, std::f64::consts::FRAC_PI_2), (0.7, 5.9), (5.9, 0.7)] { + cases.push(case( + &format!("arc/r={radius}/{start}..{end}"), + EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius, start, end }), + )); + } + } + cases +} + +pub(crate) fn ellipses() -> Vec { + [0.125, 0.5, 1.0] + .into_iter() + .flat_map(|ratio| { + [(0.0, std::f64::consts::TAU), (0.7, 5.9), (5.9, 0.7)].map(|(start, end)| { + case( + &format!("ellipse/{ratio}/{start}..{end}"), + EntityKind::Ellipse(Ellipse { center: Vec3::new(1.25, -2.5, 3.75), major: Vec3::new(3.0, 4.0, 0.0), ratio, start, end }), + ) + }) + }) + .collect() +} + +pub(crate) fn polylines(variable_width: bool) -> Vec { + let mut cases = Vec::new(); + for closed in [false, true] { + for plinegen in [false, true] { + for bulge in [-1.0, 0.0, 0.5, 1.0] { + cases.push(case( + &format!("lwpolyline/closed={closed}/plinegen={plinegen}/bulge={bulge}/widths={variable_width}"), + EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { + p: Vec2::new(1.25, -2.5), + bulge, + start_width: if variable_width { 0.125 } else { 0.0 }, + end_width: if variable_width { 0.75 } else { 0.0 }, + }, + PolyVertex::with_bulge(Vec2::new(5.0, 3.0), -bulge), + PolyVertex::new(Vec2::new(-2.0, 4.0)), + ], + closed, + plinegen, + const_width: if variable_width { 0.0 } else { 0.25 }, + elevation: -3.75, + }), + )); + } + } + } + cases +} + +pub(crate) fn polylines_3d() -> Vec { + [false, true] + .map(|closed| { + case( + &format!("polyline3d/closed={closed}"), + EntityKind::Polyline3d(Polyline3d { + points: vec![Vec3::new(1.25, -2.5, 3.75), Vec3::new(5.0, 6.0, -7.0), Vec3::new(-3.0, 4.0, 9.0)], + closed, + }), + ) + }) + .into() +} + +pub(crate) fn splines(rational: bool) -> Vec { + [1, 2, 3] + .map(|degree| { + let mut spline = Spline::from_control(vec![Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], degree); + if rational { + spline.weights = vec![1.0, 0.5, 2.0, 1.0]; + } + case(&format!("spline/degree={degree}/rational={rational}"), EntityKind::Spline(spline)) + }) + .into() +} + +pub(crate) fn spline_fit(closed: bool) -> Vec { + vec![case( + &format!("spline/fit/closed={closed}"), + EntityKind::Spline(Spline::from_fit(&[Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], closed)), + )] +} + +pub(crate) fn points_and_rays() -> Vec { + let base = Vec3::new(1.25, -2.5, 3.75); + vec![ + case("point", EntityKind::Point(Point { p: base, angle: 0.0 })), + case("ray", EntityKind::Ray(RayLine { base, dir: Vec3::new(0.0, 0.0, 1.0) })), + case("xline", EntityKind::XLine(RayLine { base, dir: Vec3::new(0.0, 1.0, 0.0) })), + ] +} + +pub(crate) fn faces() -> Vec { + let corners = [Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 2.0, 3.0), Vec3::new(1.0, 5.0, 3.0), Vec3::new(4.0, 5.0, 3.0)]; + let mut cases = vec![case("solid", EntityKind::Solid(Solid { corners })), case("trace", EntityKind::Trace(Solid { corners }))]; + for hidden_edges in 0..16 { + cases.push(case(&format!("face3d/hidden={hidden_edges}"), EntityKind::Face3d(Face3d { corners, hidden_edges }))); + } + cases +} + +pub(crate) fn mtext() -> MText { + MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 1, + rotation: 0.0, + style: "Notes".into(), + contents: "first\\Psecond Ø 图 😀".into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + } +} + +pub(crate) fn mtext_placement() -> Vec { + (1..=9) + .flat_map(|attach| { + [0.0, 0.7, 5.9] + .map(|rotation| case(&format!("mtext/attach={attach}/rotation={rotation}"), EntityKind::MText(MText { attach, rotation, ..mtext() }))) + }) + .collect() +} + +pub(crate) fn mtext_spacing() -> Vec { + [0.25, 1.0, 1.75, 4.0] + .into_iter() + .flat_map(|line_spacing| { + [false, true].map(|line_spacing_exact| { + case( + &format!("mtext/spacing={line_spacing}/exact={line_spacing_exact}"), + EntityKind::MText(MText { line_spacing, line_spacing_exact, ..mtext() }), + ) + }) + }) + .collect() +} + +pub(crate) fn mtext_contents() -> Vec { + ["", " a\tb ", "{\\H0.7x;\\S1/2;}\\Pnext", "Unicode Ø 图 😀 e\u{301}"] + .map(|contents| case(&format!("mtext/contents={contents:?}"), EntityKind::MText(MText { contents: contents.into(), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_carets() -> Vec { + vec![case("mtext/caret-space", EntityKind::MText(MText { contents: "\\pxi-3,l3,t3;1.\tA\\P2.\tB ^ C".into(), ..mtext() }))] +} + +pub(crate) fn mtext_long() -> Vec { + [249, 250, 251, 255, 256, 1024] + .map(|n| case(&format!("mtext/long={n}"), EntityKind::MText(MText { contents: "Ø😀".repeat(n), ..mtext() }))) + .into() +} + +pub(crate) fn mtext_columns() -> Vec { + [ + MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 0.5, height: 7.5, ..Default::default() }, + MTextColumns { + kind: MTextColumnType::Dynamic, + count: 3, + gutter: 0.5, + height: 5.0, + heights: vec![5.0, 3.0, 4.0], + flow_reversed: true, + ..Default::default() + }, + MTextColumns { kind: MTextColumnType::Dynamic, count: 1, gutter: 0.5, height: 4.0, auto_height: true, ..Default::default() }, + ] + .into_iter() + .map(|columns| case(&format!("mtext/columns={columns:?}"), EntityKind::MText(MText { columns: Some(columns), ..mtext() }))) + .collect() +} + +pub(crate) fn leaders(elevated: bool) -> Vec { + [false, true] + .into_iter() + .flat_map(|arrow| { + [false, true].map(|spline| { + case( + &format!("leader/arrow={arrow}/spline={spline}/elevated={elevated}"), + EntityKind::Leader(Leader { + vertices: vec![ + Vec3::ZERO, + Vec3::new(2.0, 3.0, if elevated { 4.0 } else { 0.0 }), + Vec3::new(5.0, 6.0, if elevated { 7.0 } else { 0.0 }), + ], + arrow, + spline, + style: "ISO-25".into(), + }), + ) + }) + }) + .collect() +} + +pub(crate) fn hatches() -> Vec { + let vertices = [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(0.0, 8.0)].map(PolyVertex::new).to_vec(); + (0..=2) + .map(|style| { + case( + &format!("hatch/solid/style={style}"), + EntityKind::Hatch(Hatch { + pattern: "SOLID".into(), + solid: true, + loops: vec![HatchLoop { vertices: vertices.clone(), outer: true, sources: Vec::new() }], + scale: 1.0, + angle: 0.0, + associative: false, + style, + elevation: 3.75, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + }), + ) + }) + .collect() +} + +pub(crate) fn tables() -> Vec { + vec![case( + "table/cells", + EntityKind::Table(Table { + insert: Vec3::new(1.25, -2.5, 0.0), + col_widths: vec![5.0, 7.5], + row_heights: vec![2.0, 3.0], + cells: vec![ + vec![TableCell { text: "Ø".into(), ..Default::default() }, TableCell { text: "图".into(), ..Default::default() }], + vec![TableCell::default(), TableCell { text: "3.25".into(), ..Default::default() }], + ], + style: "Standard".into(), + text_height: 1.25, + title: false, + header: false, + row_styles: Vec::new(), + breaks: None, + data_link: None, + }), + )] +} + +pub(crate) fn images() -> Vec { + vec![case( + "image/reference-only", + EntityKind::Image(Image { + path: "synthetic-not-loaded.png".into(), + name: "Synthetic".into(), + insert: Vec3::new(1.0, 2.0, 3.0), + u: Vec3::new(0.5, 0.0, 0.0), + v: Vec3::new(0.0, 0.25, 0.0), + size: Vec2::new(32.0, 16.0), + clip: vec![Vec2::new(2.0, 3.0), Vec2::new(20.0, 12.0)], + clipping: true, + brightness: 37, + contrast: 61, + fade: 25, + ..Image::default() + }), + )] +} + +pub(crate) fn wipeouts(explicit_closure: bool) -> Vec { + [false, true] + .into_iter() + .map(|reversed| { + let mut boundary = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)]; + if reversed { + boundary.reverse(); + } + if explicit_closure { + boundary.push(boundary[0]); + } + case(&format!("wipeout/triangle/explicit-closure={explicit_closure}/reversed={reversed}"), EntityKind::Wipeout(Wipeout { boundary })) + }) + .collect() +} diff --git a/crates/io/tests/it/roundtrip/cases/properties.rs b/crates/io/tests/it/roundtrip/cases/properties.rs new file mode 100644 index 000000000..eab95e8ac --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/properties.rs @@ -0,0 +1,351 @@ +//! Property, symbol-table and identity inputs. Values are authored before serialization; +//! each family can fail independently without suppressing unrelated preservation checks. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, DashElement, Entity, EntityKind, HAlign, Handle, Layer, Line, Lineweight, RawTag, Text, TextStyle, Transparency, VAlign}; +use cadcraft_geom::{Vec2, Vec3}; + +use super::{Case, common_case, drawing, entity_case, text}; + +pub(crate) fn mixed_case_references(kind: &str) -> Vec { + let mut common = Common::default(); + let mut t = text(); + match kind { + "layer" => common.layer = "sURVEY_Ä".into(), + "linetype" => common.linetype = "dASH_TEST".into(), + "style" => t.style = "nOTES".into(), + _ => panic!("unknown reference family"), + } + vec![entity_case(&format!("reference/{kind}/mixed-case"), common, EntityKind::Text(t))] +} + +fn layer_case(name: &str, layer: Layer) -> Case { + let mut case = common_case(name, Common { layer: "Survey_Ä".into(), ..Common::default() }); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { name: "Survey_Ä".into(), ..layer }; + case +} + +pub(crate) fn layer_flags() -> Vec { + (0..32) + .map(|bits| { + layer_case( + &format!("layer/flags={bits}"), + Layer { + on: bits & 1 == 0, + frozen: bits & 2 != 0, + locked: bits & 4 != 0, + plot: bits & 8 == 0, + vp_freeze_new: bits & 16 != 0, + ..Layer::default() + }, + ) + }) + .collect() +} + +pub(crate) fn layer_appearance() -> Vec { + [Color::Index(1), Color::Index(255), Color::True(Rgb(17, 93, 241))] + .into_iter() + .flat_map(|color| { + [0, 37, 90].map(|transparency| { + layer_case( + &format!("layer/{color:?}/alpha={transparency}"), + Layer { + color, + transparency, + linetype: "Dash_test".into(), + lineweight: Lineweight::Mm100(53), + description: "Synthetic survey Ø 图".into(), + ..Layer::default() + }, + ) + }) + }) + .collect() +} + +pub(crate) fn layer_plot_styles() -> Vec { + ["Normal", "Review"] + .map(|plot_style| layer_case(&format!("layer/plot-style={plot_style}"), Layer { plot_style: plot_style.into(), ..Layer::default() })) + .into() +} + +fn style_case(name: &str, style: TextStyle) -> Case { + let mut case = entity_case(name, Common::default(), EntityKind::Text(Text { style: "Notes".into(), ..text() })); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = TextStyle { name: "Notes".into(), ..style }; + case +} + +pub(crate) fn text_style_metrics() -> Vec { + [0.0, 0.125, 3.5] + .into_iter() + .flat_map(|height| { + [0.5, 1.0, 2.75].map(|width_factor| { + style_case( + &format!("style/height={height}/width={width_factor}"), + TextStyle { height, width_factor, oblique: std::f64::consts::FRAC_PI_4, ..TextStyle::default() }, + ) + }) + }) + .collect() +} + +pub(crate) fn text_style_flags() -> Vec { + (0..16) + .map(|bits| { + style_case( + &format!("style/flags={bits}"), + TextStyle { + backwards: bits & 1 != 0, + upside_down: bits & 2 != 0, + vertical: bits & 4 != 0, + annotative: bits & 8 != 0, + ..TextStyle::default() + }, + ) + }) + .collect() +} + +pub(crate) fn text_style_fonts() -> Vec { + // Names only; no font files are read or required. + [("CADCraft Stroke", ""), ("SyntheticSans.ttf", ""), ("synthetic.shx", "synthetic-big.shx")] + .map(|(font, big_font)| { + style_case( + &format!("style/font={font}/big={big_font}"), + TextStyle { font: font.into(), big_font: big_font.into(), ..TextStyle::default() }, + ) + }) + .into() +} + +pub(crate) fn complex_linetypes(shape: bool) -> Vec { + [false, true] + .map(|absolute| { + let mut case = + common_case(&format!("linetype/shape={shape}/absolute={absolute}"), Common { linetype: "Dash_test".into(), ..Common::default() }); + if shape { + case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap().font = "synthetic.shx".into(); + } + let l = case.drawing.linetypes.iter_mut().find(|l| l.name == "Dash_test").unwrap(); + l.pattern.push(DashElement { + text: (!shape).then(|| "GAS Ø".into()), + shape: shape.then_some(7), + style: Some("Notes".into()), + scale: 0.75, + rotation: std::f64::consts::FRAC_PI_2, + offset: Vec2::new(0.125, -0.25), + absolute, + ..DashElement::dash(-1.0) + }); + case + }) + .into() +} + +fn xdata_case(name: &str, tags: Vec<(i32, String)>) -> Case { + common_case(name, Common { xdata: tags.into_iter().map(|(code, value)| RawTag { code, value }).collect(), ..Common::default() }) +} + +pub(crate) fn xdata_lists() -> Vec { + vec![xdata_case( + "xdata/nested-and-multiple-apps", + [ + (1001, "ROUNDTRIP_A"), + (1002, "{"), + (1000, "outer"), + (1002, "{"), + (1070, "-32768"), + (1002, "}"), + (1071, "2147483647"), + (1002, "}"), + (1001, "ROUNDTRIP_B"), + (1000, ""), + (1000, "repeated Ø"), + (1004, "00FF80"), + (1041, "0.125"), + (1042, "2.75"), + ] + .map(|(c, v)| (c, v.into())) + .into(), + )] +} + +pub(crate) fn xdata_points() -> Vec { + (1010..=1013) + .map(|code| { + xdata_case( + &format!("xdata/point={code}"), + vec![(1001, "ROUNDTRIP_POINTS".into()), (code, "1.25".into()), (code + 10, "-2.5".into()), (code + 20, "3.75".into())], + ) + }) + .collect() +} + +pub(crate) fn xdata_handles() -> Vec { + let mut case = common_case("xdata/handle-reference", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + for &handle in &handles { + case.drawing.model.modify(handle, |e| { + e.common.xdata = + vec![RawTag { code: 1001, value: "ROUNDTRIP_HANDLES".into() }, RawTag { code: 1005, value: handles.last().unwrap().hex() }] + }); + } + vec![case] +} + +pub(crate) fn handles_and_order() -> Vec { + sparse_handles(&[0x1_0000, 0x1_0000_0000]) +} + +pub(crate) fn handles_near_integer_limits() -> Vec { + // Cross f64's exact-integer limit and signed i64's limit; leave room below u64::MAX + // for generated table/object handles rather than testing allocator exhaustion here. + sparse_handles(&[(1 << 53) - 0x80, (1 << 63) - 0x80, u64::MAX - 0x1_0000]) +} + +pub(crate) fn exhausted_handles() -> Case { + // The seed and writer's additional 0x1000 reservation leave no allocation room. + sparse_handles(&[u64::MAX - 0x2000]).remove(0) +} + +fn sparse_handles(bases: &[u64]) -> Vec { + bases + .iter() + .map(|&base| { + let mut drawing = drawing(); + for index in [5, 1, 9, 3, 2, 8, 4, 7, 6] { + let kind = if index % 2 == 0 { + EntityKind::Text(Text { value: format!("entity-{index}"), ..text() }) + } else { + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(index as f64, 2.0, 3.0) }) + }; + drawing.model.push(Entity::new(Handle(base + index * 17), kind)); + } + drawing.handseed = base + 0x1000; + Case { name: format!("handles/sparse/base={base:X}"), drawing } + }) + .collect() +} + +pub(crate) fn degenerate_geometry() -> Vec { + [Vec3::ZERO, Vec3::new(1e9, -1e9, 1e-9)] + .map(|p| entity_case(&format!("line/coincident={p:?}"), Common::default(), EntityKind::Line(Line { a: p, b: p }))) + .into() +} + +pub(crate) fn text_interactions() -> Vec { + let mut cases = Vec::new(); + for rotation in [-0.7, 0.0, 5.9] { + for width_factor in [0.125, 2.75] { + for oblique in [-0.3, 0.4] { + cases.push(entity_case( + &format!("text/product/{rotation}/{width_factor}/{oblique}"), + Common::default(), + EntityKind::Text(Text { + rotation, + width_factor, + oblique, + height: 0.125, + style: "Notes".into(), + value: " Ø 图 😀 ".into(), + ..text() + }), + )); + } + } + } + cases +} + +/// Four independently overridden properties on a deliberately non-default layer. The 16 masks +/// cover inheritance/override pairs without multiplying by every possible property value. +pub(crate) fn layer_inheritance_overrides() -> Vec { + (0..16) + .map(|mask| { + let common = Common { + layer: "Survey_Ä".into(), + color: if mask & 1 == 0 { Color::ByLayer } else { Color::True(Rgb(0, 0, 0)) }, + transparency: if mask & 2 == 0 { Transparency::ByLayer } else { Transparency::Percent(0) }, + lineweight: if mask & 4 == 0 { Lineweight::ByLayer } else { Lineweight::Mm100(0) }, + linetype: if mask & 8 == 0 { "ByLayer" } else { "Continuous" }.into(), + ..Common::default() + }; + let mut case = common_case(&format!("layer/inheritance-overrides={mask:04b}"), common); + *case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { + name: "Survey_Ä".into(), + color: Color::Index(3), + transparency: 67, + lineweight: Lineweight::Mm100(53), + linetype: "Dash_test".into(), + ..Layer::default() + }; + case + }) + .collect() +} + +/// Mirrored styles affect baseline placement; their metrics must not overwrite the entity's +/// own height/width/oblique. Font layout constructs consistent inputs, never expected output. +pub(crate) fn alignment_style_interactions() -> Vec { + let mut cases = Vec::new(); + for (halign, valign) in [ + (HAlign::Left, VAlign::Top), + (HAlign::Center, VAlign::Middle), + (HAlign::Right, VAlign::Baseline), + (HAlign::Aligned, VAlign::Baseline), + (HAlign::Fit, VAlign::Baseline), + ] { + for flags in 0..4 { + let style = TextStyle { + name: "Notes".into(), + height: 7.0, + width_factor: 2.0, + oblique: 0.25, + backwards: flags & 1 != 0, + upside_down: flags & 2 != 0, + ..TextStyle::default() + }; + let mut t = Text { + insert: Vec3::new(-5.0, 3.0, 0.0), + align_pt: Some(Vec3::ZERO), + style: style.name.clone(), + halign, + valign, + height: 1.5, + width_factor: 0.75, + oblique: -0.125, + rotation: 0.7, + ..text() + }; + if !matches!(halign, HAlign::Aligned | HAlign::Fit) { + let (h, v) = cadcraft_render::text_align(halign, valign); + let params = cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: Some(Vec2::ZERO), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: style.backwards, + upside_down: style.upside_down, + }; + t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(0.0); + } + let mut case = entity_case(&format!("text/alignment-style/{halign:?}/{valign:?}/flags={flags}"), Common::default(), EntityKind::Text(t)); + *case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = style; + cases.push(case); + } + } + cases +} + +pub(crate) fn long_text() -> Vec { + [254, 255, 256, 1024] + .map(|length| { + entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() })) + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/collections.rs b/crates/io/tests/it/roundtrip/collections.rs new file mode 100644 index 000000000..9b9acf36b --- /dev/null +++ b/crates/io/tests/it/roundtrip/collections.rs @@ -0,0 +1,263 @@ +//! Drawing-collection contracts beyond model space. Assertions cover explicitly authored data, +//! not generated timestamps/extents, allocation seeds or extra implementation-owned objects. +//! Named tables are compared by identity; paper entities retain order and handles. These are +//! focused collection contracts, not a claim that every Drawing/session field is persistent. + +use super::{ + cases::{self, Case}, + compare, +}; +use cadcraft_color::Color; +use cadcraft_doc::{ + Common, Drawing, EntityKind, Group, HVal, Layer, LayerState, Layout, Line, Linetype, NamedView, Space, TextStyle, Ucs, Viewport, VportConfig, + VportTile, +}; +use cadcraft_geom::{Vec2, Vec3}; + +pub(super) fn foreign_records() -> Case { + use cadcraft_doc::{Handle, RawTag, foreign::ForeignObject}; + let mut drawing = cases::drawing(); + let foreign = std::sync::Arc::make_mut(&mut drawing.foreign); + foreign.dictionary.push(("SYNTHETIC_PAYLOAD".into(), Handle(0xA000))); + foreign.targets.insert(Handle(0xA001), ("NOD".into(), String::new())); + foreign.objects.push(ForeignObject { + handle: Handle(0xA000), + kind: "XRECORD".into(), + tags: [ + (5, "A000"), + (330, "A001"), + (100, "AcDbXrecord"), + (280, "1"), + (1, "Original Ø payload"), + (90, "-7"), + (310, "00FF80"), + (10, "1.25"), + (20, "-2.5"), + (30, "3.75"), + ] + .map(|(code, value)| RawTag { code, value: value.into() }) + .into(), + }); + drawing.handseed = 0xA100; + Case { name: "drawing/foreign-xrecord".into(), drawing } +} + +pub(super) fn assert_foreign_records(a: &Drawing, b: &Drawing, context: &str) { + // Rereads may add implementation-owned dictionaries; this case owns one explicit key. + let key = "SYNTHETIC_PAYLOAD"; + let (_, handle) = a.foreign.dictionary.iter().find(|(name, _)| name == key).expect("authored dictionary entry"); + let (_, target) = b.foreign.dictionary.iter().find(|(name, _)| name == key).unwrap_or_else(|| panic!("{context}.dictionary[{key}]: missing key")); + let source = a.foreign.objects.iter().find(|o| o.handle == *handle).unwrap(); + let back = b.foreign.objects.iter().find(|o| o.handle == *target).expect("dictionary target must resolve"); + assert_eq!(back.kind, source.kind, "{context}.object.kind"); + let owner = back.tags.iter().find(|t| t.code == 330).and_then(|t| cadcraft_doc::Handle::parse_hex(&t.value)).expect("XRECORD owner"); + assert_eq!(b.foreign.targets.get(&owner), Some(&("NOD".into(), String::new())), "{context}.object.owner"); + // Foreign's documented contract reallocates object handles and remaps references. + // Identity here is the NOD key and its resolved target, not the numeric allocation. + // Preserve every payload tag (including its order) after the subclass marker. + fn payload(tags: &[cadcraft_doc::RawTag]) -> &[cadcraft_doc::RawTag] { + let start = + tags.iter().position(|t| t.code == 100 && t.value == "AcDbXrecord").unwrap_or_else(|| panic!("missing XRECORD subclass: {tags:?}")); + &tags[start + 1..] + } + assert_eq!(payload(&back.tags), payload(&source.tags), "{context}.object.payload"); +} + +const HEADER_KEYS: &[&str] = &["LTSCALE", "CELTSCALE", "LUPREC", "AUPREC", "INSBASE", "TEXTSIZE", "CLAYER", "TEXTSTYLE"]; + +pub(super) fn headers() -> Case { + let mut drawing = cases::drawing(); + drawing.header.set_f64("LTSCALE", 2.75); + drawing.header.set_f64("CELTSCALE", 0.125); + drawing.header.set_i64("LUPREC", 7); + drawing.header.set_i64("AUPREC", 3); + drawing.header.set("INSBASE", HVal::Point(Vec3::new(1.25, -2.5, 3.75))); + drawing.header.set_f64("TEXTSIZE", 3.25); + + Case { name: "drawing/header-variables".into(), drawing } +} + +pub(super) fn named_header_reference(key: &str) -> Case { + let mut case = headers(); + case.name = format!("drawing/header-reference/{key}"); + case.drawing.header.set_str( + key, + match key { + "CLAYER" => "Survey_Ä", + "TEXTSTYLE" => "Notes", + _ => panic!("unknown header reference"), + }, + ); + case +} + +pub(super) fn assert_headers(a: &Drawing, b: &Drawing, context: &str) { + for key in HEADER_KEYS { + assert_eq!(b.header.get(key), a.header.get(key), "{context}.header.{key}"); + } +} + +pub(super) fn symbol_tables() -> Case { + let mut drawing = cases::drawing(); + drawing.layers.push(Layer { + name: "UnusedLayer".into(), + color: Color::Index(5), + locked: true, + description: "Unused Ø".into(), + ..Layer::default() + }); + drawing.linetypes.push(Linetype::simple("UnusedDash", "Unused synthetic", &[3.0, -0.5, 0.0, -0.25])); + drawing.text_styles.push(TextStyle { name: "UnusedStyle".into(), font: "SyntheticSans.ttf".into(), width_factor: 1.25, ..TextStyle::default() }); + Case { name: "drawing/unreferenced-tables".into(), drawing } +} + +pub(super) fn assert_symbol_tables(a: &Drawing, b: &Drawing, context: &str) { + for layer in &a.layers { + assert_eq!(b.layer(&layer.name), Some(layer), "{context}.layer[{}]", layer.name); + } + for linetype in &a.linetypes { + assert_eq!(b.linetype(&linetype.name), Some(linetype), "{context}.linetype[{}]", linetype.name); + } + for style in &a.text_styles { + assert_eq!(b.text_style(&style.name), Some(style), "{context}.style[{}]", style.name); + } +} + +pub(super) fn views_and_ucs() -> Case { + let mut drawing = cases::drawing(); + drawing.views.push(NamedView { name: "SurveyView".into(), center: Vec2::new(1.25, -2.5), height: 25.0, width: 40.0, ..NamedView::default() }); + drawing.ucss.push(Ucs { + name: "RotatedUCS".into(), + origin: Vec3::new(3.0, 4.0, 5.0), + x_axis: Vec3::new(0.0, 1.0, 0.0), + y_axis: Vec3::new(-1.0, 0.0, 0.0), + }); + Case { name: "drawing/views-and-ucs".into(), drawing } +} + +pub(super) fn assert_views_and_ucs(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.views, a.views, "{context}.views"); + assert_eq!(b.ucss, a.ucss, "{context}.ucss"); +} + +pub(super) fn vports() -> Case { + let mut drawing = cases::drawing(); + drawing.vports.push(VportConfig { + name: "Split".into(), + tiles: vec![ + VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 }, + VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 }, + ], + }); + Case { name: "drawing/tiled-viewports".into(), drawing } +} + +pub(super) fn assert_vports(a: &Drawing, b: &Drawing, context: &str) { + for viewport in &a.vports { + assert_eq!(b.vports.iter().find(|v| v.name == viewport.name), Some(viewport), "{context}.vport[{}]", viewport.name); + } +} + +pub(super) fn groups() -> Case { + let mut case = cases::common_case("drawing/groups", Common::default()); + let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect(); + case.drawing.groups.push(Group { + name: "Synthetic".into(), + description: "Ordered overlapping membership Ø".into(), + selectable: true, + members: vec![handles[2], handles[0]], + }); + case.drawing.groups.push(Group { name: "Other".into(), description: String::new(), selectable: false, members: vec![handles[1], handles[2]] }); + case +} + +pub(super) fn assert_groups(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.groups.len(), a.groups.len(), "{context}.groups.len"); + for group in &a.groups { + assert_eq!(b.groups.iter().find(|g| g.name == group.name), Some(group), "{context}.group[{}]", group.name); + } +} + +pub(super) fn layer_states() -> Case { + let mut drawing = cases::drawing(); + let mut layers = drawing.layers.clone(); + for layer in &mut layers { + layer.on = false; + layer.locked = true; + } + drawing.layer_states.push(LayerState { name: "Saved".into(), layers }); + Case { name: "drawing/layer-state".into(), drawing } +} + +pub(super) fn assert_layer_states(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layer_states, a.layer_states, "{context}.layer_states"); +} + +pub(super) fn layouts() -> Case { + let mut drawing = cases::drawing(); + drawing.layouts = vec![Layout::new("Sheet_A", 1), Layout::new("Sheet_B", 2)]; + for (index, layout) in drawing.layouts.iter_mut().enumerate() { + layout.page.paper_mm = true; + // PageSetup stores portrait dimensions; landscape is a separate orientation flag. + layout.page.width_mm = 297.0; + layout.page.height_mm = 420.0; + layout.page.scale = 0.5; + layout.page.plot_area = "window".into(); + layout.page.plot_window = [1.0, 2.0, 201.0, 142.0]; + layout.view = Some((Vec2::new(10.0, 20.0), 100.0 + index as f64)); + } + drawing.active_layout = Some("Sheet_B".into()); + for name in ["Sheet_A", "Sheet_B"] { + drawing.add(&Space::Paper(name.into()), Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 20.0, 0.0) })).unwrap(); + drawing + .add( + &Space::Paper(name.into()), + Common::default(), + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 60.0, 0.0), + width: 80.0, + height: 50.0, + view_center: Vec2::new(5.0, 7.0), + view_height: 25.0, + id: 2, + locked: true, + frozen_layers: vec!["Survey_Ä".into()], + ..Viewport::default() + }), + ) + .unwrap(); + } + Case { name: "drawing/layouts-and-paper-entities".into(), drawing } +} + +#[test] +fn collection_assertions_reject_lost_authored_data() { + type Check = fn(&Drawing, &Drawing, &str); + type Mutate = fn(&mut Drawing); + let cases: [(Case, Check, Mutate); 6] = [ + (headers(), assert_headers, |d| d.header.set_f64("TEXTSIZE", 9.0)), + (symbol_tables(), assert_symbol_tables, |d| d.layers.retain(|l| l.name != "UnusedLayer")), + (views_and_ucs(), assert_views_and_ucs, |d| d.ucss.clear()), + (groups(), assert_groups, |d| d.groups.first_mut().unwrap().members.reverse()), + (layouts(), assert_layouts, |d| d.layouts.first_mut().unwrap().page.scale = 2.0), + (foreign_records(), assert_foreign_records, |d| std::sync::Arc::make_mut(&mut d.foreign).objects.clear()), + ]; + for (case, verify, mutate) in cases { + verify(&case.drawing, &case.drawing, &case.name); + let mut changed = case.drawing.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| verify(&case.drawing, &changed, &case.name)).is_err(), "{}: lost data must fail", case.name); + } +} + +pub(super) fn assert_layouts(a: &Drawing, b: &Drawing, context: &str) { + assert_eq!(b.layouts.len(), a.layouts.len(), "{context}.layouts.len"); + assert_eq!(b.active_layout, a.active_layout, "{context}.active_layout"); + for layout in &a.layouts { + let back = b.layouts.iter().find(|l| l.name == layout.name).expect("layout must survive"); + assert_eq!(back.tab_order, layout.tab_order, "{context}.{}.tab_order", layout.name); + assert_eq!(back.page, layout.page, "{context}.{}.page", layout.name); + assert_eq!(back.view, layout.view, "{context}.{}.view", layout.name); + compare::entities(a, b, &layout.entities, &back.entities, &format!("{context}.layout[{}]", layout.name)); + } +} diff --git a/crates/io/tests/it/roundtrip/compare.rs b/crates/io/tests/it/roundtrip/compare.rs new file mode 100644 index 000000000..d4bd1541a --- /dev/null +++ b/crates/io/tests/it/roundtrip/compare.rs @@ -0,0 +1,374 @@ +//! Shared preservation assertions for ordered entities, authored blocks and referenced tables. +//! Every field of the explicitly supported entities is checked; converted angles alone allow +//! floating-point roundoff, never wrapping or geometric approximation. Blocks are visited once +//! as stored definitions, not recursively expanded, so cyclic references remain bounded. +//! Header defaults, generated objects and other drawing collections are outside this slice. + +use std::fmt::Debug; + +use cadcraft_doc::{Arc, Attrib, Block, Circle, Common, Drawing, Entity, EntityKind, EntityStore, Insert, Line, MText, Text}; + +fn equal(expected: &T, actual: &T, context: &str, field: &str) { + assert_eq!(actual, expected, "{context}.{field}"); +} + +fn angle(expected: f64, actual: f64, context: &str, field: &str) { + // Two floating-point unit conversions, not a geometric approximation or angle wrapping. + let tolerance = 8.0 * f64::EPSILON * expected.abs().max(1.0); + assert!( + expected.is_finite() && actual.is_finite() && (actual - expected).abs() <= tolerance, + "{context}.{field}: expected {expected:?}, actual {actual:?}, tolerance {tolerance:e}" + ); +} + +pub(super) fn model(expected: &Drawing, actual: &Drawing, context: &str) { + entities(expected, actual, &expected.model, &actual.model, &format!("{context}.model")); + for (name, block) in &expected.blocks { + let path = format!("{context}.block[{name}]"); + let back = actual.block(name).unwrap_or_else(|| panic!("{path}: missing block")); + let Block { + name, + annotative, + base, + entities: contents, + description, + xref_overlay, + xref_status, + anonymous, + xref_path, + explodable, + units, + array, + } = block.as_ref(); + equal(name, &back.name, &path, "name"); + equal(annotative, &back.annotative, &path, "annotative"); + equal(base, &back.base, &path, "base"); + equal(description, &back.description, &path, "description"); + equal(xref_overlay, &back.xref_overlay, &path, "xref_overlay"); + equal(xref_status, &back.xref_status, &path, "xref_status"); + equal(anonymous, &back.anonymous, &path, "anonymous"); + equal(xref_path, &back.xref_path, &path, "xref_path"); + equal(explodable, &back.explodable, &path, "explodable"); + equal(units, &back.units, &path, "units"); + equal(array, &back.array, &path, "array"); + entities(expected, actual, contents, &back.entities, &path); + } +} + +pub(super) fn entities(expected: &Drawing, actual: &Drawing, source: &EntityStore, back: &EntityStore, context: &str) { + equal(&source.len(), &back.len(), context, "len"); + for (index, (a, b)) in source.iter().zip(back.iter()).enumerate() { + let path = format!("{context}.entity[{index}]"); + entity(a, b, &path); + layer_reference(expected, actual, &a.common.layer, &path); + linetype_reference(expected, actual, &a.common.linetype, &path); + let dimension_style = match &a.kind { + EntityKind::Dimension(d) => Some(d.style.as_str()), + EntityKind::Leader(l) => Some(l.style.as_str()), + EntityKind::Tolerance(t) => Some(t.style.as_str()), + _ => None, + }; + if let Some(name) = dimension_style { + equal(&expected.dim_style(name), &actual.dim_style(name), &path, "referenced_dimension_style"); + if let Some(style) = expected.dim_style(name) { + equal(&expected.text_style(&style.text_style), &actual.text_style(&style.text_style), &path, "dimension_text_style"); + } + } + let style = match &a.kind { + EntityKind::Text(t) => Some(t.style.as_str()), + EntityKind::MText(t) => Some(t.style.as_str()), + EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.style.as_str()), + _ => None, + }; + if let Some(style) = style { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + let attributes: &[Attrib] = match &a.kind { + EntityKind::AttDef(a) => std::slice::from_ref(a), + EntityKind::Insert(i) => &i.attribs, + EntityKind::MLeader(m) => m.block.as_ref().map_or(&[], |i| i.attribs.as_slice()), + _ => &[], + }; + for (index, a) in attributes.iter().enumerate() { + let path = format!("{path}.attrib[{index}]"); + if let Some(name) = &a.props.layer { + layer_reference(expected, actual, name, &path); + } + if let Some(name) = &a.props.linetype { + linetype_reference(expected, actual, name, &path); + } + for style in std::iter::once(a.text.style.as_str()).chain(a.multiline.as_ref().map(|m| m.mtext.style.as_str())) { + equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style"); + } + } + } +} + +fn layer_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + let layer = expected.layer(name); + equal(&layer, &actual.layer(name), context, "referenced_layer"); + if let Some(layer) = layer { + linetype_reference(expected, actual, &layer.linetype, context); + } +} + +fn linetype_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) { + linetype(expected.linetype(name), actual.linetype(name), &format!("{context}.linetype[{name}]")); + if let Some(linetype) = expected.linetype(name) { + for element in &linetype.pattern { + if let Some(style) = &element.style { + equal(&expected.text_style(style), &actual.text_style(style), context, "linetype_text_style"); + } + } + } +} + +fn linetype(expected: Option<&cadcraft_doc::Linetype>, actual: Option<&cadcraft_doc::Linetype>, context: &str) { + let (Some(a), Some(b)) = (expected, actual) else { + equal(&expected, &actual, context, "presence"); + return; + }; + let cadcraft_doc::Linetype { name, description, pattern } = a; + equal(name, &b.name, context, "name"); + equal(description, &b.description, context, "description"); + equal(&pattern.len(), &b.pattern.len(), context, "pattern.len"); + for (index, (a, b)) in pattern.iter().zip(&b.pattern).enumerate() { + let path = format!("{context}.element[{index}]"); + let cadcraft_doc::DashElement { length, text, shape, style, scale, rotation, offset, absolute } = a; + equal(length, &b.length, &path, "length"); + equal(text, &b.text, &path, "text"); + equal(shape, &b.shape, &path, "shape"); + equal(style, &b.style, &path, "style"); + equal(scale, &b.scale, &path, "scale"); + angle(*rotation, b.rotation, &path, "rotation"); + equal(offset, &b.offset, &path, "offset"); + equal(absolute, &b.absolute, &path, "absolute"); + } +} + +fn entity(expected: &Entity, actual: &Entity, context: &str) { + let Entity { handle, common: properties, kind } = expected; + equal(handle, &actual.handle, context, "handle"); + common(properties, &actual.common, &format!("{context}.common")); + match (kind, &actual.kind) { + (EntityKind::Line(Line { a, b }), EntityKind::Line(other)) => { + equal(a, &other.a, context, "line.a"); + equal(b, &other.b, context, "line.b"); + } + (EntityKind::Circle(Circle { center, radius }), EntityKind::Circle(other)) => { + equal(center, &other.center, context, "circle.center"); + equal(radius, &other.radius, context, "circle.radius"); + } + (EntityKind::Text(a), EntityKind::Text(b)) => text(a, b, &format!("{context}.text")), + (EntityKind::Arc(Arc { center, radius, start, end }), EntityKind::Arc(b)) => { + equal(center, &b.center, context, "arc.center"); + equal(radius, &b.radius, context, "arc.radius"); + angle(*start, b.start, context, "arc.start"); + angle(*end, b.end, context, "arc.end"); + } + (EntityKind::MText(a), EntityKind::MText(b)) => mtext(a, b, context), + (EntityKind::AttDef(a), EntityKind::AttDef(b)) => attrib(a, b, context), + (EntityKind::Insert(a), EntityKind::Insert(b)) => insert(a, b, context), + ( + EntityKind::Point(_) + | EntityKind::Ellipse(_) + | EntityKind::LwPolyline(_) + | EntityKind::Polyline3d(_) + | EntityKind::Spline(_) + | EntityKind::Ray(_) + | EntityKind::XLine(_) + | EntityKind::Solid(_) + | EntityKind::Trace(_) + | EntityKind::Face3d(_) + | EntityKind::Leader(_) + | EntityKind::Hatch(_) + | EntityKind::Table(_) + | EntityKind::Image(_) + | EntityKind::Wipeout(_) + | EntityKind::Helix(_) + | EntityKind::MLine(_) + | EntityKind::MLeader(_) + | EntityKind::Region(_) + | EntityKind::Dimension(_) + | EntityKind::Tolerance(_) + | EntityKind::Viewport(_), + _, + ) => equal(kind, &actual.kind, context, "kind"), + _ => panic!("{context}.kind: unsupported or changed type: expected {kind:?}, actual {:?}", actual.kind), + } +} + +fn common(expected: &Common, actual: &Common, context: &str) { + // No `..`: adding a field requires deciding its preservation contract here. + let Common { layer, color, linetype, lineweight, ltscale, transparency, visible, thickness, extrusion, plot_style, anno_scales, xdata } = + expected; + equal(layer, &actual.layer, context, "layer"); + equal(color, &actual.color, context, "color"); + equal(linetype, &actual.linetype, context, "linetype"); + equal(lineweight, &actual.lineweight, context, "lineweight"); + equal(ltscale, &actual.ltscale, context, "ltscale"); + equal(transparency, &actual.transparency, context, "transparency"); + equal(visible, &actual.visible, context, "visible"); + equal(thickness, &actual.thickness, context, "thickness"); + equal(extrusion, &actual.extrusion, context, "extrusion"); + equal(plot_style, &actual.plot_style, context, "plot_style"); + equal(anno_scales, &actual.anno_scales, context, "anno_scales"); + equal(xdata, &actual.xdata, context, "xdata"); +} + +fn text(expected: &Text, actual: &Text, context: &str) { + let Text { insert, align_pt, height, value, rotation, width_factor, oblique, style, halign, valign } = expected; + equal(insert, &actual.insert, context, "insert"); + equal(align_pt, &actual.align_pt, context, "align_pt"); + equal(height, &actual.height, context, "height"); + equal(value, &actual.value, context, "value"); + angle(*rotation, actual.rotation, context, "rotation"); + equal(width_factor, &actual.width_factor, context, "width_factor"); + angle(*oblique, actual.oblique, context, "oblique"); + equal(style, &actual.style, context, "style"); + equal(halign, &actual.halign, context, "halign"); + equal(valign, &actual.valign, context, "valign"); +} + +fn mtext(expected: &MText, actual: &MText, context: &str) { + let MText { insert, height, width, attach, rotation, style, contents, line_spacing, line_spacing_exact, columns } = expected; + equal(insert, &actual.insert, context, "mtext.insert"); + equal(height, &actual.height, context, "mtext.height"); + equal(width, &actual.width, context, "mtext.width"); + equal(attach, &actual.attach, context, "mtext.attach"); + angle(*rotation, actual.rotation, context, "mtext.rotation"); + equal(style, &actual.style, context, "mtext.style"); + equal(contents, &actual.contents, context, "mtext.contents"); + equal(line_spacing, &actual.line_spacing, context, "mtext.line_spacing"); + equal(line_spacing_exact, &actual.line_spacing_exact, context, "mtext.line_spacing_exact"); + equal(columns, &actual.columns, context, "mtext.columns"); +} + +fn attrib(expected: &Attrib, actual: &Attrib, context: &str) { + let Attrib { tag, text: t, invisible, constant, prompt, props, multiline, modes } = expected; + equal(tag, &actual.tag, context, "attrib.tag"); + text(t, &actual.text, context); + equal(invisible, &actual.invisible, context, "attrib.invisible"); + equal(constant, &actual.constant, context, "attrib.constant"); + equal(prompt, &actual.prompt, context, "attrib.prompt"); + equal(props, &actual.props, context, "attrib.props"); + equal(modes, &actual.modes, context, "attrib.modes"); + match (multiline, &actual.multiline) { + (Some(a), Some(b)) => { + equal(&a.read_as, &b.read_as, context, "attrib.read_as"); + mtext(&a.mtext, &b.mtext, context); + } + _ => equal(multiline, &actual.multiline, context, "attrib.multiline"), + } +} + +fn insert(expected: &Insert, actual: &Insert, context: &str) { + let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing } = expected; + equal(block, &actual.block, context, "insert.block"); + equal(insert, &actual.insert, context, "insert.insert"); + equal(scale, &actual.scale, context, "insert.scale"); + angle(*rotation, actual.rotation, context, "insert.rotation"); + equal(cols, &actual.cols, context, "insert.cols"); + equal(rows, &actual.rows, context, "insert.rows"); + equal(col_spacing, &actual.col_spacing, context, "insert.col_spacing"); + equal(row_spacing, &actual.row_spacing, context, "insert.row_spacing"); + equal(&attribs.len(), &actual.attribs.len(), context, "insert.attribs.len"); + for (index, (a, b)) in attribs.iter().zip(&actual.attribs).enumerate() { + attrib(a, b, &format!("{context}.attrib[{index}]")); + } +} + +#[test] +fn comparison_rejects_lost_fields_and_changed_handles() { + use cadcraft_doc::Handle; + use cadcraft_geom::Vec3; + + let original = Entity::new(Handle(0x123), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) })); + let mutations: [fn(&mut Entity); 4] = [ + |e| e.handle = Handle(0x456), + |e| e.common.visible = false, + |e| e.common.xdata.push(cadcraft_doc::RawTag { code: 1000, value: "unexpected".into() }), + |e| e.kind = EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 2.0 }), + ]; + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| entity(&original, &changed, "comparison self-test")).is_err()); + } +} + +#[test] +fn comparison_rejects_lost_or_changed_referenced_linetypes() { + use cadcraft_doc::{Linetype, Space}; + use cadcraft_geom::Vec3; + + for (name, entity_linetype, layer_linetype) in [ + ("direct", "ReviewDashed", "Continuous"), + ("mixed-case", "reviewdashed", "Continuous"), + ("inherited", "ByLayer", "ReviewDashed"), + ("inherited-mixed-case", "ByLayer", "reviewdashed"), + ] { + let mut original = Drawing::new_metric(); + original.linetypes.push(Linetype::simple("ReviewDashed", "Synthetic", &[2.0, -1.0])); + original.layer_mut("0").unwrap().linetype = layer_linetype.into(); + original + .add( + &Space::Model, + Common { linetype: entity_linetype.into(), ..Common::default() }, + EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 3.0) }), + ) + .unwrap(); + let referenced = if entity_linetype == "ByLayer" { layer_linetype } else { entity_linetype }; + assert!(original.linetype(referenced).is_some(), "{name}: valid reference"); + model(&original, &original, name); + let mutations: [fn(&mut Drawing); 2] = [ + |d| d.linetypes.retain(|l| l.name != "ReviewDashed"), + |d| d.linetypes.iter_mut().find(|l| l.name == "ReviewDashed").unwrap().pattern.clear(), + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, name)).is_err(), "{name}: mutation {index} must be rejected"); + } + } +} + +#[test] +fn comparison_rejects_block_and_attribute_dependency_losses() { + use super::cases::{blocks, properties}; + let original = blocks::attributes("overrides").remove(0).drawing; + let mutations: [fn(&mut Drawing); 4] = [ + |d| { + d.blocks.remove("Z_Leaf"); + }, + |d| std::sync::Arc::make_mut(d.blocks.get_mut("Z_Leaf").unwrap()).base.x += 1.0, + |d| d.layer_mut("Survey_Ä").unwrap().color = cadcraft_color::Color::Index(5), + |d| { + let handle = d.model.iter().next().unwrap().handle; + d.model.modify(handle, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") }; + i.attribs.reverse(); + }); + }, + ]; + model(&original, &original, "unchanged block and attributes"); + for mutate in mutations { + let mut changed = original.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "block dependency self-test")).is_err()); + } + let original = properties::complex_linetypes(false).remove(0).drawing; + let mut changed = original.clone(); + changed.text_styles.retain(|s| s.name != "Notes"); + assert!(std::panic::catch_unwind(|| model(&original, &changed, "linetype style self-test")).is_err()); + let cyclic = blocks::nested(true).remove(0).drawing; + model(&cyclic, &cyclic, "cycles compare without expansion"); +} + +#[test] +fn angle_tolerance_does_not_hide_real_changes_or_nonfinite_values() { + angle(0.7, 0.7_f64.to_degrees().to_radians(), "self-test", "angle"); + for actual in [0.700001, f64::NAN, f64::INFINITY] { + assert!(std::panic::catch_unwind(|| angle(0.7, actual, "self-test", "angle")).is_err()); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/conformance.rs b/crates/io/tests/it/roundtrip/dwg/conformance.rs new file mode 100644 index 000000000..ff58850ce --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/conformance.rs @@ -0,0 +1,87 @@ +//! Independent bridge expectations, not an independent DWG codec. Writer checks inspect native +//! objects without CADCraft's reader; reader fixtures originate in native objects without our +//! DXF writer. acadrust still supplies the binary codec in both directions, so these tests do +//! not establish interoperability with another DWG implementation. All fixtures are synthetic. + +use super::super::cases; +use acadrust::types::Vector3; +use acadrust::{CadDocument, DwgReader, DwgWriter, DxfVersion, EntityType}; +use cadcraft_doc::{Circle, Common, EntityKind, Line, Text}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; +use std::io::Cursor; + +#[test] +fn writer_populates_native_geometry_without_our_reader() { + let source = cases::common_case("native-writer", Common::default()); + for version in Version::ALL { + let bytes = write_with(&source.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let entities: Vec<_> = native.entities().collect(); + assert_eq!(entities.len(), 3, "{version}"); + let EntityType::Line(line) = entities[0] else { panic!("expected native LINE") }; + assert_eq!(line.start, Vector3::new(-1.25, 2.5, 3.75), "{version}"); + assert_eq!(line.end, Vector3::new(8.125, -13.5, 21.25), "{version}"); + let EntityType::Circle(circle) = entities[1] else { panic!("expected native CIRCLE") }; + assert_eq!(circle.center, Vector3::new(-7.25, 11.5, 4.75), "{version}"); + assert_eq!(circle.radius, 3.125, "{version}"); + let EntityType::Text(text) = entities[2] else { panic!("expected native TEXT") }; + assert_eq!(text.value, "CAD Ø", "{version}"); + assert_eq!(text.insertion_point, Vector3::new(2.5, -3.75, 4.125), "{version}"); + assert_eq!(text.height, 2.25, "{version}"); + assert_eq!(text.width_factor, 1.0, "{version}"); + assert_eq!(text.rotation, 0.0, "{version}"); + } +} + +#[test] +#[ignore = "Confirmed native DWG mismatch: a pi/2 linetype rotation is stored as pi/2 degrees; DXF unit interpretation needs investigation"] +fn complex_linetype_rotation_is_correct_in_native_objects() { + // Both document APIs specify radians. Do not infer correctness from a DXF round trip: + // reader and writer can share a unit error. Public DXF references disagree about LTYPE + // group 50, so this asserts the end-to-end native angle, not a guessed DXF encoding. + for shape in [false, true] { + for case in cases::properties::complex_linetypes(shape) { + for version in Version::ALL { + let bytes = write_with(&case.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap(); + let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap(); + let line = native.line_types.iter().find(|l| l.name == "Dash_test").unwrap(); + let elements: Vec<_> = line.elements.iter().filter_map(|e| e.complex.as_ref()).collect(); + assert_eq!(elements.len(), 1, "{} {version}", case.name); + let rotation = elements[0].rotation; + assert!( + (rotation - std::f64::consts::FRAC_PI_2).abs() <= 8.0 * f64::EPSILON, + "{} {version}: native rotation {rotation} radians, expected pi/2", + case.name + ); + } + } + } +} + +#[test] +fn reader_accepts_native_fixtures_without_our_writer() { + for version in [DxfVersion::AC1015, DxfVersion::AC1018, DxfVersion::AC1021, DxfVersion::AC1024, DxfVersion::AC1027, DxfVersion::AC1032] { + let mut native = CadDocument::with_version(version); + let handles = [ + native.add_entity(EntityType::Line(acadrust::entities::Line::from_coords(1.25, -2.5, 3.75, -4.5, 5.25, -6.75))).unwrap(), + native.add_entity(EntityType::Circle(acadrust::entities::Circle::from_coords(7.25, -8.5, 9.75, 2.125))).unwrap(), + native.add_entity(EntityType::Text(acadrust::entities::Text::with_value("Independent Ø", Vector3::new(-3.25, 4.5, 1.75)))).unwrap(), + ]; + let expected = [ + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 }), + EntityKind::Text(Text { insert: Vec3::new(-3.25, 4.5, 1.75), height: 1.0, value: "Independent Ø".into(), ..cases::text() }), + ]; + let bytes = DwgWriter::write_to_vec(&native).unwrap(); + let back = read(&bytes, "native-fixture.dwg").unwrap(); + assert!(back.read_notes.is_empty(), "{:?}", back.read_notes); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.len(), expected.len()); + for ((entity, expected), handle) in back.model.iter().zip(&expected).zip(handles) { + assert_eq!(entity.handle.0, u64::from(handle), "{version:?}"); + assert_eq!(&entity.kind, expected, "{version:?}"); + assert_eq!(entity.common, Common::default(), "{version:?}"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/dimensions.rs b/crates/io/tests/it/roundtrip/dwg/dimensions.rs new file mode 100644 index 000000000..3599d43f5 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/dimensions.rs @@ -0,0 +1,210 @@ +//! DWG anonymous dimension-block suffixes are not persistent identities (#2257). Match blocks +//! through dimensions with preserved entity handles, then compare authored content and properties +//! strictly. Only anonymous *D names may change; missing references and merged blocks fail. +//! This verifier covers the model-space dimension fixtures, not arbitrary drawing graph remapping. +//! +//! Autodesk documents that anonymous block numbers may change between drawing sessions: +//! https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-AutoLISP/files/GUID-EB9BFDAA-487A-4586-A556-1F5F84BCF2F3.htm +//! ODA's DWG specification §20.4.52.1 shows a BLOCK_HEADER named *D without a suffix; +//! DIMENSION records reference their block by handle, not by its reconstructed name: +//! https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::Version; + +use super::super::{ + cases::{self, Case, complex}, + compare, +}; + +pub(super) fn check() { + for case in complex::dimensions().into_iter().chain([paired_dimensions(["*D42", "*D7"])]) { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + // DIMFXL has no native field before R2007 (same limit as other dimension-style cases). + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version_using(&case.name, &case.drawing, &expected, version, equivalent); + } + } +} + +// Two independent cache blocks with different authored geometry, plus an ordinary named block. +// Names are supplied explicitly so comparator controls do not learn expectations from a save. +fn paired_dimensions(names: [&str; 2]) -> Case { + let mut drawing = cases::drawing(); + for (index, (case, name)) in complex::dimensions().into_iter().zip(names).enumerate() { + let entity = case.drawing.model.iter().next().unwrap(); + let mut kind = entity.kind.clone(); + let EntityKind::Dimension(dim) = &mut kind else { panic!("fixture dimension") }; + dim.block = Some(name.into()); + drawing.add(&Space::Model, entity.common.clone(), kind).unwrap(); + let mut block = Block::new(name); + for entity in case.drawing.block("*D42").unwrap().entities.iter() { + block.entities.push(Entity { handle: drawing.new_handle(), ..entity.as_ref().clone() }); + } + // This circle is authored cache content, not something dimension regeneration produces. + block + .entities + .push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(5.0, 3.0, 0.0), radius: 0.25 + index as f64 }))); + drawing.blocks.insert(name.into(), Arc::new(block)); + } + let mut named = Block::new("UserPart"); + named.entities.push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 2.0, 0.0), radius: 2.0 }))); + drawing.blocks.insert(named.name.clone(), Arc::new(named)); + Case { name: "dimension/distinct-anonymous-caches-and-named-block".into(), drawing } +} + +fn anonymous_dimension_name(name: &str) -> bool { + name.strip_prefix("*D").is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_digit())) +} + +fn equivalent(expected: &Drawing, actual: &Drawing, context: &str) { + let mut names = BTreeMap::new(); + for entity in expected.model.iter() { + let EntityKind::Dimension(dim) = &entity.kind else { continue }; + let before = dim.block.as_deref().expect("fixture has a dimension block"); + let back = actual.entity(entity.handle).unwrap_or_else(|| panic!("{context}: missing dimension {}", entity.handle.hex())); + let EntityKind::Dimension(dim) = &back.kind else { panic!("{context}: dimension changed type") }; + let after = dim.block.as_deref().unwrap_or_else(|| panic!("{context}: dimension lost its block reference")); + let source = expected.block(before).expect("fixture block exists"); + let target = actual.block(after).unwrap_or_else(|| panic!("{context}: dangling dimension block reference {after}")); + if before != after { + assert!(source.anonymous && target.anonymous, "{context}: only anonymous dimension blocks may be renamed"); + assert!( + anonymous_dimension_name(before) && anonymous_dimension_name(after), + "{context}: unexpected block name mapping {before} -> {after}" + ); + } + if let Some(previous) = names.insert(before.to_string(), after.to_string()) { + assert_eq!(previous, after, "{context}: one dimension block split into different references"); + } + } + assert!(!names.is_empty(), "{context}: fixture must exercise dimension block references"); + assert_eq!(names.values().collect::>().len(), names.len(), "{context}: distinct dimension blocks were merged"); + assert_eq!(actual.blocks.len(), expected.blocks.len(), "{context}: block lost or extra cache generated"); + + // Adapt only the validated names, never content or properties learned from the output. + // Rebuild the map to handle renumberings that reuse another source block's old name. + let mut renamed = expected.clone(); + renamed.blocks.clear(); + for (name, block) in &expected.blocks { + let mapped = names.get(name).unwrap_or(name); + let mut block = block.clone(); + Arc::make_mut(&mut block).name = mapped.clone(); + assert!(renamed.blocks.insert(mapped.clone(), block).is_none(), "{context}: block name collision"); + } + for entity in expected.model.iter() { + if let EntityKind::Dimension(dim) = &entity.kind { + let mapped = names.get(dim.block.as_ref().expect("fixture block")).expect("mapped reference"); + renamed.model.modify(entity.handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = Some(mapped.clone()); + }); + } + } + compare::model(&renamed, actual, context); +} + +#[test] +fn comparison_accepts_only_anonymous_dimension_renumbering() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + equivalent(&original, &original, "unchanged"); + for names in [["*D1", "*D2"], ["*D7", "*D42"]] { + let renamed = paired_dimensions(names).drawing; + equivalent(&original, &renamed, "renumbered, including reused source names"); + // The general comparator must still reject renaming outside this DWG-specific check. + assert!(std::panic::catch_unwind(|| compare::model(&original, &renamed, "strict comparison")).is_err()); + } +} + +fn dimension_handles(d: &Drawing) -> Vec { + d.model.iter().filter(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).collect() +} + +fn set_reference(d: &mut Drawing, handle: Handle, name: Option<&str>) { + assert!(d.model.modify(handle, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.block = name.map(str::to_string); + })); +} + +#[test] +fn comparison_rejects_broken_references_content_and_block_properties() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + let renamed = paired_dimensions(["*D1", "*D2"]).drawing; + equivalent(&original, &renamed, "valid control"); + let mutations: [fn(&mut Drawing); 14] = [ + |d| set_reference(d, dimension_handles(d)[0], None), + |d| set_reference(d, dimension_handles(d)[0], Some("*D999")), + |d| set_reference(d, dimension_handles(d)[0], Some("*D2")), + |d| { + let hs = dimension_handles(d); + set_reference(d, hs[0], Some("*D2")); + set_reference(d, hs[1], Some("*D1")); + }, + |d| { + d.blocks.remove("*D1"); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).entities = Default::default(), + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.common.visible = false); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().last().unwrap().handle; + block.entities.modify(h, |e| { + let EntityKind::Circle(circle) = &mut e.kind else { panic!("fixture circle") }; + circle.radius += 1.0; + }); + }, + |d| { + let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap()); + let h = block.entities.iter().next().unwrap().handle; + block.entities.modify(h, |e| e.handle = Handle(0xFFFF)); + }, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).base.x += 1.0, + |d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).anonymous = false, + |d| { + d.blocks.insert("*D999".into(), Arc::new(Block::new("*D999"))); + }, + |d| { + let mut block = d.blocks.remove("UserPart").unwrap(); + Arc::make_mut(&mut block).name = "RenamedPart".into(); + d.blocks.insert("RenamedPart".into(), block); + }, + |d| { + let h = dimension_handles(d)[0]; + d.model.modify(h, |e| { + let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") }; + dim.p14.x += 1.0; + }); + }, + ]; + for (index, mutate) in mutations.into_iter().enumerate() { + let mut changed = renamed.clone(); + mutate(&mut changed); + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "mutation")).is_err(), "mutation {index} must fail"); + } +} + +#[test] +fn comparison_rejects_changed_prefixes_and_named_dimension_cache_renaming() { + let original = paired_dimensions(["*D42", "*D7"]).drawing; + for name in ["*U1", "*D", "*Dcustom", "UserCache"] { + let changed = paired_dimensions([name, "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "wrong name kind")).is_err()); + } + let original = paired_dimensions(["AuthoredCache", "*D7"]).drawing; + let changed = paired_dimensions(["RenamedCache", "*D2"]).drawing; + assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "named cache")).is_err()); +} diff --git a/crates/io/tests/it/roundtrip/dwg/entities.rs b/crates/io/tests/it/roundtrip/dwg/entities.rs new file mode 100644 index 000000000..58f97d92f --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/entities.rs @@ -0,0 +1,171 @@ +//! DWG property families stay separate: a failing escape, plot-style or alignment regression +//! must not disable unrelated TEXT or Common tests. Pending cases assert the original model, +//! never today's lossy output. Only confirmed failures receive an explicit ignore reason. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, EntityKind, Transparency}; +use cadcraft_io::Version; + +use super::super::cases; +use super::{check, check_version}; + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style Review becomes ByLayer, including R2018"] +fn named_plot_style_is_preserved() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: named plot style is lost in combination with other properties"] +fn common_property_interactions_with_named_plot_style() { + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +#[ignore = "Confirmed DWG bridge loss: literal Unicode/MIF escape text is decoded again, including R2018"] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); +} +#[test] +#[ignore = "Confirmed change: missing justified TEXT align_pt becomes (0,0,elevation), moving text"] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: ByBlock plot style becomes ByLayer"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} +#[test] +#[ignore = "Confirmed loss on the shared DXF path: non-default extrusion becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} + +#[test] +fn text_points_share_one_elevation_in_dwg() { + // Public ODA DWG specification, TEXT (1): insertion and alignment points contain XY; + // a single elevation supplies both Z coordinates. This is a representational limit, not + // a bug or a comparison tolerance. DXF's original XYZ cases are still checked unchanged. + // https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf + let cases = cases::noncoplanar_text_alignment(); + assert!(!cases.is_empty()); + for case in cases { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + let EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + let point = t.align_pt.as_mut().expect("second point"); + point.z = t.insert.z; + }); + } + for version in Version::ALL { + check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn r2000_explicit_color_and_transparency_downgrade() { + // R2000 has no true-color/alpha fields. An exact red RGB maps to ACI 1; neither a fixed + // alpha nor transparency ByBlock is representable. Expected values are authored here, + // not learnt from an initial save, and are checked again after the second save. + for transparency in [Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)] { + let case = cases::common_case( + &format!("legacy/true-red/transparency={transparency:?}"), + Common { color: Color::True(Rgb(255, 0, 0)), transparency, ..Common::default() }, + ); + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| { + e.common.color = Color::Index(1); + e.common.transparency = Transparency::ByLayer; + }); + } + check_version(&case.name, &case.drawing, &expected, Version::R2000); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/extended.rs b/crates/io/tests/it/roundtrip/dwg/extended.rs new file mode 100644 index 000000000..001712d28 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/extended.rs @@ -0,0 +1,395 @@ +//! Native DWG extensions to the shared preservation matrix. Version limits (native columns, +//! attributes, HELIX/MULTILEADER and table fields) are explicit, as are named-reference +//! resolution and legacy defaults. Other inputs use the full save-version domain. + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use super::check; +use cadcraft_io::Version; + +// DWG references a table record by handle, not by an independently spelled name. These +// explicit expected spellings test resolution, not a blanket case-folding comparator. +fn canonical_reference(kind: &str, name: &str) { + for case in properties::mixed_case_references(kind) { + let mut expected = case.drawing.clone(); + let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect(); + for handle in handles { + expected.model.modify(handle, |e| match kind { + "layer" => e.common.layer = name.into(), + "linetype" => e.common.linetype = name.into(), + "style" => { + let cadcraft_doc::EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") }; + t.style = name.into(); + } + _ => panic!("unknown reference family"), + }); + } + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +#[test] +fn legacy_block_metadata() { + // DWG BLOCK_HEADER gains insertion units and explodability in R2007 (ODA specification). + for case in blocks::metadata() { + let mut expected = case.drawing.clone(); + let block = std::sync::Arc::make_mut(expected.blocks.get_mut("Z_Leaf").unwrap()); + block.explodable = true; + block.units = 0; + for version in [Version::R2000, Version::R2004] { + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} + +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + canonical_reference("layer", "Survey_Ä"); +} +#[test] +fn mixed_case_linetypes() { + canonical_reference("linetype", "Dash_test"); +} +#[test] +fn mixed_case_styles() { + canonical_reference("style", "Notes"); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +#[ignore = "Confirmed DWG loss: named layer plot style Review becomes Normal"] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); +} +#[test] +#[ignore = "Confirmed DWG loss: periodic closed fit spline reopens as an open spline"] +fn closed_fit_splines() { + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DWG loss: MTEXT columns disappear"] +fn mtext_columns() { + since_2007(geometry::mtext_columns()); +} +#[test] +fn leaders() { + with_dimension_styles(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed shared DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed shared DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +#[ignore = "Confirmed DWG loss: a space following an MTEXT caret is consumed"] +fn mtext_carets() { + check(geometry::mtext_carets()); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + since_2007(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + with_dimension_styles(complex::tolerances()); +} +#[test] +fn dimensions() { + super::dimensions::check(); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); +} +#[test] +#[ignore = "Confirmed DWG loss: current layer CLAYER becomes 0"] +fn header_current_layer() { + collection(collections::named_header_reference("CLAYER"), collections::assert_headers); +} +#[test] +fn header_text_style() { + collection(collections::named_header_reference("TEXTSTYLE"), collections::assert_headers); +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +#[ignore = "Confirmed DWG data loss: saving an unregistered entity succeeds but drops it instead of preserving it or returning an error"] +fn unknown_entities_are_preserved_or_explicitly_refused() { + for version in Version::ALL { + super::super::unknown::check("memory.dwg", version, false); + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} + +fn with_dimension_styles(cases: Vec) { + // Fixed-length extension lines (DIMFXL) were added in 2007. Older DWG records have no + // slot for the inactive default length, and the bridge supplies zero on read. + for case in cases { + for version in Version::ALL { + let mut expected = case.drawing.clone(); + if version < Version::R2007 { + for style in &mut expected.dim_styles { + style.fixed_ext_length = 0.0; + } + } + super::check_version(&case.name, &case.drawing, &expected, version); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/mod.rs b/crates/io/tests/it/roundtrip/dwg/mod.rs new file mode 100644 index 000000000..6f9c90e1d --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/mod.rs @@ -0,0 +1,75 @@ +//! Native DWG Drawing → bytes → Drawing preservation, using the same cases and assertions as +//! DXF. Every representable case runs at all six save versions and through two saves. R2000's +//! lack of true color/transparency is a format limit, tested separately with explicit expected +//! downgrades; it is not a normalization of modern output. TEXT's single elevation is also +//! checked separately for non-coplanar inputs. Extended cases also specify handle-based name +//! resolution, anonymous dimension-block names and legacy block/dimension-style limits +//! explicitly. Other differences fail. +//! Native-object conformance checks bypass one bridge direction, not the underlying codec. +//! +//! Run `cargo test -p cadcraft-io --test it roundtrip::dwg`; add `-- --ignored` to reproduce +//! pending defects. No DWG bytes are imported from another application or kept as fixtures. + +mod conformance; +mod dimensions; +mod entities; +mod extended; + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, EntityKind, Transparency}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases, compare}; + +fn check(cases: Vec) { + for case in cases { + assert!( + !case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z))), + "{}: non-coplanar TEXT belongs in the explicit single-elevation test", + case.name + ); + let mut tested = 0; + for version in Version::ALL { + if version == Version::R2000 && needs_color_or_transparency(&case.drawing) { + continue; + } + check_version(&case.name, &case.drawing, &case.drawing, version); + tested += 1; + } + assert!(tested > 0, "{}: the version domain must not exclude a whole case", case.name); + } +} + +fn needs_color_or_transparency(d: &Drawing) -> bool { + d.model.iter().any(|e| matches!(e.common.color, Color::True(_)) || e.common.transparency != Transparency::ByLayer) + || d.layers.iter().any(|l| matches!(l.color, Color::True(_)) || l.transparency != 0) +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + check_version_using(case, original, expected, version, |a, b, context| { + compare::model(a, b, context); + extra(a, b, context); + }); +} + +fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, version: Version, verify: impl Fn(&Drawing, &Drawing, &str)) { + let options = SaveOptions { version: Some(version), binary: false }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · DWG {version} · pass={pass}"); + let bytes = write_with(¤t, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + verify(expected, &back, &context); + if pass == 2 { + verify(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/attributes.rs b/crates/io/tests/it/roundtrip/dxf/attributes.rs new file mode 100644 index 000000000..0962f7bc9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/attributes.rs @@ -0,0 +1,163 @@ +//! Check multiline ATTRIB values, embedded geometry and legacy placement independently. +//! Unchanged MTEXT rendering does not excuse corrupted attribute values or fallback TEXT +//! justification (#2255). Full field preservation remains in `extended::multiline_attributes`; +//! the legacy-placement test must reach its geometry assertion even while that check fails. +//! ODA's DWG specification §20.4.4 maps the R2018+ embedded object to DXF group 101. + +use super::super::cases::blocks; +use cadcraft_doc::{Attrib, Drawing, EntityKind, Space}; +use cadcraft_io::{SaveOptions, Version}; +use cadcraft_render::{DisplayList, Options, build}; + +const EDITED_VALUE: &str = "edited {value}\nthird line"; +const EDITED_CONTENTS: &str = "edited \\{value\\}\\Pthird line"; + +/// The fixture's primary attribute, not its unrelated single-line control or its ATTDEF. +fn primary(drawing: &Drawing) -> &Attrib { + let mut attributes = drawing + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.attribs.iter()), + _ => None, + }) + .flatten() + .filter(|a| a.tag == "ID"); + let attribute = attributes.next().expect("primary ATTRIB must survive"); + assert!(attributes.next().is_none(), "primary ATTRIB must not be duplicated"); + attribute +} + +fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) { + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + let mut changed = 0; + for handle in handles { + drawing.model.modify(handle, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + for a in i.attribs.iter_mut().filter(|a| a.tag == "ID") { + edit(a); + changed += 1; + } + } + }); + } + assert_eq!(changed, 1, "edit exactly the primary ATTRIB"); +} + +fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing { + let options = SaveOptions { version: Some(version), binary }; + let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + back +} + +fn geometry(drawing: &Drawing) -> DisplayList { + build(drawing, &Space::Model, &Options::default()) +} + +fn same_geometry(expected: &DisplayList, actual: &DisplayList, context: &str) { + assert!(!expected.prims.is_empty(), "{context}: nonempty fixture"); + assert_eq!(expected.prims.len(), actual.prims.len(), "{context}: primitive count"); + for (a, b) in expected.prims.iter().zip(&actual.prims) { + assert_eq!(a.kind, b.kind, "{context}: primitive kind"); + assert_eq!(expected.points(a), actual.points(b), "{context}: world-space geometry"); + } +} + +/// Check saved data, not only the effective text drawn by `multiline_text()`. That method can +/// hide a corrupt value/read_as pair or replace stale embedded contents from the plain value. +fn saved_text(drawing: &Drawing, value: &str, contents: &str, context: &str) { + let a = primary(drawing); + assert_eq!(a.text.value, value, "{context}: attribute value"); + let m = a.multiline.as_ref().expect("embedded MTEXT must survive"); + assert_eq!(m.read_as, value, "{context}: read_as"); + assert_eq!(m.mtext.contents, contents, "{context}: raw embedded contents"); +} + +#[test] +fn multiline_geometry_and_value_edits_survive_two_saves() { + for case in blocks::multiline_attributes() { + let expected = geometry(&case.drawing); + let mut edited = case.drawing.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + let edited_expected = geometry(&edited); + assert_ne!(expected.verts, edited_expected.verts, "value edit must actually change the drawn text"); + for binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{} · R2018 · binary={binary} · pass={pass}", case.name); + current = reopen(¤t, Version::R2018, binary, &context); + saved_text(¤t, "first\nsecond", "first\\Psecond", &context); + same_geometry(&expected, &geometry(¤t), &context); + let mut edited = current.clone(); + edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into()); + same_geometry(&edited_expected, &geometry(&edited), &format!("{context} · edit after reopening")); + let context = format!("{context} · save after editing"); + let back = reopen(&edited, Version::R2018, binary, &context); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, &context); + same_geometry(&edited_expected, &geometry(&back), &context); + } + } + } +} + +#[test] +fn saved_text_checks_reject_corruption_hidden_by_rendering() { + let mut source = blocks::multiline_attributes().remove(0).drawing; + edit_primary(&mut source, |a| a.text.value = EDITED_VALUE.into()); + let back = reopen(&source, Version::R2018, true, "mutation control"); + saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, "mutation control"); + for field in ["value", "read_as", "contents", "value-and-read_as"] { + let mut corrupt = back.clone(); + edit_primary(&mut corrupt, |a| { + let m = a.multiline.as_mut().unwrap(); + match field { + "value" => a.text.value = "CORRUPTED VALUE".into(), + "read_as" => m.read_as = "CORRUPTED VALUE".into(), + "contents" => m.mtext.contents = "CORRUPTED CONTENTS".into(), + "value-and-read_as" => { + a.text.value = "CORRUPTED VALUE".into(); + m.read_as.clone_from(&a.text.value); + } + _ => unreachable!(), + } + }); + if field == "value-and-read_as" { + same_geometry(&geometry(&back), &geometry(&corrupt), "corruption invisible to geometry"); + } + assert!(std::panic::catch_unwind(|| saved_text(&corrupt, EDITED_VALUE, EDITED_CONTENTS, field)).is_err(), "must reject {field}"); + } +} + +#[test] +#[ignore = "Confirmed DXF placement loss (#2255): R2018 save changes subsequent legacy ATTRIB placement; existing reader fix #1942"] +fn legacy_placement_is_independent_of_an_intermediate_modern_save() { + for case in blocks::multiline_attributes() { + for version in [Version::R2000, Version::R2004] { + for binary in [false, true] { + let legacy_context = format!("{} · legacy={version} · binary={binary}", case.name); + let direct = reopen(&case.drawing, version, binary, &legacy_context); + let a = primary(&direct); + assert!(a.multiline.is_none(), "{legacy_context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{legacy_context}: fallback value"); + let expected = geometry(&direct); + for modern_binary in [false, true] { + let mut current = case.drawing.clone(); + for pass in 1..=2 { + let context = format!("{legacy_context} · intermediate=R2018 · binary={modern_binary} · pass={pass}"); + current = reopen(¤t, Version::R2018, modern_binary, &context); + let indirect = reopen(¤t, version, binary, &context); + let a = primary(&indirect); + assert!(a.multiline.is_none(), "{context}: single-line fallback required"); + assert_eq!(a.text.value, "first second", "{context}: fallback value"); + same_geometry(&expected, &geometry(&indirect), &context); + } + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/common.rs b/crates/io/tests/it/roundtrip/dxf/common.rs new file mode 100644 index 000000000..6e40977c9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/common.rs @@ -0,0 +1,197 @@ +//! Independent input and output contracts for common LINE/CIRCLE/TEXT properties. Read fixtures +//! are handwritten DXF, never writer output; write expectations are literal groups, never a +//! reread Drawing. Absence, explicit zero and inheritance are distinct. Pending tests localize +//! the existing extrusion and ByBlock plot-style losses without accepting them as correct. +//! References: https://ezdxf.readthedocs.io/en/stable/dxfentities/dxfgfx.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/true_color.html +//! https://ezdxf.readthedocs.io/en/stable/concepts/transparency.html + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn fixture(properties: &str) -> Drawing { + let records = [ + ("LINE", "AcDbLine", "10\n1\n20\n2\n30\n3\n11\n4\n21\n5\n31\n6\n"), + ("CIRCLE", "AcDbCircle", "10\n7\n20\n8\n30\n9\n40\n2.5\n"), + ("TEXT", "AcDbText", "10\n3\n20\n4\n40\n2.5\n1\nfixture\n"), + ]; + let mut bytes = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n"); + for (kind, subclass, geometry) in records { + bytes.push_str(&format!("0\n{kind}\n100\nAcDbEntity\n{properties}100\n{subclass}\n{geometry}")); + } + bytes.push_str("0\nENDSEC\n0\nEOF\n"); + read(bytes.as_bytes(), "fixture.dxf").unwrap() +} + +fn assert_read(properties: &str, expected: &Common) { + let drawing = fixture(properties); + assert_eq!(drawing.model.len(), 3); + assert!(drawing.skipped_records.is_empty()); + for entity in drawing.model.iter() { + assert_eq!(&entity.common, expected, "{} with groups {properties:?}", entity.kind.type_name()); + } +} + +fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec)> { + let mut drawing = super::conformance::model(); + if drawing.layer(&common.layer).is_none() { + drawing.layers.push(Layer::new(&common.layer)); + } + let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect(); + for handle in handles { + drawing.model.modify(handle, |e| e.common = common.clone()); + } + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::>(), ["LINE", "CIRCLE", "TEXT"]); + entities +} + +fn assert_write(common: Common, present: &[Tag], absent: &[i32]) { + for version in Version::ALL { + for binary in [false, true] { + for (kind, groups) in output(&common, version, binary) { + let context = format!("{version} binary={binary} {kind} {common:?}"); + for tag in present { + assert_eq!(groups.iter().filter(|t| t.code == tag.code).collect::>(), [tag], "{context}: group {}", tag.code); + } + for code in absent { + assert!(!groups.iter().any(|t| t.code == *code), "{context}: group {code} should be omitted"); + } + } + } + } +} + +#[test] +fn missing_optional_groups_and_explicit_defaults() { + assert_read("", &Common::default()); + assert_read("8\n0\n6\nByLayer\n62\n256\n370\n-1\n48\n1\n60\n0\n39\n0\n210\n0\n220\n0\n230\n1\n", &Common::default()); + // Omission is this writer's convention, not a requirement that other DXF writers omit them. + assert_write(Common::default(), &[Tag::s(8, "0")], &[6, 62, 370, 48, 60, 39, 420, 440, 210, 220, 230]); +} + +#[test] +fn layer_linetype_scale_visibility_and_thickness_have_independent_groups() { + let common = + Common { layer: "Fixture".into(), linetype: "Continuous".into(), ltscale: 2.75, visible: false, thickness: -1.25, ..Common::default() }; + assert_read("8\nFixture\n6\nContinuous\n48\n2.75\n60\n1\n39\n-1.25\n", &common); + assert_write(common, &[Tag::s(8, "Fixture"), Tag::s(6, "Continuous"), Tag::f(48, 2.75), Tag::i(60, 1), Tag::f(39, -1.25)], &[62, 420, 440]); + assert_read("6\nByBlock\n", &Common { linetype: "ByBlock".into(), ..Common::default() }); + assert_write(Common { linetype: "ByBlock".into(), ..Common::default() }, &[Tag::s(6, "ByBlock")], &[]); +} + +#[test] +fn lineweight_distinguishes_logical_modes_and_explicit_zero() { + for (value, lineweight) in [ + (-1, Lineweight::ByLayer), + (-2, Lineweight::ByBlock), + (-3, Lineweight::Default), + (0, Lineweight::Mm100(0)), + (53, Lineweight::Mm100(53)), + (211, Lineweight::Mm100(211)), + ] { + let common = Common { lineweight, ..Common::default() }; + assert_read(&format!("370\n{value}\n"), &common); + if value == -1 { + assert_write(common, &[], &[370]); + } else { + assert_write(common, &[Tag::i(370, value)], &[]); + } + } +} + +#[test] +fn aci_color_distinguishes_inheritance_and_explicit_color() { + for (value, color) in + [(256, Color::ByLayer), (0, Color::ByBlock)].into_iter().chain((1..=255).map(|index| (i64::from(index), Color::Index(index)))) + { + let common = Common { color, ..Common::default() }; + assert_read(&format!("62\n{value}\n"), &common); + if value == 256 { + assert_write(common, &[], &[62, 420]); + } else { + assert_write(common, &[Tag::i(62, value)], &[420]); + } + } +} + +#[test] +fn true_color_overrides_aci_in_either_tag_order_including_black() { + // 0x12_34_56 = 1,193,046. The black value 0 must not be mistaken for an absent group. + for (value, rgb) in [(1_193_046, Rgb(0x12, 0x34, 0x56)), (0, Rgb(0, 0, 0))] { + let common = Common { color: Color::True(rgb), ..Common::default() }; + for aci in [0, 1, 256] { + assert_read(&format!("62\n{aci}\n420\n{value}\n"), &common); + assert_read(&format!("420\n{value}\n62\n{aci}\n"), &common); + } + assert_write(common, &[Tag::i(420, value)], &[]); + } +} + +#[test] +fn transparency_distinguishes_inheritance_and_explicit_opacity() { + assert_read("", &Common::default()); + assert_write(Common::default(), &[], &[440]); + // High byte: 01 = ByBlock, 02 = explicit alpha. Low byte: opacity, not transparency. + // Independent integer oracle: floor(255 * (1 - p/100)) = 255 - ceil(255*p/100). + // No production alpha conversion is used. Check every supported integer percentage. + for (raw, transparency) in std::iter::once((0x0100_0000, Transparency::ByBlock)).chain((0..=90).map(|percent| { + let opacity = 255 - (255 * i64::from(percent) + 99) / 100; + (0x0200_0000 + opacity, Transparency::Percent(percent)) + })) { + let common = Common { transparency, ..Common::default() }; + assert_read(&format!("440\n{raw}\n"), &common); + assert_write(common, &[Tag::i(440, raw)], &[]); + } + // Another writer may round up instead; both neighboring alpha values map to the same + // integer percentage. This is a read expectation, not normalization of writer output. + assert_read("440\n33554560\n", &Common { transparency: Transparency::Percent(50), ..Common::default() }); + assert_read("440\n33554458\n", &Common { transparency: Transparency::Percent(90), ..Common::default() }); +} + +#[test] +fn reads_line_extrusion_without_changing_world_space_endpoints() { + for (groups, expected) in [("210\n0\n220\n0\n230\n-1\n", Vec3::new(0.0, 0.0, -1.0)), ("220\n1\n230\n0\n", Vec3::new(0.0, 1.0, 0.0))] { + let drawing = fixture(groups); + let line = drawing.model.iter().next().unwrap(); + assert_eq!(line.common.extrusion, expected); + let EntityKind::Line(geometry) = &line.kind else { panic!("expected LINE") }; + assert_eq!(geometry.a, Vec3::new(1.0, 2.0, 3.0)); + assert_eq!(geometry.b, Vec3::new(4.0, 5.0, 6.0)); + } +} + +#[test] +#[ignore = "Known writer loss: a non-default extrusion is omitted; the independent LINE read test passes"] +fn writes_nondefault_extrusion_groups() { + // X/Y may be omitted at zero, but Z=-1 must not disappear into the default Z=1. + assert_write(Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }, &[Tag::f(230, -1.0)], &[]); +} + +#[test] +#[ignore = "Known writer loss: ByLayer and ByBlock plot styles produce identical files"] +fn writer_must_distinguish_bylayer_from_byblock_plot_style() { + // The available group-380 HEADER enum is not sufficient evidence for a universal entity + // encoding; do not prescribe an unverified tag here. Identical complete outputs prove the + // loss is already in the writer, independently of any reader or representation choice. + let by_layer = super::conformance::model(); + let mut by_block = by_layer.clone(); + let handles: Vec<_> = by_block.model.iter().map(|e| e.handle).collect(); + for handle in handles { + by_block.model.modify(handle, |e| e.common.plot_style = "ByBlock".into()); + } + for version in Version::ALL { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let a = write_with(&by_layer, "memory.dxf", &options).unwrap(); + let b = write_with(&by_block, "memory.dxf", &options).unwrap(); + assert!(a != b, "{version} binary={binary}: plot style is not encoded anywhere in the file"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/conformance.rs b/crates/io/tests/it/roundtrip/dxf/conformance.rs new file mode 100644 index 000000000..3f036be2c --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/conformance.rs @@ -0,0 +1,185 @@ +//! Independent synthetic entity records prevent matching reader/writer mistakes from passing +//! the round-trip suite. Only public DXF group meanings are used; no reference-app files or assets. +//! Entity semantics: https://ezdxf.readthedocs.io/en/stable/dxfentities/text.html +//! and https://ezdxf.readthedocs.io/en/stable/dxfentities/line.html + +use cadcraft_color::Color; +use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::compare; + +const ENTITIES: &[u8] = b"0\nSECTION\n2\nENTITIES\n\ +0\nLINE\n5\n100\n8\n0\n62\n3\n60\n1\n39\n-2.5\n10\n1.25\n20\n-2.5\n30\n3.75\n11\n-4.5\n21\n5.25\n31\n-6.75\n\ +0\nCIRCLE\n5\n101\n8\n0\n10\n7.25\n20\n-8.5\n30\n9.75\n40\n2.125\n\ +0\nTEXT\n5\n102\n8\n0\n10\n-3.25\n20\n4.5\n30\n1.75\n40\n2.5\n1\nfixture text\n50\n30\n41\n1.25\n51\n-15\n7\nStandard\n\ +0\nENDSEC\n0\nEOF\n"; + +pub(super) fn model() -> Drawing { + let mut d = Drawing::new_metric(); + d.add( + &Space::Model, + Common { color: Color::Index(3), visible: false, thickness: -2.5, ..Common::default() }, + EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }), + ) + .unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 })).unwrap(); + d.add( + &Space::Model, + Common::default(), + EntityKind::Text(Text { + insert: Vec3::new(-3.25, 4.5, 1.75), + align_pt: None, + height: 2.5, + value: "fixture text".into(), + rotation: 30_f64.to_radians(), + width_factor: 1.25, + oblique: -15_f64.to_radians(), + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }), + ) + .unwrap(); + d +} + +#[test] +fn reads_independent_line_circle_and_text_records() { + let back = read(ENTITIES, "fixture.dxf").unwrap(); + compare::model(&model(), &back, "independent entity fixture"); +} + +#[test] +fn writes_standard_entity_groups_not_just_self_readable_data() { + let expected = [ + ( + "LINE", + vec![ + Tag::s(5, "100"), + Tag::i(62, 3), + Tag::i(60, 1), + Tag::f(39, -2.5), + Tag::f(10, 1.25), + Tag::f(20, -2.5), + Tag::f(30, 3.75), + Tag::f(11, -4.5), + Tag::f(21, 5.25), + Tag::f(31, -6.75), + ], + ), + ("CIRCLE", vec![Tag::s(5, "101"), Tag::f(10, 7.25), Tag::f(20, -8.5), Tag::f(30, 9.75), Tag::f(40, 2.125)]), + ( + "TEXT", + vec![ + Tag::s(5, "102"), + Tag::f(10, -3.25), + Tag::f(20, 4.5), + Tag::f(30, 1.75), + Tag::f(40, 2.5), + Tag::s(1, "fixture text"), + Tag::f(41, 1.25), + Tag::s(7, "Standard"), + ], + ), + ]; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), expected.len()); + for ((kind, groups), (expected_kind, expected_groups)) in entities.iter().zip(&expected) { + let context = format!("{version} binary={binary} {expected_kind}"); + assert_eq!(kind, expected_kind, "{context}"); + for tag in expected_groups { + let values: Vec<_> = groups.iter().filter(|t| t.code == tag.code).collect(); + assert_eq!(values, [tag], "{context}: group {}", tag.code); + } + if kind == "TEXT" { + for (code, degrees) in [(50, 30.0), (51, -15.0)] { + let values: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(values.len(), 1, "{context}: group {code}"); + assert!((values[0].f64() - degrees).abs() < 1e-13, "{context}: group {code} is in degrees"); + } + } + } + } + } +} + +#[test] +fn reads_text_alignment_codes_independently_of_the_writer() { + let horizontal = [(0, HAlign::Left), (1, HAlign::Center), (2, HAlign::Right), (3, HAlign::Aligned), (4, HAlign::Middle), (5, HAlign::Fit)]; + let vertical = [(0, VAlign::Baseline), (1, VAlign::Bottom), (2, VAlign::Middle), (3, VAlign::Top)]; + for (hcode, halign) in horizontal { + for (vcode, valign) in vertical { + if hcode >= 3 && vcode != 0 { + continue; + } + let bytes = format!( + "0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n30\n5\n40\n2.5\n1\nA\n72\n{hcode}\n73\n{vcode}\n11\n6\n21\n7\n31\n8\n0\nENDSEC\n0\nEOF\n" + ); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!((text.halign, text.valign), (halign, valign), "72={hcode}, 73={vcode}"); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 5.0)); + let expected_point = if hcode == 0 && vcode == 0 { None } else { Some(Vec3::new(6.0, 7.0, 8.0)) }; + assert_eq!(text.align_pt, expected_point, "72={hcode}, 73={vcode}"); + } + } +} + +// Odd values above 2^53, above i64::MAX and near u64::MAX expose floating-point coercion, +// signed conversion and truncation. Literal hex and numeric values are independent expectations. +const LARGE_HANDLES: [(u64, &str); 3] = + [(9_007_199_254_740_993, "20000000000001"), (9_223_372_036_854_775_809, "8000000000000001"), (18_446_744_073_709_486_081, "FFFFFFFFFFFF0001")]; + +#[test] +fn reads_large_literal_hex_handles_without_numeric_coercion() { + let mut fixture = String::from("0\nSECTION\n2\nENTITIES\n"); + for (_, hex) in LARGE_HANDLES { + fixture.push_str(&format!("0\nLINE\n5\n{hex}\n8\n0\n10\n0\n20\n0\n11\n1\n21\n2\n")); + } + fixture.push_str("0\nENDSEC\n0\nEOF\n"); + let back = read(fixture.as_bytes(), "fixture.dxf").unwrap(); + assert!(back.read_notes.is_empty()); + assert!(back.skipped_records.is_empty()); + assert_eq!(back.model.iter().map(|e| e.handle.0).collect::>(), LARGE_HANDLES.map(|(numeric, _)| numeric)); +} + +#[test] +fn writes_large_handles_as_exact_hex_groups() { + let mut drawing = Drawing::new_metric(); + for (numeric, _) in LARGE_HANDLES { + drawing.model.push(Entity::new(Handle(numeric), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 0.0) }))); + } + drawing.handseed = 0xffff_ffff_ffff_1000; + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(entities.len(), LARGE_HANDLES.len()); + for ((kind, groups), (_, hex)) in entities.iter().zip(LARGE_HANDLES) { + assert_eq!(kind, "LINE"); + assert_eq!(groups.iter().filter(|t| t.code == 5).collect::>(), [&Tag::s(5, hex)], "{version} binary={binary} handle={hex}"); + } + } + } +} + +#[test] +fn left_baseline_text_ignores_the_unused_second_alignment_point() { + // DXF TEXT groups 11/21/31 are meaningful only when 72 or 73 is nonzero. The model + // intentionally represents the unused point as None, not Some(Vec3::ZERO). + let bytes = b"0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n40\n2.5\n1\nA\n72\n0\n73\n0\n11\n0\n21\n0\n31\n0\n0\nENDSEC\n0\nEOF\n"; + let back = read(bytes, "fixture.dxf").unwrap(); + let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") }; + assert_eq!(text.align_pt, None); + assert_eq!(text.insert, Vec3::new(3.0, 4.0, 0.0)); +} diff --git a/crates/io/tests/it/roundtrip/dxf/entities.rs b/crates/io/tests/it/roundtrip/dxf/entities.rs new file mode 100644 index 000000000..5816d5017 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/entities.rs @@ -0,0 +1,121 @@ +//! DXF runs every shared input family in both encodings and all writable versions. Keep known +//! failures isolated from other property sweeps; the comparison contract is never weakened. + +use super::super::cases; + +fn check(cases: Vec) { + for case in cases { + super::check(&case.name, &case.drawing); + } +} + +#[test] +fn empty_model_and_defaults() { + check(cases::empty_and_defaults()); +} +#[test] +fn line_endpoints_and_circle_centers_radii() { + check(cases::geometry()); +} +#[test] +fn layers() { + check(cases::layers()); +} +#[test] +fn layer_inherited_linetypes() { + check(cases::layer_linetypes()); +} +#[test] +fn colors() { + check(cases::colors()); +} +#[test] +fn linetypes() { + check(cases::linetypes()); +} +#[test] +fn lineweights() { + check(cases::lineweights()); +} +#[test] +fn linetype_scales() { + check(cases::linetype_scales()); +} +#[test] +fn transparencies() { + check(cases::transparencies()); +} +#[test] +fn visibility() { + check(cases::visibility()); +} +#[test] +fn thicknesses() { + check(cases::thicknesses()); +} +#[test] +fn plot_styles() { + check(cases::plot_styles()); +} +#[test] +fn foreign_xdata() { + check(cases::foreign_xdata()); +} +#[test] +fn common_property_interactions() { + check(cases::common_interactions("ByLayer")); + check(cases::common_interactions("Review")); +} +#[test] +fn text_values() { + check(cases::text_values()); +} +#[test] +fn text_literal_escapes() { + check(cases::text_literal_escapes()); +} +#[test] +fn text_heights() { + check(cases::text_heights()); +} +#[test] +fn text_rotations() { + check(cases::text_rotations()); +} +#[test] +fn text_widths() { + check(cases::text_widths()); +} +#[test] +fn text_obliques() { + check(cases::text_obliques()); +} +#[test] +fn text_styles() { + check(cases::text_styles()); +} +#[test] +fn annotation_scales() { + check(cases::annotation_scales()); +} +#[test] +fn text_alignment_with_points() { + check(cases::coplanar_text_alignment()); + check(cases::noncoplanar_text_alignment()); +} +#[test] +fn text_alignment_without_points() { + check(cases::text_alignment(false)); +} + +#[test] +#[ignore = "Known writer loss: ByBlock plot style becomes ByLayer on the first R2000 ASCII save"] +fn plot_style_byblock_is_preserved() { + check(cases::byblock_plot_style()); +} + +#[test] +#[ignore = "Known writer loss: groups 210/220/230 are omitted; a LINE's normal becomes (0,0,1)"] +fn nondefault_extrusion_is_preserved() { + check(cases::extrusions()); +} diff --git a/crates/io/tests/it/roundtrip/dxf/extended.rs b/crates/io/tests/it/roundtrip/dxf/extended.rs new file mode 100644 index 000000000..4e8415337 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/extended.rs @@ -0,0 +1,334 @@ +//! Extended DXF property families. Pending losses stay separate from passing families; +//! version domains are explicit for Lock position, HELIX/MULTILEADER (2007) and the native +//! multiline-attribute representation (2018). + +use super::super::cases::{Case, blocks, complex, geometry, properties}; +use super::super::collections; +use cadcraft_io::Version; + +fn check(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + super::check(&case.name, &case.drawing); + } +} +fn since_2007(cases: Vec) { + assert!(!cases.is_empty()); + for case in cases { + for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) { + super::check_version(&case.name, &case.drawing, &case.drawing, version); + } + } +} + +#[test] +fn mixed_case_layers() { + check(properties::mixed_case_references("layer")); +} +#[test] +fn mixed_case_linetypes() { + check(properties::mixed_case_references("linetype")); +} +#[test] +fn mixed_case_styles() { + check(properties::mixed_case_references("style")); +} +#[test] +fn layer_flags() { + check(properties::layer_flags()); +} +#[test] +fn layer_appearance() { + check(properties::layer_appearance()); +} +#[test] +fn layer_plot_styles() { + check(properties::layer_plot_styles()); +} +#[test] +fn text_style_metrics() { + check(properties::text_style_metrics()); +} +#[test] +fn text_style_flags() { + check(properties::text_style_flags()); +} +#[test] +fn text_style_fonts() { + check(properties::text_style_fonts()); +} +#[test] +fn linetype_text() { + check(properties::complex_linetypes(false)); +} +#[test] +fn linetype_shapes() { + check(properties::complex_linetypes(true)); +} +#[test] +fn xdata_lists() { + check(properties::xdata_lists()); +} +#[test] +fn xdata_points() { + check(properties::xdata_points()); +} +#[test] +fn xdata_handles() { + check(properties::xdata_handles()); +} +#[test] +fn handles_and_order() { + check(properties::handles_and_order()); +} +#[test] +fn handles_near_integer_limits() { + check(properties::handles_near_integer_limits()); +} +#[test] +fn degenerate_lines() { + check(properties::degenerate_geometry()); +} +#[test] +fn layer_inheritance_overrides() { + check(properties::layer_inheritance_overrides()); +} +#[test] +fn alignment_style_interactions() { + check(properties::alignment_style_interactions()); +} +#[test] +fn nested_attributes() { + check(blocks::nested_attributes()); +} +#[test] +fn text_interactions() { + check(properties::text_interactions()); +} +#[test] +fn long_text() { + check(properties::long_text()); +} +#[test] +fn arcs() { + check(geometry::arcs()); +} +#[test] +fn ellipses() { + check(geometry::ellipses()); +} +#[test] +fn polyline_constant_widths() { + check(geometry::polylines(false)); +} +#[test] +fn polyline_variable_widths() { + check(geometry::polylines(true)); +} +#[test] +fn polylines_3d() { + check(geometry::polylines_3d()); +} +#[test] +fn splines() { + check(geometry::splines(false)); +} +#[test] +fn rational_splines() { + check(geometry::splines(true)); +} +#[test] +fn fit_splines() { + check(geometry::spline_fit(false)); + check(geometry::spline_fit(true)); +} +#[test] +fn points_and_rays() { + check(geometry::points_and_rays()); +} +#[test] +fn faces() { + check(geometry::faces()); +} +#[test] +fn mtext_placement() { + check(geometry::mtext_placement()); +} +#[test] +fn mtext_spacing() { + check(geometry::mtext_spacing()); +} +#[test] +fn mtext_contents() { + check(geometry::mtext_contents()); + check(geometry::mtext_carets()); +} +#[test] +fn mtext_long() { + check(geometry::mtext_long()); +} +#[test] +#[ignore = "Confirmed DXF loss: MTEXT column group 48 is also read as Common.ltscale"] +fn mtext_columns() { + check(geometry::mtext_columns()); +} +#[test] +fn leaders() { + check(geometry::leaders(false)); +} +#[test] +fn hatches() { + check(geometry::hatches()); +} +#[test] +fn tables() { + check(geometry::tables()); +} +#[test] +fn images() { + check(geometry::images()); +} +#[test] +fn wipeouts() { + check(geometry::wipeouts(true)); +} +#[test] +fn wipeout_implicit_closure() { + // The format closes the stored boundary explicitly; this is not an extra editable corner. + // Keep grip behavior separate from this serialization contract (#2256). + for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) { + for version in Version::ALL { + super::check_version(&case.name, &case.drawing, &expected.drawing, version); + } + } +} +#[test] +#[ignore = "Confirmed DXF loss: LEADER vertex Z coordinates become zero"] +fn leaders_3d() { + check(geometry::leaders(true)); +} +#[test] +#[ignore = "Confirmed DXF loss: ATTRIB constant flag becomes false"] +fn constant_attributes() { + check(blocks::attributes("constant")); +} +#[test] +fn block_transformations() { + check(blocks::transformations()); +} +#[test] +fn block_arrays() { + check(blocks::arrays()); +} +#[test] +fn block_metadata() { + check(blocks::metadata()); +} +#[test] +fn nested_blocks() { + check(blocks::nested(false)); +} +#[test] +fn cyclic_blocks() { + check(blocks::nested(true)); +} +#[test] +fn unreferenced_blocks() { + check(blocks::unreferenced()); +} +#[test] +fn attribute_flags() { + check(blocks::attributes("flags")); +} +#[test] +fn attribute_modes() { + check(blocks::attributes("modes")); +} +#[test] +fn attribute_lock() { + since_2007(blocks::attributes("lock")); +} +#[test] +#[ignore = "Confirmed DXF read loss (#2255): multiline header changes Left to Center and synthesizes align_pt; existing fix #1942"] +fn multiline_attributes() { + for case in blocks::multiline_attributes() { + super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018); + } +} +#[test] +fn attribute_overrides() { + check(blocks::attributes("overrides")); +} +#[test] +fn helices() { + since_2007(complex::helices()); +} +#[test] +fn mlines() { + check(complex::mlines()); +} +#[test] +fn mleaders() { + since_2007(complex::mleaders()); +} +#[test] +fn regions() { + check(complex::regions()); +} +#[test] +fn tolerances() { + check(complex::tolerances()); +} +#[test] +fn dimensions() { + check(complex::dimensions()); +} + +fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) { + for version in Version::ALL { + super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify); + } +} +#[test] +fn header_variables() { + collection(collections::headers(), collections::assert_headers); + for key in ["CLAYER", "TEXTSTYLE"] { + collection(collections::named_header_reference(key), collections::assert_headers); + } +} +#[test] +fn unreferenced_tables() { + collection(collections::symbol_tables(), collections::assert_symbol_tables); +} +#[test] +fn foreign_dictionary_records() { + collection(collections::foreign_records(), collections::assert_foreign_records); +} +#[test] +fn unknown_entity_payload_and_references() { + for version in Version::ALL { + for binary in [false, true] { + super::super::unknown::check("memory.dxf", version, binary); + } + } +} +#[test] +fn views_and_ucs() { + collection(collections::views_and_ucs(), collections::assert_views_and_ucs); +} +#[test] +fn tiled_viewports() { + collection(collections::vports(), collections::assert_vports); +} +#[test] +fn groups() { + collection(collections::groups(), collections::assert_groups); +} +#[test] +fn layer_states() { + collection(collections::layer_states(), collections::assert_layer_states); +} +#[test] +fn layouts_and_paperspace() { + collection(collections::layouts(), collections::assert_layouts); +} diff --git a/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs new file mode 100644 index 000000000..b447638eb --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs @@ -0,0 +1,138 @@ +//! Independent synthetic DXF records for the extended geometry slice. Literal group streams +//! specify both reader input and writer expectations; neither side obtains its oracle from +//! the other. Group meanings: https://ezdxf.readthedocs.io/en/stable/dxfentities/index.html + +use super::super::{cases, compare}; +use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +struct Fixture { + wire_type: &'static str, + kind: EntityKind, + groups: &'static str, + output: Vec, +} + +fn fixtures() -> Vec { + vec![ + Fixture { + wire_type: "ARC", + kind: EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius: 2.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.5\n50\n0\n51\n90\n", + output: vec![Tag::f(10, 1.25), Tag::f(20, -2.5), Tag::f(30, 3.75), Tag::f(40, 2.5), Tag::f(50, 0.0), Tag::f(51, 90.0)], + }, + Fixture { + wire_type: "ELLIPSE", + kind: EntityKind::Ellipse(Ellipse { + center: Vec3::new(1.0, 2.0, 3.0), + major: Vec3::new(3.0, 4.0, 0.0), + ratio: 0.5, + start: 0.0, + end: std::f64::consts::TAU, + }), + groups: "10\n1\n20\n2\n30\n3\n11\n3\n21\n4\n31\n0\n40\n0.5\n41\n0\n42\n6.283185307179586\n", + output: vec![Tag::f(11, 3.0), Tag::f(21, 4.0), Tag::f(31, 0.0), Tag::f(40, 0.5), Tag::f(41, 0.0), Tag::f(42, std::f64::consts::TAU)], + }, + Fixture { + wire_type: "LWPOLYLINE", + kind: EntityKind::LwPolyline(LwPolyline { + vertices: vec![ + PolyVertex { p: Vec2::new(1.25, -2.5), bulge: 0.5, start_width: 0.125, end_width: 0.75 }, + PolyVertex::new(Vec2::new(5.0, 3.0)), + ], + closed: true, + plinegen: true, + const_width: 0.0, + elevation: -3.75, + }), + groups: "90\n2\n70\n129\n38\n-3.75\n10\n1.25\n20\n-2.5\n40\n0.125\n41\n0.75\n42\n0.5\n10\n5\n20\n3\n", + output: vec![ + Tag::i(90, 2), + Tag::i(70, 129), + Tag::f(38, -3.75), + Tag::f(10, 1.25), + Tag::f(10, 5.0), + Tag::f(20, -2.5), + Tag::f(20, 3.0), + Tag::f(40, 0.125), + Tag::f(41, 0.75), + Tag::f(42, 0.5), + ], + }, + Fixture { + wire_type: "MTEXT", + kind: EntityKind::MText(MText { + insert: Vec3::new(1.25, -2.5, 3.75), + height: 2.25, + width: 12.5, + attach: 5, + rotation: std::f64::consts::FRAC_PI_2, + style: "Standard".into(), + contents: "first\\Psecond".into(), + line_spacing: 1.75, + line_spacing_exact: true, + columns: None, + }), + groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.25\n41\n12.5\n71\n5\n50\n90\n7\nStandard\n1\nfirst\\Psecond\n73\n2\n44\n1.75\n", + output: vec![ + Tag::f(40, 2.25), + Tag::f(41, 12.5), + Tag::i(71, 5), + Tag::f(50, 90.0), + Tag::s(7, "Standard"), + Tag::s(1, "first\\Psecond"), + Tag::i(73, 2), + Tag::f(44, 1.75), + ], + }, + Fixture { + wire_type: "POINT", + kind: EntityKind::Point(Point { p: Vec3::new(1.0, 2.0, 3.0), angle: 0.0 }), + groups: "10\n1\n20\n2\n30\n3\n", + output: vec![Tag::f(10, 1.0), Tag::f(20, 2.0), Tag::f(30, 3.0)], + }, + Fixture { + wire_type: "RAY", + kind: EntityKind::Ray(RayLine { base: Vec3::new(1.0, 2.0, 3.0), dir: Vec3::new(0.0, 1.0, 0.0) }), + groups: "10\n1\n20\n2\n30\n3\n11\n0\n21\n1\n31\n0\n", + output: vec![Tag::f(11, 0.0), Tag::f(21, 1.0), Tag::f(31, 0.0)], + }, + ] +} + +#[test] +fn reads_extended_entities_from_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let bytes = format!("0\nSECTION\n2\nENTITIES\n0\n{name}\n5\n100\n{}0\nENDSEC\n0\nEOF\n", fixture.groups); + let expected = cases::entity_case(name, Common::default(), fixture.kind.clone()); + let back = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + compare::model(&expected.drawing, &back, name); + } +} + +#[test] +fn writes_extended_entities_with_independent_groups() { + for fixture in fixtures() { + let name = fixture.wire_type; + let source = cases::entity_case(name, Common::default(), fixture.kind.clone()); + for version in Version::ALL { + for binary in [false, true] { + let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + assert_eq!(records.len(), 1); + let (kind, groups) = &records[0]; + assert_eq!(kind, name); + for code in fixture.output.iter().map(|t| t.code) { + let expected: Vec<_> = fixture.output.iter().filter(|t| t.code == code).collect(); + let actual: Vec<_> = groups.iter().filter(|t| t.code == code).collect(); + assert_eq!(actual, expected, "{name} {version} binary={binary} group={code}"); + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/mod.rs b/crates/io/tests/it/roundtrip/dxf/mod.rs new file mode 100644 index 000000000..929c1a483 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/mod.rs @@ -0,0 +1,56 @@ +//! DXF preservation through public Drawing → bytes → Drawing APIs, without disk or UI. +//! Covers modeled entity families, stored block graphs, references and selected drawing +//! collections, not whole-drawing/session equivalence. Each case runs in ASCII and binary, +//! twice, at every representable save version; expectations never come from writer output. +//! WIPEOUT's explicit closing marker is an authored format expectation, not a new corner. +//! +//! Run: `cargo test -p cadcraft-io --test it roundtrip::dxf`. +//! Add `-- --ignored` to reproduce the pending preservation cases. Those tests +//! assert desired preservation, not today's wrong result; remove `ignore` when fixing them. +//! Independent fixtures cover the core entity groups; deep compound-entity interoperability +//! still needs separate format checks beyond the self-round-trip matrix. + +mod attributes; +mod common; +mod conformance; +mod entities; +mod extended; +mod geometry_groups; +mod table_flags; + +use super::compare; +use cadcraft_doc::Drawing; +use cadcraft_io::{SaveOptions, Version, read, save_version, write_with}; + +fn check(case: &str, original: &Drawing) { + for version in Version::ALL { + check_version(case, original, original, version); + } +} + +fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) { + check_version_with(case, original, expected, version, |_, _, _| {}); +} + +fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) { + for binary in [false, true] { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("{case} · {version} · binary={binary} · pass={pass}"); + let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}")); + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}")); + assert_eq!(save_version(&back), version, "{context}: version"); + assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records); + compare::model(expected, &back, &context); + extra(expected, &back, &context); + if pass == 2 { + compare::model(¤t, &back, &format!("{context} · resave stability")); + extra(¤t, &back, &format!("{context} · resave stability")); + } + current = back; + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/table_flags.rs b/crates/io/tests/it/roundtrip/dxf/table_flags.rs new file mode 100644 index 000000000..cefc28bb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/table_flags.rs @@ -0,0 +1,128 @@ +//! Independent standard LAYER/STYLE flag contracts. Literal wire-to-field mappings cover the +//! small modeled flag domains, including off-vs-frozen and vertical-vs-mirrored distinctions. +//! Reader fixtures are handwritten records; writer checks inspect tags, not our Drawing reader. +//! Annotative STYLE xdata is covered separately by the round-trip tests, not this native-bit test. +//! References: Autodesk DXF Reference, LAYER and STYLE symbol table entries. + +use cadcraft_color::Color; +use cadcraft_doc::{Drawing, Layer, TextStyle}; +use cadcraft_dxf::{Tag, Value, records, sections}; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn layers() -> Vec<(Layer, i64)> { + let mut cases = Vec::new(); + // (group 70, frozen, frozen in new viewports, locked). Off is the sign of 62, + // not another flag; plot is group 290 and must not accidentally change any of these. + for (flags, frozen, vp_freeze_new, locked) in [ + (0, false, false, false), + (1, true, false, false), + (2, false, true, false), + (3, true, true, false), + (4, false, false, true), + (5, true, false, true), + (6, false, true, true), + (7, true, true, true), + ] { + for on in [false, true] { + for plot in [false, true] { + cases.push(( + Layer { name: "Flags".into(), color: Color::Index(3), frozen, vp_freeze_new, locked, on, plot, ..Layer::default() }, + flags, + )); + } + } + } + cases +} + +fn styles() -> Vec<(TextStyle, i64, i64)> { + let mut cases = Vec::new(); + for (generation, backwards, upside_down) in [(0, false, false), (2, true, false), (4, false, true), (6, true, true)] { + for (flags, vertical) in [(0, false), (4, true)] { + cases.push(( + TextStyle { name: "Flags".into(), font: "Synthetic.ttf".into(), backwards, upside_down, vertical, ..TextStyle::default() }, + flags, + generation, + )); + } + } + cases +} + +fn fixture(table: &str, body: &str) -> Drawing { + let bytes = format!("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\n{table}\n70\n1\n0\n{table}\n2\nFlags\n{body}0\nENDTAB\n0\nENDSEC\n0\nEOF\n"); + let drawing = read(bytes.as_bytes(), "fixture.dxf").unwrap(); + assert!(drawing.read_notes.is_empty(), "{:?}", drawing.read_notes); + assert!(drawing.skipped_records.is_empty()); + drawing +} + +fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec { + let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap(); + let tags = cadcraft_dxf::parse(&bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect(); + assert_eq!(matching.len(), 1, "{version} binary={binary} {table}"); + matching.into_iter().next().unwrap().1 +} + +fn group(tags: &[Tag], expected: Tag, context: &str) { + assert_eq!(tags.iter().filter(|t| t.code == expected.code).collect::>(), [&expected], "{context}"); +} + +#[test] +fn reads_all_layer_flag_combinations() { + for (expected, flags) in layers() { + let aci = if expected.on { 3 } else { -3 }; + let plot = i64::from(expected.plot); + let drawing = fixture("LAYER", &format!("70\n{flags}\n62\n{aci}\n6\nContinuous\n290\n{plot}\n")); + assert_eq!(drawing.layer("Flags"), Some(&expected), "70={flags} 62={aci} 290={plot}"); + } +} + +#[test] +fn writes_all_layer_flag_combinations() { + for (layer, flags) in layers() { + let mut drawing = Drawing::new_metric(); + drawing.layers.push(layer.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "LAYER", version, binary); + let context = format!("{version} binary={binary} {layer:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(62, if layer.on { 3 } else { -3 }), &context); + if layer.plot { + // This writer omits the default true; the fixture above tests explicit true. + assert!(!tags.iter().any(|t| t.code == 290), "{context}"); + } else { + group(&tags, Tag::new(290, Value::Bool(false)), &context); + } + } + } + } +} + +#[test] +fn reads_all_native_text_style_flags() { + for (expected, flags, generation) in styles() { + let drawing = fixture("STYLE", &format!("70\n{flags}\n71\n{generation}\n40\n0\n41\n1\n50\n0\n3\nSynthetic.ttf\n4\n\n")); + assert_eq!(drawing.text_style("Flags"), Some(&expected), "70={flags} 71={generation}"); + } +} + +#[test] +fn writes_all_native_text_style_flags() { + for (style, flags, generation) in styles() { + let mut drawing = Drawing::new_metric(); + drawing.text_styles.push(style.clone()); + for version in Version::ALL { + for binary in [false, true] { + let tags = output(&drawing, "STYLE", version, binary); + let context = format!("{version} binary={binary} {style:?}"); + group(&tags, Tag::i(70, flags), &context); + group(&tags, Tag::i(71, generation), &context); + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/handle_limits.rs b/crates/io/tests/it/roundtrip/handle_limits.rs new file mode 100644 index 000000000..3cff2b3e4 --- /dev/null +++ b/crates/io/tests/it/roundtrip/handle_limits.rs @@ -0,0 +1,40 @@ +//! Exhausting generated-handle space may be refused, but must not silently lose authored +//! entities. This is separate from the preservation matrix's large handles with free capacity. +//! Neither a particular allocator strategy nor a successful save is required at exhaustion. + +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +use super::{cases::properties, compare}; + +fn preserves_or_refuses(path: &str, binary: bool) { + let original = properties::exhausted_handles().drawing; + for version in Version::ALL { + let options = SaveOptions { version: Some(version), binary }; + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("exhausted handles · {path} {version} binary={binary} pass={pass}"); + let Ok(bytes) = write_with(¤t, path, &options) else { + break; // An explicit save error is safe; a nominal success must preserve the data. + }; + let back = read(&bytes, path).unwrap_or_else(|e| panic!("{context}: successful save is unreadable: {e}")); + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + compare::model(&original, &back, &context); + current = back; + } + } +} + +#[test] +#[ignore = "Confirmed DXF data loss: exhausted generated handles produce a successful save that reopens with no model entities"] +fn dxf_exhausted_handles_are_preserved_or_refused() { + for binary in [false, true] { + preserves_or_refuses("memory.dxf", binary); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn dwg_exhausted_handles_are_preserved_or_refused() { + preserves_or_refuses("memory.dwg", false); +} diff --git a/crates/io/tests/it/roundtrip/mod.rs b/crates/io/tests/it/roundtrip/mod.rs new file mode 100644 index 000000000..d38f84eb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/mod.rs @@ -0,0 +1,11 @@ +//! In-memory preservation tests grouped by file format. DXF and DWG use the same drawing cases +//! and strict assertions; each format owns its version domain and explicitly pending regressions. + +mod cases; +mod collections; +mod compare; +#[cfg(not(target_arch = "wasm32"))] +mod dwg; +mod dxf; +mod handle_limits; +mod unknown; diff --git a/crates/io/tests/it/roundtrip/unknown.rs b/crates/io/tests/it/roundtrip/unknown.rs new file mode 100644 index 000000000..5883a078c --- /dev/null +++ b/crates/io/tests/it/roundtrip/unknown.rs @@ -0,0 +1,106 @@ +//! Unknown-entity preservation is payload- and reference-aware: the entity's handle, Common +//! fields, model-space membership and every application payload tag must survive. Transport +//! owner handles may be reallocated, but the DXF owner must still resolve to model space. +//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it. + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown}; +use cadcraft_dxf::{records, sections}; +use cadcraft_geom::Vec3; +use cadcraft_io::{SaveOptions, Version, read, write_with}; + +fn drawing() -> Drawing { + let mut d = super::cases::drawing(); + let handle = d.new_handle(); + let target = d.new_handle(); + let tags = vec![ + RawTag { code: 5, value: handle.hex() }, + // The store supplies the owner when saved, as it does for every modeled entity. + RawTag { code: 330, value: "0".into() }, + RawTag { code: 100, value: "AcDbEntity".into() }, + RawTag { code: 8, value: "0".into() }, + RawTag { code: 100, value: "SyntheticWidget".into() }, + RawTag { code: 1, value: "Original Ø payload".into() }, + RawTag { code: 90, value: "-7".into() }, + RawTag { code: 310, value: "00FF80".into() }, + RawTag { code: 10, value: "1.25".into() }, + RawTag { code: 20, value: "-2.5".into() }, + RawTag { code: 30, value: "3.75".into() }, + RawTag { code: 330, value: target.hex() }, + ]; + d.model.push(Entity::new(handle, EntityKind::Unknown(Unknown { dxf_type: "SYNTHETIC_WIDGET".into(), tags }))); + d.model.push(Entity::new(target, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) }))); + d +} + +fn payload(tags: &[RawTag]) -> &[RawTag] { + let start = tags.iter().position(|t| t.code == 100 && t.value == "SyntheticWidget").expect("application subclass must survive"); + &tags[start..] +} + +fn preserved(original: &Drawing, back: &Drawing, context: &str) { + assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes); + assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records); + assert_eq!(back.model.len(), original.model.len(), "{context}: entity count"); + for (a, b) in original.model.iter().zip(back.model.iter()) { + assert_eq!(b.handle, a.handle, "{context}: handle"); + assert_eq!(b.common, a.common, "{context}: Common"); + match (&a.kind, &b.kind) { + (EntityKind::Unknown(a), EntityKind::Unknown(b)) => { + assert_eq!(b.dxf_type, a.dxf_type, "{context}: application entity type"); + assert_eq!(payload(&b.tags), payload(&a.tags), "{context}: full payload including handle reference"); + } + _ => assert_eq!(b.kind, a.kind, "{context}: geometry"), + } + } +} + +fn dxf_owner(bytes: &[u8]) { + let tags = cadcraft_dxf::parse(bytes).unwrap(); + let sections = sections(&tags); + let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags); + let (_, model) = tables + .iter() + .find(|(kind, tags)| kind == "BLOCK_RECORD" && tags.iter().any(|t| t.code == 2 && t.str().eq_ignore_ascii_case("*Model_Space"))) + .unwrap(); + let model_handle = model.iter().find(|t| t.code == 5).unwrap().str(); + let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags); + let (_, widget) = entities.iter().find(|(kind, _)| kind == "SYNTHETIC_WIDGET").unwrap(); + assert_eq!(widget.iter().find(|t| t.code == 330).unwrap().str(), model_handle, "unknown entity belongs to the model-space block"); +} + +pub(super) fn check(path: &str, version: Version, binary: bool) { + let original = drawing(); + let mut current = original.clone(); + for pass in 1..=2 { + let context = format!("unknown entity {path} {version} binary={binary} pass={pass}"); + let bytes = match write_with(¤t, path, &SaveOptions { version: Some(version), binary }) { + Ok(bytes) => bytes, + Err(_) if path.ends_with(".dwg") && pass == 1 => return, + Err(error) => panic!("{context}: {error}"), + }; + if path.ends_with(".dxf") { + assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding"); + dxf_owner(&bytes); + } else { + assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version"); + } + let back = read(&bytes, path).unwrap(); + preserved(&original, &back, &context); + preserved(¤t, &back, &context); + current = back; + } +} + +#[test] +fn unknown_payload_assertion_rejects_lost_references() { + let original = drawing(); + preserved(&original, &original, "unchanged unknown entity"); + let mut changed = original.clone(); + let handle = changed.model.iter().next().unwrap().handle; + changed.model.modify(handle, |e| { + let EntityKind::Unknown(u) = &mut e.kind else { panic!("expected unknown entity") }; + u.tags.pop(); + }); + assert_eq!(original.model.iter().next().unwrap().common, Common::default()); + assert!(std::panic::catch_unwind(|| preserved(&original, &changed, "missing application reference")).is_err()); +} diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index 10f910ae8..57f99dbe2 100644 --- a/crates/io/tests/it/roundtrip_audit.rs +++ b/crates/io/tests/it/roundtrip_audit.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use cadcraft_doc::{Drawing, EntityKind}; use cadcraft_dxf::{Tag, records, sections}; -use cadcraft_io::{read_dxf, write_dxf}; +use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with}; /// DXF text from lines of `code value` (the value may be empty). fn dxf(src: &str) -> String { @@ -878,19 +878,50 @@ fn saving_twice_writes_the_same_records() { assert_eq!(a.counts(), b.counts()); } +/// CLASS group 91, the instance count, came with the 2004 format (DXF Reference, CLASSES). A +/// 2004+ save counts the records of every class, generated and kept; an R2000 save has none, +/// even for a kept class whose record had one (AutoCAD LT 2026 stops responding on an R2000 file +/// whose CLASS records carry a 91). #[test] -fn class_records_have_instance_counts_for_generated_and_preserved_records() { - let (_, a, b) = saves(&fixture()); +fn class_records_have_instance_counts_from_2004_only() { + // The fixture's ACME_OBJECT class as a 2004+ writer has it, with its count. + let src = fixture().replacen("ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n", "ACME_OBJECT\n2\nAcmeObject\n3\nAcmeApp\n90\n0\n91\n1\n", 1); + assert_ne!(src, fixture()); + let (d, a, b) = saves(&src); for out in [&a, &b] { - let counts = out.counts(); for class in out.in_section("CLASSES", "CLASS") { - let name = s(class, 1); - let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64); - assert_eq!(instance_count, counts.get(&name).map(|n| *n as i64), "CLASS {name}"); + let codes: Vec = class.iter().map(|t| t.code).collect(); + assert_eq!(codes, [1, 2, 3, 90, 280, 281], "R2000 CLASS {}", s(class, 1)); } assert!(out.in_section("CLASSES", "CLASS").iter().any(|c| s(c, 1) == "ACME_OBJECT")); - assert_eq!(counts.get("ACME_OBJECT"), Some(&1)); } + // Binary files the same way: none at R2000, the count at 2018. + let acme = |version: Version| { + let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).expect("save binary"); + let tags = cadcraft_dxf::parse(&bytes).expect("parse binary"); + let class = sections(&tags) + .into_iter() + .filter(|sec| sec.name == "CLASSES") + .flat_map(|sec| records(&sec.tags)) + .find(|(kind, class)| kind == "CLASS" && s(class, 1) == "ACME_OBJECT") + .map(|(_, class)| class) + .expect("ACME_OBJECT CLASS"); + let codes: Vec = class.iter().map(|t| t.code).collect(); + (codes, class.iter().find(|t| t.code == 91).map(Tag::i64)) + }; + assert_eq!(acme(Version::R2000), (vec![1, 2, 3, 90, 280, 281], None), "binary R2000 ACME_OBJECT CLASS"); + assert_eq!(acme(Version::R2018), (vec![1, 2, 3, 90, 91, 280, 281], Some(1)), "binary 2018 ACME_OBJECT CLASS"); + let r2018 = write_with(&d, "x.dxf", &SaveOptions { version: Some(Version::R2018), binary: false }).expect("save 2018"); + let out = Out::new(std::str::from_utf8(&r2018).expect("UTF-8")); + let counts = out.counts(); + for class in out.in_section("CLASSES", "CLASS") { + let name = s(class, 1); + let codes: Vec = class.iter().map(|t| t.code).collect(); + assert_eq!(codes, [1, 2, 3, 90, 91, 280, 281], "2018 CLASS {name}"); + let instance_count = class.iter().find(|t| t.code == 91).map(Tag::i64); + assert_eq!(instance_count, Some(counts.get(&name).map_or(0, |n| *n as i64)), "CLASS {name}"); + } + assert_eq!(counts.get("ACME_OBJECT"), Some(&1)); } #[test] @@ -979,3 +1010,183 @@ fn dwg_keeps_extension_dictionaries_xrecords_and_entity_xdata() { assert_eq!(d.block("DOOR").map(|b| b.description.as_str()), Some("A door leaf")); assert!(d.ucss.iter().any(|u| u.name == "TILTED")); } + +/// A linear dimension whose two extension origins are snapped to `a` and `b`, so the saved file +/// carries a DIMASSOC naming both objects. +fn add_associated_dimension(d: &mut Drawing, a: cadcraft_doc::Handle, b: cadcraft_doc::Handle) { + use cadcraft_doc::{AssocSnap, Common, DimAssoc, DimKind, Dimension, Space}; + use cadcraft_geom::Vec3; + let at = |point: &str, handle| DimAssoc { point: point.into(), handle, snap: AssocSnap::Mid }; + let dm = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(5.0, -3.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: None, + overrides: Default::default(), + assoc: vec![at("p13", a), at("p14", b)], + marks: Default::default(), + insertion_point: Vec3::ZERO, + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).expect("add dimension"); +} + +/// The handle of the one DIMASSOC object of a written file. +fn dimassoc_handle(o: &Out) -> String { + let assocs = o.of("DIMASSOC"); + assert_eq!(assocs.len(), 1, "one DIMASSOC"); + h(assocs.first().copied().unwrap_or_default()) +} + +/// A source object that already has a reactor to a kept foreign object (the fixture's line 2A +/// names ACME_OBJECT 42) keeps it when a DIMASSOC reactor is added: one group with both (review on #2589). +#[test] +fn a_dimension_source_keeps_its_foreign_reactors_next_to_the_dimassoc() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let line = o.of("LINE").into_iter().find(|t| h(t).eq_ignore_ascii_case("2A")).expect("line 2A"); + assert_eq!(line.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1, "one reactor group"); + let reactors = group(line, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT")), "foreign reactor kept: {reactors:?}"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} +/// An unmodelled entity a DIMASSOC refers to gets the reactor too, next to what it had (review on #2589). +#[test] +fn an_unknown_dimension_source_carries_the_dimassoc_reactor() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x30), cadcraft_doc::Handle(0x2A)); + let o = Out::new(&write_dxf(&d)); + let widget = entity_by_text(&o, "ACME_WIDGET", "widget-in-model"); + let reactors = group(widget, "ACAD_REACTORS"); + assert!(reactors.contains(&dimassoc_handle(&o)), "DIMASSOC reactor: {reactors:?}"); + assert_eq!(widget.iter().filter(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").count(), 1); + assert_eq!(owner(widget), h(o.named("BLOCK_RECORD", "*Model_Space")), "owner unchanged"); + assert!(o.dangling().is_empty(), "{:?}", o.dangling()); +} + +/// The handles of the `{ACAD_REACTORS` group of the record of handle `at`, after checking the +/// written text holds exactly one such group, placed as the DXF reference orders a record: +/// handle (5), then the reactors, then the owner (330), then the first subclass marker (100) and +/// any xdata (1001). Each handle appears once and names a DIMASSOC when `assoc_only` is set. +fn one_reactor_group(o: &Out, at: &str, assoc_only: bool) -> Vec { + let (_, rec) = o.handle(at).expect("source record"); + let codes: Vec<(i32, String)> = rec.iter().map(|t| (t.code, t.str())).collect(); + let starts: Vec = (0..codes.len()).filter(|&i| codes[i].0 == 102 && codes[i].1 == "{ACAD_REACTORS").collect(); + assert_eq!(starts.len(), 1, "exactly one reactor group in {codes:?}"); + let start = starts[0]; + let end = start + codes[start..].iter().position(|c| c.0 == 102 && c.1 == "}").expect("group end"); + let handles: Vec = codes[start + 1..end].iter().map(|c| c.1.clone()).collect(); + assert!(codes[start + 1..end].iter().all(|c| c.0 == 330), "only 330 groups inside: {codes:?}"); + let unique: std::collections::HashSet = handles.iter().map(|r| r.to_ascii_uppercase()).collect(); + assert_eq!(unique.len(), handles.len(), "each reactor once: {handles:?}"); + let pos = |code: i32| codes.iter().position(|c| c.0 == code); + assert_eq!(pos(5), Some(0), "handle first"); + let owner_at = end + 1 + codes[end + 1..].iter().position(|c| c.0 == 330).expect("owner"); + assert!(owner_at > end, "owner after the reactors: {codes:?}"); + assert!(end < pos(100).expect("subclass marker"), "reactors before the first subclass marker"); + if let Some(x) = pos(1001) { + assert!(end < x, "reactors before the xdata"); + } + if assoc_only { + for r in &handles { + assert_eq!(o.handle(r).map(|(k, _)| k), Some("DIMASSOC"), "{r} resolves to a DIMASSOC"); + } + } + handles +} + +/// A record that already has a reactors group (line 2A names ACME_OBJECT 42) comes out with one +/// group holding the foreign handle and the DIMASSOC, each once, in the same order after a +/// second save and with the extension dictionary group still between it and the owner. +/// Pinning test for the merge (review on #2589). +#[test] +fn a_record_with_a_reactor_group_gets_one_group_with_every_handle_once() { + let mut d = read_dxf(fixture().as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let reactors = one_reactor_group(&o, "2A", false); + assert_eq!(reactors.len(), 2, "foreign + DIMASSOC: {reactors:?}"); + let assoc = dimassoc_handle(&o); + assert_eq!(reactors.iter().filter(|r| r.eq_ignore_ascii_case(&assoc)).count(), 1); + assert!(reactors.iter().any(|r| o.handle(r).is_some_and(|(k, _)| k == "ACME_OBJECT"))); + let (_, line) = o.handle("2A").expect("line"); + let order: Vec = line.iter().map(|t| t.code).collect(); + let start = |name: &str| line.iter().position(|t| t.code == 102 && t.str() == format!("{{{name}")); + let (rx, xd) = (start("ACAD_REACTORS"), start("ACAD_XDICTIONARY")); + assert!(rx.is_some() && xd.is_some() && rx < xd, "reactors group before the extension dictionary group: {order:?}"); + assert_eq!(group(line, "ACAD_XDICTIONARY").len(), 1); + // Our reader accepts it, the association survives, and a second save keeps the same group. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(again.of("DIMASSOC").len(), 1); + let second = one_reactor_group(&again, "2A", false); + assert_eq!(second.len(), 2, "{second:?}"); + assert!(second.iter().any(|r| again.handle(r).is_some_and(|(k, _)| k == "DIMASSOC"))); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A record with no reactors group (line 2A with its group removed, keeping the extension +/// dictionary and the xdata) gets a new one in the preamble, between the handle and the owner, +/// so before the first subclass marker and the xdata. Pinning test (review on #2589). +#[test] +fn a_record_without_a_reactor_group_gets_a_new_one_in_the_preamble() { + let bare = fixture().replacen("5\n2A\n102\n{ACAD_REACTORS\n330\n42\n102\n}\n", "5\n2A\n", 1); + assert_ne!(bare, fixture(), "the fixture line lost its reactors"); + let mut d = read_dxf(bare.as_bytes()).expect("read fixture"); + add_associated_dimension(&mut d, cadcraft_doc::Handle(0x2A), cadcraft_doc::Handle(0x2A)); + let text = write_dxf(&d); + let o = Out::new(&text); + let (_, line) = o.handle("2A").expect("line"); + assert!(line.iter().any(|t| t.code == 1001), "the record keeps its xdata"); + let xdict_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_XDICTIONARY").expect("xdict group"); + let reactors = one_reactor_group(&o, "2A", true); + assert_eq!(reactors, vec![dimassoc_handle(&o)]); + // The preamble is the handle, then the reactors group and the extension dictionary group in + // the DXF reference's order, then the owner: nothing else in between. + let reactors_at = line.iter().position(|t| t.code == 102 && t.str() == "{ACAD_REACTORS").expect("reactor group"); + let owner_at = line.iter().position(|t| t.code == 330 && t.str() != reactors[0]).expect("owner"); + assert!(reactors_at < xdict_at && xdict_at < owner_at, "reactors {reactors_at}, xdict {xdict_at}, owner {owner_at}"); + assert!(line.iter().take(owner_at).all(|t| matches!(t.code, 5 | 102 | 330 | 360)), "only handle and groups before the owner"); + // Reading it back keeps the association and the DIMASSOC still resolves. + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + one_reactor_group(&again, "2A", true); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} + +/// A new line used by two dimensions (so by two DIMASSOC objects) lists both, once each, in one +/// group that sits between its handle and its owner. Pinning test (review on #2589). +#[test] +fn a_source_used_by_two_associations_lists_both_dimassocs_once() { + use cadcraft_doc::{Common, Line, Space}; + use cadcraft_geom::Vec3; + let mut d = Drawing::new_metric(); + let line = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })).expect("add line"); + add_associated_dimension(&mut d, line, line); + add_associated_dimension(&mut d, line, line); + let text = write_dxf(&d); + let o = Out::new(&text); + let assocs: Vec = o.of("DIMASSOC").into_iter().map(h).collect(); + assert_eq!(assocs.len(), 2, "one DIMASSOC per dimension"); + let reactors = one_reactor_group(&o, &line.hex(), true); + let mut got: Vec = reactors.iter().map(|r| r.to_ascii_uppercase()).collect(); + let mut want: Vec = assocs.iter().map(|r| r.to_ascii_uppercase()).collect(); + got.sort(); + want.sort(); + assert_eq!(got, want, "both associations, once each"); + let back = read_dxf(text.as_bytes()).expect("reopen"); + let again = Out::new(&write_dxf(&back)); + assert_eq!(one_reactor_group(&again, &line.hex(), true).len(), 2); + assert!(again.dangling().is_empty(), "{:?}", again.dangling()); +} diff --git a/crates/io/tests/it/tables_objects_roundtrip.rs b/crates/io/tests/it/tables_objects_roundtrip.rs index 0f75e4cd3..ec2fe7726 100644 --- a/crates/io/tests/it/tables_objects_roundtrip.rs +++ b/crates/io/tests/it/tables_objects_roundtrip.rs @@ -2,7 +2,9 @@ //! multileader styles, layout page setups, named views, UCSs and groups. use cadcraft_doc::geom::{Vec2, Vec3}; -use cadcraft_doc::{Common, DashElement, Drawing, EntityKind, Group, Line, Linetype, MLeaderStyle, NamedView, Space, TextStyle, Ucs, library}; +use cadcraft_doc::{ + Common, DashElement, Drawing, EntityKind, Group, Line, Linetype, MLeaderStyle, NamedView, Space, TableStyle, TextStyle, Ucs, library, +}; use cadcraft_io::{read_dxf, write_dxf}; fn round_trip(d: &Drawing) -> Drawing { @@ -82,6 +84,15 @@ fn multileader_styles_and_current_style_round_trip() { assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout"); } +#[test] +fn current_table_style_round_trips() { + let mut d = Drawing::new_imperial(); + d.table_styles.push(TableStyle { name: "TS".into(), ..TableStyle::default() }); + d.header.set_str("CTABLESTYLE", "TS"); + let r = round_trip(&d); + assert_eq!(r.header.str("CTABLESTYLE", ""), "TS"); +} + #[test] fn layout_page_setup_and_paper_view_round_trip() { let mut d = Drawing::new_imperial(); diff --git a/crates/io/tests/it/text_carets_roundtrip.rs b/crates/io/tests/it/text_carets_roundtrip.rs new file mode 100644 index 000000000..d327c6694 --- /dev/null +++ b/crates/io/tests/it/text_carets_roundtrip.rs @@ -0,0 +1,126 @@ +//! Carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings are written as `^ ` and read back: AutoCAD +//! reads a bare `^2` in `10^2` as a control character. A tab is `^I`, as AutoCAD writes it. + +use cadcraft_doc::{Attrib, Common, DimKind, Dimension, Drawing, EntityKind, HAlign, Space, Text, VAlign}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +fn text(value: &str) -> Text { + Text { + insert: Vec3::new(1.0, 1.0, 0.0), + align_pt: None, + height: 2.5, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +fn dimension(label: &str) -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.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: label.into(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + insertion_point: Vec3::ZERO, + } +} + +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("10^2 m, A^JB"))).unwrap(); + let attdef = Attrib { + tag: "AREA".into(), + text: text("1^3"), + invisible: false, + constant: false, + prompt: "Area in m^2".into(), + props: Default::default(), + multiline: None, + modes: Default::default(), + }; + d.add(&Space::Model, Common::default(), EntityKind::AttDef(attdef)).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dimension("<> x10^3"))).unwrap(); + d +} + +fn strings(k: &EntityKind) -> Vec { + match k { + EntityKind::Text(t) => vec![t.value.clone()], + EntityKind::AttDef(a) => vec![a.text.value.clone(), a.prompt.clone()], + EntityKind::Dimension(dm) => vec![dm.text.clone()], + _ => Vec::new(), + } +} + +fn all_strings(d: &Drawing) -> Vec { + d.model.iter().flat_map(|e| strings(&e.kind)).collect() +} + +#[test] +fn carets_are_written_escaped_and_read_back() { + let dxf = write_dxf(&drawing()); + // `^J` stays two characters: a single-line string has no line feeds. + for escaped in ["10^ 2 m, A^ JB", "1^ 3", "Area in m^ 2", "<> x10^ 3"] { + assert!(dxf.contains(escaped), "{escaped}"); + } + let back = read_dxf(dxf.as_bytes()).unwrap(); + assert_eq!(all_strings(&back), ["10^2 m, A^JB", "1^3", "Area in m^2", "<> x10^3"]); + // Older files have bare carets, `^J` too: kept as they are (a single-line string has no line feed). + let old = read_dxf(dxf.replace("10^ 2 m, A^ JB", "10^2 m, A^JB").as_bytes()).unwrap(); + assert_eq!(all_strings(&old)[0], "10^2 m, A^JB"); + assert!(write_dxf(&old).contains("10^ 2 m, A^ JB")); + // AutoCAD writes a tab as `^I`: it is read as a tab and written back as `^I`. + let tab = read_dxf(dxf.replace("10^ 2 m, A^ JB", "A^IB").as_bytes()).unwrap(); + assert_eq!(all_strings(&tab)[0], "A\tB"); + assert!(write_dxf(&tab).contains("A^IB")); + // Through a DWG. + let dwg = cadcraft_io::write(&drawing(), "t.dwg").unwrap(); + assert_eq!(all_strings(&cadcraft_io::read(&dwg, "t.dwg").unwrap()), all_strings(&drawing())); +} + +#[test] +fn a_caret_before_a_space_or_at_the_end_round_trips() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("x^ y^"))).unwrap(); + let dxf = write_dxf(&d); + assert!(dxf.contains("x^ y^ "), "{dxf}"); + assert_eq!(all_strings(&read_dxf(dxf.as_bytes()).unwrap()), ["x^ y^"]); +} + +#[test] +fn entget_shows_plain_carets_and_entmake_takes_them() { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Text(text("x^ y 10^2"))).unwrap(); + let e = d.model.iter().next().unwrap(); + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + assert!(groups.iter().any(|t| t.code == 1 && t.str() == "x^ y 10^2"), "{groups:?}"); + // entmod of entget changes nothing; entmake takes a plain string. + let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap(); + assert_eq!(strings(&back), ["x^ y 10^2"]); + let made = [cadcraft_dxf::Tag::s(1, "p^ q")]; + assert_eq!(strings(&cadcraft_io::entity_from_groups("TEXT", &made).unwrap().1), ["p^ q"]); + // Every kind, and a DIMENSION's style name (also group 3) as it is. + let d = drawing(); + for e in d.model.iter() { + let (kind, groups) = cadcraft_io::entity_groups(&d, e); + let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap(); + assert_eq!(strings(&back), strings(&e.kind), "{kind}"); + } +} diff --git a/crates/io/tests/it/view_target.rs b/crates/io/tests/it/view_target.rs new file mode 100644 index 000000000..c6a2eea27 --- /dev/null +++ b/crates/io/tests/it/view_target.rs @@ -0,0 +1,181 @@ +//! A view's centre is stored in display coordinates, whose origin is the view target (DXF +//! Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and 17/27/37, VIEW 10/20 and 12/22/32). +//! ArchiCAD's AutoCAD 2018 export puts each layout viewport's location in the target with a +//! centre of 0,0, so reading the centre alone showed model point 0,0 (#2492). + +use cadcraft_doc::geom::Vec2; +use cadcraft_doc::{Drawing, EntityKind, Viewport}; +use cadcraft_io::{read_dxf, write_dxf}; + +/// An ASCII DXF of `tags`. +fn dxf(tags: &[(i32, &str)]) -> String { + tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +/// A layout viewport with view centre `center` (12/22) and view target `target` (17/27/37). +fn with_viewport(center: [&str; 2], target: [&str; 3]) -> Drawing { + looking_along(center, target, ["0", "0", "1"]) +} + +/// The same, looking along view direction `dir` (16/26/36). +fn looking_along(center: [&str; 2], target: [&str; 3], dir: [&str; 3]) -> Drawing { + let tags: Vec<(i32, &str)> = vec![ + (0, "SECTION"), + (2, "ENTITIES"), + (0, "VIEWPORT"), + (5, "40"), + (67, "1"), + (8, "0"), + (10, "148.5"), + (20, "105"), + (30, "0"), + (40, "250"), + (41, "180"), + (68, "2"), + (69, "2"), + (12, center[0]), + (22, center[1]), + (16, dir[0]), + (26, dir[1]), + (36, dir[2]), + (17, target[0]), + (27, target[1]), + (37, target[2]), + (45, "48680.2"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + read_dxf(dxf(&tags).as_bytes()).expect("read") +} + +fn viewports(d: &Drawing) -> Vec { + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id > 1 => Some(v.clone()), + _ => None, + }) + .collect() +} + +fn view_center(d: &Drawing) -> Vec2 { + let vs = viewports(d); + assert_eq!(vs.len(), 1, "{vs:?}"); + vs[0].view_center +} + +/// `a` and `b` agree to within the rounding of adding two decimals. +fn close(a: Vec2, b: Vec2) -> bool { + (a - b).len() < 1e-6 +} + +#[test] +fn a_viewport_looks_at_its_target_plus_its_centre() { + // The issue's file: centre 0,0, the facade in the target. + let c = view_center(&with_viewport(["0", "0"], ["230748.7", "-867.4", "0"])); + assert_eq!(c, Vec2::new(230748.7, -867.4)); + // Both set: their sum. A target alone of 0 leaves the centre as it is. + assert_eq!(view_center(&with_viewport(["10", "5"], ["100", "50", "7"])), Vec2::new(110.0, 55.0)); + assert_eq!(view_center(&with_viewport(["10", "5"], ["0", "0", "0"])), Vec2::new(10.0, 5.0)); +} + +/// CADCraft draws every viewport as a plan view (the view direction, 16/26/36, isn't applied). A +/// viewport looking along another direction is centred on what it looks at, its target, as +/// AutoCAD's view of it is; its centre is in that view's own axes, so only the target is exact. +#[test] +fn a_viewport_looking_along_another_direction_centres_on_its_target() { + let c = view_center(&looking_along(["0", "0"], ["500", "-200", "30"], ["1", "1", "1"])); + assert_eq!(c, Vec2::new(500.0, -200.0)); +} + +/// Saved and reopened, the viewport still looks at the same place: CADCraft writes the whole +/// location as the centre with a target of 0. +#[test] +fn the_view_survives_a_save_and_reopen() { + let d = with_viewport(["3", "4"], ["84672.2", "25851.5", "0"]); + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let c = view_center(&back); + assert!(close(c, Vec2::new(84675.2, 25855.5)), "{c:?}"); +} + +/// A target coordinate that is not a finite number reads as 0, as any DXF real does, and a sum +/// that overflows leaves the centre. +#[test] +fn a_hostile_target_leaves_the_centre() { + for target in [["1e999", "0", "0"], ["nan", "0", "0"], ["1e308", "0", "0"]] { + assert_eq!(view_center(&with_viewport(["1e308", "2"], target)), Vec2::new(1e308, 2.0), "{target:?}"); + } + assert_eq!(view_center(&with_viewport(["1", "2"], ["inf", "5", "0"])), Vec2::new(1.0, 7.0)); +} + +/// The model viewport tiles of a saved configuration (VPORT 12/22 and 17/27/37) and named views +/// (VIEW 10/20 and 12/22/32) are read the same way. +#[test] +fn vport_tiles_and_named_views_add_their_targets() { + let tags: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "VPORT"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0"), + (20, "0"), + (11, "0.5"), + (21, "1"), + (12, "1"), + (22, "2"), + (17, "100"), + (27, "200"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "VPORT"), + (2, "TWO"), + (70, "0"), + (10, "0.5"), + (20, "0"), + (11, "1"), + (21, "1"), + (12, "0"), + (22, "0"), + (17, "-40"), + (27, "30"), + (37, "0"), + (40, "50"), + (41, "1"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "VIEW"), + (0, "VIEW"), + (2, "Facade"), + (70, "0"), + (40, "20"), + (41, "30"), + (10, "1"), + (20, "2"), + (11, "0"), + (21, "0"), + (31, "1"), + (12, "367097.3"), + (22, "-441.8"), + (32, "0"), + (0, "ENDTAB"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + let d = read_dxf(dxf(tags).as_bytes()).expect("read"); + let two = d.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + let centers: Vec = two.tiles.iter().map(|t| t.center).collect(); + assert_eq!(centers, [Vec2::new(101.0, 202.0), Vec2::new(-40.0, 30.0)]); + let facade = d.views.iter().find(|v| v.name == "Facade").expect("view Facade"); + assert!(close(facade.center, Vec2::new(367098.3, -439.8)), "{:?}", facade.center); + // And again after a save and reopen. + let back = read_dxf(write_dxf(&d).as_bytes()).expect("reopen"); + let two = back.vports.iter().find(|c| c.name.eq_ignore_ascii_case("TWO")).expect("configuration TWO"); + assert_eq!(two.tiles.iter().map(|t| t.center).collect::>(), centers); + let again = back.views.iter().find(|v| v.name == "Facade").map(|v| v.center).expect("view Facade"); + assert!(close(again, Vec2::new(367098.3, -439.8)), "{again:?}"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 20a86fce7..913569e82 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -415,10 +415,17 @@ impl Backend for Headless { let (w, h) = (view.width, view.height); let png = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()).ok_or("render failed")?; + let mut r = + json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "truncated": list.truncated}); if let Some(path) = str_p("path") { - std::fs::write(&path, &png).map_err(|e| format!("{path}: {e}"))?; + let file = std::path::Path::new(&path); + let folder = cadcraft_engine::atomic_write::missing_folder(file); + cadcraft_engine::atomic_write::write_atomic_making_folders(file, &png).map_err(|e| format!("{path}: {e}"))?; + if let Some(folder) = folder { + r["createdFolder"] = json!(folder.display().to_string()); + } } - Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) + Ok(r) } "app.open" => { let path = str_p("path").ok_or("missing path")?; diff --git a/crates/render/src/dim/tests.rs b/crates/render/src/dim/tests.rs index fe3b3c74e..15001c6b7 100644 --- a/crates/render/src/dim/tests.rs +++ b/crates/render/src/dim/tests.rs @@ -19,6 +19,7 @@ fn lin(p1: (f64, f64), p2: (f64, f64), on: (f64, f64), rot: f64) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index e6ba6b7e9..9731ecbf7 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -59,6 +59,10 @@ pub struct DisplayList { pub sheet: Option, /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). pub expanded: usize, + /// The expansion budget was spent: block contents past it are not in the list. The screen, + /// plots and exports draw what is here; the CLI and the control channel report this, so an + /// output that leaves contents out is never silent (#1070). + pub truncated: bool, /// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries /// are "use object"), turned into geometry by [`pen::apply`]. pub pens: Vec, diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7e00b4c8c..e73659675 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -77,7 +77,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { let tol = b.opts.tolerance / ctx.xf.scale_factor().max(1e-12); let lw = if b.opts.lineweights { lw } else { 0.0 }; // Stroke a polyline with the entity's linetype. - let stroke = |b: &mut Builder, pts: &[Vec2]| match < { + let stroke = |b: &mut Builder, pts: &[Vec2]| match lt.as_ref().filter(|_| b.opts.linetypes) { Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); for dash in linetype::apply(pts, lt, ltscale, min) { @@ -140,7 +140,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { // the list serves a whole band of zooms. Plots and exports draw the band alone, and // a transparent band gets none (its alpha would add up along the centre). let hairline = !b.plotting && b.opts.min_dash > 0.0 && b.transparency == 0; - match < { + match lt.as_ref().filter(|_| b.opts.linetypes) { // Dashed: each dash a filled piece of the band; dots a line across it. Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); @@ -173,7 +173,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } EntityKind::LwPolyline(p) => { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; - if p.plinegen || lt.is_none() { + if !b.opts.linetypes || p.plinegen || lt.is_none() { stroke(b, &pl.tessellate(tol)); } else { for s in pl.segments() { @@ -313,7 +313,10 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n)) && ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH { - let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; + // DXF group 12: a moved dimension keeps its `*D` block where it was and the offset in + // the insertion point, so the block draws translated by it (#2634). + let off = dm.insertion_point.xy(); + let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::translate(off)) }; // DIMTFILL: the block's MTEXT carries no background mask here, so a background // around each of the block's texts is drawn before the block's first text or first // line of the text's marks, except for a text that a SOLID of the block covers. @@ -333,6 +336,10 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim .take(MAX_FILL_TEXTS) .filter(|be| !covered(be)) .filter_map(|be| block_text_box(ctx.d, be, gap)) + .map(|mut corners| { + corners.iter_mut().for_each(|c| *c += off); + corners + }) .collect() }; let mut fill_pending = !boxes.is_empty(); @@ -355,7 +362,7 @@ fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dim /// Draw a polyline with a linetype (None: continuous). pub(crate) fn stroke_with(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64, pts: &[Vec2]) { - match lt { + match lt.filter(|_| b.opts.linetypes) { Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); for dash in linetype::apply(pts, lt, ltscale, min) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6fa236e4c..e93c9fbff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -291,10 +291,14 @@ impl Builder<'_> { /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { self.list.expanded = self.list.expanded.saturating_add(1); - match self.shared_budget { + let ok = match self.shared_budget { Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION, None => self.list.expanded <= MAX_BLOCK_EXPANSION, + }; + if !ok { + self.list.truncated = true; } + ok } } diff --git a/crates/render/src/mline.rs b/crates/render/src/mline.rs index bbaacb5cc..882f3a626 100644 --- a/crates/render/src/mline.rs +++ b/crates/render/src/mline.rs @@ -59,7 +59,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &MLine, rgb: Ink, #[allow(clippy::too_many_arguments)] fn stroke(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, scale: f64, min: f64, pts: &[Vec2]) { - match lt { + match lt.filter(|_| b.opts.linetypes) { Some(lt) => { for dash in linetype::apply(pts, lt, scale, min) { match dash.as_slice() { diff --git a/crates/render/src/paper.rs b/crates/render/src/paper.rs index 457a2a48d..ff4a027ba 100644 --- a/crates/render/src/paper.rs +++ b/crates/render/src/paper.rs @@ -155,3 +155,30 @@ mod tests { assert!(s.size.x > 0.0 && s.printable == s.bounds()); } } + +/// What a layout's saved page setup says about how to plot it, for a plot that was not given the +/// matching options (#2493): the plot window, whether to scale to fit, the saved scale, and +/// centring. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SavedPlot { + /// The saved plot window, when the plot area is "window" and its corners are a real box. + pub window: Option, + /// Scale to fit the window (or the extents) on the printable area. + pub fit: bool, + /// The saved plot scale, paper units per drawing unit, when it isn't scale to fit or 1:1. + pub scale: Option, + /// Centre the plot on the printable area. + pub center: bool, +} + +/// The saved plot settings of `page`. A layout plotted over its whole sheet ("layout" plot area) +/// has nothing to fit, so scale to fit only counts for a window or the extents. +pub fn saved_plot(page: &PageSetup) -> SavedPlot { + let [x1, y1, x2, y2] = page.plot_window; + let window = (page.plot_area.eq_ignore_ascii_case("window") && [x1, y1, x2, y2].iter().all(|v| v.is_finite())) + .then(|| Bounds2::new(Vec2::new(x1, y1), Vec2::new(x2, y2))) + .filter(|w| w.width() > 0.0 && w.height() > 0.0); + let fit = page.scale_to_fit && (window.is_some() || page.plot_area.eq_ignore_ascii_case("extents")); + let scale = Some(page.scale).filter(|s| !page.scale_to_fit && s.is_finite() && *s > 0.0 && (*s - 1.0).abs() > 1e-9); + SavedPlot { window, fit, scale, center: page.center } +} diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 8cbcb95b9..3fc72f518 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -15,6 +15,10 @@ pub struct Options { pub min_dash: f64, /// Draw text (off for very coarse previews). pub text: bool, + /// Draw lines with their linetypes. Off, every line is drawn whole and continuous, as picking + /// sees it: a dashed line is hit across its gaps, and a small pattern on a long line makes + /// millions of dashes. + pub linetypes: bool, /// Highlighted (selected) handles are not special here; the canvas overlays them. pub fill: bool, pub lineweights: bool, @@ -52,6 +56,7 @@ impl Default for Options { tolerance: 0.001, min_dash: 0.0, text: true, + linetypes: true, fill: true, lineweights: false, view_height: 0.0, diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 72ac821d0..a32818a38 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -222,6 +222,37 @@ impl Hairlines { } } +/// Blank (white) everything outside `printable`, a rectangle in paper units with the origin at the +/// sheet's bottom-left, on a pixmap showing the whole sheet at `px_per_unit` pixels per paper unit. +/// A placed plot is clipped to the printable area this way, in the PNG plot and in the preview. +pub fn blank_outside_printable(pm: &mut Pixmap, printable: &Bounds2, px_per_unit: f64) { + let ph = f64::from(pm.height()); + blank_outside_pixels( + pm, + (printable.min.x * px_per_unit, ph - printable.max.y * px_per_unit), + (printable.max.x * px_per_unit, ph - printable.min.y * px_per_unit), + ); +} + +/// Blank (white) everything outside the pixel rectangle from `top_left` to `bottom_right` (image +/// coordinates, y down; rounded and clamped to the image). For a pixmap whose view does not map +/// the sheet onto the whole image, map the printable corners through `View::to_screen`. +pub fn blank_outside_pixels(pm: &mut Pixmap, top_left: (f64, f64), bottom_right: (f64, f64)) { + let (pw, ph) = (pm.width(), pm.height()); + let col = |x: f64| x.round().clamp(0.0, f64::from(pw)) as usize; + let row = |y: f64| y.round().clamp(0.0, f64::from(ph)) as usize; + let (x0, x1) = (col(top_left.0), col(bottom_right.0)); + let (y0, y1) = (row(top_left.1), row(bottom_right.1)); + let stride = pw as usize * 4; + for (r, line) in pm.data_mut().chunks_exact_mut(stride.max(4)).enumerate() { + for (c, px) in line.as_chunks_mut::<4>().0.iter_mut().enumerate() { + if r < y0 || r >= y1 || c < x0 || c >= x1 { + px.copy_from_slice(&[255, 255, 255, 255]); + } + } + } +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ae35f27f9..e4b314ff5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -370,6 +370,7 @@ fn lin_dim() -> cadcraft_doc::Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } @@ -1122,3 +1123,28 @@ fn raster_hatch_pattern_matches_one_by_one() { assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data()); } } + +/// `Options::linetypes` off draws a dashed line, a dashed wide polyline and a linetype on a layer +/// as the same whole line a Continuous one draws: what hit tests look at. +#[test] +fn without_linetypes_every_line_is_drawn_whole() { + let drawing = |lt: &str| { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + d.layer_mut("0").unwrap().linetype = lt.into(); + let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(30.0, 0.0, 0.0) }); + d.add(&Space::Model, Common::default(), line).unwrap(); + let vertices = [(0.0, 5.0), (30.0, 5.0)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).to_vec(); + let wide = cadcraft_doc::LwPolyline { vertices, closed: false, const_width: 0.2, elevation: 0.0, plinegen: false }; + d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(wide)).unwrap(); + d + }; + let whole = Options { linetypes: false, ..Options::default() }; + let continuous = build(&drawing("Continuous"), &Space::Model, &Options::default()); + let dashed = build(&drawing("DASHED"), &Space::Model, &whole); + assert_eq!(dashed.prims.len(), continuous.prims.len()); + assert_eq!(dashed.verts, continuous.verts); + assert_eq!(dashed.tris, continuous.tris); + let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default()); + assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len()); +} diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6f1e57517..67c31c694 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -174,6 +174,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou // The contents are in paper units now: draw their pens before clipping. pen::apply(&mut sub.list, unit_mm); b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); + b.list.truncated |= sub.list.truncated; append_clipped(&mut b.list, &sub.list, clip); } diff --git a/crates/render/tests/it/dim_angular_centre.rs b/crates/render/tests/it/dim_angular_centre.rs index 9e9c42bdc..149638e0c 100644 --- a/crates/render/tests/it/dim_angular_centre.rs +++ b/crates/render/tests/it/dim_angular_centre.rs @@ -23,6 +23,7 @@ fn dim(kind: DimKind) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/dim_post_tolerance.rs b/crates/render/tests/it/dim_post_tolerance.rs index 907271a7c..83177d6ec 100644 --- a/crates/render/tests/it/dim_post_tolerance.rs +++ b/crates/render/tests/it/dim_post_tolerance.rs @@ -22,6 +22,7 @@ fn text(vars: serde_json::Value) -> (String, String) { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; let mut st = DimStyle::default(); let rejected = st.apply_fields(vars.as_object().unwrap()); diff --git a/crates/render/tests/it/dim_text_fill.rs b/crates/render/tests/it/dim_text_fill.rs index b90e1595d..46028a576 100644 --- a/crates/render/tests/it/dim_text_fill.rs +++ b/crates/render/tests/it/dim_text_fill.rs @@ -26,6 +26,7 @@ fn dimension() -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/dim_text_format.rs b/crates/render/tests/it/dim_text_format.rs index e70deea08..779667e5b 100644 --- a/crates/render/tests/it/dim_text_format.rs +++ b/crates/render/tests/it/dim_text_format.rs @@ -23,6 +23,7 @@ fn linear(len: f64) -> Dimension { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, } } diff --git a/crates/render/tests/it/render_props.rs b/crates/render/tests/it/render_props.rs index f210dc4e1..a40b1988d 100644 --- a/crates/render/tests/it/render_props.rs +++ b/crates/render/tests/it/render_props.rs @@ -47,6 +47,7 @@ fn dimensions_take_the_viewport_layer_colour() { overrides: Default::default(), assoc: Vec::new(), marks: Default::default(), + insertion_point: Vec3::ZERO, }; d.add(&Space::Model, on("A"), EntityKind::Dimension(dim)).unwrap(); d.add(&Space::Model, on("A"), EntityKind::Line(Line { a: Vec3::new(0.0, -1.0, 0.0), b: Vec3::new(10.0, -1.0, 0.0) })).unwrap(); diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2..3116b01db 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,8 +1,7 @@ -//! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. //! -//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules -//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml +//! entry is verified. //! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! JSON says the entry was verified (`people_entry`). //! diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index d062f69e9..716c27371 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -1,5 +1,5 @@ //! Autosave and Drawing Recovery in the app. The engine decides what to write and writes it -//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME and SAVEFILEPATH with +//! ([`cadcraft_engine::autosave`]); this runs it every frame, keeps SAVETIME, SAVEFILEPATH and ISAVEBAK with //! the preferences, and shows the Drawing Recovery list after a crash: each autosaved drawing can //! be opened (as an unsaved copy) or discarded. On the web there is no autosave yet. @@ -13,9 +13,9 @@ use crate::theme::Tokens; /// The host storage key for [`prefs_json`]. pub const PREFS_KEY: &str = "cadcraft.autosave"; -/// SAVETIME and SAVEFILEPATH as JSON for the host's storage. +/// SAVETIME, SAVEFILEPATH and ISAVEBAK as JSON for the host's storage. pub fn prefs_json() -> String { - json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath() }).to_string() + json!({ "savetime": autosave::savetime(), "saveFilePath": autosave::savefilepath(), "backup": autosave::isavebak() }).to_string() } /// Restore what [`prefs_json`] saved; malformed values are ignored. @@ -27,6 +27,21 @@ pub fn load_prefs(json: &str) { if let Some(p) = v.get("saveFilePath").and_then(Value::as_str) { autosave::set_savefilepath(p); } + if let Some(b) = v.get("backup").and_then(Value::as_bool) { + autosave::set_isavebak(b); + } +} + +/// What the autosave failure notice says (see [`failure_notice`]). +#[derive(Clone, Debug, PartialEq)] +pub struct AutosaveNotice { + /// The drawings it has told the user about. Another drawing's failure is news, even after + /// the notice was dismissed. + pub reported: std::collections::BTreeSet, + pub message: String, + /// The user closed it. The failure stays recorded so the notice doesn't come back on every + /// retry; it does for the next failure after a write has worked. + pub dismissed: bool, } /// What the GPU error notice says (see [`gpu_notice`]). @@ -87,6 +102,40 @@ impl CadApp { if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) { _ctx.request_repaint_after(wait); } + #[cfg(not(target_arch = "wasm32"))] + { + // A write that fails while the app is idle must still reach the notice. + if self.autosave.as_ref().is_some_and(cadcraft_engine::autosave::Autosaver::results_pending) { + _ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } + self.note_autosave_failure(_ctx); + } + } + + /// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the + /// command line and shown until it is dismissed or a write works. + #[cfg(not(target_arch = "wasm32"))] + fn note_autosave_failure(&mut self, ctx: &egui::Context) { + let failures: Vec<(u64, String)> = self.autosave.iter().flat_map(|s| s.failures()).map(|(uid, why)| (uid, why.to_string())).collect(); + if failures.is_empty() { + self.autosave_notice = None; + return; + } + let notice = + self.autosave_notice.get_or_insert_with(|| AutosaveNotice { reported: Default::default(), message: String::new(), dismissed: false }); + notice.reported.retain(|uid| failures.iter().any(|(failed, _)| failed == uid)); + for (uid, message) in &failures { + if notice.reported.insert(*uid) { + self.session.echo(format!("Autosave failed: {message}")); + notice.message.clone_from(message); + notice.dismissed = false; + ctx.request_repaint(); + } + } + // The drawing it names recovered while another still fails. + if let Some((_, message)) = failures.first().filter(|_| !failures.iter().any(|(_, m)| *m == notice.message)) { + notice.message.clone_from(message); + } } /// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the @@ -104,8 +153,9 @@ impl CadApp { /// The app closes normally: this app's autosave files are not needed any more. pub fn shutdown_autosave(&mut self) { + // After the interface gave up, the recovery copies are all that's left of the work. #[cfg(not(target_arch = "wasm32"))] - if let Some(s) = &mut self.autosave { + if let Some(s) = self.autosave.as_mut().filter(|_| !self.fault.broken) { s.shutdown(); } } @@ -113,7 +163,7 @@ impl CadApp { /// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic /// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch. -fn utc(secs: u64) -> String { +pub(crate) fn utc(secs: u64) -> String { let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); let (y, mo, d) = civil_from_days(secs / 86_400); // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. @@ -239,6 +289,37 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) { } } +/// The notice that autosave isn't working, so unsaved changes have no recovery copy. It doesn't +/// block the drawing: the user needs to be able to save. +pub fn failure_notice(app: &mut CadApp, ctx: &egui::Context) { + let Some(n) = app.autosave_notice.as_ref().filter(|n| !n.dismissed) else { return }; + let t = Tokens::get(); + let mut dismiss = false; + egui::Window::new("Autosave failed") + .id(egui::Id::unique("cc_autosave_notice")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("CADCraft could not write a recovery copy, so recent changes would be lost if the app stopped now."); + ui.label(RichText::new("Save your work. Autosave tries again after the SAVETIME interval; its folder is in Options.").strong()); + ui.add_space(4.0); + ui.collapsing("Details", |ui| { + ui.label(RichText::new(&n.message).color(t.text_dim).monospace()); + }); + // A row just as tall as the button (a plain right-to-left layout takes the rest). + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + if dismiss && let Some(n) = &mut app.autosave_notice { + n.dismissed = true; + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fcc464be3..39040c366 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -26,7 +26,7 @@ mod widgets; use cursor::{draw_crosshair, draw_snap_marker, draw_tracking, effective_point, override_cursor}; use cycle::{CycleList, cycle_badge, cycle_click, cycle_list, over_canvas_menu, shift_space_cycle}; pub(crate) use draw::{Look, draw_highlight, draw_list}; -use draw::{draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; +use draw::{dashed_rect, draw_drag_gpu, draw_highlight_gpu, draw_list_gpu, list_mesh_key, selection_area_fill}; use dyn_boxes::dynamic_input; pub(crate) use dyn_boxes::length_units; use grid::draw_grid; @@ -982,8 +982,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) { xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), ); - let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()]; - painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); + painter.extend(dashed_rect(painter.clip_rect(), pa, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); (Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper)) } None => { @@ -1112,8 +1111,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) { } let st = Stroke::new(1.0, if crossing { t.crossing_stroke } else { t.window_stroke }); if crossing { - let pts = vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.extend(Shape::dashed_line(&pts, st, 5.0, 3.0)); + painter.extend(dashed_rect(painter.clip_rect(), r, st, 5.0, 3.0)); } else { painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle); } diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 9ca1d9314..146466b24 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -19,6 +19,64 @@ fn clip_infinite(xf: &Xf, base: Vec2, dir: Vec2, ray: bool) -> Option<[Pos2; 2]> Some([xf.to_screen(a), xf.to_screen(b)]) } +/// Points past the view's edge that a dashed outline is still drawn to: more than one dash +/// period, so a dash cut at the clip edge never shows. +const CLIP_MARGIN: f32 = 16.0; + +/// The parts of a world-space polyline within `xf`'s view, as screen points, each with how far +/// along the whole polyline it starts (in points). Clipped in world coordinates, so an outline far +/// larger than the screen costs only what can show, and its points stay precise when zoomed far +/// in: dashing a whole line made billions of dashes (#2299). +fn visible_runs(xf: &Xf, pts: &[Vec2]) -> Vec<(Vec, f64)> { + let vis = xf.world_bounds().expand(f64::from(CLIP_MARGIN) / xf.scale); + let mut runs: Vec<(Vec, f64)> = Vec::new(); + // World length before the current segment; whether the last run reaches its start. + let (mut along, mut open) = (0.0, false); + for w in pts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + match cadcraft_render::clip::clip_segment(a, b, &vis) { + Some((p, q)) => { + match runs.last_mut() { + Some((run, _)) if open && p == a => run.push(xf.to_screen(q)), + _ => runs.push((vec![xf.to_screen(p), xf.to_screen(q)], (along + a.dist(p)) * xf.scale)), + } + open = q == b; + } + None => open = false, + } + along += a.dist(b); + } + runs +} + +/// Dashes along a screen polyline that starts `along` points into the outline it is part of, so +/// they sit where they would on the whole outline. +fn dashes(run: &[Pos2], along: f64, stroke: Stroke, dash: f32, gap: f32, out: &mut Vec) { + let period = f64::from(dash + gap); + let offset = (period - along.rem_euclid(period)) % period; + out.extend(Shape::dashed_line_with_offset(run, stroke, &[dash], &[gap], offset as f32)); +} + +/// A dashed rectangle outline, each edge clipped to `clip` first: a rectangle zoomed far larger +/// than the screen (a layout's printable area, a selection window) gets only the dashes that can +/// show (#2299). Each edge keeps its direction and its dashes start where they would on the whole +/// edge, so they don't crawl as the rectangle or the view moves. +pub(super) fn dashed_rect(clip: Rect, r: Rect, stroke: Stroke, dash: f32, gap: f32) -> Vec { + let v = |p: Pos2| Vec2::new(f64::from(p.x), f64::from(p.y)); + let c = clip.expand(CLIP_MARGIN); + let c = Bounds2::new(v(c.min), v(c.max)); + let mut out = Vec::new(); + let edges = + [(r.left_top(), r.right_top()), (r.right_top(), r.right_bottom()), (r.right_bottom(), r.left_bottom()), (r.left_bottom(), r.left_top())]; + for (a, b) in edges { + if let Some((p, q)) = cadcraft_render::clip::clip_segment(v(a), v(b), &c) { + let pos = |w: Vec2| Pos2::new(w.x as f32, w.y as f32); + dashes(&[pos(p), pos(q)], v(a).dist(p), stroke, dash, gap, &mut out); + } + } + out +} + /// Draw a display list. pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: bool) { let vis = xf.world_bounds(); @@ -279,25 +337,31 @@ impl Look { /// outlined, since filling it would hide the drawing under it. pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, look: Look) { let stroke = Stroke::new(look.width, color); - let line = |pts: Vec, out: &mut Vec| { + let screen_line = |pts: Vec, along: f64, out: &mut Vec| { if look.dashed { - out.extend(Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + dashes(&pts, along, stroke, 6.0, 4.0, out); } else { out.push(Shape::line(pts, stroke)); } }; + // Only the part of an outline in view (#2299). + let line = |pts: &[Vec2], out: &mut Vec| { + for (run, along) in visible_runs(xf, pts) { + screen_line(run, along, out); + } + }; let mut shapes = Vec::new(); let mut fill = egui::Mesh::default(); let fill_color = color.gamma_multiply(crate::gpu::HIGHLIGHT_FILL_ALPHA); for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { let pts = list.points(prim); match prim.kind { - Kind::Polyline => line(pts.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes), + Kind::Polyline => line(pts, &mut shapes), Kind::Infinite { ray } => { if let (Some(b), Some(d)) = (pts.first(), pts.get(1)) && let Some(seg) = clip_infinite(xf, *b, *d, ray) { - line(seg.to_vec(), &mut shapes); + screen_line(seg.to_vec(), 0.0, &mut shapes); } } Kind::Point => { @@ -314,7 +378,7 @@ pub(crate) fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, pri } Kind::Tris | Kind::Mask => { for outline in list.fill_outlines(prim) { - line(outline.iter().map(|q| xf.to_screen(*q)).collect(), &mut shapes); + line(&outline, &mut shapes); } } } @@ -422,3 +486,130 @@ mod highlight_stability_tests { } } } + +#[cfg(test)] +mod zoomed_highlight_tests { + use super::*; + use egui::pos2; + + /// A one-segment polyline from `a` to `b`. + fn line(a: Vec2, b: Vec2) -> DisplayList { + let mut list = DisplayList { verts: vec![a, b], ..Default::default() }; + list.prims.push(cadcraft_render::DPrim { + handle: Handle(256), + color: Rgb(255, 255, 255), + aci7: true, + lw: 0.0, + transparency: 0, + kind: Kind::Polyline, + start: 0, + len: 2, + }); + list + } + + const SCREEN: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 600.0, y: 400.0 } }; + + /// The dash segments `draw_highlight` makes for `list` in `xf`. + fn dashes_of(list: &DisplayList, xf: &Xf) -> Vec<[Pos2; 2]> { + let ctx = egui::Context::default(); + let input = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| draw_highlight(ui.painter(), xf, list, &[0], Color32::LIGHT_BLUE, Look { width: 1.0, dashed: true })); + output.textures_delta.clear(); + output + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } => Some(*points), + _ => None, + }) + .collect() + } + + /// Zoomed far in on a line, the highlight dashes only what shows. Dashing the whole line made + /// one shape per 10 points of it: billions at the zoom limit, so the app froze and then ran out + /// of memory (#2299). + #[test] + fn a_zoomed_in_highlight_dashes_only_what_shows() { + let list = line(Vec2::new(-1000.0, 0.0), Vec2::new(1000.0, 0.0)); + // The smaller zoom first, so a regression fails here instead of filling memory at 1e9. + for scale in [1e3, 1e9] { + let n = dashes_of(&list, &Xf { rect: SCREEN, center: Vec2::new(0.3, 0.0), scale }).len(); + assert!((50..=70).contains(&n), "scale {scale}: {n} dashes for 600 points of line"); + } + } + + /// A line running off screen keeps its dashes where they are on the whole line, so they don't + /// crawl along it when the view pans. + #[test] + fn clipped_dashes_stay_where_the_whole_line_puts_them() { + let list = line(Vec2::new(0.0, 0.0), Vec2::new(1000.0, 0.0)); + for x in [500.3, 512.9, 731.0] { + let xf = Xf { rect: SCREEN, center: Vec2::new(x, 0.0), scale: 1.0 }; + let whole = Shape::dashed_line( + &[xf.to_screen(Vec2::ZERO), xf.to_screen(Vec2::new(1000.0, 0.0))], + Stroke::new(1.0, Color32::LIGHT_BLUE), + 6.0, + 4.0, + ); + let on_screen = |p: &[Pos2; 2]| p.iter().all(|q| SCREEN.contains(*q)); + let expected: Vec<[Pos2; 2]> = whole + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points, .. } => Some(*points).filter(on_screen), + _ => None, + }) + .collect(); + let got: Vec<[Pos2; 2]> = dashes_of(&list, &xf).into_iter().filter(on_screen).collect(); + assert!(!expected.is_empty()); + assert_eq!(got.len(), expected.len(), "centre {x}"); + for (g, e) in got.iter().zip(&expected) { + assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "centre {x}: {g:?} vs {e:?}"); + } + } + } + + /// A rectangle edge running off screen keeps its dashes where they are on the whole edge. + #[test] + fn clipped_rectangle_edges_keep_their_dashes() { + let stroke = Stroke::new(1.0, Color32::GRAY); + let segments = |shapes: Vec| -> Vec<[Pos2; 2]> { + shapes + .into_iter() + .filter_map(|s| match s { + // The top edge's dashes that are wholly on screen. + Shape::LineSegment { points, .. } if points.iter().all(|q| SCREEN.contains(*q) && q.y == 100.0) => Some(points), + _ => None, + }) + .collect() + }; + for left in [-500.3, -517.9, -1200.0] { + let r = Rect::from_min_max(pos2(left, 100.0), pos2(400.0, 300.0)); + let expected = segments(Shape::dashed_line(&[r.left_top(), r.right_top()], stroke, 4.0, 4.0)); + let got = segments(dashed_rect(SCREEN, r, stroke, 4.0, 4.0)); + assert!(!expected.is_empty()); + assert_eq!(got.len(), expected.len(), "left edge at {left}"); + for (g, e) in got.iter().zip(&expected) { + assert!(g.iter().zip(e).all(|(a, b)| a.distance(*b) < 1e-3), "left edge at {left}: {g:?} vs {e:?}"); + } + } + } + + /// A dashed rectangle far larger than the screen gets dashes only along the edges that show. + #[test] + fn a_huge_dashed_rectangle_dashes_only_what_shows() { + let stroke = Stroke::new(1.0, Color32::GRAY); + // Its left edge runs through the screen; the other three are far off it. The smaller one + // first, so a regression fails here instead of filling memory with the larger one. + for far in [1e6, 1e9] { + let huge = Rect::from_min_max(pos2(300.0, -far), pos2(far, far)); + let n = dashed_rect(SCREEN, huge, stroke, 4.0, 4.0).len(); + assert!((50..=60).contains(&n), "{far}: {n} dashes along 432 points of edge"); + } + // A rectangle inside the screen keeps all four edges. + let small = Rect::from_min_max(pos2(100.0, 100.0), pos2(180.0, 140.0)); + assert_eq!(dashed_rect(SCREEN, small, stroke, 4.0, 4.0).len(), 10 + 5 + 10 + 5); + // Off screen, nothing. + assert!(dashed_rect(SCREEN, small.translate(egui::vec2(5000.0, 0.0)), stroke, 4.0, 4.0).is_empty()); + } +} diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs index 2d548264e..f965b0ae1 100644 --- a/crates/ui-egui/src/canvas/list.rs +++ b/crates/ui-egui/src/canvas/list.rs @@ -128,6 +128,7 @@ pub(super) fn ensure_list(app: &mut CadApp, xf: &Xf, views: &[(Bounds2, f64)], c // level of detail must hold at the finest one. min_dash: 2f64.powi(lod_band) * 2.0, text: true, + linetypes: true, fill: true, lineweights: lwdisplay, view_height: st.paper_view().height, diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index c79354b6c..0857e23ee 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -128,6 +128,7 @@ pub(super) fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Con tolerance: 2f64.powi(band) * 0.5, min_dash: 2f64.powi(band) * 2.0, text: true, + linetypes: true, fill: true, lineweights: lwdisplay, view_height: st.paper_view().height, diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 9d8bd9821..fc25555e2 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -443,7 +443,9 @@ pub fn submit(app: &mut CadApp) { } return; } - app.cmdline(&text); + // A partly typed command name runs the highlighted (first) AutoComplete suggestion. + let completion = if app.session.prompting() { None } else { suggestions(text.trim()).into_iter().next() }; + app.cmdline_or(&text, completion.map(|s| s.id.to_ascii_lowercase()).as_deref()); } fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { @@ -492,7 +494,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; if !pr.raw && !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); + // As the engine's prompt text says it; only " or [" has words to translate. + let lead = match pr.keyword_lead() { + " or [" => crate::tl!(" or ["), + lead => lead, + }; + let g = p.layout_no_wrap(lead.into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; @@ -924,4 +931,39 @@ mod autocomplete_tests { tab(&mut app); assert_eq!(app.cmd.buffer, "RECTANG"); } + + fn app() -> CadApp { + let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default()); + app.start("ui.sample"); + app + } + + #[test] + fn enter_on_a_partial_name_runs_the_highlighted_suggestion() { + let mut app = app(); + app.cmd.buffer = "RECT".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("rectang")); + assert!(!app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log); + } + + #[test] + fn enter_keeps_exact_names_and_reports_names_nothing_matches() { + let mut app = app(); + // An exact alias is not replaced by a longer suggestion. + app.cmd.buffer = "l".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line")); + app.session.cancel(); + // A system variable name still acts as SETVAR. + app.cmd.buffer = "orthomode".to_string(); + submit(&mut app); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("setvar")); + app.session.cancel(); + // No suggestion: still an unknown command. + app.cmd.buffer = "zzqx".to_string(); + submit(&mut app); + assert!(app.session.running.is_none()); + assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "{:?}", app.session.log); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7330681db..9c806a96f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -269,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { "snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})), "session": app.session.summary(), "closePrompt": app.close_prompt(), - "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, + "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "truncated": app.canvas.list.as_ref().map(|l| l.truncated), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, }) } @@ -489,18 +489,25 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou }; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = s("path") { - if let Err(e) = std::fs::write(path, &png) { + let file = std::path::Path::new(&path); + let folder = cadcraft_engine::atomic_write::missing_folder(file); + if let Err(e) = cadcraft_engine::atomic_write::write_atomic_making_folders(file, &png) { return err(e); } - return ok(render_reply(&png, w, h, Some(path))); + let mut reply = render_reply(&png, w, h, Some(path), list.truncated); + if let Some(folder) = folder { + reply["createdFolder"] = json!(folder.display().to_string()); + } + return ok(reply); } - ok(render_reply(&png, w, h, None)) + ok(render_reply(&png, w, h, None, list.truncated)) } "app.open" => { let Some(path) = s("path") else { return err("missing path") }; wrap(app.run("open", json!({"path": path}))) } - "app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, p.clone())), + // Never `null` params: those mean a user's Save, which may pick a file or ask. + "app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, if p.is_null() { json!({}) } else { p.clone() })), "app.quit" => { // Programmatic calls never open dialogs: quit without the unsaved-changes prompt. app.quit_confirmed = true; @@ -536,9 +543,11 @@ fn render_view(bounds: &cadcraft_geom::Bounds2, center: Vec2, height: f64, fit: } } -/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus `bytes` and `path`. -fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value { - let mut v = json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len()}); +/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus +/// `bytes`, `path` and `truncated` (the build left block contents out, see #1070). +fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>, truncated: bool) -> Value { + let mut v = + json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len(), "truncated": truncated}); if let (Some(path), Some(o)) = (path, v.as_object_mut()) { o.insert("path".to_string(), json!(path)); } @@ -815,12 +824,14 @@ mod tests { #[test] fn render_reply_has_base64_width_height_and_keeps_old_keys() { - let r = render_reply(b"abc", 7, 5, Some("x.png")); + let r = render_reply(b"abc", 7, 5, Some("x.png"), false); assert_eq!(r["pngBase64"], "YWJj"); assert_eq!(r["width"], 7); assert_eq!(r["height"], 5); assert_eq!(r["bytes"], 3); assert_eq!(r["path"], "x.png"); - assert!(render_reply(b"abc", 7, 5, None).get("path").is_none()); + assert_eq!(r["truncated"], false); + assert!(render_reply(b"abc", 7, 5, None, true).get("path").is_none()); + assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true); } } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 28d08269a..6ebcb362e 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -1,6 +1,6 @@ //! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by -//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is +//! build.rs from contributors/contributors.json; nothing is //! read at run time. use std::cmp::Ordering; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index acdddb552..1f8035434 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "units" => units(app, ctx, &mut open), "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open), + "find" => crate::finddlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), diff --git a/crates/ui-egui/src/fault.rs b/crates/ui-egui/src/fault.rs new file mode 100644 index 000000000..7d087ed23 --- /dev/null +++ b/crates/ui-egui/src/fault.rs @@ -0,0 +1,235 @@ +//! What happens when the interface panics. Commands already run under their own guard +//! (`cadcraft_engine::guard`); this one covers the rest of the frame, so a panic in a panel keeps +//! the user's work: every drawing with unsaved changes is autosaved at once and the app says so. +//! A panic that keeps coming back leaves a plain window that only closes, with the recovery copies +//! kept for the next start. A panic hook writes a report for each, which stays on this machine. +//! None of this helps on the web, where a panic aborts. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use egui::{RichText, vec2}; + +use crate::theme::Tokens; +use crate::{CadApp, now_ms}; + +/// Where crash reports go, under the settings folder. +pub const REPORT_DIR: &str = "crash-reports"; +/// This many panics, each within [`QUIET_MS`] of the last, and the interface is given up on. +const MAX_STRIKES: u32 = 3; +/// A stretch this long without a panic means the app has recovered. +const QUIET_MS: f64 = 5_000.0; + +/// Panics caught around the frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UiFault { + /// The last panic's message, until the user dismisses the notice. + pub message: Option, + /// Drawings autosaved because of panics so far. + pub autosaved: usize, + /// Panics in the current run of them. + pub strikes: u32, + /// When the last one happened. + pub last_ms: f64, + /// The interface keeps panicking: it no longer runs, and recovery copies are kept on exit. + pub broken: bool, +} + +impl CadApp { + /// [`CadApp::logic`] under the panic guard. + pub fn guarded_logic(&mut self, ctx: &egui::Context) { + self.guarded(ctx, |app| app.logic(ctx)); + } + + /// [`CadApp::ui`] under the panic guard, then the notice about any panic. Once the interface + /// has been given up on, only the window that closes the app is drawn. + pub fn guarded_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.guarded(&ctx, |app| app.ui(ui)); + self.show_fault(&ctx); + } + + /// The notice about a caught panic, or the window that closes the app. + pub fn show_fault(&mut self, ctx: &egui::Context) { + if self.fault.broken { + broken_window(self, ctx); + } else { + notice(self, ctx); + } + } + + /// Run `frame`; a panic in it autosaves unsaved drawings instead of ending the app. + pub fn guarded(&mut self, ctx: &egui::Context, frame: impl FnOnce(&mut CadApp)) { + if self.fault.broken { + return; + } + if let Err(p) = catch_unwind(AssertUnwindSafe(|| frame(self))) { + let message = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()); + self.on_panic(ctx, message.unwrap_or_else(|| "panic".into())); + } + } + + fn on_panic(&mut self, ctx: &egui::Context, message: String) { + let now = now_ms(); + let fault = &mut self.fault; + fault.strikes = if now - fault.last_ms < QUIET_MS { fault.strikes + 1 } else { 1 }; + fault.last_ms = now; + fault.broken = fault.strikes >= MAX_STRIKES; + log::error!("the interface panicked: {message}"); + #[cfg(not(target_arch = "wasm32"))] + if let Some(saver) = &mut self.autosave { + let session = &self.session; + // The save is the point of being here: a panic inside it must not end the app either. + self.fault.autosaved += catch_unwind(AssertUnwindSafe(|| saver.emergency_save(session))).unwrap_or(0); + } + self.fault.message = Some(message); + ctx.request_repaint(); + } +} + +/// The notice after a panic. It doesn't block the drawing: the user needs to be able to save. +fn notice(app: &mut CadApp, ctx: &egui::Context) { + let Some(message) = app.fault.message.clone() else { return }; + let t = Tokens::get(); + let mut dismiss = false; + egui::Window::new("Internal error") + .id(egui::Id::unique("cc_fault_notice")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("Part of CADCraft failed, and the app kept running."); + ui.label(saved_text(app.fault.autosaved)); + ui.label(RichText::new("Save your work and restart CADCraft.").strong()); + ui.add_space(4.0); + details(ui, t, &message); + // A row just as tall as the button (a plain right-to-left layout takes the rest). + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + if dismiss { + app.fault.message = None; + } +} + +/// All that's left once the interface keeps panicking. +fn broken_window(app: &CadApp, ctx: &egui::Context) { + let t = Tokens::get(); + egui::Window::new("CADCraft has to close") + .id(egui::Id::unique("cc_fault_broken")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("Part of CADCraft keeps failing, so it can't continue."); + ui.label(saved_text(app.fault.autosaved)); + ui.label(RichText::new("Drawing Recovery offers them at the next start.").strong()); + ui.add_space(4.0); + if let Some(message) = &app.fault.message { + details(ui, t, message); + } + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close CADCraft").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); +} + +fn saved_text(autosaved: usize) -> String { + match autosaved { + 0 => "No drawing needed a new recovery copy.".to_string(), + 1 => "A recovery copy of the drawing with unsaved changes was autosaved.".to_string(), + k => format!("Recovery copies of {k} drawings with unsaved changes were autosaved."), + } +} + +fn details(ui: &mut egui::Ui, t: Tokens, message: &str) { + ui.collapsing("Details", |ui| { + ui.label(RichText::new(message).color(t.text_dim).monospace()); + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = report::last() { + ui.label(RichText::new(format!("Report: {}", path.display())).color(t.text_dim).monospace()); + } + }); +} + +#[cfg(not(target_arch = "wasm32"))] +pub use report::install_report_hook; + +#[cfg(not(target_arch = "wasm32"))] +mod report { + use std::fmt::Write as _; + use std::panic::PanicHookInfo; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Reports one run may write: a panic every frame must not fill the disk. + const MAX_PER_RUN: u32 = 5; + /// Reports kept in the folder. + const KEEP: usize = 20; + + static WRITTEN: AtomicU32 = AtomicU32::new(0); + static LAST: Mutex> = Mutex::new(None); + + /// The report of the latest panic, once one was written. + pub(super) fn last() -> Option { + LAST.lock().ok().and_then(|g| g.clone()) + } + + /// Write a report into `dir` for every panic, on any thread, before the hook already in + /// place runs. Panics that a guard then catches get one too: they are still bugs. A panic that + /// can't be reported goes on as it would have: a report never makes things worse. + pub fn install_report_hook(dir: PathBuf) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if WRITTEN.fetch_add(1, Ordering::Relaxed) < MAX_PER_RUN + && let Ok(path) = write_report(&dir, info) + && let Ok(mut last) = LAST.lock() + { + *last = Some(path); + } + previous(info); + })); + } + + fn write_report(dir: &Path, info: &PanicHookInfo<'_>) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let mut text = String::new(); + let _ = writeln!(text, "CADCraft {} on {} {}", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); + let _ = writeln!(text, "Time: {} UTC", crate::autosave::utc(now.as_secs())); + let _ = writeln!(text, "Thread: {}", std::thread::current().name().unwrap_or("unnamed")); + if let Some(l) = info.location() { + let _ = writeln!(text, "Location: {}:{}:{}", l.file(), l.line(), l.column()); + } + let _ = writeln!(text, "Message: {}", info.payload_as_str().unwrap_or("(not text)")); + let _ = writeln!(text, "\n{}", std::backtrace::Backtrace::force_capture()); + let path = dir.join(format!("crash-{:012x}-{}.txt", now.as_millis(), std::process::id())); + cadcraft_engine::atomic_write::write_atomic(&path, text.as_bytes())?; + prune(dir); + Ok(path) + } + + /// Keep the newest [`KEEP`] reports. Names start with the time, so they sort by age. + fn prune(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut reports: Vec = entries + .flatten() + .take(10_000) + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("crash-") && n.ends_with(".txt"))) + .collect(); + reports.sort(); + let extra = reports.len().saturating_sub(KEEP); + for old in reports.drain(..extra) { + let _ = std::fs::remove_file(old); + } + } +} diff --git a/crates/ui-egui/src/finddlg.rs b/crates/ui-egui/src/finddlg.rs new file mode 100644 index 000000000..7341f3cfa --- /dev/null +++ b/crates/ui-egui/src/finddlg.rs @@ -0,0 +1,294 @@ +//! Find and Replace: the window behind the FIND command. +//! +//! FIND used to be a command line that asked for its parameters and then printed a count. The +//! parameters are still what the engine takes; this window is the surface where a user can see +//! *what* matched, jump to it and replace one object at a time. It stays open while the drawing +//! is edited — find-and-replace in a drawing is a loop (look, locate, edit, look again), so a +//! window that closed on the first result would be closed and reopened all afternoon. +//! +//! Everything the engine cannot answer is left out rather than faked: the search covers the +//! current space, and the note under the switches says so in as many words. + +use egui::{Color32, RichText, Sense, TextFormat}; +use serde_json::{Value, json}; + +use crate::CadApp; +use crate::theme::{self, Tokens}; + +/// One object the search hit, as the engine reports it. +#[derive(Clone, Default)] +struct Row { + handle: String, + kind: String, + count: u64, + /// The text around the first hit, and where the hit sits inside it (characters, not bytes). + snippet: String, + hit: usize, + hit_len: usize, +} + +/// A `u64` from the reply as a `usize`, or 0 when it does not fit: an offset past the end of a +/// snippet is a bad reply, not a reason to fail the search that produced it. +fn as_index(v: Option<&Value>) -> usize { + v.and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0) +} + +impl Row { + /// Read one `matches` entry; anything missing keeps its default so a partially understood + /// reply still lists something. + fn from_json(v: &Value) -> Self { + Self { + handle: v.get("handle").and_then(Value::as_str).unwrap_or_default().to_string(), + kind: v.get("type").and_then(Value::as_str).unwrap_or_default().to_string(), + count: v.get("count").and_then(Value::as_u64).unwrap_or(0), + snippet: v.get("snippet").and_then(Value::as_str).unwrap_or_default().to_string(), + hit: as_index(v.get("hit")), + hit_len: as_index(v.get("hitLen")), + } + } + + /// The snippet in three pieces: before the hit, the hit, after it. Every slice is by + /// character and every range is clamped, so a reply with a nonsensical offset still draws. + fn pieces(&self) -> (String, String, String) { + let n = self.snippet.chars().count(); + let at = self.hit.min(n); + let end = at.saturating_add(self.hit_len).min(n); + let before: String = self.snippet.chars().take(at).collect(); + let hit: String = self.snippet.chars().skip(at).take(end.saturating_sub(at)).collect(); + let after: String = self.snippet.chars().skip(end).collect(); + (before, hit, after) + } +} + +/// The window's state between frames: kept in egui's temporary store, like the other dialogs, +/// so it lives exactly as long as the window does and never reaches the preferences file. +#[derive(Default, Clone)] +struct State { + find: String, + replace: String, + match_case: bool, + whole_word: bool, + /// The `doc_epoch` and space the rows were found in. A row's handle is only meaningful in + /// the drawing and the space it was found in, so when either moves the rows must go. + epoch: u64, + space: cadcraft_doc::Space, + rows: Vec, + /// The row Enter and Replace act on. + current: Option, + message: String, +} + +/// Run FIND and keep the parsed matches, or the engine's complaint in `message`. +fn search(app: &mut CadApp, st: &mut State) { + if st.find.trim().is_empty() { + st.rows.clear(); + st.current = None; + st.message = crate::i18n::t("Type something to find").to_string(); + return; + } + let params = json!({ "find": st.find, "matchCase": st.match_case, "wholeWord": st.whole_word }); + match app.run("find", params) { + Ok(v) => { + st.rows = v.get("matches").and_then(Value::as_array).map(|a| a.iter().map(Row::from_json).collect()).unwrap_or_default(); + st.current = if st.rows.is_empty() { None } else { Some(0) }; + let total = v.get("total").and_then(Value::as_u64).unwrap_or(0); + let objects = v.get("matches").and_then(Value::as_array).map_or(0, Vec::len); + st.message = format!("{total} match(es) in {objects} object(s)"); + } + Err(e) => { + st.rows.clear(); + st.current = None; + st.message = e; + } + } +} + +/// Select `row` in the drawing and zoom to it: the result list's rows navigate, they do not just +/// report. +fn reveal(app: &mut CadApp, st: &State, index: usize) { + let Some(row) = st.rows.get(index) else { return }; + let Some(h) = cadcraft_doc::Handle::parse_hex(&row.handle) else { return }; + app.session.set_selection(vec![h]); + let _ = app.run("zoom", json!({ "mode": "object" })); +} + +/// Replace inside the objects the result list already holds. `only` limits it to one row, so +/// Replace touches the object the user is looking at and Replace All touches the whole list. +fn replace(app: &mut CadApp, st: &mut State, only: Option) { + if st.find.trim().is_empty() { + return; + } + let handles: Vec = match only { + Some(i) => st.rows.get(i).map(|r| r.handle.clone()).into_iter().collect(), + None => st.rows.iter().map(|r| r.handle.clone()).collect(), + }; + if handles.is_empty() { + return; + } + let params = json!({ + "find": st.find, + "replace": st.replace, + "matchCase": st.match_case, + "wholeWord": st.whole_word, + "handles": handles, + }); + match app.run("find", params) { + Ok(v) => { + // The engine's reply reports what the replacement did; the re-search below would + // overwrite that message with a match count, so the replacement report is what + // the user ends up reading. + let replaced = v.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); + search(app, st); + if !replaced.is_empty() { + st.message = replaced; + } + } + Err(e) => st.message = e, + } +} + +/// The Find and Replace window. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let t = Tokens::get(); + let sid = egui::Id::unique("find_state"); + let mut st: State = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); + // A handle from an earlier drawing or space now names whatever object took its place; if + // Replace All ran on such a row it would edit an object the search never matched. Clearing + // the rows on an epoch or space change costs a re-click on Find All, which is the cheap + // side of that trade. + if *open && (st.epoch != app.session.doc_epoch || st.space != app.session.space()) { + st.epoch = app.session.doc_epoch; + st.space = app.session.space(); + st.rows.clear(); + st.current = None; + st.message.clear(); + } + // The rows answer one question — this text, with these switches, in this drawing. Editing + // any of them makes the rows stale: Replace All would rewrite with the old switches while + // the user is looking at the new ones, so the rows go and a fresh search is owed. + let criteria = (st.find.clone(), st.match_case, st.whole_word); + let mut do_search = false; + let mut do_replace: Option> = None; + let mut do_select_all = false; + let mut pick: Option = None; + egui::Window::new(crate::tl!("Find and Replace")) + .id(egui::Id::unique("Find and Replace")) + .open(open) + .collapsible(false) + .resizable(true) + .default_width(480.0) + .default_height(400.0) + .show(ctx, |ui| { + egui::Grid::new("find_fields").num_columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Find what:")); + let r = ui.add(egui::TextEdit::singleline(&mut st.find).desired_width(f32::INFINITY).hint_text(crate::tl!("Text to find"))); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + do_search = true; + } + ui.end_row(); + ui.label(crate::tl!("Replace with:")); + ui.add(egui::TextEdit::singleline(&mut st.replace).desired_width(f32::INFINITY).hint_text(crate::tl!("Replacement text"))); + ui.end_row(); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut st.match_case, crate::tl!("Match case")); + ui.checkbox(&mut st.whole_word, crate::tl!("Whole words")); + }); + ui.label(RichText::new(crate::tl!("Searches the current space.")).color(t.text_faint).small()); + ui.add_space(2.0); + ui.separator(); + let found = if st.rows.is_empty() { crate::tl!("Nothing found").to_string() } else { st.rows.len().to_string() }; + ui.label(RichText::new(format!("{} · {found}", crate::tl!("Results"))).color(t.text_dim).small()); + let body_h = (ui.available_height() - 76.0).clamp(80.0, 280.0); + egui::Frame::NONE.fill(t.field).corner_radius(3).inner_margin(egui::Margin::same(2)).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, row) in st.rows.iter().enumerate() { + let bg = if st.current == Some(i) { t.select_bg } else { Color32::TRANSPARENT }; + let inner = egui::Frame::NONE.fill(bg).corner_radius(2).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::t(&row.kind)).color(t.text_dim).small()); + ui.label(RichText::new(row.handle.as_str()).color(t.text_faint).small().monospace()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(format!("×{}", row.count)).color(t.text_faint).small()); + }); + }); + let (before, hit, after) = row.pieces(); + let mut job = egui::text::LayoutJob::default(); + let plain = TextFormat { font_id: theme::small(), color: t.text, ..Default::default() }; + let marked = TextFormat { font_id: theme::mono(), color: accent(), ..Default::default() }; + job.append(&before, 0.0, plain.clone()); + job.append(&hit, 0.0, marked); + job.append(&after, 0.0, plain); + ui.add(egui::Label::new(job).wrap()); + }); + let click = ui.interact(inner.response.rect, ui.scope_id().with(("find_row", i)), Sense::click()); + if click.clicked() { + st.current = Some(i); + pick = Some(i); + } + } + }); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Find All")).clicked() { + do_search = true; + } + let has = !st.rows.is_empty(); + if ui.add_enabled(has, egui::Button::new(crate::tl!("Select All"))).clicked() { + do_select_all = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // An empty replacement is deleting the matched text, which is a legitimate + // replacement, so the buttons care only about having something to act on. + if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace All"))).clicked() { + do_replace = Some(None); + } + if ui.add_enabled(st.current.is_some(), egui::Button::new(crate::tl!("Replace"))).clicked() { + do_replace = Some(st.current); + } + }); + }); + if !st.message.is_empty() { + ui.label(RichText::new(st.message.as_str()).color(t.text_faint).small()); + } + }); + if let Some(i) = pick { + reveal(app, &st, i); + } + if criteria != (st.find.clone(), st.match_case, st.whole_word) { + st.rows.clear(); + st.current = None; + if !do_search { + st.message.clear(); + } + } + if do_search { + search(app, &mut st); + } + if do_select_all { + let hs: Vec = st.rows.iter().filter_map(|r| cadcraft_doc::Handle::parse_hex(&r.handle)).collect(); + if !hs.is_empty() { + app.session.set_selection(hs); + st.message = format!("{} object(s) selected", st.rows.len()); + } + } + if let Some(only) = do_replace { + replace(app, &mut st, only); + } + // The state lives exactly as long as the window does: a closed window leaves nothing behind, + // so reopening it reads the drawing instead of a stale result list. + if *open { + ctx.data_mut(|d| d.insert_temp(sid, st)); + } else { + ctx.data_mut(|d| d.remove_temp::(sid)); + } +} + +/// The colour a hit is drawn in: the theme's point colour, the same orange the drawing uses for +/// points and grips, so the highlight belongs to the drawing rather than to the interface. +fn accent() -> Color32 { + Tokens::get().icon_point +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7bb00ec17..af289ff56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -47,10 +47,12 @@ pub enum Preference { Turkish, #[serde(rename = "bs")] Bosnian, + #[serde(rename = "vi")] + Vietnamese, } impl Preference { - pub const ALL: [Self; 18] = [ + pub const ALL: [Self; 19] = [ Self::Auto, Self::English, Self::Greek, @@ -69,6 +71,7 @@ impl Preference { Self::Hungarian, Self::Turkish, Self::Bosnian, + Self::Vietnamese, ]; /// Every preference in the order the Interface Language dialog lists them: Auto, then English @@ -117,6 +120,7 @@ impl Preference { Self::Hungarian => "hu", Self::Turkish => "tr", Self::Bosnian => "bs", + Self::Vietnamese => "vi", } } @@ -146,6 +150,14 @@ impl Preference { Self::Hungarian => "Magyar", Self::Turkish => "Türkçe", Self::Bosnian => "Bosanski", + // Its letters need a font egui doesn't bundle ([`crate::theme::vietnamese_font`]). + Self::Vietnamese => { + if crate::theme::shows_vietnamese(CURRENT.with(Cell::get)) { + "Tiếng Việt" + } else { + "Vietnamese" + } + } } } @@ -177,6 +189,7 @@ impl Preference { Self::Hungarian => "hu", Self::Turkish => "tr", Self::Bosnian => "bs", + Self::Vietnamese => "vi", Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"), } } @@ -193,7 +206,7 @@ pub struct Language { catalog: OnceLock>, } -pub static LANGUAGES: [Language; 17] = [ +pub static LANGUAGES: [Language; 18] = [ Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() }, // Greek: one for exactly 1; other for zero and all other integer counts. Language { @@ -243,6 +256,10 @@ pub static LANGUAGES: [Language; 17] = [ // Bosnian (Latin script, Ijekavian): one (1, 21), few (2–4, 22–24, not 12–14), other — the // Ukrainian rule for integer counts. Language { code: "bs", name: "Bosanski", source: include_str!("i18n/bs.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() }, + // Vietnamese doesn't inflect a noun for number ("3 commit"), so like Korean it has one form. + Language { + code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() + }, ]; /// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`. @@ -447,6 +464,8 @@ mod picker_order_tests { assert!(pos(Preference::Italian) < pos(Preference::Hungarian), "Magyar sorts under M"); assert!(pos(Preference::Turkish) < pos(Preference::Greek), "Latin names come before Greek"); assert!(pos(Preference::Bosnian) < pos(Preference::Czech), "Bosanski sorts under B"); + assert!(pos(Preference::Swedish) < pos(Preference::Vietnamese), "Tiếng Việt sorts under T"); + assert!(pos(Preference::Vietnamese) < pos(Preference::Turkish), "Tiếng comes before Türkçe"); assert_eq!(name_sort_key("Türkçe"), "turkce"); } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 40e14cdb5..cf5741cdd 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -305,7 +305,7 @@ Enter a vertex editing option أدخل خيار تحرير الرؤوس Enter an option أدخل خياراً Enter angle of xline أدخل زاوية الخط الإنشائي - Enter angle of xline (0) or أدخل زاوية الخط الإنشائي (0) أو + Enter angle of xline (0) أدخل زاوية الخط الإنشائي (0) Enter area of rectangle in current units أدخل مساحة المستطيل بالوحدات الحالية Enter attribute prompt أدخل موجه السمة Enter attribute tag name أدخل اسم وسم السمة @@ -316,7 +316,7 @@ Enter default attribute value أدخل قيمة السمة الافتراضية Enter degree of spline أدخل درجة السبلاين Enter delta angle أدخل زاوية دلتا - Enter delta length or أدخل طول دلتا أو + Enter delta length أدخل طول دلتا Enter dimension text أدخل نص التأشير Enter dimension text (<> = measurement) أدخل نص التأشير (<> = القياس) Enter dimension value or [name=expression] أدخل قيمة التأشير أو [name=expression] @@ -412,7 +412,7 @@ Group Edit تحرير المجموعة Group Options خيارات المجموعة Group Selection تحديد المجموعة - Hatch تظليل + Hatch تهشير Hatch Edit تحرير التهشير Hatch Pattern نمط التهشير Hatch Patterns أنماط التهشير @@ -504,7 +504,7 @@ Layout list قائمة التخطيطات Layout paper ورقة التخطيط Layout {name}: {table} التخطيط {name}: {table} - Leader قائد + Leader خط الإشارة Leaders Only خطوط الإشارة فقط Left يسار Left device button: zoom extents. Right device button: calibrate. زر الجهاز الأيسر: تكبير الحدود. زر الجهاز الأيمن: معايرة. @@ -573,7 +573,7 @@ More toolbar commands المزيد من أوامر شريط الأدوات Move نقل Move sideways to pan; push/pull or twist to zoom. حرّك جانبياً للإزاحة؛ ادفع أو اسحب أو لُف للتكبير. - Multileader قائد متعدد + Multileader خط الإشارة المتعدد Multileader Style... نمط خط الإشارة المتعدد... Multileader style نمط خط الإشارة المتعدد Multiline متعدد الأسطر @@ -643,6 +643,7 @@ One of the Crafting Apps by the ArtCraft team and community. أحد تطبيقات Crafting من فريق ArtCraft والمجتمع. Only in this drawing: {n} في هذا الرسم فقط: {n} Only in {name}: {n} في {name} فقط: {n} + Oops استعادة الكائنات الممحوة Open فتح Open (⌘O) فتح (⌘O) Open Sample Drawing فتح رسم نموذجي @@ -851,8 +852,8 @@ Select an arc or circle حدد قوساً أو دائرة Select an arc. حدد قوساً. Select an object حدد كائناً - Select an object to change or حدد كائناً لتغييره أو - Select an object to measure or حدد كائناً لقياسه أو + Select an object to change حدد كائناً لتغييره + Select an object to measure حدد كائناً لقياسه Select an object. حدد كائنًا. Select an open curve. حدد منحنى مفتوحاً. Select an open line, arc, polyline, ellipse arc or spline. حدد خطاً مفتوحاً أو قوساً أو متعدد خطوط أو قوس إهليلج أو سبلاين. @@ -866,9 +867,8 @@ Select first line حدد الخط الأول Select first line or arc حدد الخط الأول أو قوساً Select first object حدد الكائن الأول - Select first object or حدد الكائن الأول أو Select first point حدد النقطة الأولى - Select nested objects to copy or حدد الكائنات المتداخلة لنسخها أو + Select nested objects to copy حدد الكائنات المتداخلة لنسخها Select object حدد كائناً Select object to dimension حدد كائناً لتأشيره Select object to divide حدد كائناً لتقسيمه @@ -885,7 +885,7 @@ Select one polyline (Multiple is available through the JSON form). حدد متعدد خطوط واحداً (يتوفر الوضع المتعدد عبر نموذج JSON). Select path curve حدد منحنى المسار Select point or object حدد نقطة أو كائناً - Select polyline or حدد متعدد خطوط أو + Select polyline حدد متعدد خطوط Select second line حدد الخط الثاني Select second line or shift-select to apply corner حدد الخط الثاني أو حدد مع Shift لتطبيق الزاوية Select second object حدد الكائن الثاني @@ -955,9 +955,8 @@ Specify axis endpoint of ellipse حدد نقطة نهاية محور الإهليلج Specify base diameter حدد القطر الأساسي Specify base point حدد نقطة الأساس - Specify base point or حدد نقطة الأساس أو Specify base point or displacement (drag with the middle mouse button for realtime pan) حدد نقطة الأساس أو الإزاحة (اسحب بزر الفأرة الأوسط للإزاحة الفورية) - Specify base radius or حدد نصف القطر الأساسي أو + Specify base radius حدد نصف القطر الأساسي Specify center of donut حدد مركز الحلقة Specify center of ellipse حدد مركز الإهليلج Specify center of polygon حدد مركز المضلع @@ -999,7 +998,7 @@ Specify first point on circle: _tan to حدد النقطة الأولى على الدائرة: _تماس إلى Specify first source point حدد نقطة المصدر الأولى Specify height حدد الارتفاع - Specify helix height or حدد ارتفاع الحلزون أو + Specify helix height حدد ارتفاع الحلزون Specify included angle حدد الزاوية المحصورة Specify insertion base point حدد نقطة الأساس للإدراج Specify insertion point حدد نقطة الإدراج @@ -1018,7 +1017,6 @@ Specify new width for all segments حدد عرضاً جديداً لكل المقاطع Specify next point حدد النقطة التالية Specify offset distance حدد مسافة الإزاحة - Specify offset distance or حدد مسافة الإزاحة أو Specify opposite corner حدد الزاوية المقابلة Specify other corner point حدد نقطة الزاوية الأخرى Specify other endpoint of axis حدد نقطة النهاية الأخرى للمحور @@ -1031,7 +1029,6 @@ Specify radius of circle حدد نصف قطر الدائرة Specify rotation angle حدد زاوية التدوير Specify rotation angle of text حدد زاوية تدوير النص - Specify rotation angle or حدد زاوية التدوير أو Specify scale factor حدد معامل المقياس Specify second angle endpoint حدد نقطة نهاية الزاوية الثانية Specify second break point حدد نقطة الفصل الثانية @@ -1064,9 +1061,9 @@ Specify third source point or حدد نقطة المصدر الثالثة أو <متابعة> Specify through point حدد نقطة العبور Specify top diameter حدد القطر العلوي - Specify top radius or حدد نصف القطر العلوي أو + Specify top radius حدد نصف القطر العلوي Specify total angle حدد الزاوية الكلية - Specify total length or حدد الطول الكلي أو + Specify total length حدد الطول الكلي Specify width for rectangles حدد العرض للمستطيلات Sphere كرة Spline سبلاين @@ -1242,3 +1239,144 @@ menu View عرض Check for Updates… التحقق من وجود تحديثات… CADCraft is up to date. CADCraft محدّث. Could not check for updates. تعذّر التحقق من وجود تحديثات. + Add to Working Set إضافة إلى مجموعة العمل + Add: Pick points إضافة: انتقاء نقاط + Add: Select objects إضافة: تحديد كائنات + Add… إضافة… + Alias اختصار + Alias file: {file} ملف الاختصارات: {file} + Align Multileaders محاذاة خطوط الإشارة المتعددة + Analyze Shapes تحليل الأشكال + Analyze Usage تحليل الاستخدام + Animation Effects تأثيرات الحركة + Animation effects تأثيرات الحركة + Associative ترابطي + Backup copy نسخة احتياطية + Block to create or edit الكتلة المراد إنشاؤها أو تحريرها + Bottom left أسفل اليسار + Bottom right أسفل اليمين + Boundaries الحدود + Boundaries picked: {n} الحدود المنتقاة: {n} + Break-line Symbol رمز خط الكسر + Centered متمركز + Change Properties تغيير الخصائص + Choose the command the alias starts. اختر الأمر الذي يشغّله الاختصار. + Click the ruler to add a tab stop, right-click to remove one انقر على المسطرة لإضافة علامة جدولة، وانقر بالزر الأيمن لإزالتها + Click to set new origin انقر لتعيين نقطة أصل جديدة + Close Block Editor إغلاق محرر الكتل + Collect جمع + Collect Multileaders جمع خطوط الإشارة المتعددة + Color 1 اللون 1 + Color 2 اللون 2 + Command الأمر + Convert Text to Mtext تحويل النص إلى نص متعدد الأسطر + Create backup copy with each save إنشاء نسخة احتياطية عند كل حفظ + Create separate hatches إنشاء تهشيرات منفصلة + Crossing selection color: لون تحديد التقاطع: + Curved منحنٍ + Customize تخصيص + Default to boundary extents الافتراضي: امتدادات الحدود + Define each corner of the selection window with a separate click. حدد كل زاوية من نافذة التحديد بنقرة منفصلة. + Discard تجاهل + Drag Dynamic Grip سحب المقبض الديناميكي + Dynamic Block Data بيانات الكتلة الديناميكية + Dynamic Block Grips مقابض الكتلة الديناميكية + Dynamic Block Reference مرجع كتلة ديناميكية + Edit Block Definition تحرير تعريف الكتلة + Edit Command Aliases (PGP)... تحرير اختصارات الأوامر (PGP)... + Edit Multileader تحرير خط الإشارة المتعدد + Edit Reference In-Place تحرير المرجع في مكانه + Edit… تحرير… + Evaluate LISP تقييم LISP + Exclude: استثناء: + Explode Attributes to Text تفجير السمات إلى نص + Express Tools أدوات Express + Font الخط + Grips المقابض + Groups المجموعات + Hatch Pick انتقاء التهشير + Hatch and Gradient التهشير والتدرج اللوني + Hatch origin نقطة أصل التهشير + Hatches تهشيرات + Hemispherical نصف كروي + Ignore تجاهل + Imperial (inches) إنجليزي (بوصة) + Implied Loops حلقات ضمنية + Indicate selection area إظهار منطقة التحديد + Interface scale مقياس الواجهة + Inverted معكوس + Island جزيرة + Island detection اكتشاف الجزر + Italic مائل + Justify ضبط المحاذاة + Load Application... تحميل تطبيق... + Metric (millimetres) متري (مليمتر) + More than {n} aliases. أكثر من {n} اختصار. + Move/Copy/Rotate نقل/نسخ/تدوير + Multiline text نص متعدد الأسطر + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). تستخدم الرسومات الجديدة وأول رسم عند بدء التشغيل هذه الوحدات ما لم يُعيَّن قالب افتراضي (QNEWTEMPLATE). + New block كتلة جديدة + No alias file loaded. REINIT replaces these aliases with a file's. لم يُحمَّل ملف اختصارات. يستبدل REINIT هذه الاختصارات باختصارات ملف. + Not a number or distance: {text} ليس رقماً أو مسافة: {text} + Object grouping تجميع الكائنات + Object selection limit for display of grips حد تحديد الكائنات لعرض المقابض + Objects on locked layers الكائنات على الطبقات المقفلة + Open in Block Editor فتح في محرر الكتل + Origin: {x}, {y} نقطة الأصل: {x}, {y} + Outer خارجي + Overline خط علوي + Preview معاينة + Query Texts الاستعلام عن النصوص + Save Block حفظ الكتلة + Save Block As حفظ الكتلة باسم + Save Layout as Template حفظ التخطيط كقالب + Save Reference Edits حفظ تعديلات المرجع + Save alias حفظ الاختصار + Save changes to block "{name}"? حفظ التغييرات على الكتلة "{name}"؟ + Select boundaries to remove حدد الحدود المراد إزالتها + Select using two clicks only التحديد بنقرتين فقط + Selection Area Effect تأثير منطقة التحديد + Selection Preview Filter مرشح معاينة التحديد + Selection area opacity: عتامة منطقة التحديد: + Selection effect color لون تأثير التحديد + Selection modes أوضاع التحديد + Selection preview معاينة التحديد + Set Dynamic Property تعيين خاصية ديناميكية + Shape الشكل + Sheet Books to PDF... دفاتر اللوحات إلى PDF... + Show grips إظهار المقابض + Specified origin نقطة أصل محددة + Spherical كروي + Tables الجداول + Text color لون النص + Top left أعلى اليسار + Top right أعلى اليمين + Type an alias name. اكتب اسم الاختصار. + Underline تسطير + Units of new drawings وحدات الرسومات الجديدة + Unsaved changes تغييرات غير محفوظة + Use current origin استخدام نقطة الأصل الحالية + Visual Effect Settings إعدادات التأثيرات المرئية + Visual Effect Settings... إعدادات التأثيرات المرئية... + When a command is active عندما يكون أمر ما نشطاً + When no command is active عندما لا يكون أي أمر نشطاً + Window selection color: لون تحديد النافذة: + Working Set مجموعة العمل + Xref and Block In-place Editing تحرير المراجع الخارجية والكتل في مكانها + Xrefs المراجع الخارجية + built-in مدمج + overrides built-in يتجاوز المدمج + {alias} is already defined; select it and use Edit. {alias} معرَّف مسبقاً؛ حدده واستخدم «تحرير». + {command} is an unknown command. {command} أمر غير معروف. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 14565ce2f..957c48993 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -281,7 +281,7 @@ Enter a vertex editing option Unesite opciju uređivanja tjemena Enter an option Unesite opciju Enter angle of xline Unesite ugao konstrukcijske linije - Enter angle of xline (0) or Unesite ugao konstrukcijske linije (0) ili + Enter angle of xline (0) Unesite ugao konstrukcijske linije (0) Enter area of rectangle in current units Unesite površinu pravougaonika u trenutnim jedinicama Enter attribute prompt Unesite upit atributa Enter attribute tag name Unesite naziv oznake atributa @@ -292,7 +292,7 @@ Enter default attribute value Unesite zadanu vrijednost atributa Enter degree of spline Unesite stepen splajna Enter delta angle Unesite delta ugao - Enter delta length or Unesite delta dužinu ili + Enter delta length Unesite delta dužinu Enter dimension text Unesite tekst kote Enter dimension text (<> = measurement) Unesite tekst kote (<> = mjera) Enter dimension value or [name=expression] Unesite vrijednost kote ili [naziv=izraz] @@ -585,6 +585,7 @@ One of the Crafting Apps by the ArtCraft team and community. Jedna od Crafting aplikacija ArtCraft tima i zajednice. Only in this drawing: {n} Samo u ovom crtežu: {n} Only in {name}: {n} Samo u {name}: {n} + Oops Vrati obrisane objekte Open (⌘O) Otvori (⌘O) Open Sample Drawing Otvori primjer crteža Open sample drawing Otvori primjer crteža @@ -787,8 +788,8 @@ Select an arc or circle Odaberite luk ili krug Select an arc. Odaberite luk. Select an object Odaberite objekat - Select an object to change or Odaberite objekat za promjenu ili - Select an object to measure or Odaberite objekat za mjerenje ili + Select an object to change Odaberite objekat za promjenu + Select an object to measure Odaberite objekat za mjerenje Select an object. Odaberite objekat. Select an open curve. Odaberite otvorenu krivu. Select an open line, arc, polyline, ellipse arc or spline. Odaberite otvorenu liniju, luk, poliliniju, eliptični luk ili splajn. @@ -802,14 +803,13 @@ Select first line Odaberite prvu liniju Select first line or arc Odaberite prvu liniju ili luk Select first object Odaberite prvi objekat - Select first object or Odaberite prvi objekat ili Select first point Odaberite prvu tačku Add to Working Set Dodaj u radni skup Edit Reference In-Place Uredi referencu na mjestu Save Reference Edits Sačuvaj izmjene reference Working Set Radni skup Xref and Block In-place Editing Uređivanje vanjskih referenci i blokova na mjestu - Select nested objects to copy or Odaberite ugniježđene objekte za kopiranje ili + Select nested objects to copy Odaberite ugniježđene objekte za kopiranje Select object Odaberite objekat Select object to dimension Odaberite objekat za kotiranje Select object to divide Odaberite objekat za dijeljenje @@ -825,7 +825,7 @@ Select one polyline (Multiple is available through the JSON form). Odaberite jednu poliliniju (Višestruko je dostupno putem JSON oblika). Select path curve Odaberite krivu putanje Select point or object Odaberite tačku ili objekat - Select polyline or Odaberite poliliniju ili + Select polyline Odaberite poliliniju Select second line Odaberite drugu liniju Select second line or shift-select to apply corner Odaberite drugu liniju ili Shift+odabir za primjenu ugla Select second object Odaberite drugi objekat @@ -883,9 +883,8 @@ Specify axis endpoint of ellipse Navedite krajnju tačku ose elipse Specify base diameter Navedite prečnik baze Specify base point Navedite baznu tačku - Specify base point or Navedite baznu tačku ili Specify base point or displacement (drag with the middle mouse button for realtime pan) Navedite baznu tačku ili pomak (prevucite srednjim dugmetom miša za pomicanje pogleda u stvarnom vremenu) - Specify base radius or Navedite poluprečnik baze ili + Specify base radius Navedite poluprečnik baze Specify center of donut Navedite središte prstena Specify center of ellipse Navedite središte elipse Specify center of polygon Navedite središte mnogougla @@ -927,7 +926,7 @@ Specify first point on circle: _tan to Navedite prvu tačku na krugu: _tan na Specify first source point Navedite prvu izvornu tačku Specify height Navedite visinu - Specify helix height or Navedite visinu spirale ili + Specify helix height Navedite visinu spirale Specify included angle Navedite obuhvaćeni ugao Specify insertion base point Navedite baznu tačku umetanja Specify insertion point Navedite tačku umetanja @@ -981,7 +980,6 @@ Specify new width for all segments Navedite novu širinu za sve segmente Specify next point Navedite sljedeću tačku Specify offset distance Navedite udaljenost odmaka - Specify offset distance or Navedite udaljenost odmaka ili Specify opposite corner Navedite suprotni ugao Specify other corner point Navedite tačku drugog ugla Specify other endpoint of axis Navedite drugu krajnju tačku ose @@ -994,7 +992,6 @@ Specify radius of circle Navedite poluprečnik kruga Specify rotation angle Navedite ugao rotacije Specify rotation angle of text Navedite ugao rotacije teksta - Specify rotation angle or Navedite ugao rotacije ili Specify scale factor Navedite faktor skale Specify second angle endpoint Navedite drugu krajnju tačku ugla Specify second break point Navedite drugu tačku prekida @@ -1027,9 +1024,9 @@ Specify third source point or Navedite treću izvornu tačku ili Specify through point Navedite tačku kroz koju prolazi Specify top diameter Navedite gornji prečnik - Specify top radius or Navedite gornji poluprečnik ili + Specify top radius Navedite gornji poluprečnik Specify total angle Navedite ukupni ugao - Specify total length or Navedite ukupnu dužinu ili + Specify total length Navedite ukupnu dužinu Specify width for rectangles Navedite širinu za pravougaonike Sphere Sfera Spline Splajn @@ -1447,3 +1444,15 @@ menu Modify Izmijeni Save changes to block "{name}"? Sačuvati promjene u bloku „{name}”? Layouts Rasporedi Associate Poveži + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c979cd786..67a6e90f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,7 +279,7 @@ Enter a vertex editing option Zadejte možnost úpravy vrcholů Enter an option Zadejte možnost Enter angle of xline Zadejte úhel přímky - Enter angle of xline (0) or Zadejte úhel přímky (0) nebo + Enter angle of xline (0) Zadejte úhel přímky (0) Enter area of rectangle in current units Zadejte plochu obdélníku v aktuálních jednotkách Enter attribute prompt Zadejte výzvu atributu Enter attribute tag name Zadejte označení atributu @@ -290,7 +290,7 @@ Enter default attribute value Zadejte výchozí hodnotu atributu Enter degree of spline Zadejte stupeň spline Enter delta angle Zadejte změnu úhlu - Enter delta length or Zadejte změnu délky nebo + Enter delta length Zadejte změnu délky Enter dimension text Zadejte text kóty Enter dimension text (<> = measurement) Zadejte text kóty (<> = naměřená hodnota) Enter dimension value or [name=expression] Zadejte hodnotu kóty nebo [name=expression] @@ -583,6 +583,7 @@ One of the Crafting Apps by the ArtCraft team and community. Jedna z aplikací Crafting Apps od týmu ArtCraft a komunity. Only in this drawing: {n} Pouze v tomto výkresu: {n} Only in {name}: {n} Pouze v {name}: {n} + Oops Obnovit vymazané objekty Open (⌘O) Otevřít (⌘O) Open Sample Drawing Otevřít ukázkový výkres Open sample drawing Otevřít ukázku výkresu @@ -785,8 +786,8 @@ Select an arc or circle Vyberte oblouk nebo kružnici Select an arc. Vyberte oblouk. Select an object Vyberte objekt - Select an object to change or Vyberte objekt ke změně nebo - Select an object to measure or Vyberte objekt k měření nebo + Select an object to change Vyberte objekt ke změně + Select an object to measure Vyberte objekt k měření Select an object. Vyberte objekt. Select an open curve. Vyberte otevřenou křivku. Select an open line, arc, polyline, ellipse arc or spline. Vyberte otevřenou úsečku, oblouk, křivku, eliptický oblouk nebo spline. @@ -800,14 +801,13 @@ Select first line Vyberte první úsečku Select first line or arc Vyberte první úsečku nebo oblouk Select first object Vyberte první objekt - Select first object or Vyberte první objekt nebo Select first point Vyberte první bod Add to Working Set Přidat do pracovní sady Edit Reference In-Place Upravit referenci na místě Save Reference Edits Uložit úpravy reference Working Set Pracovní sada Xref and Block In-place Editing Úpravy xref a bloků na místě - Select nested objects to copy or Vyberte vnořené objekty ke kopírování nebo + Select nested objects to copy Vyberte vnořené objekty ke kopírování Select object Vyberte objekt Select object to dimension Vyberte objekt ke kótování Select object to divide Vyberte objekt k dělení @@ -823,7 +823,7 @@ Select one polyline (Multiple is available through the JSON form). Vyberte jednu křivku (možnost Multiple je dostupná ve formě JSON). Select path curve Vyberte křivku trajektorie Select point or object Vyberte bod nebo objekt - Select polyline or Vyberte křivku nebo + Select polyline Vyberte křivku Select second line Vyberte druhou úsečku Select second line or shift-select to apply corner Vyberte druhou úsečku; s klávesou Shift vytvoříte roh Select second object Vyberte druhý objekt @@ -881,9 +881,8 @@ Specify axis endpoint of ellipse Určete koncový bod osy elipsy Specify base diameter Určete průměr základny Specify base point Určete základní bod - Specify base point or Určete základní bod nebo Specify base point or displacement (drag with the middle mouse button for realtime pan) Určete základní bod nebo posunutí (tažením prostředním tlačítkem myši posunete pohled v reálném čase) - Specify base radius or Určete poloměr základny nebo + Specify base radius Určete poloměr základny Specify center of donut Určete střed prstence Specify center of ellipse Určete střed elipsy Specify center of polygon Určete střed mnohoúhelníku @@ -925,7 +924,7 @@ Specify first point on circle: _tan to Určete první bod na kružnici: _tan k Specify first source point Určete první zdrojový bod Specify height Určete výšku - Specify helix height or Určete výšku šroubovice nebo + Specify helix height Určete výšku šroubovice Specify included angle Určete středový úhel Specify insertion base point Určete základní bod vložení Specify insertion point Určete bod vložení @@ -979,7 +978,6 @@ Specify new width for all segments Určete novou šířku všech segmentů Specify next point Určete další bod Specify offset distance Určete vzdálenost ekvidistanty - Specify offset distance or Určete vzdálenost ekvidistanty nebo Specify opposite corner Určete protilehlý roh Specify other corner point Určete bod druhého rohu Specify other endpoint of axis Určete druhý koncový bod osy @@ -992,7 +990,6 @@ Specify radius of circle Určete poloměr kružnice Specify rotation angle Určete úhel otočení Specify rotation angle of text Určete úhel otočení textu - Specify rotation angle or Určete úhel otočení nebo Specify scale factor Určete faktor měřítka Specify second angle endpoint Určete druhý koncový bod úhlu Specify second break point Určete druhý bod přerušení @@ -1025,9 +1022,9 @@ Specify third source point or Určete třetí zdrojový bod nebo Specify through point Určete bod, kterým má procházet Specify top diameter Určete horní průměr - Specify top radius or Určete horní poloměr nebo + Specify top radius Určete horní poloměr Specify total angle Určete celkový úhel - Specify total length or Určete celkovou délku nebo + Specify total length Určete celkovou délku Specify width for rectangles Určete šířku obdélníků Sphere Koule Spline Spline @@ -1227,3 +1224,15 @@ menu Modify Modifikace Check for Updates… Zkontrolovat aktualizace… CADCraft is up to date. CADCraft je aktuální. Could not check for updates. Aktualizace se nepodařilo zkontrolovat. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 84b944529..0925cb6af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -222,7 +222,7 @@ Enter a vertex editing option Geben Sie eine Option zur Scheitelpunktbearbeitung ein Enter an option Geben Sie eine Option ein Enter angle of xline Geben Sie den Winkel der Konstruktionslinie ein - Enter angle of xline (0) or Geben Sie den Winkel der Konstruktionslinie ein (0) oder + Enter angle of xline (0) Geben Sie den Winkel der Konstruktionslinie ein (0) Enter area of rectangle in current units Geben Sie die Fläche des Rechtecks in den aktuellen Einheiten ein Enter attribute prompt Geben Sie die Attributaufforderung ein Enter attribute tag name Geben Sie den Attribut-Tag-Namen ein @@ -233,7 +233,7 @@ Enter default attribute value Geben Sie den Standard-Attributwert ein Enter degree of spline Geben Sie den Grad des Splines ein Enter delta angle Geben Sie den Delta-Winkel ein - Enter delta length or Geben Sie die Delta-Länge ein oder + Enter delta length Geben Sie die Delta-Länge ein Enter dimension text Geben Sie den Bemaßungstext ein Enter dimension text (<> = measurement) Geben Sie den Bemaßungstext ein (<> = Messwert) Enter dimension value or [name=expression] Geben Sie einen Bemaßungswert oder [name=expression] ein @@ -495,6 +495,7 @@ On Ein On/Off Ein/Aus One of the Crafting Apps by the ArtCraft team and community. Eine der Crafting Apps vom ArtCraft-Team und seiner Community. + Oops Gelöschte Objekte wiederherstellen Open (⌘O) Öffnen (⌘O) Open Sample Drawing Beispielzeichnung öffnen Open sample drawing Beispielzeichnung öffnen @@ -675,8 +676,8 @@ Select an arc or circle Wählen Sie einen Bogen oder Kreis Select an arc. Wählen Sie einen Bogen. Select an object Wählen Sie ein Objekt - Select an object to change or Wählen Sie ein zu änderndes Objekt oder - Select an object to measure or Wählen Sie ein zu messendes Objekt oder + Select an object to change Wählen Sie ein zu änderndes Objekt + Select an object to measure Wählen Sie ein zu messendes Objekt Select an object. Wählen Sie ein Objekt. Add to Working Set Zum Arbeitssatz hinzufügen Edit Reference In-Place Referenz an Ort und Stelle bearbeiten @@ -695,9 +696,8 @@ Select first line Wählen Sie die erste Linie Select first line or arc Wählen Sie die erste Linie oder den ersten Bogen Select first object Wählen Sie das erste Objekt - Select first object or Wählen Sie das erste Objekt oder Select first point Wählen Sie den ersten Punkt - Select nested objects to copy or Wählen Sie zu kopierende verschachtelte Objekte oder + Select nested objects to copy Wählen Sie zu kopierende verschachtelte Objekte Select object Wählen Sie ein Objekt Select object to dimension Wählen Sie das zu bemaßende Objekt Select object to divide Wählen Sie das zu teilende Objekt @@ -713,7 +713,7 @@ Select one polyline (Multiple is available through the JSON form). Wählen Sie eine Polylinie (Multiple ist über die JSON-Form verfügbar). Select path curve Wählen Sie die Pfadkurve Select point or object Wählen Sie einen Punkt oder ein Objekt - Select polyline or Wählen Sie eine Polylinie oder + Select polyline Wählen Sie eine Polylinie Select second line Wählen Sie die zweite Linie Select second line or shift-select to apply corner Wählen Sie die zweite Linie oder wählen Sie mit der Umschalttaste, um eine Ecke anzuwenden Select second object Wählen Sie das zweite Objekt @@ -766,9 +766,8 @@ Specify axis endpoint of ellipse Geben Sie den Achsenendpunkt der Ellipse an Specify base diameter Geben Sie den Basisdurchmesser an Specify base point Geben Sie den Basispunkt an - Specify base point or Geben Sie den Basispunkt an oder Specify base point or displacement (drag with the middle mouse button for realtime pan) Geben Sie den Basispunkt oder die Verschiebung an (ziehen Sie mit der mittleren Maustaste zum Schwenken in Echtzeit) - Specify base radius or Geben Sie den Basisradius an oder + Specify base radius Geben Sie den Basisradius an Specify center of donut Geben Sie den Mittelpunkt des Rings an Specify center of ellipse Geben Sie den Mittelpunkt der Ellipse an Specify center of polygon Geben Sie den Mittelpunkt des Polygons an @@ -810,7 +809,7 @@ Specify first point on circle: _tan to Geben Sie den ersten Punkt auf dem Kreis an: _tan zu Specify first source point Geben Sie den ersten Quellpunkt an Specify height Geben Sie die Höhe an - Specify helix height or Geben Sie die Höhe der Helix an oder + Specify helix height Geben Sie die Höhe der Helix an Specify included angle Geben Sie den eingeschlossenen Winkel an Specify insertion base point Geben Sie den Einfügebasispunkt an Specify insertion point Geben Sie den Einfügepunkt an @@ -829,7 +828,6 @@ Specify new width for all segments Geben Sie die neue Breite für alle Segmente an Specify next point Geben Sie den nächsten Punkt an Specify offset distance Geben Sie den Versatzabstand an - Specify offset distance or Geben Sie den Versatzabstand an oder Specify opposite corner Geben Sie die gegenüberliegende Ecke an Specify other corner point Geben Sie den anderen Eckpunkt an Specify other endpoint of axis Geben Sie den anderen Endpunkt der Achse an @@ -842,7 +840,6 @@ Specify radius of circle Geben Sie den Radius des Kreises an Specify rotation angle Geben Sie den Drehwinkel an Specify rotation angle of text Geben Sie den Drehwinkel des Textes an - Specify rotation angle or Geben Sie den Drehwinkel an oder Specify scale factor Geben Sie den Skalierfaktor an Specify second angle endpoint Geben Sie den zweiten Endpunkt des Winkels an Specify second break point Geben Sie den zweiten Unterbrechungspunkt an @@ -875,9 +872,9 @@ Specify third source point or Geben Sie den dritten Quellpunkt an oder Specify through point Geben Sie den Durchgangspunkt an Specify top diameter Geben Sie den oberen Durchmesser an - Specify top radius or Geben Sie den oberen Radius an oder + Specify top radius Geben Sie den oberen Radius an Specify total angle Geben Sie den Gesamtwinkel an - Specify total length or Geben Sie die Gesamtlänge an oder + Specify total length Geben Sie die Gesamtlänge an Specify width for rectangles Geben Sie die Breite für Rechtecke an Sphere Kugel Spline Spline @@ -1044,3 +1041,15 @@ Check for Updates… Nach Updates suchen… CADCraft is up to date. CADCraft ist auf dem neuesten Stand. Could not check for updates. Die Suche nach Updates ist fehlgeschlagen. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2825e8fd2..3bcf59c0c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -337,7 +337,7 @@ Enter a vertex editing option Εισαγάγετε επιλογή επεξεργασίας κορυφής Enter an option Εισαγάγετε επιλογή Enter angle of xline Εισαγάγετε γωνία βοηθητικής ευθείας - Enter angle of xline (0) or Εισαγάγετε γωνία βοηθητικής ευθείας (0) ή + Enter angle of xline (0) Εισαγάγετε γωνία βοηθητικής ευθείας (0) Enter area of rectangle in current units Εισαγάγετε το εμβαδόν ορθογωνίου στις τρέχουσες μονάδες Enter attribute prompt Εισαγάγετε μήνυμα προτροπής ιδιότητας Enter attribute tag name Εισαγάγετε όνομα ετικέτας ιδιότητας @@ -348,7 +348,7 @@ Enter default attribute value Εισαγάγετε προεπιλεγμένη τιμή ιδιότητας Enter degree of spline Εισαγάγετε βαθμό καμπύλης spline Enter delta angle Εισαγάγετε μεταβολή γωνίας - Enter delta length or Εισαγάγετε μεταβολή μήκους ή + Enter delta length Εισαγάγετε μεταβολή μήκους Enter dimension text Εισαγάγετε κείμενο διάστασης Enter dimension text (<> = measurement) Εισαγάγετε κείμενο διάστασης (<> = μέτρηση) Enter dimension value or [name=expression] Εισαγάγετε τιμή διάστασης ή [όνομα=έκφραση] @@ -702,6 +702,7 @@ One of the Crafting Apps by the ArtCraft team and community. Μία από τις εφαρμογές Crafting Apps της ομάδας και της κοινότητας ArtCraft. Only in this drawing: {n} Μόνο σε αυτό το σχέδιο: {n} Only in {name}: {n} Μόνο στο {name}: {n} + Oops Επαναφορά διαγραμμένων αντικειμένων Open Άνοιγμα Open (⌘O) Άνοιγμα (⌘O) Open a drawing first. Ανοίξτε πρώτα ένα σχέδιο. @@ -918,8 +919,8 @@ Select an arc or circle Επιλέξτε τόξο ή κύκλο Select an arc. Επιλέξτε τόξο. Select an object Επιλέξτε αντικείμενο - Select an object to change or Επιλέξτε αντικείμενο για αλλαγή ή - Select an object to measure or Επιλέξτε αντικείμενο για μέτρηση ή + Select an object to change Επιλέξτε αντικείμενο για αλλαγή + Select an object to measure Επιλέξτε αντικείμενο για μέτρηση Select an object. Επιλέξτε αντικείμενο. Select an open curve. Επιλέξτε ανοικτή καμπύλη. Select an open line, arc, polyline, ellipse arc or spline. Επιλέξτε ανοικτή γραμμή, τόξο, πολυγραμμή, ελλειπτικό τόξο ή καμπύλη spline. @@ -933,9 +934,8 @@ Select first line Επιλέξτε πρώτη γραμμή Select first line or arc Επιλέξτε πρώτη γραμμή ή τόξο Select first object Επιλέξτε πρώτο αντικείμενο - Select first object or Επιλέξτε πρώτο αντικείμενο ή Select first point Επιλέξτε πρώτο σημείο - Select nested objects to copy or Επιλέξτε ένθετα αντικείμενα για αντιγραφή ή + Select nested objects to copy Επιλέξτε ένθετα αντικείμενα για αντιγραφή Select object Επιλέξτε αντικείμενο Select object to dimension Επιλέξτε αντικείμενο για διαστασιολόγηση Select object to divide Επιλέξτε αντικείμενο για διαίρεση @@ -953,7 +953,7 @@ Select one polyline (Multiple is available through the JSON form). Επιλέξτε μία πολυγραμμή (η επιλογή Multiple διατίθεται μέσω JSON). Select path curve Επιλέξτε καμπύλη διαδρομής Select point or object Επιλέξτε σημείο ή αντικείμενο - Select polyline or Επιλέξτε πολυγραμμή ή + Select polyline Επιλέξτε πολυγραμμή Select second line Επιλέξτε δεύτερη γραμμή Select second line or shift-select to apply corner Επιλέξτε δεύτερη γραμμή ή κρατήστε Shift για εφαρμογή γωνίας Select second object Επιλέξτε δεύτερο αντικείμενο @@ -1035,9 +1035,8 @@ Specify axis endpoint of ellipse Καθορίστε τελικό σημείο άξονα έλλειψης Specify base diameter Καθορίστε διάμετρο βάσης Specify base point Καθορίστε σημείο βάσης - Specify base point or Καθορίστε σημείο βάσης ή Specify base point or displacement (drag with the middle mouse button for realtime pan) Καθορίστε σημείο βάσης ή μετατόπιση (σύρετε με το μεσαίο πλήκτρο του ποντικιού για μετατόπιση όψης σε πραγματικό χρόνο) - Specify base radius or Καθορίστε ακτίνα βάσης ή + Specify base radius Καθορίστε ακτίνα βάσης Specify center of donut Καθορίστε κέντρο δακτυλίου Specify center of ellipse Καθορίστε κέντρο έλλειψης Specify center of polygon Καθορίστε κέντρο πολυγώνου @@ -1079,7 +1078,7 @@ Specify first point on circle: _tan to Καθορίστε πρώτο σημείο επάνω στον κύκλο: _tan προς Specify first source point Καθορίστε πρώτο σημείο προέλευσης Specify height Καθορίστε ύψος - Specify helix height or Καθορίστε ύψος έλικας ή + Specify helix height Καθορίστε ύψος έλικας Specify included angle Καθορίστε περιεχόμενη γωνία Specify insertion base point Καθορίστε σημείο βάσης εισαγωγής Specify insertion point Καθορίστε σημείο εισαγωγής @@ -1098,7 +1097,6 @@ Specify new width for all segments Καθορίστε νέο πλάτος για όλα τα τμήματα Specify next point Καθορίστε επόμενο σημείο Specify offset distance Καθορίστε απόσταση παράλληλης μετατόπισης (Offset) - Specify offset distance or Καθορίστε απόσταση παράλληλης μετατόπισης ή Specify opposite corner Καθορίστε απέναντι γωνία Specify other corner point Καθορίστε το άλλο γωνιακό σημείο Specify other endpoint of axis Καθορίστε το άλλο τελικό σημείο άξονα @@ -1111,7 +1109,6 @@ Specify radius of circle Καθορίστε ακτίνα κύκλου Specify rotation angle Καθορίστε γωνία περιστροφής Specify rotation angle of text Καθορίστε γωνία περιστροφής κειμένου - Specify rotation angle or Καθορίστε γωνία περιστροφής ή Specify scale factor Καθορίστε συντελεστή κλίμακας Specify second angle endpoint Καθορίστε δεύτερο τελικό σημείο γωνίας Specify second break point Καθορίστε δεύτερο σημείο διακοπής @@ -1144,9 +1141,9 @@ Specify third source point or Καθορίστε τρίτο σημείο προέλευσης ή <συνέχεια> Specify through point Καθορίστε σημείο διέλευσης Specify top diameter Καθορίστε άνω διάμετρο - Specify top radius or Καθορίστε άνω ακτίνα ή + Specify top radius Καθορίστε άνω ακτίνα Specify total angle Καθορίστε συνολική γωνία - Specify total length or Καθορίστε συνολικό μήκος ή + Specify total length Καθορίστε συνολικό μήκος Specify width for rectangles Καθορίστε πλάτος ορθογωνίων Sphere Σφαίρα Spherical Σφαιρικό @@ -1338,3 +1335,15 @@ menu View Προβολή Check for Updates… Έλεγχος για ενημερώσεις… CADCraft is up to date. Το CADCraft είναι ενημερωμένο. Could not check for updates. Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2dde91eac..f0e6471ee 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -74,6 +74,8 @@ AutoConstrain Restricciones automáticas Automatic save every Guardado automático cada Automatic save file location Ubicación de los archivos de guardado automático + Backup copy Copia de seguridad + Create backup copy with each save Crear una copia de seguridad en cada guardado Axis, End Eje, extremo B N Base Base @@ -293,7 +295,7 @@ Enter a vertex editing option Introduce una opción de edición de vértices Enter an option Introduce una opción Enter angle of xline Introduce el ángulo de la línea auxiliar - Enter angle of xline (0) or Introduce el ángulo de la línea auxiliar (0) o + Enter angle of xline (0) Introduce el ángulo de la línea auxiliar (0) Enter area of rectangle in current units Introduce el área del rectángulo en las unidades actuales Enter attribute prompt Introduce la solicitud del atributo Enter attribute tag name Introduce la etiqueta del atributo @@ -304,7 +306,7 @@ Enter default attribute value Introduce el valor predeterminado del atributo Enter degree of spline Introduce el grado de la spline Enter delta angle Introduce el incremento angular - Enter delta length or Introduce el incremento de longitud o + Enter delta length Introduce el incremento de longitud Enter dimension text Introduce el texto de cota Enter dimension text (<> = measurement) Introduce el texto de cota (<> = medida) Enter dimension value or [name=expression] Introduce el valor de cota o [nombre=expresión] @@ -618,6 +620,7 @@ One of the Crafting Apps by the ArtCraft team and community. Una de las Crafting Apps del equipo y la comunidad de ArtCraft. Only in this drawing: {n} Solo en este dibujo: {n} Only in {name}: {n} Solo en {name}: {n} + Oops Restaurar objetos borrados Open Abrir Open (⌘O) Abrir (⌘O) Open Sample Drawing Abrir dibujo de ejemplo @@ -882,8 +885,8 @@ Select an arc or circle Selecciona un arco o círculo Select an arc. Selecciona un arco. Select an object Selecciona un objeto - Select an object to change or Selecciona un objeto que modificar o - Select an object to measure or Selecciona un objeto que medir o + Select an object to change Selecciona un objeto que modificar + Select an object to measure Selecciona un objeto que medir Select an object. Selecciona un objeto. Select an open curve. Selecciona una curva abierta. Select an open line, arc, polyline, ellipse arc or spline. Selecciona una línea, arco, polilínea, arco elíptico o spline abierta. @@ -897,9 +900,8 @@ Select first line Selecciona la primera línea Select first line or arc Selecciona la primera línea o arco Select first object Selecciona el primer objeto - Select first object or Selecciona el primer objeto o Select first point Selecciona el primer punto - Select nested objects to copy or Selecciona objetos anidados para copiar o + Select nested objects to copy Selecciona objetos anidados para copiar Select object Selecciona un objeto Select object to dimension Selecciona un objeto para acotar Select object to divide Selecciona un objeto para dividir @@ -915,7 +917,7 @@ Select one polyline (Multiple is available through the JSON form). Selecciona una polilínea (la opción Multiple está disponible en la forma JSON). Select path curve Selecciona la curva del camino Select point or object Selecciona un punto u objeto - Select polyline or Selecciona una polilínea o + Select polyline Selecciona una polilínea Select second line Selecciona la segunda línea Select second line or shift-select to apply corner Selecciona la segunda línea o usa Mayús para aplicar una esquina Select second object Selecciona el segundo objeto @@ -968,9 +970,8 @@ Specify axis endpoint of ellipse Indica el extremo del eje de la elipse Specify base diameter Indica el diámetro de la base Specify base point Indica el punto base - Specify base point or Indica el punto base o Specify base point or displacement (drag with the middle mouse button for realtime pan) Indica el punto base o el desplazamiento (arrastra con el botón central para encuadrar) - Specify base radius or Indica el radio de la base o + Specify base radius Indica el radio de la base Specify center of donut Indica el centro de la arandela Specify center of ellipse Indica el centro de la elipse Specify center of polygon Indica el centro del polígono @@ -1012,7 +1013,7 @@ Specify first point on circle: _tan to Indica el primer punto del círculo: _tan a Specify first source point Indica el primer punto de origen Specify height Indica la altura - Specify helix height or Indica la altura de la hélice o + Specify helix height Indica la altura de la hélice Specify included angle Indica el ángulo incluido Specify insertion base point Indica el punto base de inserción Specify insertion point Indica el punto de inserción @@ -1031,7 +1032,6 @@ Specify new width for all segments Indica la nueva anchura de todos los segmentos Specify next point Indica el siguiente punto Specify offset distance Indica la distancia de desfase - Specify offset distance or Indica la distancia de desfase o Specify opposite corner Indica la esquina opuesta Specify other corner point Indica el punto de la otra esquina Specify other endpoint of axis Indica el otro extremo del eje @@ -1044,7 +1044,6 @@ Specify radius of circle Indica el radio del círculo Specify rotation angle Indica el ángulo de giro Specify rotation angle of text Indica el ángulo de giro del texto - Specify rotation angle or Indica el ángulo de giro o Specify scale factor Indica el factor de escala Specify second angle endpoint Indica el segundo extremo del ángulo Specify second break point Indica el segundo punto de ruptura @@ -1077,9 +1076,9 @@ Specify third source point or Indica el tercer punto de origen o Specify through point Indica el punto a atravesar Specify top diameter Indica el diámetro superior - Specify top radius or Indica el radio superior o + Specify top radius Indica el radio superior Specify total angle Indica el ángulo total - Specify total length or Indica la longitud total o + Specify total length Indica la longitud total Specify width for rectangles Indica la anchura de los rectángulos Sphere Esfera Spline Spline @@ -1256,3 +1255,15 @@ Check for Updates… Buscar actualizaciones… CADCraft is up to date. CADCraft está actualizado. Could not check for updates. No se pudo buscar actualizaciones. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 42ad7ee48..2f391809e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -221,7 +221,7 @@ Enter a vertex editing option Entrez une option d'édition de sommet Enter an option Entrez une option Enter angle of xline Entrez l'angle de la ligne de construction - Enter angle of xline (0) or Entrez l'angle de la ligne de construction (0) ou + Enter angle of xline (0) Entrez l'angle de la ligne de construction (0) Enter area of rectangle in current units Entrez l'aire du rectangle dans les unités courantes Enter attribute prompt Entrez l'invite de l'attribut Enter attribute tag name Entrez le nom de l'étiquette d'attribut @@ -232,7 +232,7 @@ Enter default attribute value Entrez la valeur par défaut de l'attribut Enter degree of spline Entrez le degré de la spline Enter delta angle Entrez l'écart d'angle - Enter delta length or Entrez l'écart de longueur ou + Enter delta length Entrez l'écart de longueur Enter dimension text Entrez le texte de cotation Enter dimension text (<> = measurement) Entrez le texte de cotation (<> = mesure) Enter dimension value or [name=expression] Entrez la valeur de cotation ou [name=expression] @@ -490,6 +490,7 @@ On Activé On/Off Activer/Désactiver One of the Crafting Apps by the ArtCraft team and community. L'une des applications Crafting Apps de l'équipe et de la communauté ArtCraft. + Oops Restaurer les objets effacés Open (⌘O) Ouvrir (⌘O) Open Sample Drawing Ouvrir un dessin d'exemple Open sample drawing Ouvrir un dessin d'exemple @@ -674,8 +675,8 @@ Select an arc or circle Sélectionner un arc ou un cercle Select an arc. Sélectionner un arc. Select an object Sélectionner un objet - Select an object to change or Sélectionner un objet à modifier ou - Select an object to measure or Sélectionner un objet à mesurer ou + Select an object to change Sélectionner un objet à modifier + Select an object to measure Sélectionner un objet à mesurer Select an object. Sélectionner un objet. Select an open curve. Sélectionner une courbe ouverte. Select an open line, arc, polyline, ellipse arc or spline. Sélectionner une ligne, un arc, une polyligne, un arc elliptique ou une spline ouverts. @@ -689,9 +690,8 @@ Select first line Sélectionner la première ligne Select first line or arc Sélectionner la première ligne ou le premier arc Select first object Sélectionner le premier objet - Select first object or Sélectionner le premier objet ou Select first point Sélectionner le premier point - Select nested objects to copy or Sélectionner des objets imbriqués à copier ou + Select nested objects to copy Sélectionner des objets imbriqués à copier Select object Sélectionner un objet Select object to dimension Sélectionner un objet à coter Select object to divide Sélectionner un objet à diviser @@ -707,7 +707,7 @@ Select one polyline (Multiple is available through the JSON form). Sélectionner une polyligne (l'option Multiple est disponible via le formulaire JSON). Select path curve Sélectionner la courbe de trajectoire Select point or object Sélectionner un point ou un objet - Select polyline or Sélectionner une polyligne ou + Select polyline Sélectionner une polyligne Select second line Sélectionner la deuxième ligne Select second line or shift-select to apply corner Sélectionner la deuxième ligne ou sélectionner avec Maj pour former le coin Select second object Sélectionner le deuxième objet @@ -760,9 +760,8 @@ Specify axis endpoint of ellipse Spécifier l'extrémité de l'axe de l'ellipse Specify base diameter Spécifiez le diamètre de la base Specify base point Spécifiez le point de base - Specify base point or Spécifiez le point de base ou Specify base point or displacement (drag with the middle mouse button for realtime pan) Spécifiez le point de base ou le déplacement (faites glisser avec le bouton central de la souris pour un panoramique en temps réel) - Specify base radius or Spécifiez le rayon de base ou + Specify base radius Spécifiez le rayon de base Specify center of donut Spécifiez le centre de l'anneau Specify center of ellipse Spécifiez le centre de l'ellipse Specify center of polygon Spécifiez le centre du polygone @@ -804,7 +803,7 @@ Specify first point on circle: _tan to Spécifiez le premier point sur le cercle : _tan à Specify first source point Spécifiez le premier point source Specify height Spécifiez la hauteur - Specify helix height or Spécifiez la hauteur de l'hélice ou + Specify helix height Spécifiez la hauteur de l'hélice Specify included angle Spécifiez l'angle inclus Specify insertion base point Spécifiez le point de base d'insertion Specify insertion point Spécifiez le point d'insertion @@ -823,7 +822,6 @@ Specify new width for all segments Spécifiez la nouvelle largeur de tous les segments Specify next point Spécifiez le point suivant Specify offset distance Spécifiez la distance de décalage - Specify offset distance or Spécifiez la distance de décalage ou Specify opposite corner Spécifiez le coin opposé Specify other corner point Spécifiez l'autre point d'angle Specify other endpoint of axis Spécifiez l'autre point final de l'axe @@ -836,7 +834,6 @@ Specify radius of circle Spécifiez le rayon du cercle Specify rotation angle Spécifiez l'angle de rotation Specify rotation angle of text Spécifiez l'angle de rotation du texte - Specify rotation angle or Spécifiez l'angle de rotation ou Specify scale factor Spécifiez le facteur d'échelle Specify second angle endpoint Spécifiez le deuxième point final de l'angle Specify second break point Spécifiez le deuxième point de rupture @@ -869,9 +866,9 @@ Specify third source point or Spécifiez le troisième point source ou Specify through point Spécifiez le point de passage Specify top diameter Spécifiez le diamètre supérieur - Specify top radius or Spécifiez le rayon supérieur ou + Specify top radius Spécifiez le rayon supérieur Specify total angle Spécifiez l'angle total - Specify total length or Spécifiez la longueur totale ou + Specify total length Spécifiez la longueur totale Specify width for rectangles Spécifiez la largeur des rectangles Sphere Sphère Spline Spline @@ -1017,3 +1014,15 @@ Check for Updates… Rechercher des mises à jour… CADCraft is up to date. CADCraft est à jour. Could not check for updates. Impossible de rechercher les mises à jour. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fc27bd7ce..b87edd3c3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -350,7 +350,7 @@ Enter a vertex editing option Válassz egy csúcspont-szerkesztési lehetőséget Enter an option Válassz egy lehetőséget Enter angle of xline Add meg a szerkesztővonal szögét - Enter angle of xline (0) or Add meg a szerkesztővonal szögét (0) + Enter angle of xline (0) Add meg a szerkesztővonal szögét (0) Enter area of rectangle in current units Add meg a téglalap területét az aktuális mértékegységben Enter attribute prompt Add meg az attribútum bekérő szövegét Enter attribute tag name Add meg az attribútum címkéjét @@ -361,7 +361,7 @@ Enter default attribute value Add meg az attribútum alapértelmezett értékét Enter degree of spline Add meg a spline fokszámát Enter delta angle Add meg a szögváltozást - Enter delta length or Add meg a hosszváltozást + Enter delta length Add meg a hosszváltozást Enter dimension text Add meg a méretszámot Enter dimension text (<> = measurement) Add meg a méretszámot (<> = mért érték) Enter dimension value or [name=expression] Add meg a méret értékét vagy [név=kifejezés] @@ -711,6 +711,7 @@ One of the Crafting Apps by the ArtCraft team and community. Az ArtCraft csapata és közössége által készített Craft-alkalmazások egyike. Only in this drawing: {n} Csak ebben a rajzban: {n} Only in {name}: {n} Csak ebben: {name} – {n} + Oops Törölt objektumok visszaállítása Open Megnyitás Open (⌘O) Megnyitás (⌘O) Open Recent Legutóbbi megnyitása @@ -930,8 +931,8 @@ Select an arc or circle Válassz egy ívet vagy kört Select an arc. Válassz egy ívet. Select an object Válassz egy objektumot - Select an object to change or Válaszd ki a módosítandó objektumot - Select an object to measure or Válaszd ki a mérendő objektumot + Select an object to change Válaszd ki a módosítandó objektumot + Select an object to measure Válaszd ki a mérendő objektumot Select an object. Válassz egy objektumot. Select an open curve. Válassz egy nyitott görbét. Select an open line, arc, polyline, ellipse arc or spline. Válassz egy nyitott vonalat, ívet, vonalláncot, ellipszisívet vagy spline-t. @@ -945,9 +946,8 @@ Select first line Válaszd ki az első vonalat Select first line or arc Válaszd ki az első vonalat vagy ívet Select first object Válaszd ki az első objektumot - Select first object or Válaszd ki az első objektumot Select first point Válaszd ki az első pontot - Select nested objects to copy or Válaszd ki a másolandó beágyazott objektumokat + Select nested objects to copy Válaszd ki a másolandó beágyazott objektumokat Select object Válassz objektumot Select object to dimension Válaszd ki a méretezendő objektumot Select object to divide Válaszd ki a felosztandó objektumot @@ -964,7 +964,7 @@ Select one polyline (Multiple is available through the JSON form). Egyetlen vonalláncot válassz (a Multiple a parancs JSON-hívásával érhető el). Select path curve Válaszd ki az útvonalgörbét Select point or object Válassz pontot vagy objektumot - Select polyline or Válassz vonalláncot + Select polyline Válassz vonalláncot Select second line Válaszd ki a második vonalat Select second line or shift-select to apply corner Válaszd ki a második vonalat, Shift+kijelöléssel éles sarkot képezhetsz Select second object Válaszd ki a második objektumot @@ -1045,9 +1045,8 @@ Specify axis endpoint of ellipse Add meg az ellipszis tengelyének végpontját Specify base diameter Add meg az alap átmérőjét Specify base point Add meg a bázispontot - Specify base point or Add meg a bázispontot Specify base point or displacement (drag with the middle mouse button for realtime pan) Add meg a bázispontot vagy az elmozdulást (a középső egérgombbal húzva a nézetet valós időben mozgathatod) - Specify base radius or Add meg az alap sugarát + Specify base radius Add meg az alap sugarát Specify center of donut Add meg a gyűrű középpontját Specify center of ellipse Add meg az ellipszis középpontját Specify center of polygon Add meg a sokszög középpontját @@ -1089,7 +1088,7 @@ Specify first point on circle: _tan to Add meg a kör első pontját: _tan to Specify first source point Add meg az első forráspontot Specify height Add meg a magasságot - Specify helix height or Add meg a csavarvonal magasságát + Specify helix height Add meg a csavarvonal magasságát Specify included angle Add meg a középponti szöget Specify insertion base point Add meg a beszúrási bázispontot Specify insertion point Add meg a beszúrási pontot @@ -1108,7 +1107,6 @@ Specify new width for all segments Add meg az összes szakasz új szélességét Specify next point Add meg a következő pontot Specify offset distance Add meg az eltolási távolságot - Specify offset distance or Add meg az eltolási távolságot Specify opposite corner Add meg az átellenes sarkot Specify other corner point Add meg a másik sarokpontot Specify other endpoint of axis Add meg a tengely másik végpontját @@ -1121,7 +1119,6 @@ Specify radius of circle Add meg a kör sugarát Specify rotation angle Add meg az elforgatási szöget Specify rotation angle of text Add meg a szöveg elforgatási szögét - Specify rotation angle or Add meg az elforgatási szöget Specify scale factor Add meg a léptéktényezőt Specify second angle endpoint Add meg a szög második végpontját Specify second break point Add meg a második megszakítási pontot @@ -1154,9 +1151,9 @@ Specify third source point or Add meg a harmadik forráspontot vagy Specify through point Add meg az átmenő pontot Specify top diameter Add meg a felső átmérőt - Specify top radius or Add meg a felső sugarat + Specify top radius Add meg a felső sugarat Specify total angle Add meg a teljes szöget - Specify total length or Add meg a teljes hosszt + Specify total length Add meg a teljes hosszt Specify width for rectangles Add meg a téglalapok szélességét Sphere Gömb Spherical Gömbszerű @@ -1349,3 +1346,15 @@ menu View Nézet Check for Updates… Frissítések keresése… CADCraft is up to date. A CADCraft naprakész. Could not check for updates. A frissítések keresése nem sikerült. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 634249093..d69b4fa6c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -132,7 +132,7 @@ menu Modify Edita Center, Start, Angle Centro, inizio, angolo Center, Start, End Centro, inizio, fine Center, Start, Length Centro, inizio, lunghezza - Chamfer Cima + Chamfer Smusso Change Space Cambia spazio Change to Current Layer Cambia al layer corrente Changed: {n} Modificati: {n} @@ -288,7 +288,7 @@ menu Modify Edita Enter a vertex editing option Digitare un'opzione di modifica dei vertici Enter an option Digitare un'opzione Enter angle of xline Digitare l'angolo della linea di costruzione - Enter angle of xline (0) or Digitare l'angolo della linea di costruzione (0) o + Enter angle of xline (0) Digitare l'angolo della linea di costruzione (0) Enter area of rectangle in current units Digitare l'area del rettangolo nelle unità correnti Enter attribute prompt Digitare il messaggio dell'attributo Enter attribute tag name Digitare il nome dell'etichetta dell'attributo @@ -299,7 +299,7 @@ menu Modify Edita Enter default attribute value Digitare il valore predefinito dell'attributo Enter degree of spline Digitare il grado della spline Enter delta angle Digitare la variazione dell'angolo - Enter delta length or Digitare la variazione di lunghezza o + Enter delta length Digitare la variazione di lunghezza Enter dimension text Digitare il testo della quota Enter dimension text (<> = measurement) Digitare il testo della quota (<> = misura) Enter dimension value or [name=expression] Digitare il valore della quota o [nome=espressione] @@ -594,6 +594,7 @@ menu Modify Edita One of the Crafting Apps by the ArtCraft team and community. Una delle Crafting App del team e della comunità ArtCraft. Only in this drawing: {n} Solo in questo disegno: {n} Only in {name}: {n} Solo in {name}: {n} + Oops Ripristina oggetti cancellati Open (⌘O) Apri (⌘O) Open Sample Drawing Apri disegno di esempio Open sample drawing Apri disegno di esempio @@ -796,8 +797,8 @@ menu Modify Edita Select an arc or circle Selezionare un arco o un cerchio Select an arc. Selezionare un arco. Select an object Selezionare un oggetto - Select an object to change or Selezionare l'oggetto da modificare o - Select an object to measure or Selezionare l'oggetto da misurare o + Select an object to change Selezionare l'oggetto da modificare + Select an object to measure Selezionare l'oggetto da misurare Select an object. Selezionare un oggetto. Select an open curve. Selezionare una curva aperta. Select an open line, arc, polyline, ellipse arc or spline. Selezionare una linea, un arco, una polilinea, un arco ellittico o una spline aperti. @@ -810,9 +811,8 @@ menu Modify Edita Select first line Selezionare la prima linea Select first line or arc Selezionare la prima linea o il primo arco Select first object Selezionare il primo oggetto - Select first object or Selezionare il primo oggetto o Select first point Selezionare il primo punto - Select nested objects to copy or Selezionare gli oggetti nidificati da copiare o + Select nested objects to copy Selezionare gli oggetti nidificati da copiare Select object Selezionare l'oggetto Select object to dimension Selezionare l'oggetto da quotare Select object to divide Selezionare l'oggetto da dividere @@ -828,7 +828,7 @@ menu Modify Edita Select one polyline (Multiple is available through the JSON form). Selezionare una polilinea (Multiple è disponibile tramite il modulo JSON). Select path curve Selezionare la curva della traiettoria Select point or object Selezionare un punto o un oggetto - Select polyline or Selezionare la polilinea o + Select polyline Selezionare la polilinea Select second line Selezionare la seconda linea Select second line or shift-select to apply corner Selezionare la seconda linea o selezionare con Maiusc per applicare l'angolo Select second object Selezionare il secondo oggetto @@ -888,9 +888,8 @@ menu Modify Edita Specify axis endpoint of ellipse Specificare l'estremo dell'asse dell'ellisse Specify base diameter Specificare il diametro di base Specify base point Specificare il punto base - Specify base point or Specificare il punto base o Specify base point or displacement (drag with the middle mouse button for realtime pan) Specificare il punto base o lo spostamento (trascinare con il tasto centrale del mouse per il pan in tempo reale) - Specify base radius or Specificare il raggio di base o + Specify base radius Specificare il raggio di base Specify center of donut Specificare il centro dell'anello Specify center of ellipse Specificare il centro dell'ellisse Specify center of polygon Specificare il centro del poligono @@ -917,8 +916,8 @@ menu Modify Edita Specify fillet radius Specificare il raggio di raccordo Specify fillet radius for rectangles Specificare il raggio di raccordo dei rettangoli Specify first angle endpoint Specificare il primo estremo dell'angolo - Specify first chamfer distance Specificare la prima distanza di cimatura - Specify first chamfer distance for rectangles Specificare la prima distanza di cimatura dei rettangoli + Specify first chamfer distance Specificare la prima distanza di smusso + Specify first chamfer distance for rectangles Specificare la prima distanza di smusso dei rettangoli Specify first constraint point Specificare il primo punto del vincolo Specify first corner Specificare il primo angolo Specify first corner point Specificare il primo angolo @@ -932,7 +931,7 @@ menu Modify Edita Specify first point on circle: _tan to Specificare il primo punto sul cerchio: _tan a Specify first source point Specificare il primo punto di origine Specify height Specificare l'altezza - Specify helix height or Specificare l'altezza dell'elica o + Specify helix height Specificare l'altezza dell'elica Specify included angle Specificare l'angolo compreso Specify insertion base point Specificare il punto base di inserimento Specify insertion point Specificare il punto di inserimento @@ -986,7 +985,6 @@ menu Modify Edita Specify new width for all segments Specificare la nuova larghezza di tutti i segmenti Specify next point Specificare il punto successivo Specify offset distance Specificare la distanza di offset - Specify offset distance or Specificare la distanza di offset o Specify opposite corner Specificare l'angolo opposto Specify other corner point Specificare l'altro angolo Specify other endpoint of axis Specificare l'altro estremo dell'asse @@ -999,11 +997,10 @@ menu Modify Edita Specify radius of circle Specificare il raggio del cerchio Specify rotation angle Specificare l'angolo di rotazione Specify rotation angle of text Specificare l'angolo di rotazione del testo - Specify rotation angle or Specificare l'angolo di rotazione o Specify scale factor Specificare il fattore di scala Specify second angle endpoint Specificare il secondo estremo dell'angolo Specify second break point Specificare il secondo punto di interruzione - Specify second chamfer distance for rectangles Specificare la seconda distanza di cimatura dei rettangoli + Specify second chamfer distance for rectangles Specificare la seconda distanza di smusso dei rettangoli Specify second constraint point Specificare il secondo punto del vincolo Specify second destination point Specificare il secondo punto di destinazione Specify second end point of circle's diameter Specificare il secondo estremo del diametro del cerchio @@ -1032,9 +1029,9 @@ menu Modify Edita Specify third source point or Specificare il terzo punto di origine o Specify through point Specificare il punto di passaggio Specify top diameter Specificare il diametro superiore - Specify top radius or Specificare il raggio superiore o + Specify top radius Specificare il raggio superiore Specify total angle Specificare l'angolo totale - Specify total length or Specificare la lunghezza totale o + Specify total length Specificare la lunghezza totale Specify width for rectangles Specificare la larghezza dei rettangoli Sphere Sfera Spline Spline @@ -1367,3 +1364,15 @@ menu Modify Edita Check for Updates… Verifica aggiornamenti… CADCraft is up to date. CADCraft è aggiornato. Could not check for updates. Impossibile verificare gli aggiornamenti. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1bd7e0fd5..3f01bb695 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -281,7 +281,7 @@ Enter a vertex editing option 정점 편집 옵션 입력 Enter an option 옵션 입력 Enter angle of xline 구성선 각도 입력 - Enter angle of xline (0) or 구성선 각도 입력(0) 또는 + Enter angle of xline (0) 구성선 각도 입력(0) Enter area of rectangle in current units 현재 단위로 직사각형 면적 입력 Enter attribute prompt 속성 프롬프트 입력 Enter attribute tag name 속성 태그 이름 입력 @@ -292,7 +292,7 @@ Enter default attribute value 기본 속성 값 입력 Enter degree of spline 스플라인 차수 입력 Enter delta angle 각도 변화량 입력 - Enter delta length or 길이 변화량 입력 또는 + Enter delta length 길이 변화량 입력 Enter dimension text 치수 문자 입력 Enter dimension text (<> = measurement) 치수 문자 입력(<> = 측정값) Enter dimension value or [name=expression] 치수 값 또는 [name=expression] 입력 @@ -585,6 +585,7 @@ One of the Crafting Apps by the ArtCraft team and community. ArtCraft 팀과 커뮤니티가 만드는 Crafting Apps 제품군의 하나입니다. Only in this drawing: {n} 이 도면에만 있음: {n}개 Only in {name}: {n} {name}에만 있음: {n}개 + Oops 지운 객체 복원 Open (⌘O) 열기(⌘O) Open Sample Drawing 예제 도면 열기 Open sample drawing 예제 도면 열기 @@ -787,8 +788,8 @@ Select an arc or circle 호 또는 원 선택 Select an arc. 호를 선택하세요. Select an object 객체 선택 - Select an object to change or 변경할 객체 선택 또는 - Select an object to measure or 측정할 객체 선택 또는 + Select an object to change 변경할 객체 선택 + Select an object to measure 측정할 객체 선택 Select an object. 객체를 선택하세요. Select an open curve. 열린 곡선을 선택하세요. Select an open line, arc, polyline, ellipse arc or spline. 열린 선, 호, 폴리선, 타원 호 또는 스플라인을 선택하세요. @@ -802,9 +803,8 @@ Select first line 첫 번째 선 선택 Select first line or arc 첫 번째 선 또는 호 선택 Select first object 첫 번째 객체 선택 - Select first object or 첫 번째 객체 선택 또는 Select first point 첫 번째 점 선택 - Select nested objects to copy or 복사할 내포된 객체 선택 또는 + Select nested objects to copy 복사할 내포된 객체 선택 Select object 객체 선택 Select object to dimension 치수를 기입할 객체 선택 Select object to divide 등분할할 객체 선택 @@ -820,7 +820,7 @@ Select one polyline (Multiple is available through the JSON form). 폴리선 하나를 선택하세요(다중 선택은 JSON 형식으로 사용할 수 있습니다). Select path curve 경로 곡선 선택 Select point or object 점 또는 객체 선택 - Select polyline or 폴리선 선택 또는 + Select polyline 폴리선 선택 Select second line 두 번째 선 선택 Select second line or shift-select to apply corner 두 번째 선 선택 또는 Shift를 누른 채 선택하여 각진 모서리 적용 Select second object 두 번째 객체 선택 @@ -880,9 +880,8 @@ Specify axis endpoint of ellipse 타원 축 끝점 지정 Specify base diameter 밑면 지름 지정 Specify base point 기준점 지정 - Specify base point or 기준점 지정 또는 Specify base point or displacement (drag with the middle mouse button for realtime pan) 기준점 또는 변위 지정(마우스 가운데 버튼으로 드래그하여 실시간 초점 이동) - Specify base radius or 밑면 반지름 지정 또는 + Specify base radius 밑면 반지름 지정 Specify center of donut 도넛 중심 지정 Specify center of ellipse 타원 중심 지정 Specify center of polygon 다각형 중심 지정 @@ -924,7 +923,7 @@ Specify first point on circle: _tan to 원 위의 첫 번째 점 지정: _tan 접점 Specify first source point 첫 번째 원본점 지정 Specify height 높이 지정 - Specify helix height or 나선 높이 지정 또는 + Specify helix height 나선 높이 지정 Specify included angle 사이각 지정 Specify insertion base point 삽입 기준점 지정 Specify insertion point 삽입점 지정 @@ -978,7 +977,6 @@ Specify new width for all segments 모든 구간의 새 폭 지정 Specify next point 다음 점 지정 Specify offset distance 간격띄우기 거리 지정 - Specify offset distance or 간격띄우기 거리 지정 또는 Specify opposite corner 반대 구석 지정 Specify other corner point 다른 구석점 지정 Specify other endpoint of axis 축의 다른 끝점 지정 @@ -991,7 +989,6 @@ Specify radius of circle 원 반지름 지정 Specify rotation angle 회전 각도 지정 Specify rotation angle of text 문자 회전 각도 지정 - Specify rotation angle or 회전 각도 지정 또는 Specify scale factor 축척 비율 지정 Specify second angle endpoint 두 번째 각도 끝점 지정 Specify second break point 두 번째 끊기점 지정 @@ -1024,9 +1021,9 @@ Specify third source point or 세 번째 원본점 지정 또는 <계속> Specify through point 통과점 지정 Specify top diameter 윗면 지름 지정 - Specify top radius or 윗면 반지름 지정 또는 + Specify top radius 윗면 반지름 지정 Specify total angle 전체 각도 지정 - Specify total length or 전체 길이 지정 또는 + Specify total length 전체 길이 지정 Specify width for rectangles 직사각형 폭 지정 Sphere 구 Spline 스플라인 @@ -1247,3 +1244,15 @@ menu Modify 수정 Check for Updates… 업데이트 확인… CADCraft is up to date. CADCraft가 최신 버전입니다. Could not check for updates. 업데이트를 확인하지 못했습니다. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 8f6533d51..3d1791c1b 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -291,7 +291,7 @@ Enter a vertex editing option Digite uma opção de edição de vértice Enter an option Digite uma opção Enter angle of xline Digite o ângulo da linha infinita - Enter angle of xline (0) or Digite o ângulo da linha infinita (0) ou + Enter angle of xline (0) Digite o ângulo da linha infinita (0) Enter area of rectangle in current units Digite a área do retângulo nas unidades atuais Enter attribute prompt Digite o texto de solicitação do atributo Enter attribute tag name Digite o nome do rótulo do atributo @@ -302,7 +302,7 @@ Enter default attribute value Digite o valor padrão do atributo Enter degree of spline Digite o grau da spline Enter delta angle Digite o ângulo delta - Enter delta length or Digite o comprimento delta ou + Enter delta length Digite o comprimento delta Enter dimension text Digite o texto da cota Enter dimension text (<> = measurement) Digite o texto da cota (<> = medida) Enter dimension value or [name=expression] Digite o valor da cota ou [nome=expressão] @@ -616,6 +616,7 @@ One of the Crafting Apps by the ArtCraft team and community. Um dos Crafting Apps da equipe e da comunidade ArtCraft. Only in this drawing: {n} Somente neste desenho: {n} Only in {name}: {n} Somente em {name}: {n} + Oops Restaurar objetos apagados Open Abrir Open (⌘O) Abrir (⌘O) Open Sample Drawing Abrir desenho de exemplo @@ -879,8 +880,8 @@ Select an arc or circle Selecione um arco ou círculo Select an arc. Selecione um arco. Select an object Selecione um objeto - Select an object to change or Selecione um objeto para alterar ou - Select an object to measure or Selecione um objeto para medir ou + Select an object to change Selecione um objeto para alterar + Select an object to measure Selecione um objeto para medir Select an object. Selecione um objeto. Select an open curve. Selecione uma curva aberta. Select an open line, arc, polyline, ellipse arc or spline. Selecione uma linha aberta, arco, polilinha, arco elíptico ou spline. @@ -894,9 +895,8 @@ Select first line Selecione a primeira linha Select first line or arc Selecione a primeira linha ou arco Select first object Selecione o primeiro objeto - Select first object or Selecione o primeiro objeto ou Select first point Selecione o primeiro ponto - Select nested objects to copy or Selecione objetos aninhados para copiar ou + Select nested objects to copy Selecione objetos aninhados para copiar Select object Selecione o objeto Select object to dimension Selecione o objeto a cotar Select object to divide Selecione o objeto a dividir @@ -912,7 +912,7 @@ Select one polyline (Multiple is available through the JSON form). Selecione uma polilinha (a opção Multiple está disponível pela forma JSON). Select path curve Selecione a curva da trajetória Select point or object Selecione um ponto ou objeto - Select polyline or Selecione uma polilinha ou + Select polyline Selecione uma polilinha Select second line Selecione a segunda linha Select second line or shift-select to apply corner Selecione a segunda linha ou use Shift para aplicar o canto Select second object Selecione o segundo objeto @@ -965,9 +965,8 @@ Specify axis endpoint of ellipse Especifique a extremidade do eixo da elipse Specify base diameter Especifique o diâmetro da base Specify base point Especifique o ponto base - Specify base point or Especifique o ponto base ou Specify base point or displacement (drag with the middle mouse button for realtime pan) Especifique o ponto base ou o deslocamento (arraste com o botão do meio do mouse para pan em tempo real) - Specify base radius or Especifique o raio da base ou + Specify base radius Especifique o raio da base Specify center of donut Especifique o centro do anel Specify center of ellipse Especifique o centro da elipse Specify center of polygon Especifique o centro do polígono @@ -1009,7 +1008,7 @@ Specify first point on circle: _tan to Especifique o primeiro ponto no círculo: _tan para Specify first source point Especifique o primeiro ponto de origem Specify height Especifique a altura - Specify helix height or Especifique a altura da hélice ou + Specify helix height Especifique a altura da hélice Specify included angle Especifique o ângulo incluído Specify insertion base point Especifique o ponto base de inserção Specify insertion point Especifique o ponto de inserção @@ -1028,7 +1027,6 @@ Specify new width for all segments Especifique a nova largura para todos os segmentos Specify next point Especifique o próximo ponto Specify offset distance Especifique a distância de offset - Specify offset distance or Especifique a distância de offset ou Specify opposite corner Especifique o canto oposto Specify other corner point Especifique o outro ponto de canto Specify other endpoint of axis Especifique a outra extremidade do eixo @@ -1041,7 +1039,6 @@ Specify radius of circle Especifique o raio do círculo Specify rotation angle Especifique o ângulo de rotação Specify rotation angle of text Especifique o ângulo de rotação do texto - Specify rotation angle or Especifique o ângulo de rotação ou Specify scale factor Especifique o fator de escala Specify second angle endpoint Especifique a segunda extremidade do ângulo Specify second break point Especifique o segundo ponto de quebra @@ -1074,9 +1071,9 @@ Specify third source point or Especifique o terceiro ponto de origem ou Specify through point Especifique o ponto de passagem Specify top diameter Especifique o diâmetro superior - Specify top radius or Especifique o raio superior ou + Specify top radius Especifique o raio superior Specify total angle Especifique o ângulo total - Specify total length or Especifique o comprimento total ou + Specify total length Especifique o comprimento total Specify width for rectangles Especifique a largura para retângulos Sphere Esfera Spline Spline @@ -1256,3 +1253,212 @@ Check for Updates… Verificar atualizações… CADCraft is up to date. O CADCraft está atualizado. Could not check for updates. Não foi possível verificar atualizações. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found +# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). + Add: Pick points Adicionar: indicar pontos + Add: Select objects Adicionar: selecionar objetos + Add… Adicionar… + Alias Alias + Alias file: {file} Arquivo de aliases: {file} + Align Multileaders Alinhar multilinhas + Analyze Usage Analisar uso + Angstroms Angströms + Apply to Layout Aplicar à apresentação + Architectural Arquitetônico + Arrow size Tamanho da seta + Associate Associar + Associative Associativo + Astronomical Units Unidades astronômicas + AutoLISP (LSP) AutoLISP (LSP) + Backup copy Cópia de backup + Block to create or edit Bloco a criar ou editar + Bottom left Inferior esquerdo + Bottom right Inferior direito + Boundaries Contornos + Boundaries picked: {n} Contornos selecionados: {n} + Break-line Symbol Símbolo de linha de quebra + built-in integrado + Center the plot Centralizar a plotagem + Centered Centralizado + Centimeters Centímetros + Change Properties Alterar propriedades + Choose the command the alias starts. Escolha o comando que o alias inicia. + Clear Recent Files Limpar arquivos recentes + Click the ruler to add a tab stop, right-click to remove one Clique na régua para adicionar uma parada de tabulação; clique com o botão direito para remover + Click to set new origin Clique para definir a nova origem + Close Block Editor Fechar o editor de blocos + Collect Coletar + Collect Multileaders Coletar multilinhas + Color 1 Cor 1 + Color 2 Cor 2 + Command Comando + contains authoring elements. Open in Block Editor? contém elementos de autoria. Abrir no editor de blocos? + Convert Text to Mtext Converter texto em Mtexto + Create backup copy with each save Criar cópia de backup a cada salvamento + Create separate hatches Criar hachuras separadas + Crossing selection color: Cor da seleção por cruzamento: + Curved Curvo + Custom Personalizado + Customize Personalizar + Decameters Decâmetros + Decimal Decimal + Decimal Degrees Graus decimais + Decimeters Decímetros + Default to boundary extents Padrão para a extensão do contorno + Deg/Min/Sec Gra/Min/Seg + Dim scale overall Escala geral de cotas + Discard Descartar + Don't Save Não salvar + Drag Dynamic Grip Arrastar pinça dinâmica + Drawing Units Unidades do desenho + Dynamic Block Data Dados de bloco dinâmico + Dynamic Block Grips Pinças de bloco dinâmico + Dynamic Block Reference Referência de bloco dinâmico + Edit Block Definition Editar definição de bloco + Edit Command Aliases (PGP)... Editar aliases de comandos (PGP)... + Edit Multileader Editar multilinha + Edit… Editar… + Engineering Engenharia + Enter block names to extract Insira os nomes dos blocos a extrair + Enter geometric characteristic Insira a característica geométrica + Enter name of LISP file to load Insira o nome do arquivo LISP a carregar + Evaluate LISP Avaliar LISP + Exclude: Excluir: + Explode Attributes to Text Explodir atributos em texto + Express Tools Ferramentas Express + Feet Pés + First corner Primeiro canto + Fit to paper Ajustar ao papel + Font Fonte + Fractional Fracionário + Gigameters Gigâmetros + Grads Grados + Grips Pinças + Groups Grupos + Hatch and Gradient Hachura e gradiente + Hatch origin Origem da hachura + Hatch Pick Seleção de hachura + Hatches Hachuras + Hectometers Hectômetros + Hemispherical Hemisférico + Ignore Ignorar + Imperial (inches) Imperial (polegadas) + Implied Loops Laços implícitos + Inches Polegadas + Indicate selection area Indicar área de seleção + Insertion scale Escala de inserção + Interface scale Escala da interface + Inverted Invertido + Island Ilha + Island detection Detecção de ilhas + Italic Itálico + Justify Justificar + Kilometers Quilômetros + Landscape Paisagem + Layouts Apresentações + Light Years Anos-luz + Limits Limites + Load Application... Carregar aplicativo... + Meters Metros + Metric (millimetres) Métrico (milímetros) + Microinches Micropolegadas + Microns Mícrons + Miles Milhas + Millimeters Milímetros + Mils Mils + More than {n} aliases. Mais de {n} aliases. + Move/Copy/Rotate Mover/Copiar/Girar + Multiline text Texto multilinha + Nanometers Nanômetros + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Novos desenhos e o primeiro desenho na inicialização usam estas unidades, a menos que um modelo padrão esteja definido (QNEWTEMPLATE). + New block Novo bloco + No alias file loaded. REINIT replaces these aliases with a file's. Nenhum arquivo de aliases carregado. O REINIT substitui estes aliases pelos de um arquivo. + Object grouping Agrupamento de objetos + Object selection limit for display of grips Limite de seleção de objetos para exibir pinças + Objects on locked layers Objetos em camadas bloqueadas + Open in Block Editor Abrir no editor de blocos + Open Recent Abrir recentes + Opposite corner Canto oposto + Orientation Orientação + Origin: {x}, {y} Origem: {x}, {y} + Outer Externo + Overline Sobrelinha + overrides built-in substitui o integrado + Paper size Tamanho do papel + Parsecs Parsecs + Plot area Área de plotagem + Plot object lineweights Plotar espessuras de linha dos objetos + Plot offset Deslocamento da plotagem + Plot transparency Plotar transparência + Portrait Retrato + Precision Precisão + Preview Visualizar + Printer/plotter Impressora/plotter + Radians Radianos + Sample output Amostra de saída + Save alias Salvar alias + Save Block Salvar bloco + Save Block As Salvar bloco como + Save changes to block "{name}"? Salvar alterações no bloco "{name}"? + Save changes to {name}? Salvar alterações em {name}? + Save Layout as Template Salvar apresentação como modelo + Scientific Científico + Select boundaries to remove Selecione contornos para remover + Selection Area Effect Efeito da área de seleção + Selection area opacity: Opacidade da área de seleção: + Selection effect color Cor do efeito de seleção + Selection modes Modos de seleção + Selection preview Visualização da seleção + Selection Preview Filter Filtro de visualização da seleção + Set Dynamic Property Definir propriedade dinâmica + Shape Forma + Sheet Books to PDF... Livros de folhas em PDF... + Show grips Mostrar pinças + Specified origin Origem especificada + Spherical Esférico + Surveyor's Units Unidades de topografia + Tables Tabelas + Text color Cor do texto + This drawing Este desenho + Top left Superior esquerdo + Top right Superior direito + Type an alias name. Digite um nome de alias. + Underline Sublinhado + Unitless Adimensional + Units of new drawings Unidades de novos desenhos + Units to scale inserted content Unidades para dimensionar conteúdo inserido + Unsaved changes Alterações não salvas + US Survey Feet Pés topográficos norte-americanos + US Survey Inch Polegada topográfica norte-americana + US Survey Mile Milha topográfica norte-americana + US Survey Yard Jarda topográfica norte-americana + Use current origin Usar origem atual + Visual Effect Settings Configurações de efeitos visuais + Visual Effect Settings... Configurações de efeitos visuais... + When a command is active Quando um comando está ativo + When no command is active Quando nenhum comando está ativo + Window < Janela < + Window selection color: Cor da seleção por janela: + X offset (mm) Deslocamento X (mm) + Xrefs Referências externas + Y offset (mm) Deslocamento Y (mm) + Yards Jardas + Your changes will be lost if you don't save them. Suas alterações serão perdidas se você não salvá-las. + {alias} is already defined; select it and use Edit. {alias} já está definido; selecione-o e use Editar. + {command} is an unknown command. {command} é um comando desconhecido. +# Strings the sibling catalogs translate that pt-BR did not yet cover (translated 2026-10-31). +menu Dimension Cota +menu Draw Desenho +menu Modify Modificar +menu View Exibir diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b8f97bf73..f1a5124f0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -221,7 +221,7 @@ Enter a vertex editing option Введите параметр редактирования вершины Enter an option Введите параметр Enter angle of xline Введите угол вспомогательной прямой - Enter angle of xline (0) or Введите угол вспомогательной прямой (0) или + Enter angle of xline (0) Введите угол вспомогательной прямой (0) Enter area of rectangle in current units Введите площадь прямоугольника в текущих единицах Enter attribute prompt Введите подсказку атрибута Enter attribute tag name Введите имя тега атрибута @@ -232,7 +232,7 @@ Enter default attribute value Введите значение атрибута по умолчанию Enter degree of spline Введите степень сплайна Enter delta angle Введите изменение угла - Enter delta length or Введите изменение длины или + Enter delta length Введите изменение длины Enter dimension text Введите размерный текст Enter dimension text (<> = measurement) Введите размерный текст (<> = измеренное значение) Enter dimension value or [name=expression] Введите размерное значение или [имя=выражение] @@ -490,6 +490,7 @@ On Включено On/Off Включить/выключить One of the Crafting Apps by the ArtCraft team and community. Одна из программ Crafting Apps от команды и сообщества ArtCraft. + Oops Восстановить стертые объекты Open (⌘O) Открыть (⌘O) Open Sample Drawing Открыть образец чертежа Open sample drawing Открыть образец чертежа @@ -674,8 +675,8 @@ Select an arc or circle Выберите дугу или круг Select an arc. Выберите дугу. Select an object Выберите объект - Select an object to change or Выберите объект для изменения или - Select an object to measure or Выберите объект для измерения или + Select an object to change Выберите объект для изменения + Select an object to measure Выберите объект для измерения Select an object. Выберите объект. Select an open curve. Выберите незамкнутую кривую. Select an open line, arc, polyline, ellipse arc or spline. Выберите незамкнутый отрезок, дугу, полилинию, эллиптическую дугу или сплайн. @@ -689,9 +690,8 @@ Select first line Выберите первый отрезок Select first line or arc Выберите первый отрезок или дугу Select first object Выберите первый объект - Select first object or Выберите первый объект или Select first point Выберите первую точку - Select nested objects to copy or Выберите вложенные объекты для копирования или + Select nested objects to copy Выберите вложенные объекты для копирования Select object Выберите объект Select object to dimension Выберите объект для нанесения размера Select object to divide Выберите объект для деления @@ -707,7 +707,7 @@ Select one polyline (Multiple is available through the JSON form). Выберите одну полилинию (Multiple доступно через JSON). Select path curve Выберите кривую траектории Select point or object Выберите точку или объект - Select polyline or Выберите полилинию или + Select polyline Выберите полилинию Select second line Выберите второй отрезок Select second line or shift-select to apply corner Выберите второй отрезок или выберите с Shift, чтобы построить угол Select second object Выберите второй объект @@ -760,9 +760,8 @@ Specify axis endpoint of ellipse Укажите конечную точку оси эллипса Specify base diameter Укажите базовый диаметр Specify base point Укажите базовую точку - Specify base point or Укажите базовую точку или Specify base point or displacement (drag with the middle mouse button for realtime pan) Укажите базовую точку или смещение (перетаскивайте средней кнопкой мыши для панорамирования) - Specify base radius or Укажите базовый радиус или + Specify base radius Укажите базовый радиус Specify center of donut Укажите центр кольца Specify center of ellipse Укажите центр эллипса Specify center of polygon Укажите центр многоугольника @@ -804,7 +803,7 @@ Specify first point on circle: _tan to Укажите первую точку на круге: _tan к Specify first source point Укажите первую исходную точку Specify height Укажите высоту - Specify helix height or Укажите высоту спирали или + Specify helix height Укажите высоту спирали Specify included angle Укажите угол между сторонами Specify insertion base point Укажите базовую точку вставки Specify insertion point Укажите точку вставки @@ -823,7 +822,6 @@ Specify new width for all segments Укажите новую ширину всех сегментов Specify next point Укажите следующую точку Specify offset distance Укажите расстояние смещения - Specify offset distance or Укажите расстояние смещения или Specify opposite corner Укажите противоположный угол Specify other corner point Укажите другую угловую точку Specify other endpoint of axis Укажите другую конечную точку оси @@ -836,7 +834,6 @@ Specify radius of circle Укажите радиус круга Specify rotation angle Укажите угол поворота Specify rotation angle of text Укажите угол поворота текста - Specify rotation angle or Укажите угол поворота или Specify scale factor Укажите масштабный коэффициент Specify second angle endpoint Укажите вторую конечную точку угла Specify second break point Укажите вторую точку разрыва @@ -869,9 +866,9 @@ Specify third source point or Укажите третью исходную точку или <продолжить> Specify through point Укажите точку, через которую проходит прямая Specify top diameter Укажите диаметр вершины - Specify top radius or Укажите радиус вершины или + Specify top radius Укажите радиус вершины Specify total angle Укажите полный угол - Specify total length or Укажите общую длину или + Specify total length Укажите общую длину Specify width for rectangles Укажите ширину прямоугольников Sphere Сфера Spline Сплайн @@ -1018,3 +1015,15 @@ Check for Updates… Проверить обновления… CADCraft is up to date. У вас последняя версия CADCraft. Could not check for updates. Не удалось проверить обновления. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6bea4c3da..4d86a21be 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -295,7 +295,7 @@ Enter a vertex editing option Ange ett alternativ för hörnpunktsredigering Enter an option Ange ett alternativ Enter angle of xline Ange konstruktionslinjens vinkel - Enter angle of xline (0) or Ange konstruktionslinjens vinkel (0) eller + Enter angle of xline (0) Ange konstruktionslinjens vinkel (0) Enter area of rectangle in current units Ange rektangelns area i aktuella enheter Enter attribute prompt Ange attributets fråga Enter attribute tag name Ange attributets taggnamn @@ -306,7 +306,7 @@ Enter default attribute value Ange attributets standardvärde Enter degree of spline Ange splinens grad Enter delta angle Ange vinkeländring - Enter delta length or Ange längdändring eller + Enter delta length Ange längdändring Enter dimension text Ange måttal Enter dimension text (<> = measurement) Ange måttal (<> = mätvärde) Enter dimension value or [name=expression] Ange måttvärde eller [namn=uttryck] @@ -619,6 +619,7 @@ One of the Crafting Apps by the ArtCraft team and community. En av Crafting Apps, gjord av ArtCraft-teamet och dess community. Only in this drawing: {n} Bara i den här ritningen: {n} Only in {name}: {n} Bara i {name}: {n} + Oops Återställ raderade objekt Open Öppna Open (⌘O) Öppna (⌘O) Open Sample Drawing Öppna exempelritning @@ -877,8 +878,8 @@ Select an arc or circle Markera en båge eller cirkel Select an arc. Markera en båge. Select an object Markera ett objekt - Select an object to change or Markera ett objekt att ändra eller - Select an object to measure or Markera ett objekt att mäta eller + Select an object to change Markera ett objekt att ändra + Select an object to measure Markera ett objekt att mäta Select an object. Markera ett objekt. Select an open curve. Markera en öppen kurva. Select an open line, arc, polyline, ellipse arc or spline. Markera en öppen linje, båge, polylinje, ellipsbåge eller spline. @@ -892,9 +893,8 @@ Select first line Markera första linjen Select first line or arc Markera första linjen eller bågen Select first object Markera första objektet - Select first object or Markera första objektet eller Select first point Ange första punkten - Select nested objects to copy or Markera nästlade objekt att kopiera eller + Select nested objects to copy Markera nästlade objekt att kopiera Select object Markera objekt Select object to dimension Markera objekt att måttsätta Select object to divide Markera objekt att dela in @@ -910,7 +910,7 @@ Select one polyline (Multiple is available through the JSON form). Markera en polylinje (Multiple finns i JSON-formen). Select path curve Markera banans kurva Select point or object Ange punkt eller markera objekt - Select polyline or Markera polylinje eller + Select polyline Markera polylinje Select second line Markera andra linjen Select second line or shift-select to apply corner Markera andra linjen, eller Shift-markera för att skapa hörn Select second object Markera andra objektet @@ -963,9 +963,8 @@ Specify axis endpoint of ellipse Ange ellipsens axeländpunkt Specify base diameter Ange basdiameter Specify base point Ange baspunkt - Specify base point or Ange baspunkt eller Specify base point or displacement (drag with the middle mouse button for realtime pan) Ange baspunkt eller förskjutning (dra med mittenknappen för att panorera i realtid) - Specify base radius or Ange basradie eller + Specify base radius Ange basradie Specify center of donut Ange ringens centrum Specify center of ellipse Ange ellipsens centrum Specify center of polygon Ange polygonens centrum @@ -1007,7 +1006,7 @@ Specify first point on circle: _tan to Ange första punkten på cirkeln: _tan to Specify first source point Ange första källpunkten Specify height Ange höjd - Specify helix height or Ange helixens höjd eller + Specify helix height Ange helixens höjd Specify included angle Ange medelpunktsvinkel Specify insertion base point Ange insättningens baspunkt Specify insertion point Ange insättningspunkt @@ -1026,7 +1025,6 @@ Specify new width for all segments Ange ny bredd för alla segment Specify next point Ange nästa punkt Specify offset distance Ange förskjutningsavstånd - Specify offset distance or Ange förskjutningsavstånd eller Specify opposite corner Ange motsatt hörn Specify other corner point Ange andra hörnpunkten Specify other endpoint of axis Ange axelns andra ändpunkt @@ -1039,7 +1037,6 @@ Specify radius of circle Ange cirkelns radie Specify rotation angle Ange rotationsvinkel Specify rotation angle of text Ange textens rotationsvinkel - Specify rotation angle or Ange rotationsvinkel eller Specify scale factor Ange skalfaktor Specify second angle endpoint Ange vinkelns andra ändpunkt Specify second break point Ange andra brytpunkten @@ -1072,9 +1069,9 @@ Specify third source point or Ange tredje källpunkten eller Specify through point Ange genomgångspunkt Specify top diameter Ange toppdiameter - Specify top radius or Ange toppradie eller + Specify top radius Ange toppradie Specify total angle Ange total vinkel - Specify total length or Ange total längd eller + Specify total length Ange total längd Specify width for rectangles Ange bredd för rektanglar Sphere Sfär Spline Spline @@ -1451,3 +1448,15 @@ menu Modify Ändra Check for Updates… Sök efter uppdateringar… CADCraft is up to date. CADCraft är uppdaterat. Could not check for updates. Det gick inte att söka efter uppdateringar. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6bf85f2c3..f9f41c798 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -40,6 +40,17 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() { assert_eq!(tr("unknown", "Open"), "Open"); } +/// A prompt with options is translated without its own "or": the command line adds the +/// translated " or [" before the options ("Select polyline or [Multiple]"). +#[test] +fn prompts_with_options_leave_or_to_the_command_line() { + assert_eq!(format!("{}{}", tr("de", "Select polyline"), tr("de", " or [")), "Wählen Sie eine Polylinie oder ["); + assert_eq!(format!("{}{}", tr("ko", "Specify helix height"), tr("ko", " or [")), "나선 높이 지정 또는 ["); + assert_eq!(format!("{}{}", tr("zh", "Enter delta length"), tr("zh", " or [")), "输入增量长度 或 ["); + assert_eq!(format!("{}{}", tr("vi", "Select polyline"), tr("vi", " or [")), "Chọn polyline hoặc ["); + assert_eq!(format!("{}{}", tr("vi", "Specify base radius"), tr("vi", " or [")), "Chỉ định bán kính đáy hoặc ["); +} + /// What each language shows in English for now. Missing translations never fail a build: /// `cargo xtask i18n`, a step of `cargo xtask ci`, runs this to print the list. #[test] @@ -63,6 +74,8 @@ fn missing_commands_menus_tools_and_snap_labels(language: &str) -> Vec { let catalog = catalog(language).unwrap(); let mut sources = BTreeSet::new(); sources.extend(crate::menus::MENUS.iter().copied()); + // File ▸ Open Recent is built at run time, not from a command's menu path. + sources.extend([crate::menus::OPEN_RECENT, crate::menus::CLEAR_RECENT]); for c in cadcraft_engine::command_specs() { sources.insert(c.label); sources.extend(c.menu.iter().copied()); @@ -100,6 +113,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { "quick", "vpscale", "options/dialog", + "finddlg", ]; let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut files = Vec::new(); @@ -187,6 +201,7 @@ fn assert_additional_locale_aliases() { ("hu", &["hu", "hu-HU", "hu_HU.UTF-8", "HU-hu", " hu-HU "]), ("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", " tr-TR ", "tr-CY"]), ("bs", &["bs", "bs-BA", "bs_BA.UTF-8", "BS-ba", " bs-BA ", "bs-Latn-BA"]), + ("vi", &["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn", " vi-VN "]), ("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]), ("zh", &["zh", "zh-CN"]), ] { @@ -354,6 +369,35 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> String { text } +/// The command line in the window draws a prompt's keywords itself; it puts the same words before +/// them as the engine's prompt text: " [" after a prompt for an option ("Enter an option [?/Make/…") +/// and after a question ("Fix any errors detected? [Yes/No]"), " or [" where the keywords are +/// offered besides what is asked ("Specify next point or [Undo]"), " or [" translated. +#[test] +fn the_command_line_says_or_before_keywords_as_the_prompt_does() { + let ctx = egui::Context::default(); + for lang in ["en", "de"] { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("ui.language", json!({ "lang": lang })).unwrap(); + app.logic(&ctx); + app.session.cmdline("-layer").unwrap(); + let text = render(&mut app, &ctx); + let option = format!("\n{}\n [\n", tr(lang, "Enter an option")); + assert!(text.contains(&option), "{lang}: {option:?} in {text:?}"); + app.session.cancel(); + app.session.cmdline("line").unwrap(); + app.session.cmdline("0,0").unwrap(); + let text = render(&mut app, &ctx); + let besides = format!("\n{}\n{}\n", tr(lang, "Specify next point"), tr(lang, " or [")); + assert!(text.contains(&besides), "{lang}: {besides:?} in {text:?}"); + app.session.cancel(); + app.session.cmdline("audit").unwrap(); + let text = render(&mut app, &ctx); + let question = format!("\n{}\n [\n", tr(lang, "Fix any errors detected?")); + assert!(text.contains(&question), "{lang}: {question:?} in {text:?}"); + } +} + #[test] fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { let mut app = CadApp::new(Session::new(), Services::default()); @@ -432,12 +476,13 @@ fn dialogs_render_in_every_language_and_keep_their_source_ids() { } /// The letters of every catalog have glyphs in both UI families. Latin, Cyrillic and Greek come -/// from egui's bundled fonts alone, as in the web build; the scripts they lack need a font that -/// `install_fonts` finds, and are checked only where one is there. +/// from egui's bundled fonts alone, as in the web build; the scripts they lack (and Vietnamese's +/// tone-marked letters) need a font that `install_fonts` finds, and are checked only where one is +/// there. #[test] fn interface_fonts_cover_every_letter_in_each_catalog() { for language in translated() { - let extra_font = ["ko", "zh", "ar"].contains(&language.code); + let extra_font = ["ko", "zh", "ar", "vi"].contains(&language.code); if extra_font && !crate::theme::can_show(language.code) { continue; } @@ -558,6 +603,13 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() { }); } +/// #1129: in Chinese every menu, submenu, command, tool palette and object-snap label is +/// translated, so no menu shows English entries. +#[test] +fn chinese_menus_and_commands_are_fully_translated() { + assert_eq!(missing_commands_menus_tools_and_snap_labels("zh"), Vec::::new()); +} + /// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`. #[test] fn bosnian_counts_take_one_few_other() { @@ -566,3 +618,22 @@ fn bosnian_counts_take_one_few_other() { assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian)); assert_eq!(Preference::Bosnian.native_name(), "Bosanski"); } + +/// Vietnamese has one form for every count, and shows only with an interface font that has its +/// tone-marked letters; without one it stays English and says why. +#[test] +fn vietnamese_takes_one_form_and_needs_a_font_with_its_letters() { + assert_eq!(Preference::parse("vi"), Some(Preference::Vietnamese)); + assert_eq!(Preference::Vietnamese.native_name(), "Tiếng Việt"); + let vi = LANGUAGES.iter().find(|l| l.code == "vi").unwrap(); + assert_eq!(vi.plural_forms, 1); + assert!([0, 1, 2, 5, 21, 111].into_iter().all(|n| (vi.plural)(n) == 0)); + let mut app = CadApp::new(Session::new(), Services::default()); + let r = app.run("ui.language", json!({"lang": "vi"})).unwrap(); + let shown = crate::theme::can_show("vi"); + assert_eq!(r["shown"], if shown { "vi" } else { "en" }); + if !shown { + assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("Vietnamese letters"))); + assert_eq!(Preference::Vietnamese.name(), "Vietnamese"); + } +} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 11369e751..26308975f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -130,8 +130,8 @@ Boundary Sınır Boundary... Sınır... Box Kutu - Break Kes - Break At Point Noktadan Kes + Break Kır + Break At Point Noktadan Kır Bring Above Objects Nesnelerin Üstüne Getir Bring Annotations to Front Açıklamaları Öne Getir Bring to Front En Öne Getir @@ -281,7 +281,7 @@ Diameter Çap Dim style Ölçü stili Dimension Ölçü - Dimension Break Ölçü Kesme + Dimension Break Ölçü Kırma Dimension Edit Ölçü Düzenleme Dimension Objects Only Yalnızca Ölçülendirme Nesneleri Dimension Space Ölçü Aralığı @@ -295,7 +295,7 @@ Disassociate Dimensions Ölçülerin İlişkisini Kaldır Display Görüntü Display name Görünen ad - Display plot styles Çizim stillerini göster + Display plot styles Çizdirme stillerini göster Distance Uzaklık Divide Böl Do not plot (click: plot) Çizdirme (tıklayın: çizdir) @@ -333,7 +333,7 @@ Edit Reference In-Place Referansı Yerinde Düzenle Edit Table Cell Tablo Hücresini Düzenle Edit Text... Metni Düzenle... - Elevation Yükseklik + Elevation Kot Ellipse Elips Elliptical Arc Eliptik Yay Enable 3D mouse navigation 3B fare ile gezinmeyi etkinleştir @@ -351,7 +351,7 @@ Enter a vertex editing option Bir köşe noktası düzenleme seçeneği girin Enter an option Bir seçenek girin Enter angle of xline Yapı çizgisinin açısını girin - Enter angle of xline (0) or Yapı çizgisinin açısını girin (0) veya + Enter angle of xline (0) Yapı çizgisinin açısını girin (0) Enter area of rectangle in current units Dikdörtgenin alanını geçerli birimlerle girin Enter attribute prompt Öznitelik istemini girin Enter attribute tag name Öznitelik etiket adını girin @@ -360,9 +360,9 @@ Enter constraint type Kısıtlama türünü girin Enter continuity Süreklilik girin Enter default attribute value Varsayılan öznitelik değerini girin - Enter degree of spline Eğrinin derecesini girin + Enter degree of spline Spline derecesini girin Enter delta angle Açı farkını girin - Enter delta length or Uzunluk farkını girin veya + Enter delta length Uzunluk farkını girin Enter dimension text Ölçü metnini girin Enter dimension text (<> = measurement) Ölçü metnini girin (<> = ölçülen değer) Enter dimension value or [name=expression] Ölçü değerini girin veya [name=expression] @@ -427,7 +427,7 @@ Freeze/Thaw Dondur/Çöz Freeze/Thaw in all viewports Tüm görünüm pencerelerinde dondur/çöz Freeze/Thaw in the current viewport Geçerli görünüm penceresinde dondur/çöz - From Kimden + From Kaynak From Template... Şablondan... General Genel Geometric Center Geometrik Merkez @@ -487,7 +487,7 @@ Hide Palettes Paletleri Gizle History Geçmiş Hold Shift to add to the selection Seçime eklemek için Shift'i basılı tutun - Home Giriş + Home Başlangıç Horizontal Yatay HorizontalDistance Yatay Uzaklık How to apply: Uygulama biçimi: @@ -677,7 +677,7 @@ No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş. No external references are attached. İliştirilmiş dış referans yok. No model credits were built into this copy. Bu kopyaya model emeği bilgisi eklenmemiş. - No plot style tables. Çizim stili tablosu yok. + No plot style tables. Çizdirme stili tablosu yok. No saved layer states Kayıtlı katman durumu yok No selection Seçim yok No supported 3D mouse found Desteklenen 3B fare bulunamadı @@ -712,6 +712,7 @@ One of the Crafting Apps by the ArtCraft team and community. ArtCraft ekibi ve topluluğunun Crafting Apps uygulamalarından biri. Only in this drawing: {n} Yalnızca bu çizimde: {n} Only in {name}: {n} Yalnızca {name} içinde: {n} + Oops Silinen Nesneleri Geri Yükle Open Aç Open (⌘O) Aç (⌘O) Open Recent Son Kullanılanları Aç @@ -763,16 +764,16 @@ Pickbox size Seçim kutusu boyutu Plot Çizdir Plot (click: do not plot) Çizdir (tıklayın: çizdirme) - Plot Preview Çizim Önizlemesi - Plot Style Manager Çizim Stili Yöneticisi - Plot Style Manager... Çizim Stili Yöneticisi... - Plot Style... Çizim Stili... - Plot Styles... Çizim Stilleri... - Plot Window Çizim Penceresi - Plot style Çizim stili - Plot style attached to Çizim stilinin bağlı olduğu yer - Plot style table Çizim stili tablosu - Plot table type Çizim tablosu türü + Plot Preview Çizdirme Önizlemesi + Plot Style Manager Çizdirme Stili Yöneticisi + Plot Style Manager... Çizdirme Stili Yöneticisi... + Plot Style... Çizdirme Stili... + Plot Styles... Çizdirme Stilleri... + Plot Window Çizdirme Penceresi + Plot style Çizdirme stili + Plot style attached to Çizdirme stilinin bağlı olduğu yer + Plot style table Çizdirme stili tablosu + Plot table type Çizdirme tablosu türü Plotter Manager Çizici Yöneticisi Plotter Manager... Çizici Yöneticisi... Plus/minus Artı/eksi @@ -913,7 +914,7 @@ Select a block reference. Bir blok referansı seçin. Select a circle or arc. Bir daire veya yay seçin. Select a closed polyline Kapalı bir çoklu çizgi seçin - Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı eğri seçin. + Select a closed polyline, circle, ellipse or closed spline. Kapalı bir çoklu çizgi, daire, elips veya kapalı spline seçin. Select a closed polyline. Kapalı bir çoklu çizgi seçin. Select a curve as the path. Yol olarak bir eğri seçin. Select a dimension constraint type Bir ölçü kısıtlaması türü seçin @@ -922,7 +923,7 @@ Select a line or an arc. Bir çizgi veya yay seçin. Select a line or polyline segment Bir çizgi veya çoklu çizgi parçası seçin Select a line, arc or open polyline. Bir çizgi, yay veya açık çoklu çizgi seçin. - Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya eğri seçin. + Select a line, arc, circle, polyline, ellipse or spline as the path. Yol olarak bir çizgi, yay, daire, çoklu çizgi, elips veya spline seçin. Select a line, circle, arc or polyline segment. Bir çizgi, daire, yay veya çoklu çizgi parçası seçin. Select a line. Bir çizgi seçin. Select a polyline. Bir çoklu çizgi seçin. @@ -931,11 +932,11 @@ Select an arc or circle Bir yay veya daire seçin Select an arc. Bir yay seçin. Select an object Bir nesne seçin - Select an object to change or Değiştirilecek nesneyi seçin veya - Select an object to measure or Ölçülecek nesneyi seçin veya + Select an object to change Değiştirilecek nesneyi seçin + Select an object to measure Ölçülecek nesneyi seçin Select an object. Bir nesne seçin. Select an open curve. Açık bir eğri seçin. - Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya eğri seçin. + Select an open line, arc, polyline, ellipse arc or spline. Açık bir çizgi, yay, çoklu çizgi, elips yayı veya spline seçin. Select arc or circle Yay veya daire seçin Select arc or polyline arc segment Yay veya çoklu çizgi yay parçası seçin Select arc style Yay stilini seçin @@ -946,16 +947,15 @@ Select first line İlk çizgiyi seçin Select first line or arc İlk çizgiyi veya yayı seçin Select first object İlk nesneyi seçin - Select first object or İlk nesneyi seçin veya Select first point İlk noktayı seçin - Select nested objects to copy or Kopyalanacak iç içe nesneleri seçin veya + Select nested objects to copy Kopyalanacak iç içe nesneleri seçin Select object Nesne seçin Select object to dimension Ölçülendirilecek nesneyi seçin Select object to divide Bölünecek nesneyi seçin - Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da kırpmak için Shift ile seçin + Select object to extend or shift-select to trim Uzatılacak nesneyi seçin ya da budamak için Shift ile seçin Select object to measure Ölçülecek nesneyi seçin Select object to offset Ötelenecek nesneyi seçin - Select object to trim or shift-select to extend Kırpılacak nesneyi seçin ya da uzatmak için Shift ile seçin + Select object to trim or shift-select to extend Budanacak nesneyi seçin ya da uzatmak için Shift ile seçin Select objects Nesneleri seçin Select objects (crossing window first corner) Nesneleri seçin (kesişen pencerenin ilk köşesi) Select objects first, then choose a command Önce nesneleri seçin, sonra bir komut belirleyin @@ -965,13 +965,13 @@ Select one polyline (Multiple is available through the JSON form). Bir çoklu çizgi seçin (Multiple seçeneği JSON biçimiyle kullanılabilir). Select path curve Yol eğrisini seçin Select point or object Nokta veya nesne seçin - Select polyline or Çoklu çizgi seçin veya + Select polyline Çoklu çizgi seçin Select second line İkinci çizgiyi seçin Select second line or shift-select to apply corner İkinci çizgiyi seçin ya da köşe uygulamak için Shift ile seçin Select second object İkinci nesneyi seçin Select second object or shift-select to apply corner İkinci nesneyi seçin ya da köşe uygulamak için Shift ile seçin Select second point İkinci noktayı seçin - Select spline Eğri seçin + Select spline Spline seçin Select symmetry line Simetri çizgisini seçin Select two points İki nokta seçin Selection Seçim @@ -1046,9 +1046,8 @@ Specify axis endpoint of ellipse Elipsin eksen uç noktasını belirtin Specify base diameter Taban çapını belirtin Specify base point Taban noktasını belirtin - Specify base point or Taban noktasını belirtin veya Specify base point or displacement (drag with the middle mouse button for realtime pan) Taban noktasını veya yer değiştirmeyi belirtin (gerçek zamanlı kaydırma için orta fare düğmesiyle sürükleyin) - Specify base radius or Taban yarıçapını belirtin veya + Specify base radius Taban yarıçapını belirtin Specify center of donut Halkanın merkezini belirtin Specify center of ellipse Elipsin merkezini belirtin Specify center of polygon Çokgenin merkezini belirtin @@ -1057,7 +1056,7 @@ Specify center point of array Dizinin merkez noktasını belirtin Specify center point of base Tabanın merkez noktasını belirtin Specify corner of viewport Görünüm penceresinin köşesini belirtin - Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX or nXP) + Specify corner of window, enter a scale factor (nX or nXP) Pencerenin köşesini belirtin, bir ölçek faktörü girin (nX veya nXP) Specify diameter of circle Dairenin çapını belirtin Specify dimension arc line location Ölçü yayı çizgisinin konumunu belirtin Specify dimension line location Ölçü çizgisinin konumunu belirtin @@ -1090,7 +1089,7 @@ Specify first point on circle: _tan to Daire üzerindeki ilk noktayı belirtin: _tan to Specify first source point İlk kaynak noktayı belirtin Specify height Yüksekliği belirtin - Specify helix height or Helezon yüksekliğini belirtin veya + Specify helix height Helezon yüksekliğini belirtin Specify included angle İç açıyı belirtin Specify insertion base point Ekleme taban noktasını belirtin Specify insertion point Ekleme noktasını belirtin @@ -1109,7 +1108,6 @@ Specify new width for all segments Tüm parçalar için yeni genişliği belirtin Specify next point Sonraki noktayı belirtin Specify offset distance Öteleme uzaklığını belirtin - Specify offset distance or Öteleme uzaklığını belirtin veya Specify opposite corner Karşı köşeyi belirtin Specify other corner point Diğer köşe noktasını belirtin Specify other endpoint of axis Eksenin diğer uç noktasını belirtin @@ -1122,10 +1120,9 @@ Specify radius of circle Dairenin yarıçapını belirtin Specify rotation angle Döndürme açısını belirtin Specify rotation angle of text Metnin döndürme açısını belirtin - Specify rotation angle or Döndürme açısını belirtin veya Specify scale factor Ölçek faktörünü belirtin Specify second angle endpoint Açının ikinci uç noktasını belirtin - Specify second break point İkinci kesme noktasını belirtin + Specify second break point İkinci kırma noktasını belirtin Specify second chamfer distance for rectangles Dikdörtgenler için ikinci pah uzaklığını belirtin Specify second constraint point İkinci kısıtlama noktasını belirtin Specify second destination point İkinci hedef noktayı belirtin @@ -1148,21 +1145,21 @@ Specify starting width Başlangıç genişliğini belirtin Specify starting width for next segment Sonraki parçanın başlangıç genişliğini belirtin Specify tangent direction for the start point of arc Yayın başlangıç noktası için teğet yönünü belirtin - Specify the elevation for rectangles Dikdörtgenler için yüksekliği belirtin + Specify the elevation for rectangles Dikdörtgenler için kotu belirtin Specify thickness for rectangles Dikdörtgenler için kalınlığı belirtin Specify third point on circle Daire üzerindeki üçüncü noktayı belirtin Specify third point on circle: _tan to Daire üzerindeki üçüncü noktayı belirtin: _tan to Specify third source point or Üçüncü kaynak noktayı belirtin veya Specify through point Geçiş noktasını belirtin Specify top diameter Üst çapı belirtin - Specify top radius or Üst yarıçapı belirtin veya + Specify top radius Üst yarıçapı belirtin Specify total angle Toplam açıyı belirtin - Specify total length or Toplam uzunluğu belirtin veya + Specify total length Toplam uzunluğu belirtin Specify width for rectangles Dikdörtgenler için genişliği belirtin Sphere Küre Spherical Küresel - Spline Eğri - Spline Edit Eğri Düzenle + Spline Spline + Spline Edit Spline Düzenle Stack (type 1/2 then select) Yığ (1/2 yazıp seçin) Standard Standart Start Başlangıç @@ -1218,7 +1215,7 @@ This drawing Bu çizim Time Zaman Time-sensitive right-click: a quick click is Enter, holding opens the menu Süreye bağlı sağ tıklama: kısa tıklama Enter'dır, basılı tutmak menüyü açar - To Kime + To Hedef To get started, create or open a drawing. Başlamak için bir çizim oluşturun veya açın. Toggle PICKADD (off: each pick replaces the selection) PICKADD'i değiştir (kapalı: her seçim öncekinin yerini alır) Toggle PICKADD (on: picks add to the selection) PICKADD'i değiştir (açık: seçimler mevcut seçime eklenir) @@ -1233,7 +1230,7 @@ Track orthogonally only Yalnızca dik açılarla izle Track using all polar angle settings Tüm kutupsal açı ayarlarını kullanarak izle Transparency Saydamlık - Trim Kırp + Trim Buda True Color Gerçek Renk Turn All Layers On Tüm Katmanları Aç Turn Off Layer Katmanı Kapat @@ -1352,3 +1349,15 @@ menu View Görünüm Check for Updates… Güncellemeleri denetle… CADCraft is up to date. CADCraft güncel. Could not check for updates. Güncellemeler denetlenemedi. + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3f2de02df..5d0660112 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -286,7 +286,7 @@ Enter a vertex editing option Введіть параметр редагування вершини Enter an option Введіть параметр Enter angle of xline Введіть кут допоміжної прямої - Enter angle of xline (0) or Введіть кут допоміжної прямої (0) або + Enter angle of xline (0) Введіть кут допоміжної прямої (0) Enter area of rectangle in current units Введіть площу прямокутника в поточних одиницях Enter attribute prompt Введіть підказку атрибута Enter attribute tag name Введіть ім'я тегу атрибута @@ -297,7 +297,7 @@ Enter default attribute value Введіть типове значення атрибута Enter degree of spline Введіть степінь сплайна Enter delta angle Введіть зміну кута - Enter delta length or Введіть зміну довжини або + Enter delta length Введіть зміну довжини Enter dimension text Введіть розмірний текст Enter dimension text (<> = measurement) Введіть розмірний текст (<> = виміряне значення) Enter dimension value or [name=expression] Введіть розмірне значення або [name=expression] @@ -598,6 +598,7 @@ One of the Crafting Apps by the ArtCraft team and community. Одна з програм Crafting Apps від команди та спільноти ArtCraft. Only in this drawing: {n} Лише в цьому кресленні: {n} Only in {name}: {n} Лише в {name}: {n} + Oops Відновити стерті об'єкти Open (⌘O) Відкрити (⌘O) Open Sample Drawing Відкрити зразок креслення Open sample drawing Відкрити зразок креслення @@ -802,8 +803,8 @@ Select an arc or circle Виберіть дугу або коло Select an arc. Виберіть дугу. Select an object Виберіть об'єкт - Select an object to change or Виберіть об'єкт для зміни або - Select an object to measure or Виберіть об'єкт для вимірювання або + Select an object to change Виберіть об'єкт для зміни + Select an object to measure Виберіть об'єкт для вимірювання Select an object. Виберіть об'єкт. Select an open curve. Виберіть незамкнену криву. Select an open line, arc, polyline, ellipse arc or spline. Виберіть незамкнений відрізок, дугу, полілінію, еліптичну дугу або сплайн. @@ -817,9 +818,8 @@ Select first line Виберіть перший відрізок Select first line or arc Виберіть перший відрізок або дугу Select first object Виберіть перший об'єкт - Select first object or Виберіть перший об'єкт або Select first point Виберіть першу точку - Select nested objects to copy or Виберіть вкладені об'єкти для копіювання або + Select nested objects to copy Виберіть вкладені об'єкти для копіювання Select object Виберіть об'єкт Select object to dimension Виберіть об'єкт для нанесення розміру Select object to divide Виберіть об'єкт для поділу @@ -840,7 +840,7 @@ Select one polyline (Multiple is available through the JSON form). Виберіть одну полілінію (Multiple доступно через JSON). Select path curve Виберіть криву траєкторії Select point or object Виберіть точку або об'єкт - Select polyline or Виберіть полілінію або + Select polyline Виберіть полілінію Select second line Виберіть другий відрізок Select second line or shift-select to apply corner Виберіть другий відрізок або виберіть із Shift, щоб утворити кут Select second object Виберіть другий об'єкт @@ -900,9 +900,8 @@ Specify axis endpoint of ellipse Укажіть кінцеву точку осі еліпса Specify base diameter Укажіть діаметр основи Specify base point Укажіть базову точку - Specify base point or Укажіть базову точку або Specify base point or displacement (drag with the middle mouse button for realtime pan) Укажіть базову точку або зміщення (перетягуйте середньою кнопкою миші для панорамування) - Specify base radius or Укажіть радіус основи або + Specify base radius Укажіть радіус основи Specify center of donut Укажіть центр кільця Specify center of ellipse Укажіть центр еліпса Specify center of polygon Укажіть центр багатокутника @@ -944,7 +943,7 @@ Specify first point on circle: _tan to Укажіть першу точку на колі: _tan до Specify first source point Укажіть першу вихідну точку Specify height Укажіть висоту - Specify helix height or Укажіть висоту спіралі або + Specify helix height Укажіть висоту спіралі Specify included angle Укажіть кут між сторонами Specify insertion base point Укажіть базову точку вставлення Specify insertion point Укажіть точку вставлення @@ -1028,7 +1027,6 @@ Specify new width for all segments Укажіть нову ширину всіх сегментів Specify next point Укажіть наступну точку Specify offset distance Укажіть відстань зміщення - Specify offset distance or Укажіть відстань зміщення або Specify opposite corner Укажіть протилежний кут Specify other corner point Укажіть іншу кутову точку Specify other endpoint of axis Укажіть іншу кінцеву точку осі @@ -1041,7 +1039,6 @@ Specify radius of circle Укажіть радіус кола Specify rotation angle Укажіть кут повороту Specify rotation angle of text Укажіть кут повороту тексту - Specify rotation angle or Укажіть кут повороту або Specify scale factor Укажіть коефіцієнт масштабу Specify second angle endpoint Укажіть другу кінцеву точку кута Specify second break point Укажіть другу точку розриву @@ -1074,9 +1071,9 @@ Specify third source point or Укажіть третю вихідну точку або <продовжити> Specify through point Укажіть точку, через яку проходить пряма Specify top diameter Укажіть діаметр вершини - Specify top radius or Укажіть радіус вершини або + Specify top radius Укажіть радіус вершини Specify total angle Укажіть загальний кут - Specify total length or Укажіть загальну довжину або + Specify total length Укажіть загальну довжину Specify width for rectangles Укажіть ширину прямокутників Sphere Сфера Spline Сплайн @@ -1283,6 +1280,8 @@ Automatic save every Автозбереження кожні min хв Automatic save file location Папка файлів автозбереження + Backup copy Резервна копія + Create backup copy with each save Створювати резервну копію під час кожного збереження Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження. Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню. @@ -1374,3 +1373,15 @@ CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення. Save changes to block "{name}"? Зберегти зміни в блоці «{name}»? + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv new file mode 100644 index 000000000..34eb084eb --- /dev/null +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -0,0 +1,1462 @@ +# CADCraft Vietnamese interface catalog — original translations by @2ez4gcx with Claude Code (AI-assisted). +# SPDX-License-Identifier: MIT OR Apache-2.0 +# Written from the meaning of CADCraft English strings; no proprietary translations copied. +# AutoCAD command names, command-line keywords and system variables stay in English. +# UTF-8: contextEnglish sourceVietnamese translation. +# @plural = one form for every integer count (Vietnamese doesn't inflect a noun for number). + Apply Áp dụng + Attach DWG... Đính kèm DWG... + Bind Gắn kết + Detach Tách + Loaded Đã nạp + Make it a block of this drawing, keeping its layer names Biến thành block của bản vẽ này, giữ nguyên tên layer + Make it a block of this drawing; its layers become NAME$0$LAYER Biến thành block của bản vẽ này; các layer của nó trở thành NAME$0$LAYER + No external references are attached. Không có tham chiếu ngoài nào được đính kèm. + Not Found Không tìm thấy + Not loaded Chưa nạp + Open Mở + Overlay Chồng lên + Reference Manager Trình quản lý tham chiếu + Reference Name Tên tham chiếu + References: {n} Tham chiếu: {n} + Reload Tải lại + Reload All Tải lại tất cả + Saved Path Đường dẫn đã lưu + Unload Bỏ nạp + Unloaded Đã bỏ nạp + Unreferenced Không được tham chiếu + Unresolved Chưa phân giải + Cancel Hủy + OK OK + or [ hoặc [ + % of all commits % của tất cả commit + * Wildcard Match * Khớp ký tự đại diện + *VARIES* *KHÁC NHAU* + + | Top | 2D Wireframe + | Trên | Khung dây 2D + +Bin +Bin + +LOC +LOC + 1 Viewport 1 khung nhìn + 2 Points 2 điểm + 2 Viewports 2 khung nhìn + 2D Solid Solid 2D + 3 Points 3 điểm + 3 Viewports 3 khung nhìn + 3D Face Mặt 3D + 3D Operations Thao tác 3D + 3D Polyline Polyline 3D + 4 Viewports 4 khung nhìn + < Less than < Nhỏ hơn + <> Not Equal <> Không bằng + = Equals = Bằng + > Greater than > Lớn hơn + A variable name starts with a letter and has only letters, digits and _ Tên biến bắt đầu bằng một chữ cái và chỉ gồm chữ cái, chữ số và _ + About Giới thiệu + About CADCraft Giới thiệu CADCraft + Absolute Tuyệt đối + Add Leader Thêm đường dẫn ghi chú + Additional angles Các góc bổ sung + Align Căn chỉnh + Align Text Căn chỉnh chữ + Aligned Thẳng hàng + All Tất cả + All Annotation Objects Tất cả đối tượng chú thích + All Used Layers Tất cả layer đã dùng + All files Tất cả tệp + Angle Góc + Angular Góc + Annotation Visibility Hiển thị chú thích + Annotation scale Tỉ lệ chú thích + Apparent Intersection Giao biểu kiến + Append to current selection set Thêm vào tập chọn hiện hành + Apply to: Áp dụng cho: + Apply true color Áp dụng màu thực + Arc Cung tròn + Arc Length Chiều dài cung + Area Diện tích + Arrange Panel Sắp xếp bảng + Array Sao chép theo dãy + Attach Đính kèm + Attribute Attribute + Attribute Definition Định nghĩa attribute + Attribute Display Hiển thị attribute + Audit Kiểm tra lỗi + Auto (system language) Tự động (ngôn ngữ hệ thống) + Auto Constrain Ràng buộc tự động + AutoConstrain AutoConstrain + Axis, End Trục, Điểm cuối + Base Gốc + Baseline Đường cơ sở + Batch Publish Xuất bản hàng loạt + Binary assets added Đã thêm tài nguyên nhị phân + Binary assets removed Đã xóa tài nguyên nhị phân + Bind... Gắn kết... + Blend Pha trộn + Block Block + Block Attribute Manager... Trình quản lý attribute của block... + Block Description... Mô tả block... + Block Editor Trình sửa block + Block Reference Tham chiếu block + Block... Block... + Blocks Block + Blue Xanh dương + Bold Đậm + Boolean Boolean + Boundary Biên + Boundary... Biên... + Box Hộp + Break Ngắt + Break At Point Ngắt tại điểm + Bring Above Objects Đưa lên trên các đối tượng + Bring Annotations to Front Đưa chú thích lên trước + Bring to Front Đưa lên trước + Bulleted list Danh sách có dấu đầu dòng + ByBlock ByBlock + ByColor ByColor + ByLayer ByLayer + CADCraft Help Trợ giúp CADCraft + Cancel Hủy + Cartesian (x, y) Đề-các (x, y) + Center Tâm + Center Line Đường tâm + Center Mark Dấu tâm + Center X Tâm X + Center Y Tâm Y + Center Z Tâm Z + Center, Diameter Tâm, Đường kính + Center, Radius Tâm, Bán kính + Center, Start, Angle Tâm, Điểm đầu, Góc + Center, Start, End Tâm, Điểm đầu, Điểm cuối + Center, Start, Length Tâm, Điểm đầu, Chiều dài + Chamfer Vát góc + Change Space Đổi không gian + Change to Current Layer Đổi sang layer hiện hành + Changed: {n} Đã thay đổi: {n} + Circle Đường tròn + Circumference Chu vi + Clear History Xóa lịch sử + Clear Xóa + Clip Xén + Clip Viewport Xén khung nhìn + Clipboard Bảng tạm + Close Đóng + Close All Đóng tất cả + Close All Other Drawings Đóng tất cả bản vẽ khác + Closed Khép kín + Close Comparison Đóng so sánh + Export Comparison Xuất so sánh + Coincident Trùng khớp + Collapse Tool Sets Thu gọn bộ công cụ + Collinear Cùng đường thẳng + Color Màu + Color 8 Màu 8 + Color 9 Màu 9 + Color Index Chỉ số màu + Color... Màu... + Command Aliases Bí danh lệnh + Command History Lịch sử lệnh + Clear Command History Xóa lịch sử lệnh + Command Line Dòng lệnh + Command Line History Lines Số dòng lịch sử dòng lệnh + Command Reference Tham chiếu lệnh + Commits Commit + Company Công ty + Compare Drawings... So sánh bản vẽ... + Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Thiết kế và vẽ có sự hỗ trợ của máy tính: ứng dụng CAD mã nguồn mở, được viết hoàn toàn bằng Rust theo kiểu clean-room. + Concentric Đồng tâm + Cone Hình nón + Constraint Bars Thanh ràng buộc + Constraint Settings Cài đặt ràng buộc + Construction Line Đường dựng + Contents Nội dung + Continue Tiếp tục + Contributors Người đóng góp + Control Vertices Đỉnh điều khiển + Convert Chuyển đổi + Copy Sao chép + Copy Full File Path Sao chép đường dẫn đầy đủ của tệp + Copy History Sao chép lịch sử + Copy Layout Sao chép layout + Copy Nested Objects Sao chép đối tượng lồng nhau + Copy Selection Sao chép vùng chọn + Copy with Base Point Sao chép với điểm gốc + Count Đếm + Count List Danh sách đếm + Count Table Bảng đếm + Create Block Tạo block + Creates a user parameter Tạo một tham số người dùng + Ctrl+Enter to finish Ctrl+Enter để kết thúc + Current layer Layer hiện hành + Current selection Tập chọn hiện hành + Current: {color} Hiện tại: {color} + Cut Cắt + Cut with Base Point Cắt với điểm gốc + Cyan Lục lam + Cylinder Hình trụ + DWG DWG + DWG Reference... Tham chiếu DWG... + DXF DXF + Dark Tối + Data Extraction... Trích xuất dữ liệu... + Default Mặc định + Data Link... Liên kết dữ liệu... + Default Save Format Định dạng lưu mặc định + Default Save Version Phiên bản lưu mặc định + Define Attributes Định nghĩa attribute + Define Attributes... Định nghĩa attribute... + Defined width Bề rộng đã định nghĩa + Degree Độ + Degrees of Freedom Bậc tự do + Degrees of freedom: {n} Bậc tự do: {n} + Delete Xóa + Delete Column Xóa cột + Delete Constraints Xóa ràng buộc + Delete Dimension Style Xóa kiểu kích thước + Delete Duplicate Objects Xóa đối tượng trùng lặp + Delete Layer Xóa layer + Delete Layer State Xóa trạng thái layer + Delete Layout Xóa layout + Delete Row Xóa hàng + Delete Text Style Xóa kiểu chữ + Delta X Chênh lệch X + Delta Y Chênh lệch Y + Description Mô tả + Deselect All Bỏ chọn tất cả + Diameter Đường kính + Dim style Kiểu kích thước + Dimension Break Ngắt kích thước + Dimension Edit Sửa kích thước + Dimension Kích thước + Dimension Objects Only Chỉ đối tượng kích thước + Dimension Space Khoảng cách kích thước + Dimension Style Override Ghi đè kiểu kích thước + Dimension Style... Kiểu kích thước... + Dimension Text Edit Sửa chữ kích thước + Dimension style Kiểu kích thước + Dimensional Constraint Ràng buộc kích thước + Dimensional Constraint Parameters Tham số ràng buộc kích thước + Dimensional Constraints Ràng buộc kích thước + Disassociate Dimensions Tách liên kết kích thước + Display name Tên hiển thị + Distance Khoảng cách + Divide Chia + Do not plot (click: plot) Không in (nhấp để in) + Donut Vành tròn + Double-click to edit the description Nhấp đúp để sửa mô tả + Down Xuống + Drafting Vẽ kỹ thuật + Drafting Settings Cài đặt hỗ trợ vẽ + Drafting Settings... Cài đặt hỗ trợ vẽ... + Draw Vẽ + Draw Order Thứ tự vẽ + Drawing (DWG) Bản vẽ (DWG) + Drawing (DXF) Bản vẽ (DXF) + Drawing Limits Giới hạn bản vẽ + Drawing Template (DWT) Mẫu bản vẽ (DWT) + Drawing Utilities Tiện ích bản vẽ + Drawings (DWG, DXF) Bản vẽ (DWG, DXF) + Dynamic Động + Dynamic Dimensions Kích thước động + Dynamic Input Dynamic input + Dynamic Input (F12) Dynamic input (F12) + E E + Edit Sửa + Edit Attributes... Sửa attribute... + Edit Text... Sửa chữ... + Edit Table Cell Sửa ô bảng + Elevation Cao độ + Ellipse Elip + Elliptical Arc Cung elip + Enable Dynamic Input (F12) Bật dynamic input (F12) + End Object Isolation Kết thúc cô lập đối tượng + End Width Bề rộng cuối + End X Điểm cuối X + End Y Điểm cuối Y + End angle Góc cuối + Endpoint Đầu mút + Enter Enter + Enter Top, Zero or Bottom. Nhập Top, Zero hoặc Bottom. + Enter a fit data option Nhập tùy chọn dữ liệu khớp + Enter a pattern name Nhập tên mẫu + Enter a vertex editing option Nhập tùy chọn sửa đỉnh + Enter an option Nhập một tùy chọn + Enter angle of xline Nhập góc của đường dựng + Enter angle of xline (0) Nhập góc của đường dựng (0) + Enter area of rectangle in current units Nhập diện tích hình chữ nhật theo đơn vị hiện hành + Enter attribute prompt Nhập lời nhắc attribute + Enter attribute tag name Nhập tên thẻ attribute + Enter attribute value Nhập giá trị attribute + Enter block name Nhập tên block + Enter constraint type Nhập loại ràng buộc + Enter continuity Nhập tính liên tục + Enter default attribute value Nhập giá trị attribute mặc định + Enter degree of spline Nhập bậc của spline + Enter delta angle Nhập góc chênh lệch + Enter delta length Nhập chiều dài chênh lệch + Enter dimension text Nhập chữ kích thước + Enter dimension text (<> = measurement) Nhập chữ kích thước (<> = số đo) + Enter dimension value or [name=expression] Nhập giá trị kích thước hoặc [name=expression] + Enter dimension variable name to override Nhập tên biến kích thước để ghi đè + Enter justification type Nhập kiểu căn lề + Enter leader text Nhập chữ đường dẫn ghi chú + Enter mline scale Nhập tỉ lệ mline + Enter mode Nhập chế độ + Enter name of block to insert Nhập tên block cần chèn + Enter new text Nhập chữ mới + Enter next point Nhập điểm tiếp theo + Enter number of columns Nhập số cột + Enter number of data rows Nhập số hàng dữ liệu + Enter number of items Nhập số mục + Enter number of items along path Nhập số mục dọc theo đường dẫn + Enter number of sides Nhập số cạnh + Enter number of turns Nhập số vòng + Enter percentage length Nhập chiều dài theo phần trăm + Enter rectangle length Nhập chiều dài hình chữ nhật + Enter rectangle width Nhập chiều rộng hình chữ nhật + Enter scale factor Nhập hệ số tỉ lệ + Enter text Nhập chữ + Enter text (use \P for new paragraphs) Nhập chữ (dùng \P để xuống đoạn mới) + Enter the number of segments Nhập số đoạn + Enter twist direction of helix Nhập chiều xoắn của đường xoắn ốc + Entire drawing Toàn bộ bản vẽ + Equal Bằng nhau + Erase Xóa + Evaluate Tính giá trị + Exclude from new selection set Loại khỏi tập chọn mới + Explode Phân rã + Export Xuất + Export Table Xuất bảng + Export to PDF... Xuất ra PDF... + Expression Biểu thức + Extend Kéo dài + Extension Phần kéo dài + Extents Phạm vi + External Reference Tham chiếu ngoài + External References Tham chiếu ngoài + Extrude Đùn + File Tệp + File Tab Thẻ tệp + Fillet Bo góc + Filter Bộ lọc + Filters Bộ lọc + Find and Replace Tìm và thay thế + Find... Tìm... + First Đầu tiên + First commit Commit đầu tiên + Fit Points Điểm khớp + Fix Cố định + Flatten Objects Làm phẳng đối tượng + Format Định dạng + Freehand Tự do + Freeze Đóng băng + Freeze Layer Đóng băng layer + Freeze in new viewports Đóng băng trong khung nhìn mới + Freeze/Thaw Đóng băng/Tan băng + Freeze/Thaw in all viewports Đóng băng/Tan băng trong tất cả khung nhìn + Freeze/Thaw in the current viewport Đóng băng/Tan băng trong khung nhìn hiện hành + From Từ + From Template... Từ mẫu... + General Chung + Geometric Center Tâm hình học + Geometric Constraint Ràng buộc hình học + Geometric Constraints Ràng buộc hình học + Geometry Hình học + Get from the Drawing Lấy từ bản vẽ + Get Variable Lấy biến + Get View Lấy góc nhìn + Global Toàn cục + Global width Bề rộng toàn cục + Grab bag Tổng hợp + Gradient Gradient + Gradient... Gradient... + Green Xanh lục + Grid (F7) Lưới (F7) + Grid Display Hiển thị lưới + Grid On (F7) Bật lưới (F7) + Grid snap Snap lưới + Grid spacing Khoảng cách lưới + Grip Edit Chỉnh sửa grip + Grip Mirror Grip đối xứng + Grip Option Tùy chọn grip + Grip Rotate Grip xoay + Grip Scale Grip tỉ lệ + Grip Stretch Grip kéo giãn + Group Nhóm + Group Edit Chỉnh sửa nhóm + Group Options Tùy chọn nhóm + Group Selection Chọn nhóm + Hatch Hatch + Hatch Edit Chỉnh sửa hatch + Hatch Patterns Mẫu hatch + Hatch... Hatch... + Height Chiều cao + Height: {height} Chiều cao: {height} + Helix Đường xoắn ốc + Help Trợ giúp + Hide All Ẩn tất cả + Hide All Constraint Bars Ẩn tất cả thanh ràng buộc + Hide All Dynamic Dimensions Ẩn tất cả kích thước động + Hide Layer List Ẩn danh sách layer + Hide Objects Ẩn đối tượng + Hide Palettes Ẩn các bảng + History Lịch sử + Home Trang chủ + Horizontal Ngang + HorizontalDistance Khoảng cách ngang + How to apply: Cách áp dụng: + ID Point Điểm ID + Import Nhập + In Phóng to + In-window Menu Bar Thanh menu trong cửa sổ + Include in new selection set Đưa vào tập chọn mới + Increment angle Góc tăng dần + Index Color Màu chỉ số + Index color: {index} Màu chỉ số: {index} + Infer Constraints Suy luận ràng buộc + Inquiry Truy vấn + Insert Chèn + Insert Block Chèn block + Insert Column Chèn cột + Insert Row Chèn hàng + Insert tab Thẻ chèn + Insert this block Chèn block này + Insert Table Formula Chèn công thức bảng + Insertion Điểm chèn + Insertion X Điểm chèn X + Insertion Y Điểm chèn Y + Insertion Z Điểm chèn Z + Inspect Constraints Kiểm tra ràng buộc + Inspect Drawing Kiểm tra bản vẽ + Inspection Kiểm tra + Interface Language Ngôn ngữ giao diện + Interface Theme Chủ đề giao diện + Intersect Giao + Intersection Giao điểm + Isolate Cô lập + Isolate Layer Cô lập layer + Isolate Objects Cô lập đối tượng + Isometric Drafting Vẽ đẳng trắc + Jogged Gãy khúc + Jogged Linear Gãy khúc tuyến tính + Join Nối + Join the ArtCraft Discord… Tham gia Discord ArtCraft… + Join us on Discord Tham gia cùng chúng tôi trên Discord + Isometric Plane Mặt phẳng đẳng trắc + Isometric snap Snap đẳng trắc + Language Ngôn ngữ + Last Cuối cùng + Last commit Commit gần nhất + Layer Layer + Layer Delete Xóa layer + Layer Match Khớp layer + Layer Merge Gộp layer + Layer Off Tắt layer + Layer Properties Thuộc tính layer + Layer Properties Manager Quản lý layer + Layer Properties Manager — current viewport Quản lý layer — khung nhìn hiện hành + Layer State Trạng thái layer + Layer States Manager... Trình quản lý trạng thái layer... + Layer Tools Công cụ layer + Layer contains objects (double-click to make current) Layer có chứa đối tượng (nhấp đúp để đặt làm hiện hành) + Layer is empty (double-click to make current) Layer trống (nhấp đúp để đặt làm hiện hành) + Layers Layer + Layers Palette Bảng layer + Hide Palette Ẩn bảng + Layers displayed: {shown} of {total}. Current layer: {layer} Đang hiển thị {shown} trên {total} layer. Layer hiện hành: {layer} + Layout Layout + Layout list Danh sách layout + Leader Đường dẫn ghi chú + Leaders Only Chỉ đường dẫn ghi chú + Left Trái + Length Chiều dài + Length X Chiều dài X + Length Y Chiều dài Y + Lengthen Đổi chiều dài + Light Sáng + Line Đoạn thẳng + Line delta Chênh lệch dòng + Linear Tuyến tính + Lines +/− Dòng +/− + Lines added Dòng đã thêm + Lines deleted Dòng đã xóa + Lines of Command History Số dòng lịch sử lệnh + Linetype Loại nét + Linetype Scale Tỉ lệ loại nét + Linetype scale Tỉ lệ loại nét + Ltscale Ltscale + Linetype... Loại nét... + Lineweight Bề dày nét + Lineweight... Bề dày nét... + List Liệt kê + List Blocks Liệt kê block + List Dimension Styles Liệt kê kiểu kích thước + List Groups Liệt kê nhóm + List Layer States Liệt kê trạng thái layer + List Layouts Liệt kê layout + List System Variables Liệt kê biến hệ thống + List Text Styles Liệt kê kiểu chữ + Load all Tải tất cả + Load... Tải... + Loaded linetypes Loại nét đã tải + Lock Khóa + Lock Layer Khóa layer + Lock viewport Khóa khung nhìn + Lock/Unlock Khóa/Mở khóa + Loft Loft + MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Không liên kết với Autodesk, Inc. + MODEL MODEL + MText Chữ nhiều dòng + Magenta Magenta + Major line every Đường chính mỗi + Make Current Đặt làm hiện hành + Make Object's Layer Current Đặt layer của đối tượng làm hiện hành + Make... Tạo... + Match Layer Khớp layer + Match Properties Sao chép thuộc tính + Measure Đo + Measure Distance Đo khoảng cách + Measure Geometry Đo hình học + Merge Cells Gộp ô + Merged PRs PR đã gộp + Mid Between 2 Points Điểm giữa 2 điểm + Midpoint Điểm giữa + Mirror Lấy đối xứng + Model Model + Model Space (in viewport) Không gian mô hình (trong khung nhìn) + Modeling Mô hình hóa + Models Model + Modify Hiệu chỉnh + Move Di chuyển + Multileader Multileader + Multileader Style... Kiểu multileader... + Multileader style Kiểu multileader + Multiline Multiline + Multiline Text Chữ nhiều dòng + Multiline Text... Chữ nhiều dòng... + Multiple Nhiều + Multiple Point Nhiều điểm + My Của tôi + My properties: not available yet Thuộc tính của tôi: chưa khả dụng + N N + Name Tên + Name (A–Z) Tên (A–Z) + Named Viewports... Khung nhìn đã đặt tên... + Named Views... Góc nhìn đã đặt tên... + Nearest Điểm gần nhất + New Mới + New (metric) Mới (hệ mét) + New Drawing Bản vẽ mới + New Drawing (⌘N) Bản vẽ mới (⌘N) + New Drawing... Bản vẽ mới... + New Layer Layer mới + New Layout Layout mới + New VP Freeze Đóng băng VP mới + New Viewports... Khung nhìn mới... + New from Template... Tạo từ bản mẫu... + New layout Layout mới + New state name Tên trạng thái mới + Next Change Thay đổi tiếp theo + No Không + No blocks defined in this drawing. Chưa định nghĩa block nào trong bản vẽ này. + No contributor data was built into this copy. Bản này không chứa dữ liệu người đóng góp. + No model credits were built into this copy. Bản này không chứa thông tin ghi nhận mô hình. + No saved layer states Chưa lưu trạng thái layer nào + No selection Chưa có lựa chọn + Node Điểm nút + None Không có + Normal Bình thường + Not available Không khả dụng + Not available yet Chưa khả dụng + Numbered list Danh sách đánh số + OK OK + Object Đối tượng + Object Snap Bắt điểm + Object Snap (F3) Bắt điểm (F3) + Object Snap On (F3) Bật bắt điểm (F3) + Object Snap Tracking Dò theo điểm bắt + Object Snap Tracking (F11) Dò theo điểm bắt (F11) + Object Snap Tracking Settings Cài đặt dò theo điểm bắt + Object type: Loại đối tượng: + Oblique Nghiêng + Off Tắt + Offset Offset + On Bật + On/Off Bật/Tắt + One of the Crafting Apps by the ArtCraft team and community. Một trong các ứng dụng Crafting của nhóm và cộng đồng ArtCraft. + Only in this drawing: {n} Chỉ có trong bản vẽ này: {n} + Only in {name}: {n} Chỉ có trong {name}: {n} + Oops Khôi phục đối tượng đã xóa + Open (⌘O) Mở (⌘O) + Open Sample Drawing Mở bản vẽ mẫu + Open sample drawing Mở bản vẽ mẫu + Open Xref Mở xref + Open... Mở... + Operator: Toán tử: + Orbit Xoay quanh + Ordinate Kích thước tọa độ + Ortho (F8) Ortho (F8) + Ortho Mode Chế độ Ortho + Ortho Mode (F8) Chế độ Ortho (F8) + Osnap Settings... Cài đặt bắt điểm... + Out Thu nhỏ + Over-constrained Thừa ràng buộc + Override Ghi đè + PAPER PAPER + Percent Phần trăm + Polar (distance < angle) Cực (khoảng cách < góc) + PRs PR + Page Setup Manager Trình quản lý thiết lập trang + Page Setup Manager... Trình quản lý thiết lập trang... + Pan Pan + Pan Down Pan xuống + Pan Left Pan sang trái + Pan Right Pan sang phải + Pan Up Pan lên + Paper Space Không gian giấy + Parallel Song song + Parameters Manager Trình quản lý tham số + Parametric Tham số + Paste Dán + Paste as Block Dán thành block + Paste to Command Line Dán vào dòng lệnh + Paste to Original Coordinates Dán theo tọa độ gốc + Path Array Mảng theo đường dẫn + Pattern Mẫu + Perpendicular Vuông góc + Plot In + Plot (click: do not plot) In (nhấp để không in) + Plot Preview Xem trước bản in + Plot Window Cửa sổ in + Plot style Kiểu in + Plot style attached to Kiểu in được gắn vào + Plot style table Bảng nét in + Plot Style... Kiểu in... + Plot Style Manager Trình quản lý kiểu in + Plot Style Manager... Trình quản lý kiểu in... + Plot Styles... Kiểu in... + Plotter Manager Trình quản lý máy in + Plotter Manager... Trình quản lý máy in... + New CTB CTB mới + New STB STB mới + New style Kiểu mới + Reset Panel Layout Đặt lại bố cục bảng điều khiển + Send Hatches to Back Đưa hatch xuống dưới cùng + Send Under Objects Đưa xuống dưới đối tượng + Text Objects Only Chỉ đối tượng chữ + Use for this layout Dùng cho layout này + Display plot styles Hiển thị kiểu in + No plot style tables. Không có bảng nét in nào. + A built-in table: editing it keeps an edited copy in the drawing. Bảng dựng sẵn: khi chỉnh sửa, một bản sao đã sửa sẽ được giữ trong bản vẽ. + Keep an editable copy of this table in the drawing Giữ một bản sao có thể chỉnh sửa của bảng này trong bản vẽ + Layout {name}: {table} Layout {name}: {table} + Grayscale Thang xám + Screening Tỉ lệ phủ + Use object linetype Dùng loại nét của đối tượng + Use object lineweight Dùng bề dày nét của đối tượng + Remove Gỡ bỏ + Add Thêm + Open a drawing first. Hãy mở một bản vẽ trước. + Plot table type Loại bảng nét in + Color-dependent Phụ thuộc màu + Named Theo tên + Plus/minus Cộng/trừ + Point Điểm + Point Filters Bộ lọc điểm + Point Style... Kiểu điểm... + Polar Array Mảng tròn + Polar Tracking Dò theo góc + Polar Tracking (F10) Dò theo góc (F10) + Polar Tracking On (F10) Bật dò theo góc (F10) + Polar angle measurement Đo góc cực + Polar distance Khoảng cách cực + Polar: {distance} < {angle}° Cực: {distance} < {angle}° + PolarSnap PolarSnap + Polygon Đa giác + Polygonal Đa giác + Polygonal Viewport Khung nhìn đa giác + Polyline Polyline + Polyline Edit Chỉnh sửa polyline + Position X Vị trí X + Position Y Vị trí Y + Position Z Vị trí Z + Previous Trước + Previous Change Thay đổi trước + Previous Layer Layer trước + Primitives Khối cơ bản + Print In ra máy in + Print... In ra máy in... + Properties Thuộc tính + Properties Inspector Trình kiểm tra thuộc tính + Properties: Thuộc tính: + Proxy Proxy + Purge Dọn dẹp + Pyramid Hình chóp + Quadrant Góc phần tư + Query Entities Truy vấn đối tượng + Quick Nhanh + Quick Dimension Kích thước nhanh + Quick Properties Thuộc tính nhanh + Quick Select Chọn nhanh + Quick Select Properties Thuộc tính chọn nhanh + Quick Select... Chọn nhanh... + QuickCalc QuickCalc + QuickCalc Pick Chọn QuickCalc + Quit CADCraft Thoát CADCraft + Radius Bán kính + Raster Image Ảnh raster + Ray Tia + Read-only Chỉ đọc + Real name Tên thực + Realtime Thời gian thực + Reassociate Dimensions Liên kết lại kích thước + Recent Gần đây + Recent Commands Lệnh gần đây + Recent Input Đầu vào gần đây + Recover... Khôi phục... + Rectangle Hình chữ nhật + Recalculate Table Formulas Tính lại công thức bảng + Rectangular Hình chữ nhật + Rectangular snap Snap hình chữ nhật + Rectangular Array Mảng chữ nhật + Red Đỏ + Redo Làm lại + Redo (⇧⌘Z) Làm lại (⇧⌘Z) + Redraw Vẽ lại + Regen Tái tạo hình + Regen All Tái tạo hình toàn bộ + Region Miền + Region/Mass Properties Thuộc tính miền/khối + Reinitialize Khởi tạo lại + Relative Tương đối + Relative to last segment Tương đối với đoạn cuối + Remove Leader Xóa đường dẫn ghi chú + Remove the viewport override Xóa ghi đè khung nhìn + Rename Dimension Style Đổi tên kiểu kích thước + Rename Layer Đổi tên layer + Rename Layer State Đổi tên trạng thái layer + Rename Layout Đổi tên layout + Rename Text Style Đổi tên kiểu chữ + Rename... Đổi tên... + Repeat {command} Lặp lại {command} + Replace Block... Thay thế block... + Requires a point (x,y, @dx,dy or @d Chỉ định điểm thứ hai hoặc + Specify second source point Chỉ định điểm nguồn thứ hai + Specify side to offset Chỉ định phía offset + Specify start angle Chỉ định góc bắt đầu + Specify start point Chỉ định điểm bắt đầu + Specify start point of arc Chỉ định điểm bắt đầu của cung + Specify start point of polyline Chỉ định điểm bắt đầu của polyline + Specify start point of text Chỉ định điểm bắt đầu của chữ + Specify starting width Chỉ định bề rộng bắt đầu + Specify starting width for next segment Chỉ định bề rộng bắt đầu cho đoạn tiếp theo + Specify tangent direction for the start point of arc Chỉ định hướng tiếp tuyến tại điểm bắt đầu của cung + Specify the elevation for rectangles Chỉ định cao độ cho hình chữ nhật + Specify thickness for rectangles Chỉ định độ dày cho hình chữ nhật + Specify third point on circle Chỉ định điểm thứ ba trên đường tròn + Specify third point on circle: _tan to Chỉ định điểm thứ ba trên đường tròn: _tan to + Specify third source point or Chỉ định điểm nguồn thứ ba hoặc + Specify through point Chỉ định điểm đi qua + Specify top diameter Chỉ định đường kính trên + Specify top radius Chỉ định bán kính trên + Specify total angle Chỉ định tổng góc + Specify total length Chỉ định tổng chiều dài + Specify width for rectangles Chỉ định bề rộng cho hình chữ nhật + Sphere Hình cầu + Spline Spline + Spline Edit Chỉnh sửa spline + Stack (type 1/2 then select) Xếp chồng (nhập 1/2 rồi chọn) + Standard Standard + Start Bắt đầu + Start Width Bề rộng bắt đầu + Start X X bắt đầu + Start Y Y bắt đầu + Start angle Góc đầu + Start, Center, Angle Điểm đầu, tâm, góc + Start, Center, End Điểm đầu, tâm, điểm cuối + Start, Center, Length Điểm đầu, tâm, chiều dài + Start, End, Angle Điểm đầu, điểm cuối, góc + Start, End, Direction Điểm đầu, điểm cuối, hướng + Start, End, Radius Điểm đầu, điểm cuối, bán kính + Status Trạng thái + Status Bar Thanh trạng thái + Stretch Kéo giãn + Style: Standard Kiểu: Standard + Subtract Trừ + Sweep Quét + Switch Drawing Chuyển bản vẽ + Switch Layout Chuyển layout + Switch between model space in a viewport and paper space Chuyển giữa không gian mô hình trong khung nhìn và không gian giấy + Symmetric Đối xứng + Synchronize Attributes Đồng bộ attribute + To Đến + Toggle PICKADD (off: each pick replaces the selection) Bật/tắt PICKADD (tắt: mỗi lần chọn sẽ thay thế tập chọn) + Toggle PICKADD (on: picks add to the selection) Bật/tắt PICKADD (bật: mỗi lần chọn sẽ thêm vào tập chọn) + TOP TOP + Table Bảng + Table Breaks Ngắt bảng + Table Cell Style Kiểu ô bảng + Table Style... Kiểu bảng... + Table style Kiểu bảng + Table... Bảng... + Tan, Tan, Radius Tiếp tuyến, tiếp tuyến, bán kính + Tan, Tan, Tan Tiếp tuyến, tiếp tuyến, tiếp tuyến + Tangent Tiếp tuyến + Temporary track point Điểm dò tạm thời + Text Chữ + Text Editor Trình soạn thảo chữ + Text Height Chiều cao chữ + Text Style... Kiểu chữ... + Text height Chiều cao chữ + Text override Chữ đè + Text style Kiểu chữ + Thaw All Layers Tan băng tất cả layer + Thaw Layer Tan băng layer + There is nothing here yet. Chưa có gì ở đây. + Thickness Độ dày + Time Thời gian + To get started, create or open a drawing. Để bắt đầu, hãy tạo hoặc mở một bản vẽ. + Tool Bar Thanh công cụ + Tool Sets Bộ công cụ + Tools Công cụ + Torus Xuyến + Trace Nét rộng + Track orthogonally only Chỉ dò theo phương ngang và dọc + Track using all polar angle settings Dò theo mọi góc cực đã đặt + Transparency Độ trong suốt + Trim Cắt xén + True Color Màu thực + Turn All Layers On Bật tất cả layer + Turn Off Layer Tắt layer + Turn On Layer Bật layer + Turn Quick Properties off (QPMODE) Tắt thuộc tính nhanh (QPMODE) + Type Loại + Type a command Nhập lệnh + Type text; Enter starts a new paragraph Nhập chữ; Enter để bắt đầu đoạn mới + UCS Icon Biểu tượng UCS + Undo Hoàn tác + Undo (⌘Z) Hoàn tác (⌘Z) + Ungroup Hủy nhóm + Union Hợp + Unisolate Layer Hủy cô lập layer + Units Conversion Chuyển đổi đơn vị + Units... Đơn vị... + Unlock Layer Mở khóa layer + Unlock viewport Mở khóa khung nhìn + Unmerge Cells Tách ô + Unsaved Layer State Trạng thái layer chưa lưu + Up Lên + Update Data Links Cập nhật liên kết dữ liệu + Update Cập nhật + Use System Setting Dùng cài đặt hệ thống + User Người dùng + User Parameters Tham số người dùng + Username Tên người dùng + Volume Thể tích + VP Color Màu VP + VP Freeze Đóng băng VP + Value Giá trị + Value: Giá trị: + Variables Biến + Version Phiên bản + Version 2000 Phiên bản 2000 + Version 2004 Phiên bản 2004 + Version 2007 Phiên bản 2007 + Version 2010 Phiên bản 2010 + Version 2013 Phiên bản 2013 + Version 2018 Phiên bản 2018 + Version {version} Phiên bản {version} + Vertical Dọc + VerticalDistance Khoảng cách dọc + View Góc nhìn + ViewCube ViewCube + Viewport Khung nhìn + Viewport Layer Freeze Đóng băng layer khung nhìn + Viewport Properties Thuộc tính khung nhìn + Viewport is locked Khung nhìn đã bị khóa + Viewport scale Tỉ lệ khung nhìn + Viewport {id} Khung nhìn {id} + Viewports Khung nhìn + W W + WCS WCS + White Trắng + Width factor Hệ số rộng + Window Cửa sổ + Wipeout Che phủ + Workspace Switching Chuyển đổi không gian làm việc + Write Block Ghi block + Xline Đường thẳng vô hạn + Yellow Vàng + Yes Có + Zoom Thu phóng + Zoom All Thu phóng tất cả + Zoom Dynamic Thu phóng động + Zoom Extents Thu phóng toàn bộ + Zoom In Phóng to + Zoom Object Thu phóng đối tượng + Zoom Out Thu nhỏ + Zoom Previous Thu phóng trước đó + Zoom Realtime Thu phóng thời gian thực + Zoom Window Thu phóng cửa sổ + error lỗi + {contributors} · {commits} {contributors} · {commits} + {kind} (constraint {id}) {kind} (ràng buộc {id}) + {kind} constraint {name} = {expression} (double-click to edit) {kind} ràng buộc {name} = {expression} (nhấp đúp để sửa) + {name}: {distance} < {angle}° {name}: {distance} < {angle}° + ƒx New User Parameter ƒx Tham số người dùng mới + ΔLOC ΔLOC + −Bin −Bin + −LOC −LOC + ⌘↩ to finish ⌘↩ để kết thúc +@plural {n} commit {n} commit +@plural {n} contributor {n} người đóng góp + Drawing as bytes Bản vẽ dưới dạng byte + Services Dịch vụ + Hide CADCraft Ẩn CADCraft + Hide Others Ẩn các ứng dụng khác + Accessibility Trợ năng + Show System Cursor Hiển thị con trỏ hệ thống + Animation Effects Hiệu ứng hoạt hình + Animation effects Hiệu ứng hoạt hình + Import... Nhập... + Export... Xuất... + Batch Publish... Xuất bản hàng loạt... + Scale List... Danh sách tỉ lệ... + Annotative Object Scale Tỉ lệ đối tượng chú thích + Add Current Scale Thêm tỉ lệ hiện hành + Delete Current Scale Xóa tỉ lệ hiện hành + Add/Delete Scales... Thêm/Xóa tỉ lệ... + Synchronize Multiple-scale Positions Đồng bộ vị trí đa tỉ lệ + Annotative Update Cập nhật chú thích + Show Annotative Objects Hiển thị đối tượng chú thích + Current Scale Only Chỉ tỉ lệ hiện hành + All Scales Tất cả tỉ lệ + Drawing Recovery Manager... Trình quản lý khôi phục bản vẽ... + Tolerance... Dung sai... + Add Vertex Thêm đỉnh + Remove Vertex Xóa đỉnh + Convert to Arc Chuyển thành cung tròn + Convert to Line Chuyển thành đoạn thẳng + Stretch Vertex Kéo giãn đỉnh + Edit Array Sửa mảng + Close Array Editing Đóng chỉnh sửa mảng + Multiline... Multiline... + Multiline Style... Kiểu multiline... + Solid Editing Chỉnh sửa solid + X X + Y Y + B B + CADCraft CADCraft + More toolbar commands Thêm lệnh thanh công cụ +menu View Xem +menu Draw Vẽ +menu Dimension Kích thước +menu Modify Hiệu chỉnh + Hatch Pattern Mẫu hatch + Search for pattern Tìm mẫu + Not a number or distance: {text} Không phải số hoặc khoảng cách: {text} + Query Texts Truy vấn chữ + Analyze Shapes Phân tích hình dạng + Check for Updates… Kiểm tra cập nhật… + CADCraft is up to date. CADCraft đã là phiên bản mới nhất. + Could not check for updates. Không thể kiểm tra cập nhật. + 3D Mouse Chuột 3D + 3D Mouse Navigation Điều hướng chuột 3D + 3D Mouse... Chuột 3D... + 3D mouse busy, possibly 3DxWare or missing device permissions Chuột 3D đang bận, có thể do 3DxWare hoặc thiếu quyền thiết bị + 3D mouse connected Đã kết nối chuột 3D + 3D mouse disconnected Đã ngắt kết nối chuột 3D + 3D mouse input could not be started Không thể khởi động đầu vào chuột 3D + 3D mouse input is unavailable on this platform Đầu vào chuột 3D không khả dụng trên nền tảng này + Automatic save every Tự động lưu mỗi + Automatic save file location Vị trí tệp tự động lưu + Calibrate Hiệu chỉnh + Calibrate 3D Mouse Hiệu chỉnh chuột 3D + Collapse Palettes Thu gọn bảng + Default save version Phiên bản lưu mặc định + Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Bản vẽ chưa có tệp sẽ được lưu theo phiên bản mặc định. 0 phút sẽ tắt tự động lưu. + Enable 3D mouse navigation Bật điều hướng chuột 3D + Left device button: zoom extents. Right device button: calibrate. Nút trái của thiết bị: thu phóng toàn bộ. Nút phải của thiết bị: hiệu chỉnh. + Move sideways to pan; push/pull or twist to zoom. Di chuyển ngang để pan; đẩy/kéo hoặc xoay để thu phóng. + No supported 3D mouse found Không tìm thấy chuột 3D được hỗ trợ + Pan sensitivity Độ nhạy pan + Release the 3D mouse to calibrate its resting position Thả chuột 3D để hiệu chỉnh vị trí nghỉ của nó + Reverse zoom direction Đảo chiều thu phóng + Run Script... Chạy script... + SVG drawings (SVG, SVGZ) Bản vẽ SVG (SVG, SVGZ) + Show and hide the tools of this category Hiện và ẩn các công cụ trong danh mục này + Show in Tool Sets Hiển thị trong bộ công cụ + Time-sensitive right-click: a quick click is Enter, holding opens the menu Nhấp phải theo thời gian: nhấp nhanh là Enter, giữ lâu để mở menu + Turned off, a right-click while a command runs always opens the shortcut menu. Khi tắt, nhấp phải trong lúc lệnh đang chạy luôn mở menu ngữ cảnh. + Zoom sensitivity Độ nhạy thu phóng + min phút + Align Multileaders Căn chỉnh multileader + Collect Gom + Collect Multileaders Gom multileader + Edit Multileader Sửa multileader + Italic Nghiêng + Underline Gạch dưới + Overline Gạch trên + Justify Căn lề + Text color Màu chữ + Click the ruler to add a tab stop, right-click to remove one Nhấp vào thước để thêm điểm dừng tab, nhấp phải để xóa + Add: Pick points Thêm: chọn điểm + Add: Select objects Thêm: chọn đối tượng + Associative Liên kết + Block to create or edit Block để tạo hoặc sửa + Bottom left Dưới trái + Bottom right Dưới phải + Boundaries Đường bao + Boundaries picked: {n} Đã chọn đường bao: {n} + Centered Căn giữa + Clear Recent Files Xóa tệp gần đây + Click to set new origin Nhấp để đặt gốc mới + Close Block Editor Đóng trình sửa block + Color 1 Màu 1 + Color 2 Màu 2 + contains authoring elements. Open in Block Editor? chứa các phần tử tạo lập. Mở trong trình sửa block? + Create separate hatches Tạo hatch riêng biệt + Crossing selection color: Màu vùng chọn cắt: + Curved Cong + Custom Tùy chỉnh + Default to boundary extents Mặc định theo phạm vi đường bao + Drag Dynamic Grip Kéo grip động + Dynamic Block Data Dữ liệu block động + Dynamic Block Grips Grip block động + Dynamic Block Reference Tham chiếu block động + Edit Block Definition Sửa định nghĩa block + Evaluate LISP Tính giá trị LISP + Exclude: Loại trừ: + Grips Grip + Groups Nhóm + Hatch and Gradient Hatch và gradient + Hatch origin Gốc hatch + Hatch Pick Chọn hatch + Hatches Hatch + Hemispherical Bán cầu + Ignore Bỏ qua + Indicate selection area Chỉ định vùng chọn + Inverted Đảo ngược + Island detection Phát hiện đảo + Load Application... Tải ứng dụng... + Multiline text Chữ nhiều dòng + New block Block mới + Object grouping Gom nhóm đối tượng + Object selection limit for display of grips Giới hạn chọn đối tượng để hiển thị grip + Objects on locked layers Đối tượng trên layer bị khóa + Open in Block Editor Mở trong trình sửa block + Open Recent Mở gần đây + Origin: {x}, {y} Gốc: {x}, {y} + Outer Ngoài + Preview Xem trước + Save Block Lưu block + Save Block As Lưu block thành + Selection Area Effect Hiệu ứng vùng chọn + Selection area opacity: Độ mờ vùng chọn: + Selection effect color Màu hiệu ứng chọn + Selection modes Chế độ chọn + Selection preview Xem trước vùng chọn + Selection Preview Filter Bộ lọc xem trước vùng chọn + Set Dynamic Property Đặt thuộc tính động + Shape Hình dạng + Sheet Books to PDF... Xuất sổ tờ vẽ sang PDF... + Show grips Hiển thị grip + Specified origin Gốc đã chỉ định + Spherical Hình cầu + Tables Bảng + This drawing Bản vẽ này + Top left Trên trái + Top right Trên phải + Unsaved changes Thay đổi chưa lưu + Use current origin Dùng gốc hiện hành + Visual Effect Settings Cài đặt hiệu ứng hình ảnh + Visual Effect Settings... Cài đặt hiệu ứng hình ảnh... + When a command is active Khi có lệnh đang hoạt động + When no command is active Khi không có lệnh nào đang hoạt động + Window selection color: Màu vùng chọn cửa sổ: + Xrefs Xref + Arrow size Kích thước mũi tên + Dim scale overall Tỉ lệ kích thước tổng thể + Precision Độ chính xác + Break-line Symbol Ký hiệu ngắt đường + Convert Text to Mtext Chuyển chữ một dòng thành chữ nhiều dòng + Explode Attributes to Text Phân rã attribute thành chữ + Express Tools Express Tools + Move/Copy/Rotate Di chuyển/Sao chép/Xoay + Type an alias name. Nhập tên bí danh. + Choose the command the alias starts. Chọn lệnh mà bí danh sẽ chạy. + {command} is an unknown command. {command} là lệnh không xác định. + {alias} is already defined; select it and use Edit. {alias} đã được định nghĩa; chọn nó và dùng nút Sửa. + More than {n} aliases. Hơn {n} bí danh. + Alias file: {file} Tệp bí danh: {file} + No alias file loaded. REINIT replaces these aliases with a file's. Chưa tải tệp bí danh nào. REINIT sẽ thay các bí danh này bằng bí danh trong tệp. + Alias Bí danh + Command Lệnh + overrides built-in ghi đè lệnh tích hợp + built-in tích hợp + Add… Thêm… + Edit… Sửa… + Save alias Lưu bí danh + Discard Bỏ + Customize Tùy chỉnh + Edit Command Aliases (PGP)... Sửa bí danh lệnh (PGP)... + Interface scale Tỉ lệ giao diện + Select boundaries to remove Chọn đường bao cần xóa + Island Đảo + Select using two clicks only Chỉ chọn bằng hai lần nhấp + Define each corner of the selection window with a separate click. Xác định từng góc của cửa sổ chọn bằng một lần nhấp riêng. + Apply to Layout Áp dụng cho layout + Center the plot Căn giữa bản in + Enter block names to extract Nhập tên block cần trích xuất + Enter geometric characteristic Nhập đặc tính hình học + Enter name of LISP file to load Nhập tên tệp LISP cần tải + First corner Góc thứ nhất + Fit to paper Vừa khổ giấy + Landscape Ngang + Limits Giới hạn + Opposite corner Góc đối diện + Orientation Hướng + Paper size Khổ giấy + Plot area Vùng in + Plot object lineweights In bề dày nét đối tượng + Plot offset Độ lệch khi in + Plot transparency In độ trong suốt + Portrait Dọc + Printer/plotter Máy in/máy vẽ + Window < Cửa sổ < + X offset (mm) Độ lệch X (mm) + Y offset (mm) Độ lệch Y (mm) + AutoLISP (LSP) AutoLISP (LSP) + Units of new drawings Đơn vị của bản vẽ mới + Imperial (inches) Hệ Anh (inch) + Metric (millimetres) Hệ mét (milimét) + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Bản vẽ mới và bản vẽ đầu tiên khi khởi động dùng các đơn vị này, trừ khi đã đặt mẫu mặc định (QNEWTEMPLATE). + Analyze Usage Phân tích sử dụng + Implied Loops Vòng kín ngầm định + Angstroms Angstrom + Architectural Kiến trúc + Astronomical Units Đơn vị thiên văn + Centimeters Centimét + Decameters Decamét + Decimal Thập phân + Decimal Degrees Độ thập phân + Decimeters Decimét + Deg/Min/Sec Độ/Phút/Giây + Don't Save Không lưu + Drawing Units Đơn vị bản vẽ + Engineering Kỹ thuật + Feet Feet + Fractional Phân số + Gigameters Gigamét + Grads Grad + Hectometers Hectomét + Inches Inch + Insertion scale Tỉ lệ chèn + Kilometers Kilomét + Light Years Năm ánh sáng + Meters Mét + Microinches Microinch + Microns Micron + Miles Dặm + Millimeters Milimét + Mils Mil + Nanometers Nanomét + Parsecs Parsec + Radians Radian + Sample output Đầu ra mẫu + Save changes to {name}? Lưu thay đổi cho {name}? + Scientific Khoa học + Surveyor's Units Đơn vị trắc địa + Unitless Không đơn vị + Units to scale inserted content Đơn vị để co giãn nội dung được chèn + US Survey Feet Feet khảo sát Mỹ + US Survey Inch Inch khảo sát Mỹ + US Survey Mile Dặm khảo sát Mỹ + US Survey Yard Yard khảo sát Mỹ + Yards Yard + Your changes will be lost if you don't save them. Các thay đổi của bạn sẽ bị mất nếu bạn không lưu chúng. + Save changes to block "{name}"? Lưu thay đổi cho block "{name}"? + Layouts Layout + Associate Liên kết + Backup copy Bản sao lưu + Create backup copy with each save Tạo bản sao lưu mỗi khi lưu + Change Properties Đổi thuộc tính + Font Phông chữ + Save Layout as Template Lưu layout thành mẫu + Find All Find All + Find what: Find what: + Match case Match case + Replace Replace + Replace All Replace All + Replace with: Replace with: + Replacement text Replacement text + Results Results + Searches the current space. Searches the current space. + Text to find Text to find + Whole words Whole words + Nothing found Nothing found diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 4d8da1698..a8d979efa 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -222,7 +222,7 @@ Enter a vertex editing option 输入顶点编辑选项 Enter an option 输入选项 Enter angle of xline 输入构造线角度 - Enter angle of xline (0) or 输入构造线角度 (0) 或 + Enter angle of xline (0) 输入构造线角度 (0) Enter area of rectangle in current units 在当前单位中输入矩形面积 Enter attribute prompt 输入属性提示 Enter attribute tag name 输入属性标记名称 @@ -233,7 +233,7 @@ Enter default attribute value 输入默认属性值 Enter degree of spline 输入样条曲线次数 Enter delta angle 输入增量角度 - Enter delta length or 输入增量长度或 + Enter delta length 输入增量长度 Enter dimension text 输入标注文字 Enter dimension text (<> = measurement) 输入标注文字 (<> = 测量值) Enter dimension value or [name=expression] 输入标注值或 [名称=表达式] @@ -489,6 +489,7 @@ On 开启 On/Off 开/关 One of the Crafting Apps by the ArtCraft team and community. 由 ArtCraft 团队和社区打造的 Crafting 系列应用之一。 + Oops 恢复已擦除对象 Open (⌘O) 打开 (⌘O) Open Sample Drawing 打开示例图形 Open sample drawing 打开示例图形 @@ -668,8 +669,8 @@ Select an arc or circle 选择圆弧或圆 Select an arc. 选择一段圆弧。 Select an object 选择对象 - Select an object to change or Select an object to change or - Select an object to measure or Select an object to measure or + Select an object to change Select an object to change + Select an object to measure Select an object to measure Add to Working Set 添加到工作集 Edit Reference In-Place 就地编辑参照 Save Reference Edits 保存参照编辑 @@ -688,9 +689,8 @@ Select first line 选择第一条直线 Select first line or arc 选择第一条直线或圆弧 Select first object 选择第一个对象 - Select first object or Select first object or Select first point 指定第一点 - Select nested objects to copy or Select nested objects to copy or + Select nested objects to copy Select nested objects to copy Select object 选择对象 Select object to dimension 选择要标注的对象 Select object to divide 选择要定数等分的对象 @@ -706,7 +706,7 @@ Select one polyline (Multiple is available through the JSON form). 选择一条多段线(可通过 JSON 表单选择多个)。 Select path curve 选择路径曲线 Select point or object 选择点或对象 - Select polyline or 选择多段线或 + Select polyline 选择多段线 Select second line 选择第二条直线 Select second line or shift-select to apply corner 选择第二条直线,或按 Shift 键应用角点 Select second object 选择第二个对象 @@ -759,9 +759,8 @@ Specify axis endpoint of ellipse 指定椭圆的轴端点 Specify base diameter 指定底面直径 Specify base point 指定基点 - Specify base point or Specify base point or Specify base point or displacement (drag with the middle mouse button for realtime pan) 指定基点或位移(按住鼠标中键拖动以进行实时平移) - Specify base radius or Specify base radius or + Specify base radius Specify base radius Specify center of donut 指定圆环中心 Specify center of ellipse 指定椭圆中心 Specify center of polygon 指定多边形中心 @@ -803,7 +802,7 @@ Specify first point on circle: _tan to 指定圆上的第一点: _tan 到 Specify first source point 指定第一个源点 Specify height 指定高度 - Specify helix height or Specify helix height or + Specify helix height Specify helix height Specify included angle 指定夹角 Specify insertion base point 指定插入基点 Specify insertion point 指定插入点 @@ -822,7 +821,6 @@ Specify new width for all segments 指定所有段的新宽度 Specify next point 指定下一点 Specify offset distance 指定偏移距离 - Specify offset distance or Specify offset distance or Specify opposite corner 指定对角点 Specify other corner point 指定另一角点 Specify other endpoint of axis 指定轴的另一端点 @@ -835,7 +833,6 @@ Specify radius of circle 指定圆半径 Specify rotation angle 指定旋转角度 Specify rotation angle of text 指定文字旋转角度 - Specify rotation angle or Specify rotation angle or Specify scale factor 指定比例因子 Specify second angle endpoint 指定第二角度端点 Specify second break point 指定第二打断点 @@ -868,9 +865,9 @@ Specify third source point or 指定第三个源点或 <继续> Specify through point 指定通过点 Specify top diameter 指定顶面直径 - Specify top radius or Specify top radius or + Specify top radius Specify top radius Specify total angle 指定总角度 - Specify total length or Specify total length or + Specify total length Specify total length Specify width for rectangles 指定矩形宽度 Sphere 球体 Spline 样条曲线 @@ -1349,3 +1346,122 @@ Check for Updates… 检查更新… CADCraft is up to date. CADCraft 已是最新版本。 Could not check for updates. 无法检查更新。 +# Menu and command labels the October 2026 update missed (#1129). + Align Multileaders 对齐多重引线 + Analyze Usage 分析使用情况 + Break-line Symbol 折断线符号 + Change 更改 + Change Properties 修改特性 + Clear Recent Files 清除最近的文件 + Close Block Editor 关闭块编辑器 + Collect 合并 + Collect Multileaders 合并多重引线 + Convert Text to Mtext 将文字转换为多行文字 + Customize 自定义 + Drag Dynamic Grip 拖动动态夹点 + Dynamic Block Data 动态块数据 + Dynamic Block Grips 动态块夹点 + Dynamic Block Reference 动态块参照 + Edit Command Aliases (PGP)... 编辑命令别名 (PGP)... + Edit Multileader 编辑多重引线 + Evaluate LISP 求值 LISP + Explode Attributes to Text 将属性分解为文字 + Express Tools 扩展工具 + Groups 编组 + Hatch Pick 拾取图案填充 + Implied Loops 隐含环 + Justify 对正 + Move/Copy/Rotate 移动/复制/旋转 + Save Block 保存块 + Save Block As 块另存为 + Save Layout as Template 将布局另存为样板 + Set Dynamic Property 设置动态特性 + Units 单位 + Find All 查找全部 + Find what: 查找内容: + Match case 区分大小写 + Replace 替换 + Replace All 全部替换 + Replace with: 替换为: + Replacement text 替换文本 + Results 结果 + Searches the current space. 仅搜索当前空间。 + Text to find 要查找的文字 + Whole words 全字匹配 + Nothing found 未找到 + +# The strings the report listed as missing for zh. + Add… 添加… + Alias 别名 + Alias file: {file} 别名文件:{file} + Associative 关联 + AutoLISP (LSP) AutoLISP (LSP) + B B + Backup copy 备份副本 + Block to create or edit 要创建或编辑的块 + Choose the command the alias starts. 选择别名要启动的命令。 + Click the ruler to add a tab stop, right-click to remove one 单击标尺可添加制表位,右键单击可将其删除 + Command 命令 + Create backup copy with each save 每次保存时创建备份副本 + Crossing selection color: 交叉选择颜色: + Custom 自定义 + Define each corner of the selection window with a separate click. 分别单击以定义选择窗口的各个角点。 + Discard 放弃 + Edit Block Definition 编辑块定义 + Edit… 编辑… + Exclude: 排除: + Font 字体 + Grips 夹点 + Hatches 图案填充 + Imperial (inches) 英制(英寸) + Indicate selection area 指示选择区域 + Interface scale 界面缩放 + Island 孤岛 + Italic 斜体 + Metric (millimetres) 公制(毫米) + More than {n} aliases. 超过 {n} 个别名。 + Multiline text 多行文字 + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). 启动时的新建图形和第一个图形使用这些单位,除非设置了默认样板 (QNEWTEMPLATE)。 + New block 新建块 + No alias file loaded. REINIT replaces these aliases with a file's. 未加载别名文件。REINIT 将用文件中的别名替换这些别名。 + Object grouping 对象编组 + Object selection limit for display of grips 显示夹点时对象选择的数目上限 + Objects on locked layers 锁定图层上的对象 + Open in Block Editor 在块编辑器中打开 + Origin: {x}, {y} 原点:{x}, {y} + Overline 上划线 + Save alias 保存别名 + Save changes to block "{name}"? 保存对块“{name}”的更改吗? + Select boundaries to remove 选择要删除的边界 + Select using two clicks only 仅用两次单击选择 + Selection Area Effect 选择区域效果 + Selection Preview Filter 选择预览过滤器 + Selection area opacity: 选择区域不透明度: + Selection effect color 选择效果颜色 + Selection modes 选择模式 + Selection preview 选择预览 + Show grips 显示夹点 + Tables 表格 + Text color 文字颜色 + This drawing 此图形 + Type an alias name. 输入别名名称。 + Underline 下划线 + Units of new drawings 新图形的单位 + Unsaved changes 未保存的更改 + Visual Effect Settings 视觉效果设置 + Visual Effect Settings... 视觉效果设置... + When a command is active 命令处于活动状态时 + When no command is active 无活动命令时 + Window selection color: 窗口选择颜色: + X X + Xrefs 外部参照 + Y Y + built-in 内置 + contains authoring elements. Open in Block Editor? 包含编写元素。在块编辑器中打开吗? + overrides built-in 覆盖内置 + {alias} is already defined; select it and use Edit. {alias} 已定义;请选择它并使用编辑。 + {command} is an unknown command. {command} 是未知命令。 +menu Dimension 标注 +menu Draw 绘图 +menu Modify 修改 +menu View 视图 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91902017f..3e635ea2f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,9 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod docking; +pub mod fault; +// The Find and Replace window behind the FIND command. +pub mod finddlg; pub mod hatchdlg; mod mtext_editor; mod panel_state; @@ -56,6 +59,7 @@ pub mod palettes; pub mod parametric; pub mod plotstyles; pub mod quick; +pub mod save_loss; pub mod shortcut_menus; pub mod theme; #[cfg(not(target_arch = "wasm32"))] @@ -238,6 +242,8 @@ pub struct CadApp { pub canvas: canvas::CanvasState, /// Desktop HID status and navigation preferences; the web host supplies no HID backend. pub spacemouse: spacemouse::State, + /// Calculator data shared by its floating and docked views; durable fields live in prefs. + pub(crate) quickcalc: palettes::quickcalc::State, pub cmd: cmdline::CmdLine, pub status: Option<(String, f64)>, /// Drawings being read on worker threads ([`opening`]). @@ -290,6 +296,10 @@ pub struct CadApp { pub quit_confirmed: bool, /// A close request waiting for "Save changes?" answers ([`closing`]). pub closing: Option, + /// A save waiting for an answer about the content the file can't keep ([`save_loss`]). + pub save_loss: Option, + /// The loss the user last agreed to, so saving again doesn't ask again ([`save_loss`]). + pub save_loss_accepted: Option, /// Autosave (desktop; `None` until [`Self::enable_autosave`]). pub autosave: Option, /// Drawing Recovery: autosave files a crashed app left behind. @@ -298,6 +308,10 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// Panics caught around the frame ([`fault`]). + pub fault: fault::UiFault, + /// The notice shown while autosave fails (see [`autosave::failure_notice`]). + pub autosave_notice: Option, /// The Command Aliases dialog while open ([`aliasdlg`]). pub alias_dlg: Option, /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). @@ -322,6 +336,7 @@ impl CadApp { services, canvas: canvas::CanvasState::default(), spacemouse: spacemouse::State::default(), + quickcalc: palettes::quickcalc::State::default(), cmd: cmdline::CmdLine::default(), status: None, opening: opening::Opening::default(), @@ -350,10 +365,14 @@ impl CadApp { quit_requested: false, quit_confirmed: false, closing: None, + save_loss: None, + save_loss_accepted: None, autosave: None, recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + fault: fault::UiFault::default(), + autosave_notice: None, alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), @@ -415,6 +434,13 @@ impl CadApp { /// Submit a line of command-line text. A refused line is echoed to the history; the error is /// also returned. pub fn cmdline(&mut self, text: &str) -> Option { + self.cmdline_or(text, None) + } + + /// [`CadApp::cmdline`], but a command name nothing knows (no command, alias, UI command, LISP + /// command or system variable) runs `fallback` instead: Enter on a partly typed name accepts + /// the highlighted AutoComplete suggestion. + pub fn cmdline_or(&mut self, text: &str, fallback: Option<&str>) -> Option { if !self.session.prompting() { let first = text.split_whitespace().next().unwrap_or(""); // `cmd {json}` is a programmatic call: the engine runs it with those parameters. @@ -423,7 +449,11 @@ impl CadApp { return None; } } - let e = self.session.cmdline(text).err()?.to_string(); + let e = match (self.session.cmdline(text), fallback) { + (Ok(()), _) => return None, + (Err(cadcraft_engine::EngineError::UnknownCommand(_)), Some(f)) => return self.cmdline(f), + (Err(e), _) => e.to_string(), + }; self.session.echo(e.clone()); Some(e) } @@ -447,6 +477,7 @@ impl CadApp { "interfaceLanguage": self.ui.interface_language.code(), "saveFormat": self.ui.save_format.as_str(), "saveVersion": self.ui.save_version, + "quickcalc": self.quickcalc.prefs(), }); if self.spacemouse.settings != spacemouse::Settings::default() { prefs["spacemouse"] = json!(self.spacemouse.settings); @@ -463,6 +494,9 @@ impl CadApp { /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. pub fn load_prefs(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Some(calculator) = v.get("quickcalc") { + self.quickcalc.load_prefs(calculator); + } if let Some(prefs) = v.get("spacemouse") { let _ = self.spacemouse.load_settings(prefs); } @@ -691,11 +725,11 @@ impl CadApp { ctx.request_repaint_of(egui::ViewportId::ROOT); } - /// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The + /// Whether a modal dialog is up: the "Save changes?" prompt, the lossy-save warning or Drawing Recovery. The /// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the /// main window). The app's other dialogs are ordinary windows that leave the drawing usable. pub fn shows_modal(&self) -> bool { - self.close_prompt().is_some() || !self.recovery.items.is_empty() + self.close_prompt().is_some() || self.save_loss.is_some() || !self.recovery.items.is_empty() } /// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels' @@ -875,8 +909,10 @@ impl CadApp { dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); + save_loss::prompt(self, ui.ctx()); autosave::show(self, ui.ctx()); autosave::gpu_notice(self, ui.ctx()); + autosave::failure_notice(self, ui.ctx()); self.frame_ms = now_ms() - t0; } @@ -1110,7 +1146,8 @@ mod tests { frame(&mut a, &ctx, None); assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback"); assert_eq!(a.ui.theme, ThemePref::System); - assert_eq!(a.prefs_json(), r#"{"animations":"system","interfaceLanguage":"auto","saveFormat":"dxf","saveVersion":"2018","theme":"system"}"#); + let prefs: Value = serde_json::from_str(&a.prefs_json()).unwrap(); + assert_eq!(prefs["theme"], "system"); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7bdf4611d..ecd0c98f2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,6 +10,8 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools" /// File ▸ Open Recent: one `ui.recent.` item per recent file, then Clear Recent Files. pub const OPEN_RECENT: &str = "Open Recent"; +/// The last item of File ▸ Open Recent. +pub const CLEAR_RECENT: &str = "Clear Recent Files"; const RECENT_ITEM: &str = "ui.recent."; const RECENT_CLEAR: &str = "ui.recent.clear"; @@ -71,6 +73,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.cmdline.lines", "Command Line History Lines", &[], None), ("ui.cmdline.clear", "Clear Command History", &[], None), ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None), + ("ui.dialog.find", "Find and Replace", &[], Some("Cmd+F")), ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")), @@ -269,6 +272,10 @@ fn run_language_command(app: &mut CadApp, id: &str, params: &Value) -> Option { "No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face." } + "vi" => { + "No installed interface font has the Vietnamese letters, so the interface stays in English. Install one that does (Segoe UI, Arial, DejaVu Sans or Noto Sans) to use Vietnamese." + } + "ar" => "No Arabic font is installed, so the interface stays in English. Install one (Noto Sans Arabic, for example) to use Arabic.", "ka" => "This build has no Georgian font, so the interface stays in English. Build with craft-fonts (its Geor face) to use Georgian.", _ => "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.", }); @@ -294,6 +301,16 @@ fn should_pick_file_path(id: &str, params: &Value) -> bool { if id.starts_with("ui.") { params.is_null() || (params.get("path").is_none() && params.get("data").is_none()) } else { params.is_null() } } +/// A user's QSAVE: a drawing with no file yet picks one, and saving over a file that would lose +/// content asks first. `None` lets the engine save. JSON calls carry params and never get here; +/// they report the loss in their result instead. +fn user_qsave(app: &mut CadApp) -> Option> { + if app.session.state().is_ok_and(|s| s.path.is_none()) { + return run_ui_command(app, "ui.saveas", &Value::Null); + } + app.hold_lossy_save().then_some(Ok(Value::Null)) +} + fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option> { if let Some(result) = route_ui_subsystems(app, id, params) { return Some(result); @@ -317,6 +334,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { let picked = app.services.pick_open.as_ref().and_then(|f| f()); @@ -372,7 +395,7 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option return run_ui_command(app, "ui.saveas", &Value::Null), + "qsave" if no_path => return user_qsave(app), // EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot // dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry // params, never get here and never open a dialog. @@ -441,7 +464,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + | "ui.dialog.parameters" + | "ui.dialog.find" => { app.ui.dialog = Some(id.trim_start_matches("ui.dialog.").to_string()); Ok(Value::Null) } @@ -621,7 +645,7 @@ pub fn recent_entries(app: &CadApp) -> Vec { .map(|(i, path)| Entry::Item { label: file_name(path), id: format!("{RECENT_ITEM}{i}"), shortcut: None, enabled: true }) .collect(); let enabled = !app.ui.recent_files.is_empty(); - entries.push(Entry::Item { label: "Clear Recent Files".into(), id: RECENT_CLEAR.into(), shortcut: None, enabled }); + entries.push(Entry::Item { label: CLEAR_RECENT.into(), id: RECENT_CLEAR.into(), shortcut: None, enabled }); entries } @@ -782,23 +806,26 @@ pub fn run_transparent(app: &mut CadApp, id: &str) { } } -/// Keyboard shortcuts and function keys. -pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { +/// CADCraft's keyboard shortcuts and function keys, each with the command it runs. Every shortcut a +/// command shows (its registry `key`, or a UI command's) must be here, or pressing it does nothing. +pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)> { use egui::{Key, KeyboardShortcut, Modifiers}; let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k); let cmd = Modifiers::COMMAND; let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; - let pairs: &[(KeyboardShortcut, &str)] = &[ + vec![ (sc(cmd_shift, Key::A), "pickstyle"), (sc(cmd_shift, Key::Z), "redo"), (sc(cmd, Key::Z), "u"), (sc(cmd, Key::Y), "redo"), (sc(cmd, Key::N), "new"), + (sc(cmd, Key::W), "close"), (sc(cmd, Key::O), "ui.open"), (sc(cmd_shift, Key::S), "ui.saveas"), (sc(cmd, Key::S), "qsave"), (sc(cmd, Key::P), "plot"), (sc(cmd, Key::A), "selectall"), + (sc(cmd, Key::F), "ui.dialog.find"), (sc(cmd_shift, Key::C), "copybase"), (sc(cmd, Key::C), "copyclip"), (sc(cmd, Key::X), "cutclip"), @@ -818,7 +845,15 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(Modifiers::NONE, Key::F10), "polar"), (sc(Modifiers::NONE, Key::F11), "otrack"), (sc(Modifiers::NONE, Key::F12), "dynmode"), - ]; + ] +} + +/// Keyboard shortcuts and function keys. +pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k); + let cmd = Modifiers::COMMAND; + let pairs = builtin_shortcuts(); crate::clipboard::mirror(app, ctx); if app.closing.is_some() { return; @@ -833,7 +868,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { // Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses. let mut fire = crate::clipboard::shortcut_commands(app, ctx); ctx.input_mut(|i| { - for (s, id) in pairs { + for (s, id) in &pairs { if i.consume_shortcut(s) && !fire.contains(id) { fire.push(*id); } @@ -887,6 +922,46 @@ mod tests { (CadApp::new(Session::new(), services), asked) } + /// A registry shortcut ("Cmd+Shift+Z", "F8") as egui reads it. + fn parse_shortcut(s: &str) -> Option { + let mut m = egui::Modifiers::NONE; + let (mods, key) = s.rsplit_once('+').unwrap_or(("", s)); + for part in mods.split('+').filter(|p| !p.is_empty()) { + match part { + "Cmd" => m |= egui::Modifiers::COMMAND, + "Shift" => m |= egui::Modifiers::SHIFT, + "Alt" => m |= egui::Modifiers::ALT, + _ => return None, + } + } + Some(egui::KeyboardShortcut::new(m, egui::Key::from_name(key)?)) + } + + #[test] + fn every_shown_shortcut_runs_its_command() { + // Handled outside the table: Delete by the command line (it erases the selection only when + // the line is empty), Cmd+8 before it (QuickCalc's own field keeps focus), Cmd+Q by the + // macOS app menu, the only place that shows it. + let elsewhere = [("Delete", "erase.selection"), ("Cmd+8", "ui.toggle.quickcalc"), ("Cmd+Q", "ui.quit")]; + let table = super::builtin_shortcuts(); + let shown = cadcraft_engine::command_specs() + .iter() + .filter_map(|c| Some((c.shortcut?, c.id))) + .chain(super::UI_COMMANDS.iter().filter_map(|(id, _, _, sc)| Some(((*sc)?, *id)))); + let mut missing = Vec::new(); + for (key, id) in shown { + if elsewhere.contains(&(key, id)) { + continue; + } + let sc = parse_shortcut(key).unwrap_or_else(|| panic!("{id}: unreadable shortcut {key}")); + // The table may run a command's UI form (`ui.open` for `open`), which does the same. + if !table.iter().any(|(s, i)| *s == sc && (*i == id || i.strip_prefix("ui.") == Some(id))) { + missing.push(format!("{key} {id}")); + } + } + assert!(missing.is_empty(), "shown but not run: {missing:?}"); + } + #[test] fn plot_and_print_open_the_plot_dialog_and_exportpdf_asks_for_a_file() { set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) }); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 4c7cf6b1f..27f778735 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -10,11 +10,12 @@ //! |---|---| //! | `theme` | `"system"`, `"light"` or `"dark"` | //! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling | -//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` or `"el"` | +//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"`, `"vi"` or `"el"` | //! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings | //! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in | //! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | //! | `SAVEFILEPATH` | the folder automatic saves go to | +//! | `ISAVEBAK` | 1: saving over a drawing keeps the previous file as `.bak` (default); 0: no backup | //! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu | //! | `systemCursor` | keep the system cursor visible over the drawing area | //! | `historyLines` | command-line history lines, 0–12 | @@ -23,7 +24,7 @@ //! | `snapMarkerSize` | object snap marker size in points, 5–31 | //! | `interfaceScale` | the interface's size, 0.5–3 (1: the system's own) | //! -//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state +//! SAVETIME, SAVEFILEPATH and ISAVEBAK are the engine's save settings, kept with the autosave state //! rather than in the profile. //! //! The profile variables and the interface preferences survive restarts: the host saves @@ -147,6 +148,7 @@ struct UiChanges { save_version: Option, savetime: Option, savefilepath: Option, + isavebak: Option, time_sensitive_right_click: Option, interface_scale: Option, } @@ -203,6 +205,10 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control)); c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string()); } + k if k.eq_ignore_ascii_case("ISAVEBAK") => { + let on = v.as_bool().or_else(|| v.as_i64().filter(|n| matches!(n, 0 | 1)).map(|n| n == 1)); + c.isavebak = Some(on.ok_or("ISAVEBAK must be 0 or 1")?); + } _ => return Ok(false), } Ok(true) @@ -226,6 +232,7 @@ fn ui_json(app: &CadApp) -> Map { m.insert("saveVersion".into(), json!(app.ui.save_version)); m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime())); m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); + m.insert("ISAVEBAK".into(), json!(i32::from(cadcraft_engine::autosave::isavebak()))); m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click)); m } @@ -297,6 +304,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result { if let Some(p) = &ui.savefilepath { cadcraft_engine::autosave::set_savefilepath(p); } + if let Some(b) = ui.isavebak { + cadcraft_engine::autosave::set_isavebak(b); + } if let Some(b) = ui.time_sensitive_right_click { app.options.time_sensitive_right_click = b; } diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 93f051fd9..45be0ce67 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -225,6 +225,8 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { ui.end_row(); autosave_row(ui, changes); ui.end_row(); + backup_row(ui, changes); + ui.end_row(); savefile_row(ui, changes); ui.end_row(); }); @@ -285,6 +287,15 @@ fn autosave_row(ui: &mut egui::Ui, changes: &mut Map) { } } +/// ISAVEBAK: keep the previous file as `.bak` when saving over a drawing. +fn backup_row(ui: &mut egui::Ui, changes: &mut Map) { + ui.label(crate::tl!("Backup copy")); + let mut bak = cadcraft_engine::autosave::isavebak(); + if ui.checkbox(&mut bak, crate::tl!("Create backup copy with each save")).changed() { + changes.insert("ISAVEBAK".into(), json!(i32::from(bak))); + } +} + fn savefile_row(ui: &mut egui::Ui, changes: &mut Map) { let savefile_label = ui.label(crate::tl!("Automatic save file location")); // Edited as text; the folder changes when the field loses focus. diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index 5f601b2fa..d6948e363 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -14,10 +14,14 @@ const PICKED: &str = "QuickCalc: "; const MAX_HISTORY: usize = 100; /// Variables kept. const MAX_VARS: usize = 50; +/// Bound each saved expression/result and variable name, including hand-edited preferences. +// CAL accepts expressions up to 10,000 bytes, including a variable's name on its own. +const MAX_TEXT_BYTES: usize = 10_000; +const MAX_NAME_BYTES: usize = MAX_TEXT_BYTES; const KINDS: [(&str, &str); 4] = [("length", "Length"), ("area", "Area"), ("volume", "Volume"), ("angle", "Angle")]; #[derive(Clone, Default)] -struct State { +pub(crate) struct State { input: String, /// (expression, result) newest last. history: Vec<(String, String)>, @@ -39,8 +43,17 @@ struct State { pending: Option, } -fn id() -> egui::Id { - egui::Id::unique("cadcraft-quickcalc") +fn valid_name(name: &str) -> bool { + name.len() <= MAX_NAME_BYTES + && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !["pi", "e", "ans"].contains(&name) +} + +/// CAL variables are finite numbers or 2D points; no nested arrays or objects. +fn valid_value(value: &Value) -> bool { + let number = |v: &Value| v.as_f64().is_some_and(f64::is_finite); + number(value) || value.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(number)) } /// A variable's value as typed into an expression: a number, or a point as `[x,y]`. @@ -57,6 +70,61 @@ fn result_value(r: &Value) -> Option { } impl State { + /// Only durable calculator data: never resume an in-flight drawing pick after a restart. + pub(crate) fn prefs(&self) -> Value { + let history: Vec<_> = self + .history + .iter() + .rev() + .take(MAX_HISTORY) + .filter(|(expr, result)| expr.len() <= MAX_TEXT_BYTES && result.len() <= MAX_TEXT_BYTES) + .collect(); + let vars: Vec<_> = self.vars.iter().filter(|(name, value)| valid_name(name) && valid_value(value)).take(MAX_VARS).collect(); + json!({"history": history.into_iter().rev().collect::>(), "vars": vars, + "ans": self.ans.as_ref().filter(|v| valid_value(v))}) + } + + /// Restore bounded, independently validated entries; older preferences have no calculator. + pub(crate) fn load_prefs(&mut self, value: &Value) { + if !value.is_object() { + return; + } + if let Some(history) = value.get("history").and_then(Value::as_array) { + self.history = history + .iter() + .rev() + .take(MAX_HISTORY) + .filter_map(|entry| { + let pair = entry.as_array().filter(|a| a.len() == 2)?; + let expr = pair.first()?.as_str().filter(|s| s.len() <= MAX_TEXT_BYTES)?; + let result = pair.get(1)?.as_str().filter(|s| s.len() <= MAX_TEXT_BYTES)?; + Some((expr.to_string(), result.to_string())) + }) + .collect(); + self.history.reverse(); + } + if let Some(vars) = value.get("vars").and_then(Value::as_array) { + self.vars.clear(); + for entry in vars { + // Only valid entries count toward the limit. + if self.vars.len() >= MAX_VARS { + break; + } + let Some(pair) = entry.as_array().filter(|a| a.len() == 2) else { continue }; + let Some(name) = pair.first().and_then(Value::as_str).filter(|s| s.len() <= MAX_NAME_BYTES) else { continue }; + let name = name.to_ascii_lowercase(); + let Some(value) = pair.get(1).filter(|v| valid_value(v)) else { continue }; + if valid_name(&name) { + self.vars.retain(|(key, _)| key != &name); + self.vars.push((name, value.clone())); + } + } + } + if let Some(ans) = value.get("ans") { + self.ans = valid_value(ans).then(|| ans.clone()); + } + } + fn vars_json(&self) -> Value { let mut m = serde_json::Map::new(); for (k, v) in &self.vars { @@ -88,9 +156,7 @@ impl State { } fn add_var(&mut self, app: &mut CadApp) { let name = self.var_name.trim().to_ascii_lowercase(); - let valid = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - && !["pi", "e", "ans"].contains(&name.as_str()); + let valid = valid_name(&name); if !valid { self.error = Some(crate::tl!("A variable name starts with a letter and has only letters, digits and _").into()); return; @@ -219,7 +285,7 @@ fn value_row(ui: &mut egui::Ui, value: &mut String) { /// Quick Calculator dock body: expression entry with evaluation, variables and unit conversion. pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default(); + let mut st = std::mem::take(&mut app.quickcalc); st.collect(app); egui::ScrollArea::vertical().id_salt("dock-quickcalc").auto_shrink([false, false]).show(ui, |ui| { ui.add_space(6.0); @@ -371,16 +437,98 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); }); }); - ui.ctx().data_mut(|d| d.insert_temp(id(), st)); + app.quickcalc = st; } #[cfg(test)] mod tests { + use cadcraft_engine::Session; use egui::accesskit::Role; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; - use super::value_row; + use super::{MAX_HISTORY, MAX_TEXT_BYTES, MAX_VARS, State, value_row}; + use crate::{CadApp, Services}; + use serde_json::{Value, json}; + + #[test] + fn preferences_restore_numbers_points_history_and_ans_without_pending_input() { + let mut app = CadApp::new(Session::default(), Services::default()); + let mut state = State { var_name: "Width".into(), var_expr: "2+3".into(), ..State::default() }; + state.add_var(&mut app); + state.var_name = "origin".into(); + state.var_expr = "[3,4]".into(); + state.add_var(&mut app); + state.input = "origin*width".into(); + state.evaluate(&mut app); + state.pending = Some(123); + state.error = Some("transient error".into()); + app.quickcalc = state; + + let mut restarted = CadApp::new(Session::default(), Services::default()); + let drawing = restarted.session.doc().unwrap().clone(); + restarted.load_prefs(&app.prefs_json()); + let mut state = std::mem::take(&mut restarted.quickcalc); + assert_eq!(state.history, [("origin*width".into(), "15,20".into())]); + assert_eq!(state.vars, [("width".into(), json!(5.0)), ("origin".into(), json!([3.0, 4.0]))]); + assert_eq!(state.ans, Some(json!([15.0, 20.0]))); + assert!(state.pending.is_none()); + assert!(state.error.is_none()); + assert!(state.input.is_empty()); + state.input = "ans+origin".into(); + state.evaluate(&mut restarted); + assert_eq!(state.ans, Some(json!([18.0, 24.0]))); + assert_eq!(restarted.session.doc().unwrap(), &drawing); + assert!(restarted.session.running.is_none()); + } + + #[test] + fn malformed_entries_do_not_hide_valid_calculator_data_or_other_preferences() { + let mut app = CadApp::new(Session::default(), Services::default()); + app.load_prefs( + &json!({"theme": "light", "quickcalc": { + "history": [null, ["bad"], [false, 3], ["2+3", "5"]], + "vars": [["PI", 3], ["1bad", 5], ["nested", [[1,2],3]], ["bad", [1,2,3]], + ["object", {"x":1}], ["WIDTH", 4], ["width", 5], ["origin", [3,4]]], + "ans": "NaN" + }}) + .to_string(), + ); + assert_eq!(app.ui.theme.as_str(), "light"); + assert_eq!(app.quickcalc.history, [("2+3".into(), "5".into())]); + assert_eq!(app.quickcalc.vars, [("width".into(), json!(5)), ("origin".into(), json!([3, 4]))]); + assert!(app.quickcalc.ans.is_none()); + let saved = app.prefs_json(); + for malformed in ["not JSON", "{}", r#"{"quickcalc":null}"#, r#"{"quickcalc":{"vars":false,"history":0}}"#] { + app.load_prefs(malformed); + assert_eq!(app.prefs_json(), saved); + } + } + + #[test] + fn stored_calculator_data_is_bounded_and_cleared_data_stays_cleared() { + let mut state = State::default(); + let history: Vec<_> = (0..MAX_HISTORY + 10).map(|i| json!([i.to_string(), i.to_string()])).collect(); + let vars: Vec<_> = (0..MAX_VARS + 10).map(|i| json!([format!("v{i}"), i])).collect(); + state.load_prefs(&json!({"history": history, "vars": vars, "ans": 5})); + assert_eq!(state.history.len(), MAX_HISTORY); + assert_eq!(state.history.first().unwrap().0, "10"); + assert_eq!(state.history.last().unwrap().0, (MAX_HISTORY + 9).to_string()); + assert_eq!(state.vars.len(), MAX_VARS); + let oversized = "x".repeat(MAX_TEXT_BYTES + 1); + state.load_prefs(&json!({"history": [[oversized, "1"]], "vars": [[oversized, 1]]})); + assert!(state.history.is_empty()); + assert!(state.vars.is_empty()); + let mut restored = State { history: vec![("old".into(), "1".into())], vars: vec![("old".into(), json!(1))], ..State::default() }; + restored.load_prefs(&state.prefs()); + assert!(restored.history.is_empty()); + assert!(restored.vars.is_empty()); + assert_eq!(restored.ans, Some(json!(5))); + // An invalid answer is discarded rather than reused as a calculator value. + restored.load_prefs(&json!({"ans": [1, null]})); + assert_eq!(restored.ans, None); + assert_eq!(restored.prefs()["ans"], Value::Null); + } /// The conversion value field is named by its visible label under bundled fonts only, so a /// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name. diff --git a/crates/ui-egui/src/save_loss.rs b/crates/ui-egui/src/save_loss.rs new file mode 100644 index 000000000..2b0a28c57 --- /dev/null +++ b/crates/ui-egui/src/save_loss.rs @@ -0,0 +1,160 @@ +//! Asking before a save throws content away. DWG can't yet hold everything a drawing read from +//! another program keeps (`cadcraft_engine::cmd::file::not_saved`), a DXF of 2013 or later can't +//! hold a solid's SAT text (`cadcraft_doc::foreign::sat_text_dropped`), and QSAVE replaces the file +//! that still has it, so File ▸ Save, its shortcut or typing QSAVE says what would go first: +//! Save Anyway / Save As... / Cancel. Agreeing lasts for that same loss on that same file, so +//! later saves in the session don't ask again; the file no longer holds what was agreed to. +//! +//! Programmatic saves (`engine.execute`, the control channel, MCP, scripts) never ask: they keep +//! reporting the loss in the result's `notSaved` and on the command line. + +use std::collections::BTreeMap; + +use egui::{RichText, vec2}; +use serde_json::{Value, json}; + +use cadcraft_engine::autosave::isavebak; +use cadcraft_engine::cmd::file::{backup_path, loss_on_save, save_keeps_current}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// How many object types the warning lists; a file can name as many as it likes. +const MAX_LISTED: usize = 8; +/// How many characters of a type name the warning shows; a file chooses these too. +const MAX_NAME_CHARS: usize = 40; + +/// The user's answer to the warning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + SaveAnyway, + SaveAs, + Cancel, +} + +/// A loss the user agreed to: this drawing, this file, these objects. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Accepted { + uid: u64, + path: String, + lost: BTreeMap, +} + +/// A save held back until the user answers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pending { + loss: Accepted, +} + +impl Pending { + /// The file the save would replace. + pub fn path(&self) -> &str { + &self.loss.path + } + + /// The objects the file wouldn't keep, as "2 MLINE, 1 3DSOLID". + pub fn summary(&self) -> String { + let lost = &self.loss.lost; + let name = |t: &str| t.chars().take(MAX_NAME_CHARS).collect::(); + let mut parts: Vec = lost.iter().take(MAX_LISTED).map(|(t, n)| format!("{n} {}", name(t))).collect(); + if lost.len() > MAX_LISTED { + parts.push(crate::tf!("{count} more types", count = lost.len() - MAX_LISTED)); + } + parts.join(", ") + } +} + +impl CadApp { + /// Hold a user's QSAVE of the active drawing if it would lose content the user hasn't agreed + /// to lose, and ask. Returns whether it was held. + pub fn hold_lossy_save(&mut self) -> bool { + let Ok(st) = self.session.state() else { return false }; + let (uid, Some(path)) = (st.uid, st.path.clone()) else { return false }; + let lost = loss_on_save(&self.session, &path); + if lost.is_empty() { + return false; + } + let loss = Accepted { uid, path, lost }; + if self.save_loss_accepted.as_ref() == Some(&loss) { + return false; + } + self.save_loss = Some(Pending { loss }); + true + } + + /// Answer the open warning (no-op without one). + pub fn answer_save_loss(&mut self, choice: Choice) { + let Some(Pending { loss }) = self.save_loss.take() else { return }; + // The drawing can go away, or stop being the active one, while the warning is up. + let i = self.session.docs.iter().position(|d| d.uid == loss.uid); + match (choice, i) { + (Choice::Cancel, _) | (_, None) => self.session.echo(crate::tf!("Save cancelled: {path} was not changed.", path = loss.path)), + (Choice::SaveAs, Some(i)) => { + self.activate_doc(i); + let _ = self.run("ui.saveas", Value::Null); + } + (Choice::SaveAnyway, Some(i)) => { + self.activate_doc(i); + self.save_loss_accepted = Some(loss); + let _ = self.run("qsave", json!({})); + } + } + } + + fn activate_doc(&mut self, i: usize) { + if self.session.active != i { + let _ = self.run("document.switch", json!({ "index": i })); + } + } +} + +/// The warning, while a save waits for an answer. Cancel is what Escape does. +pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { + let Some(pending) = app.save_loss.clone() else { return }; + let t = Tokens::get(); + let path = std::path::Path::new(pending.path()); + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| pending.path().to_string()); + let backup = backup_path(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let mut choice = None; + crate::modal::instant(ctx, egui::Id::unique("cc_save_loss_prompt")).show(ctx, |ui| { + ui.set_width(420.0); + ui.label(RichText::new(crate::tf!("Saving {name} will remove content", name = file)).strong()); + ui.add_space(4.0); + ui.label(crate::tf!("This format can't keep these objects yet: {objects}.", objects = pending.summary())); + let keeps = if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("dxf")) { + crate::tl!("DXF 2010 and earlier keep them. Use Save As to keep them in the drawing.") + } else { + crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.") + }; + ui.label(RichText::new(keeps).color(t.text_dim)); + let on_disk = if save_keeps_current(path) { + crate::tf!("The current file is kept as {name} until the next save.", name = backup) + } else if isavebak() { + crate::tf!("The current file doesn't look whole, so {name} stays as it is and the current file will be replaced.", name = backup) + } else { + crate::tl!("Backup copies are off, so the current file will be replaced.").to_string() + }; + ui.label(RichText::new(on_disk).color(t.text_dim)); + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let size = vec2(96.0, 22.0); + let save_as = ui.add(egui::Button::new(RichText::new(crate::tl!("Save As...")).color(t.text)).fill(t.accent).min_size(size)); + if save_as.clicked() { + choice = Some(Choice::SaveAs); + } + if ui.add(egui::Button::new(crate::tl!("Cancel")).min_size(size)).clicked() { + choice = Some(Choice::Cancel); + } + if ui.add(egui::Button::new(crate::tl!("Save Anyway")).min_size(size)).clicked() { + choice = Some(Choice::SaveAnyway); + } + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + choice = Some(Choice::Cancel); + } + }); + if let Some(c) = choice { + app.answer_save_loss(c); + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 75dd4f6ac..48cd8ac67 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -327,19 +327,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { let chinese = language.starts_with("zh"); #[cfg(not(target_arch = "wasm32"))] { - let candidates: &[&str] = if cfg!(target_os = "macos") { - &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"] - } else if cfg!(windows) { - &["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"] - } else { - &[ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - ] - }; - for path in candidates { + for path in system_ui_candidates() { + // A Vietnamese interface takes the first one with its letters. + if language == "vi" && vietnamese_font() != Some(std::path::Path::new(path)) { + continue; + } if let Ok(bytes) = std::fs::read(path) { fonts.font_data.insert("system-ui".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes))); if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { @@ -410,6 +402,82 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { ctx.set_fonts(fonts); } +/// The system interface fonts [`install_fonts`] looks for, in order; it loads the first installed +/// one ahead of egui's bundled faces. +#[cfg(not(target_arch = "wasm32"))] +fn system_ui_candidates() -> &'static [&'static str] { + if cfg!(target_os = "macos") { + &["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc", "/Library/Fonts/Arial.ttf"] + } else if cfg!(windows) { + &["C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf"] + } else { + &[ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + ] + } +} + +/// The first of `candidates` that is `installed` and, for the Vietnamese interface, has the +/// Vietnamese letters: the system interface font [`install_fonts`] loads ahead of egui's faces. +#[cfg(not(target_arch = "wasm32"))] +fn ui_font_choice<'a>( + candidates: &[&'a str], + vietnamese: bool, + installed: impl Fn(&str) -> bool, + has_vietnamese: impl Fn(&str) -> bool, +) -> Option<&'a str> { + candidates.iter().copied().find(|p| installed(p) && (!vietnamese || has_vietnamese(p))) +} + +/// Whether `face` has every letter Vietnamese adds to Latin: the tone-marked vowels of Latin +/// Extended Additional (U+1EA0–U+1EF9), and ơ, ư, đ. +#[cfg(not(target_arch = "wasm32"))] +fn covers_vietnamese(face: &cadcraft_fonts::ttf::Face) -> bool { + ('\u{1ea0}'..='\u{1ef9}').chain(['ơ', 'Ơ', 'ư', 'Ư', 'đ', 'Đ']).all(|c| face.has_glyph(c)) +} + +/// The system interface font the Vietnamese interface takes: the first of +/// [`system_ui_candidates`] whose first face (the one egui uses) has every Vietnamese letter. +/// Segoe UI, Arial, SF, DejaVu Sans and Noto Sans do; egui's bundled faces stop at Latin +/// Extended-A, so without one the interface stays English (as the web build does). Checked once. +pub fn vietnamese_font() -> Option<&'static std::path::Path> { + #[cfg(not(target_arch = "wasm32"))] + { + static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); + FOUND + .get_or_init(|| { + let has_vietnamese = |p: &str| { + read_capped(std::path::Path::new(p), MAX_UI_FONT_BYTES) + .and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next()) + .is_some_and(|face| covers_vietnamese(&face)) + }; + ui_font_choice(system_ui_candidates(), true, |p| std::path::Path::new(p).is_file(), has_vietnamese).map(std::path::PathBuf::from) + }) + .as_deref() + } + #[cfg(target_arch = "wasm32")] + None +} + +/// Whether the interface in `language` shows the Vietnamese letters (Tiếng Việt in the language +/// picker): the Vietnamese interface with [`vietnamese_font`], any other when the system interface +/// font it loads is that one. +pub fn shows_vietnamese(language: &str) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let loaded = || ui_font_choice(system_ui_candidates(), false, |p| std::path::Path::new(p).is_file(), |_| true); + vietnamese_font().is_some_and(|v| language == "vi" || loaded().is_some_and(|p| std::path::Path::new(p) == v)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = language; + false + } +} + /// The faces `build.rs` embedded from craft-fonts that the interface loads, in order: every CJK /// face, because file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513), with the interface's own face leading. A sans-serif face reads best, and a @@ -662,11 +730,13 @@ pub fn shows_hangul(language: &str) -> bool { /// Whether the interface can show `language` without missing-glyph boxes: Chinese needs a font /// with its glyphs (installed, or the CJK face this build embedded from craft-fonts), Korean a -/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face, ქართული the +/// font with Hangul, العربية an installed Arabic font or the embedded Arabic face, Tiếng Việt a +/// system interface font with its tone-marked letters ([`vietnamese_font`]), ქართული the /// embedded Georgian face. pub fn can_show(language: &str) -> bool { match language { "ko" => shows_hangul("ko"), + "vi" => vietnamese_font().is_some(), "ar" => arabic_font().is_some() || embedded_script("Arab"), "ka" => embedded_script("Geor"), l if l.starts_with("zh") => cjk_font().is_some() || embedded_script("Hans") || embedded_script("Hant"), @@ -742,6 +812,18 @@ pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) { #[cfg(test)] mod tests { + /// The system interface font loaded: the first installed one, and for Vietnamese the first + /// installed one with its letters (a later one when the first lacks them). + #[test] + fn the_vietnamese_interface_takes_the_first_installed_font_with_its_letters() { + let candidates = ["a.ttf", "b.ttf", "c.ttf", "d.ttf"]; + let installed = |p: &str| p != "a.ttf"; + let has_vietnamese = |p: &str| p == "c.ttf" || p == "d.ttf"; + assert_eq!(super::ui_font_choice(&candidates, false, installed, has_vietnamese), Some("b.ttf")); + assert_eq!(super::ui_font_choice(&candidates, true, installed, has_vietnamese), Some("c.ttf")); + assert_eq!(super::ui_font_choice(&candidates, true, installed, |_| false), None); + } + use cadcraft_fonts::CraftFont; /// A face shaped like `build.rs` writes them, with no font file (as in `cadcraft-fonts`). diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index ffc625767..ce8f53a3f 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -3,6 +3,7 @@ //! `craft_update::updater::Updater`. use craft_update::updater::{Action, Status, Updater}; +use craft_update::{CheckResultDialog, CheckResultResponse}; use crate::CadApp; @@ -16,11 +17,15 @@ pub struct Runtime { enabled: bool, /// Help ▸ Check for Updates… was chosen; the next frame's `tick` starts it with the context. check_requested: bool, + /// The check-result dialog is open (from a requested check until it is closed). + result_open: bool, + /// The current result was already written to the command history. + result_echoed: bool, } impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false, result_echoed: false } } } @@ -31,9 +36,11 @@ impl CadApp { } } -/// Help ▸ Check for Updates…: check now, and say so when there is nothing newer. +/// Help ▸ Check for Updates…: check now, and show the result when there is nothing newer. pub fn check_now(app: &mut CadApp) { app.updates_rt.check_requested = true; + app.updates_rt.result_open = true; + app.updates_rt.result_echoed = false; } /// Start the checks and collect the answers. @@ -50,8 +57,16 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) { Status::Failed(_) => Some(crate::tl!("Could not check for updates.")), Status::Idle | Status::Checking => None, }; - if let Some(message) = outcome { + let Some(message) = outcome else { + // A check that found an update leaves `Idle`; the prompt takes over from the dialog. + if rt.result_open && matches!(rt.updater.status(), Status::Idle) { + rt.result_open = false; + } + return; + }; + if !rt.result_open { rt.updater.dismiss_status(); + } else if !std::mem::replace(&mut rt.result_echoed, true) { app.session.echo(message); } } @@ -60,6 +75,20 @@ pub fn tick(app: &mut CadApp, ctx: &egui::Context) { pub fn show(app: &mut CadApp, ctx: &egui::Context) { // The restart helper is scheduled before the app closes, so no update starts over unsaved work. let clean = !app.session.docs.iter().any(|d| d.is_dirty()); + let rt = &mut app.updates_rt; + if rt.result_open { + match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { + CheckResultResponse::Close => { + rt.result_open = false; + rt.updater.dismiss_status(); + } + CheckResultResponse::CheckAgain => { + rt.result_echoed = false; + rt.check_requested = true; + } + CheckResultResponse::None => {} + } + } match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), // The guarded close path asks about unsaved changes; the restart helper relaunches once the diff --git a/crates/ui-egui/tests/autosave_failure.rs b/crates/ui-egui/tests/autosave_failure.rs new file mode 100644 index 000000000..6e266fedd --- /dev/null +++ b/crates/ui-egui/tests/autosave_failure.rs @@ -0,0 +1,87 @@ +//! A failed autosave reaches the user: a notice and one command-line message, gone once a write +//! works. SAVEFILEPATH is process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +fn wait_for(app: &mut CadApp, ctx: &egui::Context, done: impl Fn(&CadApp) -> bool) { + for _ in 0..500 { + app.autosave_tick(ctx); + if done(app) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// The text the notice paints (the second frame: a new window sizes itself in the first). +fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(t) => text.push_str(t.galley.text()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let mut text = String::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::autosave::failure_notice(app, ui.ctx())); + out.textures_delta.clear(); // no renderer here + text.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + } + text +} + +#[test] +fn a_failed_autosave_is_shown_until_a_write_works() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let root = std::env::temp_dir().join(format!("cadcraft-autosave-failure-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // The folder can't be created while a file holds its name. + let dir = root.join("out"); + std::fs::write(&dir, b"not a folder").unwrap(); + cadcraft_engine::autosave::set_savefilepath(&dir.to_string_lossy()); + + let mut app = CadApp::new(Session::new(), Services::default()); + app.enable_autosave(&dir); + let ctx = egui::Context::default(); + app.autosave_tick(&ctx); + assert!(app.autosave_notice.is_none(), "nothing failed yet"); + + app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_some()); + let n = app.autosave_notice.clone().expect("the failure reaches the notice"); + assert!(!n.dismissed && n.message.contains("out"), "{n:?}"); + let said = |app: &CadApp| app.session.log.iter().filter(|l| l.starts_with("Autosave failed:")).count(); + assert_eq!(said(&app), 1); + assert!(painted_text(&mut app, &ctx).contains("could not write a recovery copy")); + + // Dismissed, it stays away through retries and isn't repeated at the command line. + app.autosave_notice.as_mut().unwrap().dismissed = true; + app.autosave_tick(&ctx); + assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed)); + assert!(painted_text(&mut app, &ctx).is_empty()); + + // Another drawing's failure is news, even after the first was dismissed. + app.autosave_notice.as_mut().unwrap().reported.clear(); + app.autosave_tick(&ctx); + assert!(app.autosave_notice.as_ref().is_some_and(|n| !n.dismissed)); + assert_eq!(said(&app), 2); + + // The folder comes back: the next write clears it. + std::fs::remove_file(&dir).unwrap(); + app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_none()); + assert!(app.autosave_notice.is_none(), "a write that worked ends the notice"); + + app.shutdown_autosave(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index c32012e1a..b80614182 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -37,3 +37,4 @@ mod toolsets_gear; mod toolsets_parametric; mod viewport_double_click_selection; mod workspace_focus; +mod zoomed_far_in; diff --git a/crates/ui-egui/tests/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs index 5cedfbf61..f585ae46a 100644 --- a/crates/ui-egui/tests/it/text_double_click.rs +++ b/crates/ui-egui/tests/it/text_double_click.rs @@ -73,6 +73,16 @@ fn double_clicked_text_takes_ime_composition_and_commit() { let mut h = harness(); double_click(&mut h, Vec2::new(4.0, 1.2)); assert_eq!(h.state().session.current_prompt().unwrap().message, "Enter new text"); + // Without a CJK font (none installed, no craft-fonts) the command line draws 中文 as boxes, + // which the harness takes for a bug. The IME path below is the same either way. + let drawable = h.ctx.fonts_mut(|view| { + [egui::FontFamily::Proportional, egui::FontFamily::Monospace] + .into_iter() + .all(|family| view.has_glyphs(&egui::FontId::new(12.0, family), "中文")) + }); + if !drawable { + h.ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Tofu); + } // Composition is shown, not saved; the commit is inserted exactly once (#1761). h.event(Event::Ime(ImeEvent::Preedit { text: "zhong".into(), active_range_chars: None })); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/zoomed_far_in.rs b/crates/ui-egui/tests/it/zoomed_far_in.rs new file mode 100644 index 000000000..5f6a73a21 --- /dev/null +++ b/crates/ui-egui/tests/it/zoomed_far_in.rs @@ -0,0 +1,40 @@ +//! Zooming far in with the pointer on an object (#2299): its rollover highlight is dashed, and +//! dashing the whole object made one shape per 10 points of it on screen. Zoomed to the limit +//! that was billions of shapes, so the app froze and then ran out of memory. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::json; + +/// One frame of the app; the number of shapes it painted. +fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) -> usize { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.shapes.len() +} + +#[test] +fn zooming_far_in_on_a_hovered_line_paints_what_shows() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("circle", json!({"center": [50, 50], "radius": 20})).unwrap(); + app.run("line", json!({"points": [[0, 0], [100, 100]]})).unwrap(); + app.run("line", json!({"points": [[0, 100], [100, 0]]})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Vec::new()); + let over = app.canvas.rect.unwrap().center(); + // The smaller zoom first, so a regression fails there instead of filling memory at the limit. + for height in [1e-2, 1e-9] { + // Centred on a point of the first line, which the pointer, at the canvas centre, hovers. + app.run("view.set", json!({"center": [25, 25], "height": height})).unwrap(); + let shapes = (0..3).map(|_| frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)])).max().unwrap_or(0); + // Without a hovered object there is no dashed highlight, and the count says nothing. + assert!(app.canvas.hover.is_some(), "view height {height}: the line is hovered"); + assert!(shapes < 5_000, "view height {height}: {shapes} shapes painted"); + assert_eq!(app.session.state().unwrap().view().height, height); + } +} diff --git a/crates/ui-egui/tests/quickcalc_toggle.rs b/crates/ui-egui/tests/quickcalc_toggle.rs index 9b6bec6d1..caf52f0df 100644 --- a/crates/ui-egui/tests/quickcalc_toggle.rs +++ b/crates/ui-egui/tests/quickcalc_toggle.rs @@ -9,10 +9,17 @@ use egui_kittest::kittest::{NodeT, Queryable, by}; use serde_json::{Value, json}; fn harness() -> Harness<'static, CadApp> { + harness_from_prefs("{}", false) +} + +fn harness_from_prefs(prefs: &str, render: bool) -> Harness<'static, CadApp> { let mut app = CadApp::new(Session::default(), Services::default()); + app.load_prefs(prefs); app.ui.start_tab = false; app.ui.in_window_menu = false; - let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); + let builder = if render { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); app.ui(ui); @@ -23,6 +30,44 @@ fn harness() -> Harness<'static, CadApp> { h } +#[test] +fn restarting_restores_calculator_data_and_ui_edits_are_saved_when_hidden() { + let prefs = json!({"quickcalc": { + "history": [["2+3", "5"]], "vars": [["width", 5]], "ans": 5 + }}) + .to_string(); + let screenshot = std::env::var_os("CADCRAFT_QUICKCALC_SHOT"); + let mut h = harness_from_prefs(&prefs, screenshot.is_some()); + h.state_mut().run("ui.panel.quickcalc", Value::Null).unwrap(); + h.run_steps(5); + assert!(h.query_all(by().value("2+3")).next().is_some()); + expression(&h).click(); + h.run_steps(3); + expression(&h).type_text("ans+width"); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "=").click(); + h.run_steps(3); + let prefs: Value = serde_json::from_str(&h.state().prefs_json()).unwrap(); + assert_eq!(prefs["quickcalc"]["ans"], 10.0); + assert_eq!(prefs["quickcalc"]["history"][1], json!(["ans+width", "10"])); + if let Some(path) = screenshot { + h.render().unwrap().save(std::path::PathBuf::from(path)).unwrap(); + } + + h.get_by_role_and_label(Role::Button, "Clear History").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "×").click(); + h.run_steps(3); + h.state_mut().run("ui.toggle.quickcalc", Value::Null).unwrap(); + h.run_steps(5); + let mut restarted = CadApp::new(Session::default(), Services::default()); + restarted.load_prefs(&h.state().prefs_json()); + let prefs: Value = serde_json::from_str(&restarted.prefs_json()).unwrap(); + assert_eq!(prefs["quickcalc"]["history"], json!([])); + assert_eq!(prefs["quickcalc"]["vars"], json!([])); + assert_eq!(prefs["quickcalc"]["ans"], 10.0); +} + /// In these tests QuickCalc floats alone; scope to its body to avoid the Properties text fields. /// Its first text input is the expression, before conversion values and variable definitions. fn expression<'a>(h: &'a Harness<'_, CadApp>) -> egui_kittest::Node<'a> { diff --git a/crates/ui-egui/tests/save_loss.rs b/crates/ui-egui/tests/save_loss.rs new file mode 100644 index 000000000..cdb212f94 --- /dev/null +++ b/crates/ui-egui/tests/save_loss.rs @@ -0,0 +1,108 @@ +//! Saving over a file that would lose content asks first, and only when a user saves. These run +//! in their own test binary because the engine's I/O hooks are set once per process, and the +//! library's unit tests register a writer that always fails. + +use std::path::{Path, PathBuf}; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Common, Drawing, EntityKind, RawTag, Space, Unknown}; +use serde_json::json; + +use cadcraft_ui_egui::save_loss::{Choice, Pending}; +use cadcraft_ui_egui::{CadApp, Services, menus}; + +const ORIGINAL: &[u8] = b"original"; + +fn unknown(kind: &str) -> EntityKind { + EntityKind::Unknown(Unknown { dxf_type: kind.into(), tags: vec![RawTag { code: 8, value: "0".into() }] }) +} + +/// A drawing with objects of `types`, which DWG can't keep, opened from a DWG file on disk. +fn opened(tag: &str, types: &[&str]) -> (CadApp, PathBuf) { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-loss-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plan.dwg"); + std::fs::write(&path, ORIGINAL).unwrap(); + let mut d = Drawing::new_metric(); + for t in types { + d.add(&Space::Model, Common::default(), unknown(t)).unwrap(); + } + let mut s = Session::empty(); + s.open_drawing(d, "Plan.dwg", Some(path.to_string_lossy().into_owned())); + (CadApp::new(s, Services::default()), path) +} + +fn cleanup(path: &Path) { + if let Some(d) = path.parent() { + let _ = std::fs::remove_dir_all(d); + } +} + +#[test] +fn a_users_save_asks_before_the_file_changes_and_cancel_leaves_it() { + let (mut app, path) = opened("cancel", &["3DSOLID", "MLINE", "MLINE"]); + menus::activate(&mut app, "qsave"); + let pending = app.save_loss.clone().expect("the save waits for an answer"); + assert_eq!(pending.summary(), "1 3DSOLID, 2 MLINE"); + assert!(app.shows_modal()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL, "nothing is written before the answer"); + + app.answer_save_loss(Choice::Cancel); + assert!(app.save_loss.is_none()); + assert_eq!(std::fs::read(&path).unwrap(), ORIGINAL); + cleanup(&path); +} + +#[test] +fn save_anyway_saves_and_the_same_loss_is_not_asked_again() { + let (mut app, path) = opened("anyway", &["MLINE"]); + menus::activate(&mut app, "qsave"); + app.answer_save_loss(Choice::SaveAnyway); + assert!(app.save_loss.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL); + + menus::activate(&mut app, "qsave"); + assert!(app.save_loss.is_none(), "already agreed to"); + + // A new kind of loss is a new question. + app.session.doc_mut().unwrap().add(&Space::Model, Common::default(), unknown("3DSOLID")).unwrap(); + menus::activate(&mut app, "qsave"); + assert_eq!(app.save_loss.as_ref().map(Pending::summary).as_deref(), Some("1 3DSOLID, 1 MLINE")); + cleanup(&path); +} + +#[test] +fn a_save_that_loses_nothing_does_not_ask() { + let (mut app, path) = opened("clean", &[]); + menus::activate(&mut app, "qsave"); + assert!(app.save_loss.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), ORIGINAL); + cleanup(&path); +} + +/// Programmatic calls never open dialogs: the loss is in the result instead. +#[test] +fn programmatic_saves_never_ask() { + let (mut app, path) = opened("json", &["MLINE"]); + let r = app.run("qsave", json!({})).unwrap(); + assert!(app.save_loss.is_none()); + assert_eq!(r["notSaved"], json!({ "MLINE": 1 })); + cleanup(&path); +} + +/// Type names come from the file: however many, however long, the warning stays readable. +#[test] +fn a_hostile_list_of_types_is_capped() { + let long = "X".repeat(5000); + let many: Vec = (0..50).map(|i| format!("{long}{i}")).collect(); + let refs: Vec<&str> = many.iter().map(String::as_str).collect(); + let (mut app, path) = opened("hostile", &refs); + menus::activate(&mut app, "qsave"); + let s = app.save_loss.as_ref().unwrap().summary(); + assert!(s.chars().count() < 500, "{}", s.len()); + assert!(s.ends_with("42 more types"), "{s}"); + cleanup(&path); +} diff --git a/crates/ui-egui/tests/ui_faults.rs b/crates/ui-egui/tests/ui_faults.rs new file mode 100644 index 000000000..3c382e38f --- /dev/null +++ b/crates/ui-egui/tests/ui_faults.rs @@ -0,0 +1,97 @@ +//! A panic in the interface keeps the user's work: unsaved drawings are autosaved at once, the +//! app says so and writes a report, and a panic that keeps coming back leaves only a way to close +//! with the recovery copies kept. The panic hook is process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +fn panic_in_a_nested_ui(app: &mut CadApp, ctx: &egui::Context, text: &'static str) -> String { + let mut painted = String::new(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx().clone(); + app.guarded(&ctx, |_| { + egui::CentralPanel::default().show(ui, |ui| { + ui.vertical(|ui| { + ui.label("before"); + std::panic::panic_any(text); + }); + }); + }); + app.show_fault(ui.ctx()); + }); + out.textures_delta.clear(); // no renderer here + out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + painted +} + +fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String { + let mut painted = String::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.show_fault(ui.ctx())); + out.textures_delta.clear(); // no renderer here + painted.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + } + painted +} + +fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(t) => { + text.push_str(t.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)), + _ => {} + } +} + +fn recovery_copies(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "sv$")).count()).unwrap_or(0) +} + +#[test] +fn a_panic_saves_the_work_and_reports_and_repeated_ones_close_safely() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let root = std::env::temp_dir().join(format!("cadcraft-ui-faults-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (auto, reports) = (root.join("autosave"), root.join("crash-reports")); + cadcraft_ui_egui::fault::install_report_hook(reports.clone()); + + let mut app = CadApp::new(Session::new(), Services::default()); + app.enable_autosave(&auto); + app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + let ctx = egui::Context::default(); + + // One panic: the drawing has a whole recovery copy by the time the frame goes on. + panic_in_a_nested_ui(&mut app, &ctx, "a bug in a panel"); + assert_eq!(app.fault.message.as_deref(), Some("a bug in a panel")); + assert_eq!((app.fault.autosaved, app.fault.broken), (1, false)); + assert_eq!(recovery_copies(&auto), 1); + let text = painted_text(&mut app, &ctx); + assert!(text.contains("kept running") && text.contains("recovery copy"), "{text}"); + + let report = std::fs::read_dir(&reports).unwrap().flatten().next().expect("a report").path(); + let report = std::fs::read_to_string(report).unwrap(); + assert!(report.contains("Message: a bug in a panel") && report.contains("ui_faults.rs"), "{report}"); + assert!(report.starts_with("CADCraft "), "{report}"); + + // The app still works after it. + app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap(); + + // Panics that keep coming: the interface stops running and only offers to close. + panic_in_a_nested_ui(&mut app, &ctx, "again"); + panic_in_a_nested_ui(&mut app, &ctx, "and again"); + assert!(app.fault.broken); + let mut ran = false; + app.guarded(&ctx, |_| ran = true); + assert!(!ran, "a broken interface isn't run again"); + assert!(painted_text(&mut app, &ctx).contains("Drawing Recovery offers them")); + + // Closing now keeps the copies for the next start. + app.shutdown_autosave(); + assert_eq!(recovery_copies(&auto), 1); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/contributors.md b/docs/contributors.md index acda57b24..13f693149 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -32,20 +30,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 46de46f5e..91c2982f9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `cmdline.state` | | Current prompt, keywords, accepted input kinds, history tail. | | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | -| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | +| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo, and what reading the file left out: `skipped` records by section, `readNotes` in words) and entities with handles and geometry. | | `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d2af85803..d85415123 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (R2000 DXF CLASS records without instance counts, so AutoCAD LT 2026 opens them) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -64,9 +64,9 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | R12 (AC1009), ASCII | read, write | yes: no version gating; old-style POLYLINE/VERTEX read (polyface meshes flattened to 3D polylines) | **no** | | R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no | -| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure (later versions add only optional groups) with that `$ACADVER`; UTF-8 text from 2007; no CLASS instance counts (91) | +| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | yes, each of 2000–2018 (#421): R2000 structure with that `$ACADVER`; UTF-8 text from 2007; CLASS instance counts (91) from 2004 only (#635; with them, AutoCAD LT 2026 stops responding on an R2000 file) | | R13–R2018, binary | read, write | yes | yes, each of 2000–2018 (SAVEAS `binary`; a binary file stays binary on QSAVE) | -| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | writes `\U+` escapes (#169), which is correct; declares `ANSI_1252` | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | yes (#505): ASCII and binary DXF before 2007 decode from their code page (ANSI_874/932/936/949/950/1250–1258, DOS437/850/852/866, ISO 8859, Macintosh); a file that is valid UTF-8 reads as UTF-8; Johab and other DOS code pages read as ANSI_1252 (`dxf/src/codepage.rs`) | before 2007 writes text in the declared code page (the one read from the file, else `ANSI_1252`), characters outside it as `\U+` escapes; an unknown code page, or ASCII DXF whose code page bytes would also read as UTF-8, gives pure ASCII with escapes (#169); a double-byte character whose trail byte is `\` (Shift-JIS, Big5, GBK) is escaped where `U+` or `M+` follows it | | `\U+`/`\M+` escapes | yes | yes (`\M+` since #505; Johab `\M+4` stays literal) | `\U+` yes | ### DXF content: read and write by object type @@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | -| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement | +| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement; a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | @@ -140,11 +140,13 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | R2000 DXF: CLASS records written without group 91 (instance count), which came with the 2004 format; AutoCAD LT 2026 stopped responding on every R2000 DXF CADCraft saved (checked black-box in AutoCAD LT 2026) | | 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name | +| 2026-10-19 | minor | Pre-2007 DXF saves write text in the file's code page (`Ação` as ANSI_1252 bytes, not `A\U+00E7\U+00E3o`); characters outside it stay `\U+` escapes | | 2026-10-20 | minor | DIMENSION: the `*D` block's MTEXT, and the MTEXT EXPLODE leaves, hold the diameter sign as `%%c` instead of the decoded U+2300, which no SHX font has, so other programs plotted diameter dimensions in an SHX style without the sign (#1645) | | 2026-10-21 | minor | Proxy objects and entities keep their class through a save: group 91 renumbered to the written CLASSES (the class it names is kept), a proxy whose number names no class dropped (#1812) | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | diff --git a/docs/gaps.md b/docs/gaps.md index b270525d6..f8ddac941 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,10 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (BEDIT in the Block Editor switches blocks: F3 updated) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (QSAVE asks before losing content and keeps a `.bak`: rows 1, FF1 and F20 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work -list: agents pick from here (top of a section first), and remove or update a row in the same PR -that closes it. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours +list: agents pick from here (top of a section first). A PR names the rows it closes in its +description, and maintainers update them in batches. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours are Opus 5.5 agent-hours, calibrated as explained there. "Est." means a judgement from the cited evidence. Rows marked **[alpha blocker]** are what fails the [alpha gate](roadmap.md#alpha-gate); do them first. @@ -13,7 +13,7 @@ do them first. | # | Gap | Why it ranks here | Est. | Doc | |---|---|---|---|---| -| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | +| 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors; a user's QSAVE now asks before replacing the file and keeps the previous one as `.bak`, which reduces the harm but does not close this row) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | @@ -41,7 +41,7 @@ do them first. | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | -| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | +| F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export Open and Save has the backup copy checkbox (ISAVEBAK). | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | | F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | @@ -73,7 +73,7 @@ Detail: [file-format-parity.md](file-format-parity.md). | # | Gap | User impact | Est. | |---|---|---|---| -| FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data | High | 6–10 | +| FF1 | **[alpha blocker]** Round-trip what we don't model. DXF done (#528): unknown entities and objects (with their CLASS records), foreign xdata (hyperlinks included), extension dictionaries, persistent reactors, DICTIONARYVAR and XRECORDs, layer states, colour book colours, references remapped on save; IMAGE, VIEW/UCS, groups, block descriptions, true colours, transparency (#201, #204, #208, #209, #213, #223); WIPEOUT and gradients (#147, #162). Left: through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour book names and reactors; UCS elevation; MTEXT background colours; unknown entities follow only layer edits, and renamed layers and blocks lose their foreign data A user's QSAVE now asks before such a loss and keeps the previous file as `.bak`; the loss itself remains. | High | 6–10 | | FF2 | Multileader leftovers (native MULTILEADER read/write landed, #459): several landings per multileader (extra leader roots are folded into the lines), per-object overrides (leader colour and linetype, arrowhead symbol, text frame), annotative scale contexts; check the text placement of our MULTILEADER output in AutoCAD | Medium | 3–5 | | FF3 | Save-version leftovers (DXF/DWG 2000–2018, binary DXF, SAVEAS `version`/`binary` and the default save version landed, #421): no R14 DWG or R12 DXF output; the Save dialog can't pick the version per file (the file picker doesn't report the chosen type; File > Default Save Format sets it); no command-line SAVEAS format prompt | Low | 2–4 | | FF4 | Code-page leftovers (pre-2007 DXF read in their `$DWGCODEPAGE` landed, #505): Johab (ANSI_1361) and DOS 855/857/860–865/869 read as ANSI_1252; saving declares `ANSI_1252` instead of the file's code page (the text is pure ASCII, so still correct); acadrust approximates DOS code pages in pre-2007 DWG (DOS437 as 866, DOS850 as 1252) | Low | 1–2 | @@ -107,7 +107,7 @@ Detail: [localization-parity.md](localization-parity.md). | # | Gap | Est. | |---|---|---| | L1 | Catalog covers the UI (989 rows, #36) but not engine diagnostics, CLI/MCP replies or command history | 4–6 | -| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi from the standard's list | 35–55 + native review | +| L2 | AutoCAD for Mac's other languages (zh-Hans, es, fr, ja, ko, it; de landed 2026-10-11) plus pt, hi, ar, id, vi | 35–55 + native review | | L3 | Complex-script shaping for Devanagari and other Indic scripts in drawing text; RTL (mirrored) UI. Arabic-script shaping and bidi in drawing text landed (#287) | 20–30 | | L4 | IME fixes on every platform (PRs #110–#119); vertical text styles | 6–10 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | QSAVE asks before replacing a file with content the DWG writer can't keep, and ISAVEBAK keeps the previous file as `.bak`: row 1 narrowed, FF1 and F20 updated | | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ff44de470..c6f43ee4a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,8 +31,8 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. English. For scale: about 1,160 unique capitalised string literals exist in `crates/engine` and `crates/ui-egui` (grep, 2026-10-10). - **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback - and FONTALT, so CJK characters render (#173). DXF text is written with `\U+` escapes so - non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode + and FONTALT, so CJK characters render (#173). Pre-2007 DXF text is written in the file's code page, + with `\U+` escapes for characters outside it, so non-ASCII survives in R2000 files (#169). Lines with right-to-left text get the Unicode Bidirectional Algorithm and OpenType shaping with `harfrust` (Arabic, Persian and Urdu joining, lam-alef, ZWNJ, marks; Hebrew), in TEXT and MTEXT including wrapping (#287). Left-to-right lines are still placed one glyph per code point: **no shaping** for Devanagari and other Indic @@ -46,7 +46,7 @@ shaping and right-to-left text through TrueType, vertical SHX text styles. ## Languages -Order and columns from craftrules' progress-docs standard. Every UI percentage is **measured** +Every UI percentage is **measured** from the catalogs (0 rows for every language without a catalog file). Hours are **estimated**: about 3–5 h per language for an Opus 5.5 agent to translate the ~990 catalog rows and fix layout, plus native review by a human; extending the catalog to the remaining English-only strings diff --git a/docs/localization.md b/docs/localization.md index bebd86f76..1d6a71ae6 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -46,8 +46,8 @@ The translations are original, AI-assisted Korean CAD terminology (for example, 치수, 모깎기, 모따기, 간격띄우기 and 객체 스냅). Independent native proofreading and platform-specific layout/IME validation remain follow-up work. -Brazilian Portuguese has 1,231 catalog rows and also covers the reference catalog and every marked UI -literal: commands, menus, tool groups and tooltips, object snaps, drafting prompts, dialogs and +Brazilian Portuguese has 1,447 catalog rows and also covers the reference catalog, every marked UI +literal and the strings the sibling catalogs translate: commands, menus, tool groups and tooltips, object snaps, drafting prompts, dialogs and Preferences; its gaps are now zero. Its plural rule is `one|other` (0 and 1 are `one`), as CLDR defines for `pt`, and the catalog's accented letters (`Á Â Í Ó Ú à á â ã ç é ê í ó ô õ ú`) render in the bundled fonts. The original catalog was contributed by @WladmirJunior; the remaining rows were completed diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 53ffffc96..a91ee2f25 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-30 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-11-01 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 449 registered commands. | Menu | Items | Covered | % | |---|---|---|---| diff --git a/docs/releasing.md b/docs/releasing.md index 85c7c7cbd..3595c6b68 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing CADCraft -The canonical recipe is craftrules' [`release/playbook.md`](https://github.com/storytold/craftrules/blob/main/release/playbook.md); this file covers only what's CADCraft-specific. +This file describes how CADCraft is released. - **Version:** `[workspace.package] version` in the root `Cargo.toml` is the single source. `cargo xtask version` prints it; `cargo xtask version set X.Y.Z[-pre]` changes it. The app shows it in About, `cadcraft --version` and `cadcraft-cli --version`; release builds also embed the commit (`CADCRAFT_BUILD_SHA`). - **Cut a release:** bump the version on `main`, then push `main` to `release` (`git push origin main:release`). Every push to `release` runs `.github/workflows/release.yml` and creates or updates the draft GitHub Release `CADCraft v`. A `workflow_dispatch` run with `version: X.Y.Z-rc.N` makes a test build. A dispatch on any other branch is a dry run: the unsigned jobs (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) build, the `release` environment refuses the signing jobs (macOS, Windows), and no release is drafted. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3d46be9df..025a43f54 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,8 +26,7 @@ Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows. ## Alpha gate -The core workflows a typical AutoCAD professional does every day (craftrules progress-docs -standard). CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are +The core workflows a typical AutoCAD professional does every day. CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are the alpha checklist. Main platform: macOS. | Core workflow | Works end to end? | Evidence | Hours to pass | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b5407d60..fc36dea7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -65,8 +65,7 @@ mainstream and essentials numbers use multiplicative discounts. ## Mainstream practitioner The typical AutoCAD professional is a 2D drafter or detailer: draw, modify, dimension, use -blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded per -the standard: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces, +blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces, Share), Sheet Set Manager administration, specialist hardware, languages other than English. | Area (weekly use) | Weight | Depth | Weighted | @@ -215,7 +214,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Feature breadth | Weighted parity | Remaining | Note | |---|---|---|---|---| | 2026-10-10 (merge) | unchanged (233/491 re-scanned) | ready ≈ 38% (37.7%), mainstream ≈ 28%, essentials ≈ 46% | unchanged within rounding | main landed dynamic input pointer boxes (#60), the Ukrainian catalog (#36), WIPEOUT/gradient DXF fixes (#147, #162): UI/UX 40% → 43%, localization 3% → 5% | -| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added (craftrules standard) | +| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added | | 2026-10-10 | 245/491 (50%), engine-only 233/491 | features ≈ 41%, ready for real work ≈ 37% | 1,300–2,100 h | full re-measure in this format (this file) | | 2026-10-07 | 233/491 (47%) | ≈ 29% overall, 2D ≈ 55% | ≈ 570 h | old ROADMAP: milestone percentages reported by the building agents | diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 5154fe076..5c84b8470 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.cadcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -61,6 +63,12 @@ OUT="$DIST/$BASENAME.flatpak" # --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing. flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 000000000..d2d2e0f25 --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build cadcraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/cadcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="cadcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/cadcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml index 651b9a358..448ba058d 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '26.08' sdk: org.freedesktop.Sdk command: cadcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index 88a49e136..e4b19cb81 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: cadcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11