diff --git a/.github/workflows/ast-grep.yml b/.github/workflows/ast-grep.yml new file mode 100644 index 000000000..aab570b63 --- /dev/null +++ b/.github/workflows/ast-grep.yml @@ -0,0 +1,26 @@ +# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan. +# Seconds, no Rust build. +name: ast-grep + +on: + push: + branches: [main] + paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"] + pull_request: + paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ast-grep-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + # Tracked at @main like the other shared workflows. + uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main + with: + config: lint/ast-grep/sgconfig.yml + paths: apps/cadcraft-cli/src apps/cadcraft/src/launch_args.rs apps/cadcraft/src/main.rs diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b..ea61d8430 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # 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/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f7bb0996e..167839e9e 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -30,3 +30,5 @@ jobs: with: app-name: cadcraft app-dir: CADCraft + install-relative-path: Programs/ArtCraft/CADCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index cd6de23e3..f805cdddd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **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`). -- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- **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 - **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. @@ -47,7 +47,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. ## Contributor credits (About window) @@ -66,3 +66,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e124d102a..31ea4b474 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -65,6 +65,7 @@ Generated-in-code assets are original and have no file to list: | `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | +| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker | | `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | | `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | diff --git a/CLAUDE.md b/CLAUDE.md index f15d4522b..f5036db11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **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`). -- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- **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 - **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources. @@ -46,4 +46,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. +`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 0633f38f3..51693203d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -218,7 +268,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -564,13 +614,14 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "clap", "craft-ui", "craft-update", "craft-window", @@ -583,6 +634,7 @@ dependencies = [ "rfd", "serde_json", "sys-locale", + "thiserror 2.0.21", "windows", "winit", "winresource", @@ -591,25 +643,30 @@ dependencies = [ [[package]] name = "cadcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-mcp", "cadcraft-render", + "clap", + "clap_complete", + "clap_mangen", + "same-file", "serde_json", + "thiserror 2.0.21", ] [[package]] name = "cadcraft-color" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-constraints" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-doc", "cadcraft-geom", @@ -619,7 +676,7 @@ dependencies = [ [[package]] name = "cadcraft-doc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-color", "cadcraft-geom", @@ -630,14 +687,14 @@ dependencies = [ [[package]] name = "cadcraft-dwg" -version = "0.13.0" +version = "0.14.0" dependencies = [ "acadrust", ] [[package]] name = "cadcraft-dxf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "encoding_rs", "log", @@ -646,7 +703,7 @@ dependencies = [ [[package]] name = "cadcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-color", "cadcraft-constraints", @@ -667,7 +724,7 @@ dependencies = [ [[package]] name = "cadcraft-fonts" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-geom", "harfrust 0.14.0", @@ -677,14 +734,14 @@ dependencies = [ [[package]] name = "cadcraft-geom" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", ] [[package]] name = "cadcraft-io" -version = "0.13.0" +version = "0.14.0" dependencies = [ "acadrust", "cadcraft-color", @@ -705,11 +762,12 @@ dependencies = [ [[package]] name = "cadcraft-mcp" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-render", + "clap", "getrandom 0.4.3", "log", "serde_json", @@ -717,7 +775,7 @@ dependencies = [ [[package]] name = "cadcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -731,7 +789,7 @@ dependencies = [ [[package]] name = "cadcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-color", "cadcraft-doc", @@ -757,7 +815,7 @@ dependencies = [ [[package]] name = "cadcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cadcraft-engine", "cadcraft-io", @@ -863,6 +921,65 @@ dependencies = [ "num-traits", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "5.4.3" @@ -907,6 +1024,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -1068,13 +1191,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1519,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2085,6 +2209,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2387,6 +2517,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.15.0" @@ -3241,6 +3377,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.4.5" @@ -3293,7 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3813,6 +3955,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "ron" version = "0.12.2" @@ -3882,7 +4030,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3939,7 +4087,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4328,6 +4476,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subsetter" version = "0.2.6" @@ -4408,10 +4562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4801,6 +4955,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.29.0" @@ -5311,7 +5471,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5734,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "serde_json", @@ -5955,9 +6115,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 8c4230e22..da6bca3b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] # default-members [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -29,9 +29,13 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false } +clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4.6.11" +clap_mangen = { version = "0.3.3", features = ["env"] } # foundation thiserror = "2" +same-file = "1" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" @@ -61,7 +65,8 @@ eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -69,9 +74,11 @@ arboard = { version = "3.6", default-features = false } unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" type_complexity = "allow" [profile.dev.package."*"] diff --git a/README.md b/README.md index dd0abd25d..c45b619dc 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ crashed survives the next start. The file stops growing at 16 MiB. `--version` w | Variable | Effect | |---|---| -| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) | +| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`); empty means no settings folder (no log file, autosave, workspace or plotters) | | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. | The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead. @@ -238,6 +238,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see [docs/customization.md](docs/customization.md). +`cadcraft-cli --help` documents every subcommand and flag; exit codes, streams, environment +variables and its RFC-0012 conventions are in [docs/cli.md](docs/cli.md). + ## Architecture ```text @@ -311,6 +314,7 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of Installers and executables are code-signed. The installer adds CADCraft to the Start menu and asks whether to add a desktop shortcut too (unticked by default). For a silent install with the desktop shortcut: `msiexec /i cadcraft--windows-x64.msi /qn DESKTOPSHORTCUT=1`. +A silent install is per-user by default; add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's diff --git a/apps/cadcraft-cli/Cargo.toml b/apps/cadcraft-cli/Cargo.toml index b852183f4..cac076c17 100644 --- a/apps/cadcraft-cli/Cargo.toml +++ b/apps/cadcraft-cli/Cargo.toml @@ -6,6 +6,14 @@ edition.workspace = true license.workspace = true publish = false +[lib] +name = "cadcraft_cli" +path = "src/lib.rs" + +[[bin]] +name = "cadcraft-cli" +path = "src/main.rs" + [lints] workspace = true @@ -14,4 +22,13 @@ cadcraft-engine = { workspace = true } cadcraft-io = { workspace = true } cadcraft-mcp = { workspace = true } cadcraft-render = { workspace = true } +clap = { workspace = true } +same-file = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +# Shell completions and man pages (examples/man-and-completions.rs, for the Linux packages); not +# in the binary. +clap_complete = { workspace = true } +clap_mangen = { workspace = true } diff --git a/apps/cadcraft-cli/examples/man-and-completions.rs b/apps/cadcraft-cli/examples/man-and-completions.rs new file mode 100644 index 000000000..ddcfbf062 --- /dev/null +++ b/apps/cadcraft-cli/examples/man-and-completions.rs @@ -0,0 +1,41 @@ +//! Writes cadcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish, +//! PowerShell) and man pages ([`clap_mangen`]: `cadcraft-cli.1` and one per subcommand, such as +//! `cadcraft-cli-convert.1`) into a directory. The Linux packages ship them +//! (`packaging/linux/package.sh`): +//! +//! ```sh +//! cargo run -p cadcraft-cli --example man-and-completions -- +//! ``` + +use std::path::Path; +use std::process::ExitCode; + +use clap::ValueEnum; +use clap_complete::Shell; + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let (Some(dir), None) = (args.next(), args.next()) else { + eprintln!("usage: man-and-completions "); + return ExitCode::from(2); + }; + let dir = Path::new(&dir); + match generate(dir) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}: {e}", dir.display()); + ExitCode::FAILURE + } + } +} + +pub(crate) fn generate(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut cmd = cadcraft_cli::command(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut cmd, "cadcraft-cli", dir)?; + } + // A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand, + // which `clap_mangen` would otherwise give pages of its own (`cadcraft-cli-help-convert.1`). + clap_mangen::generate_to(cadcraft_cli::command(), dir) +} diff --git a/apps/cadcraft-cli/src/args.rs b/apps/cadcraft-cli/src/args.rs new file mode 100644 index 000000000..16b57eeb7 --- /dev/null +++ b/apps/cadcraft-cli/src/args.rs @@ -0,0 +1,786 @@ +//! The command line, as clap types. Values are parsed into their types here and the relations +//! between flags are checked, so a bad value or combination is a usage error (exit 2) naming the +//! flag, before any work starts; the handlers receive typed values only. A flag a subcommand +//! doesn't take is a usage error too, so a typo can't be silently ignored. A value flag never takes +//! the next flag as its value: a value starting with `-` is written `--save=-out.dxf` (or +//! `./-out.dxf`). + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_io::Format; +use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars}; +use clap::builder::{OsStringValueParser, PathBufValueParser, TypedValueParser}; +use clap::error::ErrorKind; +use clap::parser::ValueSource; +use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; +use serde_json::Value; + +/// Help text that names the formats of `list`, built once from [`Format::READ`] or [`Format::WRITE`] +/// so it can't drift from what the code accepts. +fn with_formats(cell: &'static std::sync::OnceLock, before: &str, list: &[Format], after: &str) -> &'static str { + cell.get_or_init(|| format!("{before}({}){after}", Format::list(list))) +} + +/// The drawing an input argument names. +fn read_help() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "The drawing to read ", Format::READ, "") +} + +fn open_help() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "Drawings to open ", Format::READ, "") +} + +fn write_help() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "The file to write; its extension picks the format ", Format::WRITE, "") +} + +fn save_help() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "Save the drawing to this file; its extension picks the format ", Format::WRITE, "") +} + +fn export_help() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "Export the drawing to this file; its extension picks the format ", Format::WRITE, "") +} + +fn convert_about() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + with_formats(&C, "Convert a drawing; OUT's extension picks the format ", Format::WRITE, "") +} + +fn convert_long_about() -> &'static str { + static C: std::sync::OnceLock = std::sync::OnceLock::new(); + C.get_or_init(|| { + format!( + "{}\n\nImage output (png 2400x1600, svg, pdf) shows model space, fitted to the drawing extents unless\n--window or --view says otherwise.", + convert_about() + ) + }) +} + +/// cadcraft-cli: headless CADCraft +#[derive(Parser)] +#[command(name = "cadcraft-cli", version = cadcraft_engine::build_version(), arg_required_else_help = true)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand)] +pub(crate) enum Command { + Info(InfoArgs), + Convert(ConvertArgs), + Run(RunArgs), + Commands(CommandsArgs), + Mcp(McpArgs), + Perf(PerfArgs), + Sample(SampleArgs), +} + +/// Print a drawing's summary as JSON +#[derive(Args)] +pub(crate) struct InfoArgs { + #[arg(help = read_help())] + pub(crate) file: PathBuf, + /// One line of JSON instead of pretty-printed + #[arg(long)] + pub(crate) compact: bool, +} + +/// Convert a drawing; OUT's extension picks the format +#[derive(Args)] +#[command(about = convert_about(), long_about = convert_long_about())] +pub(crate) struct ConvertArgs { + #[arg(value_name = "IN", help = read_help())] + pub(crate) input: PathBuf, + #[arg(value_name = "OUT", help = write_help())] + pub(crate) output: PathBuf, + #[command(flatten)] + pub(crate) framing: FramingArgs, + /// Allow an output that is an input file, replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) settings: Settings, +} + +/// The settings the desktop app reads from its environment, for the subcommands that draw or run +/// commands. A variable is the flag's fallback; an empty one counts as unset, except +/// `CADCRAFT_CONFIG_DIR`. +#[derive(Args)] +#[command(next_help_heading = "Settings")] +pub(crate) struct Settings { + /// The settings folder; plotter configurations are in its `plotters` folder (default: the + /// platform's settings folder). An empty value means no settings folder + // Not RFC-0012 rule 1.7's "an empty variable counts as unset": an empty value is the + // documented way to run with no settings folder (isolated runs, tests), and it predates the + // CLI port. Keep it out of any blanket treatment of empty variables. + #[arg(long, value_name = "DIR", env = "CADCRAFT_CONFIG_DIR", value_parser = OsStringValueParser::new().map(PathBuf::from))] + pub(crate) config_dir: Option, + /// The font that replaces a missing one (FONTALT) + #[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")] + pub(crate) fontalt: Option, + /// The fonts tried, in order, for characters a font lacks, comma-separated (FONTFALLBACK) + #[arg(long, value_name = "FONTS", env = "CADCRAFT_FONTFALLBACK")] + pub(crate) fontfallback: Option, +} + +impl Settings { + /// Install the settings for this run; an unset one is the default. An empty or blank + /// `--config-dir` is no settings folder. + pub(crate) fn apply(&self) { + cadcraft_engine::settings_dir::set(self.settings_dir()); + cadcraft_engine::fonts::ttf::set_font_alt(self.fontalt.as_deref().unwrap_or("")); + cadcraft_engine::fonts::ttf::set_fallback_fonts(self.fontfallback.as_deref().unwrap_or("")); + } + + /// The settings folder: `--config-dir`, else the platform's; `None` when the flag is empty or + /// blank. + fn settings_dir(&self) -> Option { + match &self.config_dir { + Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None, + Some(dir) => Some(dir.clone()), + None => cadcraft_engine::settings_dir::platform(|k| std::env::var_os(k)), + } + } +} + +// What part of model space an image shows: shared by `convert` and `run`. +#[derive(Args)] +pub(crate) struct FramingArgs { + /// Show this rectangle (drawing units, x0, + /// What part of model space the image shows (case-insensitive) + #[arg(long, value_name = "extents|current", value_enum, ignore_case = true)] + pub(crate) view: Option, +} + +/// `--view`. +#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] +pub(crate) enum ViewMode { + /// Always fit the drawing extents + Extents, + /// The current view, as last set by zoom, pan or view.set (a freshly opened drawing's view is + /// its extents plus a 10% margin) + Current, +} + +/// Open drawings (or start a new one), run scripts and commands in order, save or export the result +/// +/// Drawings open in the order given, and the last one is current; without any, a new imperial +/// drawing is used. --script, --script-file and --cmd steps run in the order given, and each +/// --cmd prints its JSON result on one line (JSON lines); a command with no result prints nothing. +/// An image --export (png 2400x1600, svg, pdf) shows model space: +/// the current view if the steps changed it (zoom, pan, view.set…), otherwise the drawing +/// extents, unless --window or --view says otherwise. +#[derive(Args)] +#[command(group(ArgGroup::new("framing").args(["window", "view"]).multiple(true).requires("export")))] +#[command(after_long_help = r#"--cmd chaining: + A --cmd parameter value of exactly "{last}" is replaced with the last object handle an + earlier --cmd in the same run returned, so a command can act on what the previous one made: + --cmd 'mview {"p1":[20,20],"p2":[200,150]}' prints {"viewports":["100"]} + --cmd 'viewport.set {"handle":"{last}","scale":1}' changes viewport 100 + It works in any parameter, at any depth (e.g. "handles":["{last}"]); every other string is + passed unchanged. With no earlier returned handle the run fails and says so."#)] +pub(crate) struct RunArgs { + #[arg(value_name = "FILE", help = open_help())] + pub(crate) files: Vec, + /// Open the built-in sample drawing + #[arg(long)] + pub(crate) sample: bool, + /// Start a new metric drawing + #[arg(long)] + pub(crate) metric: bool, + /// Command-line script text to run (`\n` separates lines) + #[arg(long, value_name = "TEXT", action = ArgAction::Append)] + pub(crate) script: Vec, + /// A script file (.scr) to run + #[arg(long, value_name = "F.scr", action = ArgAction::Append)] + pub(crate) script_file: Vec, + /// An engine command and its JSON parameters (default {}); see "--cmd chaining" in --help + #[arg(long, value_name = "id {json}", action = ArgAction::Append, value_parser = cmd_step)] + pub(crate) cmd: Vec, + #[arg(long, value_name = "OUT.dxf", help = save_help())] + pub(crate) save: Option, + #[arg(long, value_name = "OUT.png", help = export_help())] + pub(crate) export: Option, + #[command(flatten)] + pub(crate) framing: FramingArgs, + /// Allow an output that is an input file, replacing the original + #[arg(long)] + pub(crate) in_place: bool, + #[command(flatten)] + pub(crate) settings: Settings, + /// The drawings, in command-line order, set by [`parse`]. + #[arg(skip)] + pub(crate) drawings: Vec, + /// The steps, in command-line order, set by [`parse`]. + #[arg(skip)] + pub(crate) steps: Vec, +} + +/// A drawing `run` opens. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Drawing { + File(PathBuf), + Sample, + Metric, +} + +/// A `--script`, `--script-file` or `--cmd` of `run`. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Step { + Script(String), + ScriptFile(PathBuf), + Cmd(CmdStep), +} + +/// `--cmd 'id {json}'`: an engine command and its parameters. `{last}` placeholders are filled in +/// when it runs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CmdStep { + pub(crate) id: String, + pub(crate) params: Value, +} + +/// Print the command catalog as JSON +#[derive(Args)] +pub(crate) struct CommandsArgs { + /// Only the commands whose id or label contains this text (case-insensitive) + pub(crate) filter: Option, + /// One line of JSON instead of pretty-printed + #[arg(long)] + pub(crate) compact: bool, +} + +/// Run the MCP server on stdio: a headless drawing session, or a bridge to the running app +#[derive(Args)] +pub(crate) struct McpArgs { + /// Bridge to the app's control channel (its --control port, e.g. 127.0.0.1:7979) + #[arg(long, value_name = "HOST:PORT", value_parser = connect_addr)] + pub(crate) connect: Option, + /// Reach the app at the first call instead of at startup, so the server can start before the + /// app is open + #[arg(long, requires = "connect")] + pub(crate) lazy: bool, + /// Like --lazy, and a call that finds nothing at HOST:PORT opens the app with that control + /// port first (macOS) + #[arg(long, requires = "connect")] + pub(crate) launch: bool, + #[command(flatten)] + pub(crate) settings: Settings, +} + +/// Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list +/// build of a drawing, zoomed in step by step +#[derive(Args)] +pub(crate) struct PerfArgs { + /// An entity count (clamped to 1-20000000), or a drawing to time + #[arg(value_name = "N|FILE", value_parser = PathBufValueParser::new().map(perf_target))] + pub(crate) target: Option, +} + +/// What `perf` times. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PerfTarget { + /// A synthetic drawing with this many entities, within [`MAX_PERF_COUNT`]. + Count(usize), + File(PathBuf), +} + +/// The largest synthetic drawing `perf` builds. +const MAX_PERF_COUNT: usize = 20_000_000; + +/// `perf N|FILE`: a value that reads as a count is one, clamped to 1..=[`MAX_PERF_COUNT`] so `perf +/// 0` still times something; anything else is a drawing. +fn perf_target(p: PathBuf) -> PerfTarget { + match p.to_str().and_then(|t| t.parse::().ok()) { + Some(n) => PerfTarget::Count(n.clamp(1, MAX_PERF_COUNT)), + None => PerfTarget::File(p), + } +} + +/// Write a built-in sample drawing +#[derive(Args)] +pub(crate) struct SampleArgs { + /// Which sample + #[arg(value_name = "bracket|floorplan", value_enum)] + pub(crate) name: SampleName, + #[arg(value_name = "OUT", help = write_help())] + pub(crate) output: PathBuf, + #[command(flatten)] + pub(crate) settings: Settings, +} + +/// `sample`'s drawings. +#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)] +pub(crate) enum SampleName { + Bracket, + #[value(alias = "floor")] + Floorplan, +} + +/// What part of model space an image export shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Framing { + /// `run`: the current view if the commands changed it, else the extents. `convert`: extents. + Auto, + Extents, + Current, + Window(Bounds2), +} + +/// Why a `--window` value is refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +pub(crate) enum WindowError { + #[error("expected four numbers x0,y0,x1,y1")] + NeedsFour, + #[error("{name} must be a finite number, got `{value}`")] + NotFinite { name: &'static str, value: String }, + #[error("need x0 < x1 and y0 < y1 (lower-left corner first)")] + Order, + #[error(transparent)] + Limits(#[from] cadcraft_io::IoError), +} + +/// Why a value is refused: clap prints it after the flag and its value (exit 2). +#[derive(Debug, thiserror::Error)] +pub(crate) enum ValueError { + #[error("expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")] + Connect, + #[error("expected a command id, then optionally its JSON parameters")] + CmdId, + #[error(transparent)] + CmdJson(#[from] serde_json::Error), +} + +/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2). +#[derive(Debug, thiserror::Error)] +enum UsageError { + /// The framing flags choose what an image shows; a drawing file has nothing to frame. + #[error("{flag} only applies to image output (png, svg, pdf), not `{}`", .out.display())] + FramingNeedsImage { flag: &'static str, out: PathBuf }, + /// The output is one of the input files, so writing it would replace the original. + #[error("{} is an input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())] + OutIsInput(PathBuf), +} + +impl UsageError { + fn into_clap(self, cmd: &mut clap::Command) -> clap::Error { + let kind = match self { + Self::FramingNeedsImage { .. } | Self::OutIsInput(_) => ErrorKind::ArgumentConflict, + }; + cmd.error(kind, self) + } +} + +/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics. +pub(crate) fn parse_window(text: &str) -> Result { + let parts: Vec<&str> = text.split(',').map(str::trim).collect(); + let [x0, y0, x1, y1] = parts.as_slice() else { + return Err(WindowError::NeedsFour); + }; + let num = |name: &'static str, v: &str| -> Result { + match v.parse::() { + Ok(n) if n.is_finite() => Ok(n), + _ => Err(WindowError::NotFinite { name, value: v.to_string() }), + } + }; + let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?); + if !(x0 < x1 && y0 < y1) { + return Err(WindowError::Order); + } + let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + cadcraft_io::check_window(&w)?; + Ok(w) +} + +/// `run --cmd 'id {json}'`: the id up to the first space, then JSON parameters (default `{}`). +/// Whether they suit the command is the command's to say. +fn cmd_step(s: &str) -> Result { + let (id, json) = s.split_once(' ').unwrap_or((s, "{}")); + if id.is_empty() { + return Err(ValueError::CmdId); + } + Ok(CmdStep { id: id.to_string(), params: serde_json::from_str(json)? }) +} + +impl FramingArgs { + /// The framing `--window` or `--view` asks for (clap refuses both together). + pub(crate) fn framing(&self) -> Framing { + match (self.window, self.view) { + (Some(w), _) => Framing::Window(w), + (None, Some(ViewMode::Extents)) => Framing::Extents, + (None, Some(ViewMode::Current)) => Framing::Current, + (None, None) => Framing::Auto, + } + } + + /// The framing flag given, if any. + fn flag(&self) -> Option<&'static str> { + if self.window.is_some() { + Some("--window") + } else if self.view.is_some() { + Some("--view") + } else { + None + } + } +} + +/// A framing flag given with an output that isn't an image (the extension decides). `run` without +/// `--export` is clap's `requires` error. +fn framing_error(cli: &Cli) -> Option { + let (framing, out): (&FramingArgs, Option<&Path>) = match &cli.command { + Command::Convert(c) => (&c.framing, Some(&c.output)), + Command::Run(r) => (&r.framing, r.export.as_deref()), + _ => return None, + }; + let (flag, out) = (framing.flag()?, out?); + (!cadcraft_io::is_image(out)).then(|| UsageError::FramingNeedsImage { flag, out: out.to_path_buf() }) +} + +/// An output that is the same file as an input, without `--in-place`. An output that doesn't exist +/// can't be an input, and an I/O error in the comparison is left to the open or write that follows. +fn out_is_input(cli: &Cli) -> Option { + let same = |input: &Path, out: &Path| out.exists() && same_file::is_same_file(input, out).unwrap_or(false); + match &cli.command { + Command::Convert(c) if !c.in_place && same(&c.input, &c.output) => Some(UsageError::OutIsInput(c.output.clone())), + Command::Run(r) if !r.in_place => { + let inputs = r.drawings.iter().filter_map(|d| if let Drawing::File(f) = d { Some(f) } else { None }); + let inputs: Vec<&PathBuf> = inputs.collect(); + [&r.save, &r.export].into_iter().flatten().find(|out| inputs.iter().any(|i| same(i, out))).map(|out| UsageError::OutIsInput(out.clone())) + } + _ => None, + } +} + +/// The backend `cadcraft-cli mcp` serves from. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum McpTarget { + /// An in-process drawing session (no `--connect`). + Headless, + /// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call; + /// `launch`: opened then if it isn't running. + Connect { addr: String, lazy: bool, launch: bool }, +} + +impl McpArgs { + pub(crate) fn target(&self) -> McpTarget { + match &self.connect { + // `--launch` connects at the first call too (the app opens then). + Some(addr) => McpTarget::Connect { addr: addr.clone(), lazy: self.lazy || self.launch, launch: self.launch }, + None => McpTarget::Headless, + } + } +} + +/// `mcp --connect`: an empty, flag-like or port-less address is an error, never a silent fall back +/// to a headless session (#103). The host is not resolved here. +fn connect_addr(s: &str) -> Result { + let s = s.trim(); + let valid = !s.starts_with('-') + && s.rsplit_once(':').is_some_and(|(host, port)| { + let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host); + !host.is_empty() && port.parse::().is_ok() + }); + if valid { Ok(s.to_string()) } else { Err(ValueError::Connect) } +} + +/// The values of `id` with their command-line indices. +fn indexed<'a, T: Clone + Send + Sync + 'static>(m: &'a ArgMatches, id: &str) -> impl Iterator { + let at = m.indices_of(id).into_iter().flatten(); + let values = m.try_get_many::(id).ok().flatten().into_iter().flatten(); + at.zip(values) +} + +/// The command-line index of flag `id`, when it was given. +fn flag_index(m: &ArgMatches, id: &str) -> Option { + if m.value_source(id) == Some(ValueSource::CommandLine) { m.indices_of(id).and_then(Iterator::last) } else { None } +} + +/// `run`'s drawings and steps back in command-line order: clap keeps each flag's values apart, +/// their indices say how they interleave. +fn run_order(m: &ArgMatches) -> (Vec, Vec) { + let mut drawings: Vec<(usize, Drawing)> = indexed(m, "files").map(|(i, f): (_, &PathBuf)| (i, Drawing::File(f.clone()))).collect(); + drawings.extend(flag_index(m, "sample").map(|i| (i, Drawing::Sample))); + drawings.extend(flag_index(m, "metric").map(|i| (i, Drawing::Metric))); + drawings.sort_by_key(|d| d.0); + let mut steps: Vec<(usize, Step)> = indexed(m, "script").map(|(i, s): (_, &String)| (i, Step::Script(s.clone()))).collect(); + steps.extend(indexed(m, "script_file").map(|(i, s): (_, &PathBuf)| (i, Step::ScriptFile(s.clone())))); + steps.extend(indexed(m, "cmd").map(|(i, c): (_, &CmdStep)| (i, Step::Cmd(c.clone())))); + steps.sort_by_key(|s| s.0); + (drawings.into_iter().map(|d| d.1).collect(), steps.into_iter().map(|s| s.1).collect()) +} + +/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not +/// be valid Unicode, text must. A bare `--help` or `-h` is help even after a mistake on the same +/// line (`run --script --help`): clap reports the mistake first. +pub(crate) fn parse(args: &[OsString]) -> Result { + parse_with(command(|k| std::env::var_os(k)), args) +} + +/// The command line. An empty or blank variable counts as unset, except `CADCRAFT_CONFIG_DIR`: +/// empty means no settings folder. `var` looks a variable up. +fn command(var: impl Fn(&OsStr) -> Option + Copy) -> clap::Command { + let blank = move |a: clap::Arg| if a.get_id() == "config_dir" { a } else { blank_env_is_unset(a, var) }; + Cli::command().mut_args(blank).mut_subcommands(|s| s.mut_args(blank)) +} + +/// [`parse`] with `cmd` from [`command`]; a bad value from a variable is a usage error naming the +/// variable. +fn parse_with(mut cmd: clap::Command, args: &[OsString]) -> Result { + let argv = std::iter::once(OsString::from("cadcraft-cli")).chain(args.iter().cloned()); + let matches = match cmd.try_get_matches_from_mut(argv) { + // `use_stderr`: a real error, not the help `-h` asked for (a retry would make it `--help`). + Err(e) if e.use_stderr() && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => { + let help = [OsString::from("cadcraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")]; + return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e)); + } + matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?, + }; + let mut cli = Cli::from_arg_matches(&matches)?; + if let (Command::Run(r), Some((_, m))) = (&mut cli.command, matches.subcommand()) { + (r.drawings, r.steps) = run_order(m); + } + if let Some(e) = framing_error(&cli).or_else(|| out_is_input(&cli)) { + if let Some(sub) = matches.subcommand_name().and_then(|n| cmd.find_subcommand_mut(n)) { + return Err(e.into_clap(sub)); + } + return Err(e.into_clap(&mut cmd)); + } + Ok(cli) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn parse_args(a: &[&str]) -> Result { + parse(&a.iter().map(OsString::from).collect::>()) + } + + fn mcp(a: &[&str]) -> Result { + let args: Vec<&str> = std::iter::once("mcp").chain(a.iter().copied()).collect(); + match parse_args(&args)?.command { + Command::Mcp(m) => Ok(m.target()), + _ => panic!("{a:?} is not mcp"), + } + } + + /// Every file argument's help names each format of the list it uses. + #[test] + fn help_names_every_format() { + let cli = Cli::command(); + let help = |sub: &str, arg: &str| { + let a = cli.find_subcommand(sub).and_then(|c| c.get_arguments().find(|a| a.get_id() == arg)).expect("argument exists"); + a.get_help().map(|h| h.to_string()).unwrap_or_default() + }; + let cases: [(&str, &str, &[Format]); 8] = [ + ("info", "file", Format::READ), + ("convert", "input", Format::READ), + ("convert", "output", Format::WRITE), + ("run", "files", Format::READ), + ("run", "save", Format::WRITE), + ("run", "export", Format::WRITE), + ("sample", "output", Format::WRITE), + ("convert", "", Format::WRITE), + ]; + for (sub, arg, list) in cases { + let text = if arg.is_empty() { + cli.find_subcommand(sub).and_then(|c| c.get_about()).map(|a| a.to_string()).unwrap_or_default() + } else { + help(sub, arg) + }; + for f in list { + assert!(text.contains(f.extension()), "`{sub} {arg}` help `{text}` lacks {}", f.extension()); + } + } + } + + #[test] + fn mcp_without_connect_is_headless() { + assert_eq!(mcp(&[]).unwrap(), McpTarget::Headless); + } + + #[test] + fn mcp_connect_with_address_selects_remote() { + let to = |addr: &str, lazy, launch| McpTarget::Connect { addr: addr.into(), lazy, launch }; + assert_eq!(mcp(&["--connect", "127.0.0.1:7979"]).unwrap(), to("127.0.0.1:7979", false, false)); + assert_eq!(mcp(&["--connect", " localhost:7979 "]).unwrap(), to("localhost:7979", false, false)); + assert_eq!(mcp(&["--lazy", "--connect", "localhost:7979"]).unwrap(), to("localhost:7979", true, false)); + assert_eq!(mcp(&["--connect", "localhost:7979", "--launch"]).unwrap(), to("localhost:7979", true, true)); + assert_eq!(mcp(&["--connect", "[::1]:7979"]).unwrap(), to("[::1]:7979", false, false)); + // Without an app to reach they mean nothing: never a headless session. + for flag in ["--lazy", "--launch"] { + assert_eq!(mcp(&[flag]).unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument, "{flag}"); + } + } + + #[test] + fn mcp_connect_without_address_is_an_error_not_headless() { + for bad in [ + &["--connect"][..], + &["--connect", ""], + &["--connect", " "], + &["--connect=-v"], + &["--connect", "example.com"], + &["--connect", ":7979"], + &["--connect", "host:99999"], + &["--connect", "[]:1"], + ] { + let r = mcp(bad); + let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") }; + assert_eq!(e.exit_code(), 2, "{bad:?}"); + assert!(e.to_string().contains("--connect "), "{bad:?}: {e}"); + } + } + + #[test] + fn mcp_rejects_unknown_and_repeated_arguments() { + assert!(mcp(&["--conect", "127.0.0.1:7979"]).is_err()); + assert!(mcp(&["127.0.0.1:7979"]).is_err()); + // clap names the unknown flag, not the value it left missing. + for flag in ["--foo", "-v"] { + assert_eq!(mcp(&["--connect", flag]).unwrap_err().kind(), clap::error::ErrorKind::UnknownArgument, "{flag}"); + } + assert!(mcp(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"]).is_err()); + } + + #[test] + fn run_keeps_command_line_order() { + let args = + ["run", "a.dxf", "--script", "S1", "--metric", "--cmd", "c1", "b.dxf", "--script-file", "f.scr", "--sample", "--cmd", "c2 {\"n\":1}"]; + let Command::Run(r) = parse_args(&args).unwrap().command else { panic!("not run") }; + let file = |f: &str| Drawing::File(f.into()); + let cmd = |id: &str, params| Step::Cmd(CmdStep { id: id.into(), params }); + assert_eq!(r.drawings, [file("a.dxf"), Drawing::Metric, file("b.dxf"), Drawing::Sample]); + assert_eq!(r.steps, [Step::Script("S1".into()), cmd("c1", json!({})), Step::ScriptFile("f.scr".into()), cmd("c2", json!({"n": 1}))]); + } + + #[test] + fn cmd_values() { + assert_eq!(cmd_step("line").unwrap(), CmdStep { id: "line".into(), params: json!({}) }); + assert_eq!(cmd_step("line [1]").unwrap(), CmdStep { id: "line".into(), params: json!([1]) }); + for bad in ["", " {}", "line {bad json", "line "] { + assert!(cmd_step(bad).is_err(), "`{bad}` should be rejected"); + } + } + + #[test] + fn perf_targets() { + let target = |a: &[&str]| -> Option { + let args: Vec<&str> = std::iter::once("perf").chain(a.iter().copied()).collect(); + let Command::Perf(p) = parse_args(&args).unwrap().command else { panic!("not perf") }; + p.target + }; + assert_eq!(target(&[]), None); + assert_eq!(target(&["5"]), Some(PerfTarget::Count(5))); + assert_eq!(target(&["0"]), Some(PerfTarget::Count(1))); + assert_eq!(target(&["999999999"]), Some(PerfTarget::Count(MAX_PERF_COUNT))); + assert_eq!(target(&["a.dxf"]), Some(PerfTarget::File("a.dxf".into()))); + assert_eq!(target(&["1e3"]), Some(PerfTarget::File("1e3".into()))); + } + + #[test] + fn framing_options() { + let framing = |out: &str, a: &[&str]| -> Result { + let args: Vec<&str> = ["convert", "a.dxf", out].iter().chain(a).copied().collect(); + let Command::Convert(c) = parse_args(&args)?.command else { panic!("not convert") }; + Ok(c.framing.framing()) + }; + let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)); + assert_eq!(framing("b.png", &[]).unwrap(), Framing::Auto); + assert_eq!(framing("b.png", &["--view", "extents"]).unwrap(), Framing::Extents); + assert_eq!(framing("b.png", &["--view", "CURRENT"]).unwrap(), Framing::Current); + assert_eq!(framing("b.png", &["--window", "0,0,2,1"]).unwrap(), Framing::Window(b(0.0, 0.0, 2.0, 1.0))); + assert_eq!(framing("b.dxf", &[]).unwrap(), Framing::Auto); + let kind = |out: &str, a: &[&str]| framing(out, a).map(|_| ()).unwrap_err().kind(); + assert_eq!(kind("b.png", &["--view", "selection"]), ErrorKind::InvalidValue); + assert_eq!(kind("b.png", &["--window", "0,0,2,1", "--view", "extents"]), ErrorKind::ArgumentConflict); + assert_eq!(kind("b.dxf", &["--view", "extents"]), ErrorKind::ArgumentConflict); + assert_eq!(kind("b.dxf", &["--window", "0,0,2,1"]), ErrorKind::ArgumentConflict); + } + + #[test] + fn window_parses() { + let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)); + assert_eq!(parse_window("0,0,30,20").unwrap(), b(0.0, 0.0, 30.0, 20.0)); + assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 ").unwrap(), b(-5.5, -1000.0, 2000.0, 4.0)); + } + + #[test] + fn hostile_window_is_an_error() { + for w in [ + "", + "1,2,3", + "1,2,3,4,5", + "a,0,1,1", + "NaN,0,1,1", + "0,0,inf,1", + "0,0,1e400,1", + "0,0,0,1", + "0,0,1,0", + "5,0,1,1", + "0,5,1,1", + "-1e300,-1e300,1e300,1e300", + "0,0,1e-12,1e-12", + ] { + assert!(parse_window(w).is_err(), "`{w}` should be rejected"); + } + } + + /// The subcommands that draw or run commands take the settings flags; the others refuse them. + #[test] + fn settings_flags() { + let Command::Convert(c) = + parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p", "--fontalt", "Noto Sans", "--fontfallback="]).unwrap().command + else { + panic!("not convert") + }; + assert_eq!(c.settings.config_dir, Some(PathBuf::from("/p"))); + assert_eq!((c.settings.fontalt.as_deref(), c.settings.fontfallback.as_deref()), (Some("Noto Sans"), Some(""))); + for empty in ["--config-dir=", "--config-dir= "] { + let Command::Run(r) = parse_args(&["run", "--sample", empty]).unwrap().command else { panic!("not run") }; + assert_eq!(r.settings.settings_dir(), None, "`{empty}` is no settings folder"); + } + let Command::Convert(c) = parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p"]).unwrap().command else { panic!("not convert") }; + assert_eq!(c.settings.settings_dir(), Some(PathBuf::from("/p"))); + assert!(parse_args(&["sample", "bracket", "b.dxf", "--fontalt", "x"]).is_ok()); + assert!(parse_args(&["mcp", "--config-dir", "/p"]).is_ok()); + assert!(parse_args(&["info", "a.dxf", "--config-dir", "/p"]).is_err()); + } + + #[test] + fn blank_variables_count_as_unset_except_the_config_dir() { + let cmd = command(|_| Some("".into())); + for sub in cmd.get_subcommands().filter(|s| s.get_arguments().any(|a| a.get_id() == "fontalt")) { + let env = |id: &str| sub.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env()); + assert_eq!((env("fontalt"), env("fontfallback")), (None, None), "{}", sub.get_name()); + assert_eq!(env("config_dir"), Some(OsStr::new("CADCRAFT_CONFIG_DIR")), "{}", sub.get_name()); + } + } + + /// `--fontalt` falls back to `PATH` and rejects every value here, as a bad value from a + /// variable would; tests can't set a variable (`set_var` is unsafe). + #[test] + fn a_bad_value_from_a_variable_names_the_variable() { + // Cargo sets CARGO_PKG_NAME for every test binary, so the variable always has a value. + let reject = clap::builder::PossibleValuesParser::new(["never"]); + let cmd = || Cli::command().mut_subcommand("sample", |s| s.mut_arg("fontalt", |a| a.env("CARGO_PKG_NAME").value_parser(reject.clone()))); + let args = |a: &[&str]| a.iter().map(OsString::from).collect::>(); + let text = parse_with(cmd(), &args(&["sample"])).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(text.contains("for 'CARGO_PKG_NAME'") && !text.contains("--fontalt"), "{text}"); + let text = parse_with(cmd(), &args(&["sample", "--fontalt", "x"])).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(text.contains("--fontalt"), "{text}"); + } +} diff --git a/apps/cadcraft-cli/src/commands.rs b/apps/cadcraft-cli/src/commands.rs new file mode 100644 index 000000000..51fc06606 --- /dev/null +++ b/apps/cadcraft-cli/src/commands.rs @@ -0,0 +1,17 @@ +use super::args::CommandsArgs; +use super::error::Result; +use super::output::Streams; +use cadcraft_engine::Session; +use serde_json::Value; + +pub(crate) fn commands(a: &CommandsArgs, io: &mut Streams) -> Result { + let s = Session::new(); + let f = a.filter.as_ref().map(|x| x.to_ascii_lowercase()); + let v: Vec = cadcraft_engine::command_specs() + .iter() + .filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str()))) + .map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default()) + .collect(); + io.json(&Value::Array(v), a.compact); + Ok(()) +} diff --git a/apps/cadcraft-cli/src/convert.rs b/apps/cadcraft-cli/src/convert.rs new file mode 100644 index 000000000..6a9c4edbf --- /dev/null +++ b/apps/cadcraft-cli/src/convert.rs @@ -0,0 +1,22 @@ +use super::args::ConvertArgs; +use super::error::Result; +use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message}; +use super::{open, unicode}; +use cadcraft_engine::Session; + +pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result { + let i = unicode(&a.input)?; + let framing = a.framing.framing(); + let mut s = Session::empty(); + open(&mut s, &a.input)?; + if let Some(m) = skipped_message(s.doc()?, i) { + io.errln(format_args!("{m}")); + } + if cadcraft_io::is_image(&a.output) + && let Some(m) = undrawn_message(s.doc()?, i) + { + io.errln(format_args!("{m}")); + } + let window = export_window(&s, framing, None)?; + export_framed(&s, &a.output, window, io) +} diff --git a/apps/cadcraft-cli/src/error.rs b/apps/cadcraft-cli/src/error.rs new file mode 100644 index 000000000..51b816890 --- /dev/null +++ b/apps/cadcraft-cli/src/error.rs @@ -0,0 +1,29 @@ +use crate::output::OutputError; +use crate::perf::PerfError; +use crate::run::RunError; + +/// Why a subcommand failed: printed as `error: {e}`, exit 1. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CliError { + #[error(transparent)] + Engine(#[from] cadcraft_engine::EngineError), + #[error(transparent)] + Doc(#[from] cadcraft_engine::doc::DocError), + #[error(transparent)] + Format(#[from] cadcraft_io::IoError), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Run(#[from] RunError), + #[error(transparent)] + Output(#[from] OutputError), + #[error(transparent)] + Perf(#[from] PerfError), + /// The engine and io crates take file names as text. + #[error("{}: the file name is not valid Unicode", .0.display())] + NotUnicode(std::path::PathBuf), + #[error("cannot connect to {addr}: {source}")] + McpConnect { addr: String, source: std::io::Error }, +} + +pub(crate) type Result = std::result::Result; diff --git a/apps/cadcraft-cli/src/info.rs b/apps/cadcraft-cli/src/info.rs new file mode 100644 index 000000000..bac94108e --- /dev/null +++ b/apps/cadcraft-cli/src/info.rs @@ -0,0 +1,14 @@ +use super::args::InfoArgs; +use super::error::Result; +use super::open; +use super::output::Streams; +use cadcraft_engine::Session; +use serde_json::json; + +pub(crate) fn info(a: &InfoArgs, io: &mut Streams) -> Result { + let mut s = Session::empty(); + open(&mut s, &a.file)?; + let v = s.execute("drawing.inspect", &json!({ "entities": false }))?; + io.json(&v, a.compact); + Ok(()) +} diff --git a/apps/cadcraft-cli/src/lib.rs b/apps/cadcraft-cli/src/lib.rs new file mode 100644 index 000000000..c858417e2 --- /dev/null +++ b/apps/cadcraft-cli/src/lib.rs @@ -0,0 +1,315 @@ +//! `cadcraft-cli`: headless CADCraft. The subcommands and their options are the clap types in +//! `args.rs`; `cadcraft-cli --help` lists them. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::ffi::OsString; +use std::io::Write; + +use cadcraft_engine::Session; +use serde_json::json; + +mod args; +mod commands; +mod convert; +mod error; +mod info; +mod mcp; +mod output; +mod perf; +mod run; +mod sample; + +#[cfg(test)] +use args::Framing; +use args::{Command, parse}; +use clap::CommandFactory; +use commands::commands; +use convert::convert; +use error::Result; +use info::info; +use mcp::mcp; +use output::Streams; +#[cfg(test)] +use output::{export_window, model_views, write_atomic}; +use perf::perf; +use run::run_cmds; +#[cfg(test)] +use run::{fill_last, returned_handle}; +use sample::sample; + +fn install_io() { + cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { + read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), + write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), + plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), + }); + cadcraft_engine::cmd::file::set_write_as(|d, name, o| { + cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) + }); + // AutoLISP's entget, entmake and entmod see entities as DXF groups. + cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec { + groups: cadcraft_io::entity_groups, + entity: cadcraft_io::entity_from_groups, + }); +} + +/// `path` as text, for the engine and io calls that take a file name as a string. +fn unicode(path: &std::path::Path) -> Result<&str> { + path.to_str().ok_or_else(|| error::CliError::NotUnicode(path.to_path_buf())) +} + +fn open(s: &mut Session, path: &std::path::Path) -> Result { + s.execute("open", &json!({ "path": unicode(path)? }))?; + Ok(()) +} + +/// The command line's clap definition, for generating shell completions and man pages. +pub fn command() -> clap::Command { + args::Cli::command() +} + +/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok, +/// 1 failure, 2 usage). clap reads the arguments as they are: a value that is not valid Unicode +/// is a usage error, never a panic as with `std::env::args()`. +pub fn run(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32 +where + I: IntoIterator, + T: Into, +{ + let args: Vec = args.into_iter().map(Into::into).collect(); + // Help and `--version` (`help`, `-h`, ` --help`, `-V`) come back as errors too, + // for `out`, exit 0: the subcommand never runs. + let cli = match parse(&args) { + Ok(cli) => cli, + Err(e) => { + let to: &mut dyn Write = if e.use_stderr() { err } else { out }; + return match write!(to, "{}", e.render()) { + Err(w) if w.kind() != std::io::ErrorKind::BrokenPipe => { + let _ = writeln!(err, "error: can't write the output: {w}"); + 1 + } + _ => e.exit_code(), + }; + } + }; + // The engine's hooks are set-once, so installing them on every call is harmless. + install_io(); + match &cli.command { + Command::Convert(a) => a.settings.apply(), + Command::Run(a) => a.settings.apply(), + Command::Mcp(a) => a.settings.apply(), + Command::Sample(a) => a.settings.apply(), + Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {} + } + let mut io = Streams::new(out, err); + let result = match &cli.command { + Command::Info(a) => info(a, &mut io), + Command::Convert(a) => convert(a, &mut io), + Command::Run(a) => run_cmds(a, &mut io), + Command::Commands(a) => commands(a, &mut io), + Command::Mcp(a) => mcp(a), + Command::Perf(a) => perf(a.target.as_ref(), &mut io), + Command::Sample(a) => sample(a, &mut io), + }; + let mut code = 0; + if let Err(e) = result { + io.errln(format_args!("error: {e}")); + code = 1; + } + io.finish(); + if io.stdout_failed() { 1 } else { code } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::doc::Space; + use cadcraft_engine::geom::{Bounds2, Vec2}; + + fn scratch_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + struct FailingWriter(std::io::ErrorKind); + + impl Write for FailingWriter { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(self.0.into()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn a_failed_write_of_help_exits_1_unless_the_pipe_is_closed() { + let mut err = Vec::new(); + assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::StorageFull), &mut err), 1); + assert!(String::from_utf8_lossy(&err).starts_with("error: ")); + assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::BrokenPipe), &mut Vec::new()), 0); + } + + #[test] + fn write_atomic_replaces_file_and_leaves_no_staging_files() { + let dir = scratch_dir("replace"); + let out = dir.join("a.dxf"); + write_atomic(&out, b"old").expect("create"); + write_atomic(&out, b"new").expect("overwrite"); + assert_eq!(std::fs::read(&out).expect("read"), b"new"); + assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn write_atomic_failure_keeps_destination_and_cleans_up() { + let dir = scratch_dir("fail"); + let out = dir.join("target"); + std::fs::create_dir(&out).expect("dir as destination"); + std::fs::write(out.join("keep"), b"x").expect("seed"); + assert!(write_atomic(&out, b"new").is_err()); + assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x"); + assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 { + Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)) + } + + fn sample_session() -> Session { + let mut s = Session::empty(); + s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); + s + } + + fn view_box(svg: &str) -> String { + let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0); + svg[i..].split('"').next().unwrap_or("").to_string() + } + + fn assert_near(a: Bounds2, e: Bounds2) { + let d = (a.min - e.min).len().max((a.max - e.max).len()); + assert!(d < 1e-9, "{a:?} != {e:?}"); + } + + #[test] + fn last_placeholder_takes_the_previous_handle() { + let mut s = sample_session(); + let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h = returned_handle(&s, &r).expect("LINE returns its handle"); + assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)).unwrap(), json!({"handle": h.hex()})); + // Nested, as in a selection list. + assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)).unwrap(), json!({"handles": [h.hex(), "1"]})); + // Only the exact token: a name or a sentence that merely contains it stays as written. + for p in [json!("last"), json!("{last} "), json!({"name": "last"})] { + assert_eq!(fill_last(&p, Some(h)).unwrap(), p); + } + // Nothing returned yet: an error, not a token passed to the command as a bad handle. + assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().to_string().contains("{last}")); + } + + /// Handles are read from a result by looking the strings up in the drawing: counts and names + /// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`. + #[test] + fn returned_handle_reads_only_real_handles() { + let mut s = Session::empty(); + s.new_drawing(false); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap(); + let h = returned_handle(&s, &r).expect("mview returns its viewport handle"); + assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}"); + assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None); + // The most recent of several. + let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); + let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap(); + let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap()); + assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb)); + } + + #[test] + fn default_framing_is_extents() { + // convert: nothing ran, always the extents. + let s = sample_session(); + assert_eq!(export_window(&s, Framing::Auto, None).unwrap(), None); + // run: commands that leave the view alone keep the extents framing. + let mut s = sample_session(); + let before = model_views(&s); + s.script("LINE 0,0 10,10\n\n").unwrap(); + assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None); + // A fresh drawing too. + let mut s = Session::empty(); + s.new_drawing(false); + let before = model_views(&s); + s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap(); + assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None); + } + + #[test] + fn zoom_window_frames_export() { + let mut s = sample_session(); + let before = model_views(&s); + s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap(); + let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); + // The CLI viewport is 3:2, so a 3:2 window is shown exactly. + assert_near(w, b(0.0, 0.0, 30.0, 20.0)); + assert_eq!(export_window(&s, Framing::Current, Some(&before)).unwrap(), Some(w)); + // --view extents and --window override the zoom. + assert_eq!(export_window(&s, Framing::Extents, Some(&before)).unwrap(), None); + let explicit = b(1.0, 1.0, 2.0, 2.0); + assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)).unwrap(), Some(explicit)); + // A taller window keeps its height; the 3:2 image adds room left and right. + s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap(); + assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0)); + } + + #[test] + fn zoom_object_frames_the_selection() { + let mut s = sample_session(); + let before = model_views(&s); + // The smallest entity with an area, selected then framed with ZOOM Object. + let d = s.doc().unwrap(); + let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0); + let e = d + .model + .iter() + .filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0) + .min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width())) + .unwrap(); + let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model)); + s.set_selection(vec![h]); + s.execute("zoom", &json!({"mode": "object"})).unwrap(); + let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); + assert!(w.contains(eb.min) && w.contains(eb.max)); + assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}"); + } + + #[test] + fn run_export_honours_zoom() { + let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |n: &str| dir.join(n).to_string_lossy().to_string(); + let run_svg = |extra: &[&str], out: &str| -> String { + let mut args: Vec = ["run", "--sample"].iter().chain(extra).map(|a| a.to_string()).collect(); + args.extend(["--export".to_string(), path(out)]); + assert_eq!(run(args, &mut Vec::new(), &mut Vec::new()), 0); + std::fs::read_to_string(path(out)).unwrap() + }; + let base = run_svg(&[], "base.svg"); + let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg"); + let window = run_svg(&["--window", "0,0,30,20"], "window.svg"); + let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg"); + assert_ne!(view_box(&zoomed), view_box(&base)); + assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000"); + assert_eq!(zoomed, window); + assert_eq!(forced, base); + // Hostile --window: a usage error, nothing written. + let mut err = Vec::new(); + assert_eq!(run(["run", "--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")], &mut Vec::new(), &mut err), 2); + assert!(String::from_utf8_lossy(&err).contains("--window")); + assert!(!dir.join("bad.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 99a8b4282..7561b96b5 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -1,1111 +1,8 @@ -//! `cadcraft-cli`: headless CADCraft. -//! -//! ```text -//! cadcraft-cli info FILE.dxf summary as JSON -//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current] -//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...] -//! cadcraft-cli commands [FILTER] command catalog (JSON) -//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app) -//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing -//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step -//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing -//! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -/// `println!` that doesn't panic when stdout is closed (`cadcraft-cli commands | head`) with -/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is -/// dropped and the work goes on: see `stdout_failed`. -macro_rules! outln { - ($($arg:tt)*) => {{ - use std::io::Write as _; - if !$crate::stdout_gone() { - if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { - $crate::stdout_failed(e); - } - } - }}; -} - -use std::process::ExitCode; - -use cadcraft_engine::doc::{Handle, Space}; -use cadcraft_engine::geom::{Bounds2, Vec2}; -use cadcraft_engine::{Session, View}; -use serde_json::{Value, json}; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// Set once a write to stdout has failed; later output is dropped. -static STDOUT_GONE: AtomicBool = AtomicBool::new(false); -/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1. -static STDOUT_ERROR: AtomicBool = AtomicBool::new(false); - -fn stdout_gone() -> bool { - STDOUT_GONE.load(Ordering::Relaxed) -} - -/// stdout went away. The program doesn't stop there: the rest of `run` (later `--cmd` results, -/// `--save`, `--export`) still happens, so `cadcraft-cli run … --save out.dxf | head` keeps the -/// drawing. A reader that stopped early (a closed pipe) is not an error, as in ripgrep; any other -/// write error is reported and makes the exit status 1. -fn stdout_failed(e: std::io::Error) { - if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe { - return; - } - eprintln!("cadcraft-cli: can't write to stdout: {e}"); - STDOUT_ERROR.store(true, Ordering::Relaxed); -} - -const USAGE: &str = "usage: - cadcraft-cli info FILE.dxf - cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING] - cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING] - cadcraft-cli commands [FILTER] - cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] - cadcraft-cli perf [N | FILE] - cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png) - cadcraft-cli --version - -image framing (png 2400x1600, svg, pdf; always model space): - default convert: fit the drawing extents. - run --export: the current view if the script/commands changed it - (zoom, pan, view.set...), otherwise fit the drawing extents. - --window x0,y0,x1,y1 show this rectangle (drawing units, x0 Result { - let parts: Vec<&str> = text.split(',').map(str::trim).collect(); - let [x0, y0, x1, y1] = parts.as_slice() else { - return Err(format!("--window needs four numbers x0,y0,x1,y1, got `{text}`")); - }; - let num = |name: &str, v: &str| -> Result { - match v.parse::() { - Ok(n) if n.is_finite() => Ok(n), - _ => Err(format!("--window: {name} must be a finite number, got `{v}`")), - } - }; - let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?); - if !(x0 < x1 && y0 < y1) { - return Err(format!("--window: need x0 < x1 and y0 < y1 (lower-left corner first), got {x0},{y0},{x1},{y1}")); - } - let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; - cadcraft_io::check_window(&w).map_err(|e| format!("--window: {e}"))?; - Ok(w) -} - -/// Parse a framing option (`--window`, `--view`) at `a`, taking its value from `it`. `Ok(false)` -/// means `a` is not a framing option. -fn framing_option<'a>(a: &str, it: &mut impl Iterator, framing: &mut Framing) -> Result { - let f = match a { - "--window" => Framing::Window(parse_window(it.next().ok_or("--window needs x0,y0,x1,y1")?)?), - "--view" => match it.next().map(|v| v.to_ascii_lowercase()).as_deref() { - Some("extents") => Framing::Extents, - Some("current") => Framing::Current, - _ => return Err("--view needs `extents` or `current`".into()), - }, - _ => return Ok(false), - }; - if *framing != Framing::Auto && std::mem::discriminant(framing) != std::mem::discriminant(&f) { - return Err("use only one of --window and --view".into()); - } - *framing = f; - Ok(true) -} - -/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it). -fn model_views(s: &Session) -> Vec<(u64, Option)> { - s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect() -} - -/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio -/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view). -fn current_window(s: &Session) -> Option { - let st = s.state().ok()?; - let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?; - let (w, h) = s.viewport_px; - let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5; - Some(Bounds2::new(v.center - half, v.center + half)) -} - -/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`] -/// taken before the commands ran (`None` for `convert`, where nothing ran). -fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option)]>) -> Result, String> { - match framing { - Framing::Window(w) => Ok(Some(w)), - Framing::Extents => Ok(None), - Framing::Current => current_window(s).map(Some).ok_or_else(|| "--view current: the drawing has no model-space view".into()), - Framing::Auto => { - let Some(before) = before else { return Ok(None) }; - let Ok(st) = s.state() else { return Ok(None) }; - let now = model_views(s); - let was = before.iter().find(|(uid, _)| *uid == st.uid); - let is = now.iter().find(|(uid, _)| *uid == st.uid); - // Only a view the commands changed counts; a drawing opened by the commands keeps - // the extents framing. - match (was, is) { - (Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)), - _ => Ok(None), - } - } - } -} - -fn install_io() { - cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { - read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), - write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), - plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), - }); - cadcraft_engine::cmd::file::set_write_as(|d, name, o| { - cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) - }); - // AutoLISP's entget, entmake and entmod see entities as DXF groups. - cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec { - groups: cadcraft_io::entity_groups, - entity: cadcraft_io::entity_from_groups, - }); -} - -fn open(s: &mut Session, path: &str) -> Result<(), String> { - s.execute("open", &json!({ "path": path })).map(|_| ()).map_err(|e| e.to_string()) -} - -#[derive(Clone, Copy)] -struct OptionSpec { - name: &'static str, - takes_value: bool, -} - -fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), String> { - let mut i = 0; - while let Some(arg) = args.get(i) { - if let Some(name) = arg.strip_prefix("--") { - let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { - return Err(format!("{command}: unknown option --{name}")); - }; - if spec.takes_value && args.get(i + 1).is_some_and(|value| !value.starts_with("--")) { - i += 2; - } else { - i += 1; - } - } else { - i += 1; - } - } - Ok(()) -} - -const NO_OPTIONS: &[OptionSpec] = &[]; - -fn info(path: &str) -> Result<(), String> { - let mut s = Session::empty(); - open(&mut s, path)?; - let v = s.execute("drawing.inspect", &json!({ "entities": false })).map_err(|e| e.to_string())?; - outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); - Ok(()) -} - -/// 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. -fn write_atomic(out: &str, bytes: &[u8]) -> std::io::Result<()> { - cadcraft_engine::atomic_write::write_atomic(std::path::Path::new(out), bytes) -} - -fn export(s: &Session, out: &str) -> Result<(), String> { - export_framed(s, out, None) -} - -fn export_framed(s: &Session, out: &str, window: Option) -> Result<(), String> { - let d = s.doc().map_err(|e| e.to_string())?; - let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?; - write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?; - eprintln!("wrote {out} ({} bytes)", bytes.len()); - if cadcraft_engine::cmd::file::drops_unknown(out) { - let lost = cadcraft_engine::cmd::file::not_saved(d); - if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) { - eprintln!("{m}"); - } - } - Ok(()) -} - -fn sample(args: &[String]) -> Result<(), String> { - let (Some(which), Some(out)) = (args.first(), args.get(1)) else { return Err(USAGE.into()) }; - let d = match which.as_str() { - "bracket" => cadcraft_engine::sample::bracket(), - "floorplan" | "floor" => cadcraft_engine::sample::floor_plan(), - other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")), - }; - let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?; - write_atomic(out, &bytes).map_err(|e| format!("{out}: {e}"))?; - eprintln!("wrote {out} ({} bytes)", bytes.len()); - Ok(()) -} - -/// Reject an explicit framing option without an image output to apply it to. -fn check_framed_output(framing: Framing, out: Option<&str>) -> Result<(), String> { - match out { - _ if framing == Framing::Auto => Ok(()), - Some(o) if !cadcraft_io::is_image(o) => Err(format!("--window/--view only apply to image output (png, svg, pdf), not `{o}`")), - Some(_) => Ok(()), - None => Err("--window/--view need an image output (--export OUT.png|svg|pdf)".into()), - } -} - -fn convert(args: &[String]) -> Result<(), String> { - let mut framing = Framing::Auto; - let mut files = Vec::new(); - let mut it = args.iter(); - while let Some(a) = it.next() { - if !framing_option(a, &mut it, &mut framing)? { - files.push(a); - } - } - let [i, o] = files.as_slice() else { return Err(USAGE.into()) }; - check_framed_output(framing, Some(o))?; - let mut s = Session::empty(); - open(&mut s, i)?; - if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) { - eprintln!("{m}"); - } - if cadcraft_io::is_image(o) - && let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i) - { - eprintln!("{m}"); - } - let window = export_window(&s, framing, None)?; - export_framed(&s, o, window) -} - -/// The warning for the model-space entities of `path` an image can't show, by type (those kept -/// as unknown objects, `Proxy` in `info`), or `None` when there are none. -fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option { - let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); - for e in d.model.iter() { - if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind { - let n = types.entry(u.dxf_type.as_str()).or_insert(0); - *n = n.saturating_add(1); - } - } - let n = types.values().fold(0usize, |a, n| a.saturating_add(*n)); - if n == 0 { - return None; - } - let list: Vec = types.iter().map(|(t, n)| format!("{t}: {n}")).collect(); - let entities = if n == 1 { "entity" } else { "entities" }; - Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", "))) -} - -/// The warning for the records of `path` the reader could not place, or `None` when there are none. -fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option { - let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)); - if n == 0 { - return None; - } - let list: Vec = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect(); - let records = if n == 1 { "record" } else { "records" }; - Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", "))) -} - -/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an -/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so -/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed -/// through unchanged, and `--script` text is never rewritten. -const LAST_HANDLE: &str = "{last}"; - -/// How deep [`result_handles`] walks a command result (results are the engine's own JSON). -const RESULT_DEPTH: usize = 16; - -/// Result keys whose values are object references: the engine's result convention for handles. -/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`, -/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a -/// count, and is never read as a handle — a group named `100` is a name, not object `100`. -fn handle_field(key: &str) -> bool { - matches!( - key, - "handle" - | "handles" - | "viewport" - | "viewports" - | "boundary" - | "clip" - | "insert" - | "members" - | "source" - | "objects" - | "entities" - | "selection" - | "parts" - | "refs" - | "matches" - | "line" - | "arc" - | "blocks" - ) -} - -/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them, -/// or objects that carry handle fields of their own (`entities`, `parts`). -fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { - if depth > RESULT_DEPTH { - return; - } - match v { - Value::String(t) => { - if let Some(h) = Handle::parse_hex(t) - && s.doc().is_ok_and(|d| d.entity(h).is_some()) - { - out.push(h); - } - } - Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)), - Value::Object(m) => { - for (k, x) in m { - if handle_field(k) { - collect_result_handles(s, x, depth + 1, out); - } else { - find_result_handles(s, x, depth + 1, out); - } - } - } - _ => {} - } -} - -/// Find handle fields nested in containers that are not handles themselves (`groups` in -/// `groups.list`, whose members are handles): strings here are names, never references. -fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { - if depth > RESULT_DEPTH { - return; - } - match v { - Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)), - Value::Object(m) => { - for (k, x) in m { - if handle_field(k) { - collect_result_handles(s, x, depth + 1, out); - } else { - find_result_handles(s, x, depth + 1, out); - } - } - } - _ => {} - } -} - -/// The object handles a command result mentions, in the order it lists them. A handle-only result -/// (a handle string or an array of them) counts as it is; otherwise only handle fields do. -fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { - match v { - Value::String(_) => collect_result_handles(s, v, depth, out), - Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out), - Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out), - _ => {} - } -} - -/// The last handle `r` returned: what a later `{last}` stands for. -fn returned_handle(s: &Session, r: &Value) -> Option { - let mut hs = Vec::new(); - result_handles(s, r, 0, &mut hs); - hs.last().copied() -} - -/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful -/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another -/// drawing current. -fn active_drawing(s: &Session) -> Option { - s.docs.get(s.active).map(|st| st.uid) -} - -/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an -/// error, never a token reaching the command as a (bad) handle. -fn fill_last(p: &Value, h: Option) -> Result { - match p { - Value::String(t) if t.as_str() == LAST_HANDLE => match h { - Some(h) => Ok(Value::String(h.hex())), - None => Err(format!("`{LAST_HANDLE}` needs an earlier --cmd in this run to have returned a handle")), - }, - Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::, _>>().map(Value::Array), - Value::Object(m) => { - m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::, _>>().map(Value::Object) - } - other => Ok(other.clone()), - } -} - -fn run(args: &[String]) -> Result<(), String> { - let mut s = Session::empty(); - let mut it = args.iter(); - let mut save = None; - let mut exp: Option = None; - let mut framing = Framing::Auto; - let mut steps: Vec<(String, String)> = Vec::new(); - while let Some(a) = it.next() { - if framing_option(a, &mut it, &mut framing)? { - continue; - } - match a.as_str() { - "--sample" => { - s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); - } - "--metric" => { - s.new_drawing(true); - } - "--script" => steps.push(("script".into(), it.next().ok_or("--script needs text")?.replace("\\n", "\n"))), - "--script-file" => { - let p = it.next().ok_or("--script-file needs a path")?; - steps.push(("script".into(), std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?)); - } - "--cmd" => steps.push(("cmd".into(), it.next().ok_or("--cmd needs `id {json}`")?.clone())), - "--save" => save = Some(it.next().ok_or("--save needs a path")?.clone()), - "--export" => exp = Some(it.next().ok_or("--export needs a path")?.clone()), - f if !f.starts_with("--") => open(&mut s, f)?, - other => return Err(format!("unknown option {other}")), - } - } - if s.docs.is_empty() { - s.new_drawing(false); - } - check_framed_output(framing, exp.as_deref())?; - let before = model_views(&s); - let mut last_handle: Option = None; - for (kind, body) in steps { - // A handle belongs to the drawing that returned it: if this step makes another drawing - // current (`new`, `open`, `document.switch`, `close`, or a script running one of them), - // the remembered handle is stale. A handle the step itself returns is taken afterwards, - // so it is a handle of the drawing it made current. - let drawing = active_drawing(&s); - match kind.as_str() { - "script" => { - s.script(&body).map_err(|e| e.to_string())?; - if active_drawing(&s) != drawing { - last_handle = None; - } - } - _ => { - let (id, json) = body.split_once(' ').unwrap_or((&body, "{}")); - let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?; - let params = fill_last(¶ms, last_handle).map_err(|e| format!("--cmd {id}: {e}"))?; - let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?; - if active_drawing(&s) != drawing { - last_handle = None; - } - if let Some(h) = returned_handle(&s, &r) { - last_handle = Some(h); - } - if !r.is_null() { - outln!("{r}"); - } - } - } - } - for l in &s.log { - eprintln!("{l}"); - } - if let Some(p) = save { - export(&s, &p)?; - } - if let Some(p) = exp { - let window = if cadcraft_io::is_image(&p) { export_window(&s, framing, Some(&before))? } else { None }; - export_framed(&s, &p, window)?; - } - Ok(()) -} - -/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency. -struct Rng(u64); - -impl Rng { - fn next(&mut self) -> f64 { - self.0 ^= self.0 >> 12; - self.0 ^= self.0 << 25; - self.0 ^= self.0 >> 27; - (self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 - } - fn range(&mut self, lo: f64, hi: f64) -> f64 { - lo + (hi - lo) * self.next() - } -} - -/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread -/// over a square whose side grows with sqrt(n), so density stays constant. -fn synthetic(n: usize, side: f64) -> Result { - use cadcraft_engine::doc::{self as d, Common, EntityKind, Space}; - use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; - let mut dr = d::Drawing::new_imperial(); - let mut r = Rng(0x9E37_79B9_7F4A_7C15); - let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0); - for i in 0..n { - let (x, y) = (r.range(0.0, side), r.range(0.0, side)); - let s = r.range(0.5, 8.0); - let kind = match i % 5 { - 0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }), - 1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }), - 2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }), - 3 => EntityKind::LwPolyline(d::LwPolyline { - vertices: (0..6) - .map(|k| PolyVertex { - p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3), - bulge: if k == 2 { 0.4 } else { 0.0 }, - start_width: 0.0, - end_width: 0.0, - }) - .collect(), - closed: i % 2 == 0, - const_width: 0.0, - elevation: 0.0, - plinegen: false, - }), - _ => EntityKind::Text(d::Text { - insert: v3(x, y), - align_pt: None, - height: s * 0.2, - value: format!("T{i}"), - rotation: 0.0, - width_factor: 1.0, - oblique: 0.0, - style: "Standard".into(), - halign: d::HAlign::Left, - valign: d::VAlign::Baseline, - }), - }; - dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?; - } - Ok(dr) -} - -/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the -/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at -/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use. -fn perf_file(path: &str) -> Result<(), String> { - use cadcraft_engine::geom::{Bounds2, Vec2}; - use cadcraft_engine::render::{Options, build}; - use std::time::Instant; - let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; - let mut s = Session::empty(); - let t = Instant::now(); - open(&mut s, path)?; - let open_ms = ms(t); - let st = s.state().map_err(|e| e.to_string())?; - let (d, space) = (&st.doc, &st.space); - let ext = d.extents(space); - let size = ext.width().max(ext.height()); - if ext.is_empty() || !(size.is_finite() && size > 0.0) { - return Err("perf: the drawing is empty".into()); - } - outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height()); - outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims"); - let mut finest = Options::default(); - for k in 0..5 { - let zoom = 4f64.powi(k); - let h = size / zoom; - let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32); - let half = Vec2::new(h * 0.8, h * 0.5); - let visible = Bounds2::new(ext.center() - half, ext.center() + half); - let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() }; - let t = Instant::now(); - let n = build(d, space, &screen).prims.len(); - outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t)); - finest = screen; - } - // Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above). - let t = Instant::now(); - let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len(); - outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t)); - Ok(()) -} - -fn perf(args: &[String]) -> Result<(), String> { - use cadcraft_engine::doc::Space; - use cadcraft_engine::geom::{Bounds2, Vec2}; - use cadcraft_engine::{select, snap}; - use std::time::Instant; - if let Some(path) = args.first().filter(|a| a.parse::().is_err()) { - return perf_file(path); - } - let n: usize = match args.first() { - Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?, - None => 200_000, - }; - let n = n.clamp(1, 20_000_000); - let side = (n as f64).sqrt() * 10.0; - let mut rows: Vec<(String, f64, String)> = Vec::new(); - let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; - - let t = Instant::now(); - let d = synthetic(n, side)?; - rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities"))); - - // Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel). - let px = side / 1600.0; - let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() }; - let t = Instant::now(); - let list = cadcraft_engine::render::build(&d, &Space::Model, &opts); - rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count()))); - - // Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px). - let ap = 0.1; - let mut r = Rng(42); - let pts: Vec = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect(); - let t = Instant::now(); - let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap); - rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}"))); - let t = Instant::now(); - let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count(); - let total = ms(t); - rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0))); - - // One edit, then a pick: only the touched chunk is re-indexed. - let mut edited = d.clone(); - if let Some(h) = edited.model.last().map(|e| e.handle) { - edited.model.modify(h, |e| e.common.layer = "0".into()); - } - let t = Instant::now(); - let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap); - rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new())); - - let mut sel = 0usize; - let t = Instant::now(); - for k in 0..20 { - let c = Vec2::new(r.range(0.0, side), r.range(0.0, side)); - let half = side * 0.05; - sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len(); - } - rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected"))); - - let t = Instant::now(); - let fence: Vec = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect(); - let fsel = select::select_fence(&d, &Space::Model, &fence).len(); - rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected"))); - - let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT; - let t = Instant::now(); - let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count(); - let total = ms(t); - rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0))); - - let t = Instant::now(); - let bytes = cadcraft_io::write(&d, "perf.dxf").map_err(|e| e.to_string())?; - rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6))); - let t = Instant::now(); - let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?; - rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count()))); - - outln!("{:<40} {:>12} notes", "operation", "ms"); - outln!("{}", "-".repeat(80)); - for (name, t, note) in rows { - outln!("{name:<40} {t:>12.2} {note}"); - } - Ok(()) -} - -/// The backend `cadcraft-cli mcp` serves from. -#[derive(Debug, PartialEq, Eq)] -enum McpTarget { - /// An in-process drawing session (no `--connect`). - Headless, - /// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call; - /// `launch`: opened then if it isn't running. - Connect { addr: String, lazy: bool, launch: bool }, -} - -/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing, -/// empty or flag-like value is an error, never a silent fall back to a headless session (#103). -fn parse_mcp_args(args: &[String]) -> Result { - let mut addr: Option = None; - let (mut lazy, mut launch) = (false, false); - let mut it = args.iter(); - while let Some(a) = it.next() { - match a.as_str() { - "--connect" => { - let a = it - .next() - .map(|v| v.trim()) - .filter(|v| !v.is_empty() && !v.starts_with('-')) - .ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?; - if addr.is_some() { - return Err("--connect given more than once".into()); - } - addr = Some(a.to_string()); - } - "--lazy" => lazy = true, - "--launch" => launch = true, - other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")), - } - } - match addr { - // `--launch` connects at the first call too (the app opens then). - Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }), - None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()), - None => Ok(McpTarget::Headless), - } -} - -fn mcp(args: &[String]) -> Result<(), String> { - let backend: Box = match parse_mcp_args(args)? { - McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)), - McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) { - Ok(r) => Box::new(r), - Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")), - }, - McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()), - }; - let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod mcp_args_tests { - use super::*; - - fn args(a: &[&str]) -> Vec { - a.iter().map(|s| s.to_string()).collect() - } - - #[test] - fn mcp_without_connect_is_headless() { - assert_eq!(parse_mcp_args(&args(&[])), Ok(McpTarget::Headless)); - } - - #[test] - fn mcp_connect_with_address_selects_remote() { - let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch }); - assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false)); - assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false)); - assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false)); - assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true)); - // Without an app to reach they mean nothing: never a headless session. - assert!(parse_mcp_args(&args(&["--lazy"])).is_err()); - assert!(parse_mcp_args(&args(&["--launch"])).is_err()); - } - - #[test] - fn mcp_connect_without_address_is_an_error_not_headless() { - for bad in [&["--connect"][..], &["--connect", ""], &["--connect", " "], &["--connect", "--foo"], &["--connect", "-v"]] { - let r = parse_mcp_args(&args(bad)); - let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") }; - assert!(e.contains("--connect needs HOST:PORT"), "{bad:?}: {e}"); - } - } - - #[test] - fn mcp_rejects_unknown_and_repeated_arguments() { - assert!(parse_mcp_args(&args(&["--conect", "127.0.0.1:7979"])).is_err()); - assert!(parse_mcp_args(&args(&["127.0.0.1:7979"])).is_err()); - assert!(parse_mcp_args(&args(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"])).is_err()); - } -} - -fn main() -> ExitCode { - let code = cli(); - if STDOUT_ERROR.load(Ordering::Relaxed) { ExitCode::FAILURE } else { code } -} - -fn cli() -> ExitCode { - install_io(); - let args: Vec = std::env::args().skip(1).collect(); - let rest: Vec = args.iter().skip(1).cloned().collect(); - let r = match args.first().map(String::as_str) { - Some("info") => validate_options("info", &rest, NO_OPTIONS).and_then(|_| rest.first().ok_or_else(|| USAGE.to_string())).and_then(|p| info(p)), - Some("convert") => convert(&rest), - Some("run") => run(&rest), - Some("perf") => validate_options("perf", &rest, NO_OPTIONS).and_then(|_| perf(&rest)), - Some("sample") => validate_options("sample", &rest, NO_OPTIONS).and_then(|_| sample(&rest)), - Some("commands") => validate_options("commands", &rest, NO_OPTIONS).map(|_| { - let s = Session::new(); - let f = rest.first().map(|x| x.to_ascii_lowercase()); - let v: Vec = cadcraft_engine::command_specs() - .iter() - .filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str()))) - .map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default()) - .collect(); - outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); - }), - Some("mcp") => mcp(&rest), - Some("--version" | "-V") => { - outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - Some("--help" | "-h" | "help") => { - outln!("{USAGE}"); - Ok(()) - } - _ => Err(USAGE.into()), - }; - match r { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("{e}"); - ExitCode::FAILURE - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn scratch_dir(tag: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("create scratch dir"); - dir - } - - #[test] - fn write_atomic_replaces_file_and_leaves_no_staging_files() { - let dir = scratch_dir("replace"); - let out = dir.join("a.dxf"); - let out = out.to_str().expect("utf-8 path"); - write_atomic(out, b"old").expect("create"); - write_atomic(out, b"new").expect("overwrite"); - assert_eq!(std::fs::read(out).expect("read"), b"new"); - assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn write_atomic_failure_keeps_destination_and_cleans_up() { - let dir = scratch_dir("fail"); - let out = dir.join("target"); - std::fs::create_dir(&out).expect("dir as destination"); - std::fs::write(out.join("keep"), b"x").expect("seed"); - assert!(write_atomic(out.to_str().expect("utf-8 path"), b"new").is_err()); - assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x"); - assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 { - Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)) - } - - fn sample_session() -> Session { - let mut s = Session::empty(); - s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); - s - } - - fn view_box(svg: &str) -> String { - let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0); - svg[i..].split('"').next().unwrap_or("").to_string() - } - - fn assert_near(a: Bounds2, e: Bounds2) { - let d = (a.min - e.min).len().max((a.max - e.max).len()); - assert!(d < 1e-9, "{a:?} != {e:?}"); - } - - #[test] - fn window_parses() { - assert_eq!(parse_window("0,0,30,20"), Ok(b(0.0, 0.0, 30.0, 20.0))); - assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 "), Ok(b(-5.5, -1000.0, 2000.0, 4.0))); - } - - #[test] - fn hostile_window_is_an_error() { - for w in [ - "", - "1,2,3", - "1,2,3,4,5", - "a,0,1,1", - "NaN,0,1,1", - "0,0,inf,1", - "0,0,1e400,1", - "0,0,0,1", - "0,0,1,0", - "5,0,1,1", - "0,5,1,1", - "-1e300,-1e300,1e300,1e300", - "0,0,1e-12,1e-12", - ] { - assert!(parse_window(w).is_err(), "`{w}` should be rejected"); - } - } - - #[test] - fn framing_options() { - let parse = |args: &[&str]| -> Result { - let args: Vec = args.iter().map(|a| a.to_string()).collect(); - let mut f = Framing::Auto; - let mut it = args.iter(); - while let Some(a) = it.next() { - assert!(framing_option(a, &mut it, &mut f)?, "{a} is a framing option"); - } - Ok(f) - }; - assert_eq!(parse(&["--view", "extents"]), Ok(Framing::Extents)); - assert_eq!(parse(&["--view", "CURRENT"]), Ok(Framing::Current)); - assert_eq!(parse(&["--window", "0,0,2,1"]), Ok(Framing::Window(b(0.0, 0.0, 2.0, 1.0)))); - assert!(parse(&["--view"]).is_err()); - assert!(parse(&["--view", "selection"]).is_err()); - assert!(parse(&["--window"]).is_err()); - assert!(parse(&["--window", "0,0,2,1", "--view", "extents"]).is_err()); - assert!(check_framed_output(Framing::Extents, Some("a.dxf")).is_err()); - assert!(check_framed_output(Framing::Extents, None).is_err()); - assert!(check_framed_output(Framing::Extents, Some("a.png")).is_ok()); - assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok()); - } - - #[test] - fn last_placeholder_takes_the_previous_handle() { - let mut s = sample_session(); - let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); - let h = returned_handle(&s, &r).expect("LINE returns its handle"); - assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)), Ok(json!({"handle": h.hex()}))); - // Nested, as in a selection list. - assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)), Ok(json!({"handles": [h.hex(), "1"]}))); - // Only the exact token: a name or a sentence that merely contains it stays as written. - for p in [json!("last"), json!("{last} "), json!({"name": "last"})] { - assert_eq!(fill_last(&p, Some(h)), Ok(p.clone())); - } - // Nothing returned yet: an error, not a token passed to the command as a bad handle. - assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().contains("{last}")); - } - - /// Handles are read from a result by looking the strings up in the drawing: counts and names - /// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`. - #[test] - fn returned_handle_reads_only_real_handles() { - let mut s = Session::empty(); - s.new_drawing(false); - s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); - let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap(); - let h = returned_handle(&s, &r).expect("mview returns its viewport handle"); - assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}"); - assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None); - // The most recent of several. - let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); - let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap(); - let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap()); - assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb)); - } - - #[test] - fn default_framing_is_extents() { - // convert: nothing ran, always the extents. - let s = sample_session(); - assert_eq!(export_window(&s, Framing::Auto, None), Ok(None)); - // run: commands that leave the view alone keep the extents framing. - let mut s = sample_session(); - let before = model_views(&s); - s.script("LINE 0,0 10,10\n\n").unwrap(); - assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None)); - // A fresh drawing too. - let mut s = Session::empty(); - s.new_drawing(false); - let before = model_views(&s); - s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap(); - assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None)); - } - - #[test] - fn zoom_window_frames_export() { - let mut s = sample_session(); - let before = model_views(&s); - s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap(); - let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); - // The CLI viewport is 3:2, so a 3:2 window is shown exactly. - assert_near(w, b(0.0, 0.0, 30.0, 20.0)); - assert_eq!(export_window(&s, Framing::Current, Some(&before)), Ok(Some(w))); - // --view extents and --window override the zoom. - assert_eq!(export_window(&s, Framing::Extents, Some(&before)), Ok(None)); - let explicit = b(1.0, 1.0, 2.0, 2.0); - assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)), Ok(Some(explicit))); - // A taller window keeps its height; the 3:2 image adds room left and right. - s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap(); - assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0)); - } - - #[test] - fn zoom_object_frames_the_selection() { - let mut s = sample_session(); - let before = model_views(&s); - // The smallest entity with an area, selected then framed with ZOOM Object. - let d = s.doc().unwrap(); - let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0); - let e = d - .model - .iter() - .filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0) - .min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width())) - .unwrap(); - let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model)); - s.set_selection(vec![h]); - s.execute("zoom", &json!({"mode": "object"})).unwrap(); - let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); - assert!(w.contains(eb.min) && w.contains(eb.max)); - assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}"); - } - - #[test] - fn run_export_honours_zoom() { - let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = |n: &str| dir.join(n).to_string_lossy().to_string(); - let run_svg = |extra: &[&str], out: &str| -> String { - let mut args: Vec = ["--sample"].iter().chain(extra).map(|a| a.to_string()).collect(); - args.extend(["--export".to_string(), path(out)]); - run(&args).unwrap(); - std::fs::read_to_string(path(out)).unwrap() - }; - let base = run_svg(&[], "base.svg"); - let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg"); - let window = run_svg(&["--window", "0,0,30,20"], "window.svg"); - let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg"); - assert_ne!(view_box(&zoomed), view_box(&base)); - assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000"); - assert_eq!(zoomed, window); - assert_eq!(forced, base); - // Hostile --window: a clear error, nothing written. - let bad: Vec = ["--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")].iter().map(|a| a.to_string()).collect(); - assert!(run(&bad).unwrap_err().contains("--window")); - assert!(!dir.join("bad.png").exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - fn args(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).into()).collect() - } - - #[test] - fn validators_accept_only_the_documented_options() { - assert!(validate_options("info", &args(&["file.dxf"]), NO_OPTIONS).is_ok()); - assert!(validate_options("perf", &args(&["100"]), NO_OPTIONS).is_ok()); - assert!(validate_options("sample", &args(&["bracket", "out.dxf"]), NO_OPTIONS).is_ok()); - assert!(validate_options("commands", &args(&["line"]), NO_OPTIONS).is_ok()); - for (command, specs) in [("info", NO_OPTIONS), ("perf", NO_OPTIONS), ("sample", NO_OPTIONS), ("commands", NO_OPTIONS)] { - assert!(validate_options(command, &args(&["--typo"]), specs).is_err()); - } - } - - #[test] - fn unknown_options_are_rejected_before_side_effects() { - assert_eq!(validate_options("info", &args(&["missing.dxf", "--typo"]), NO_OPTIONS), Err("info: unknown option --typo".into())); - assert_eq!(validate_options("perf", &args(&["1", "--typo"]), NO_OPTIONS), Err("perf: unknown option --typo".into())); - assert_eq!(validate_options("sample", &args(&["bracket", "out.dxf", "--typo"]), NO_OPTIONS), Err("sample: unknown option --typo".into())); - } +fn main() { + // `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()); + std::process::exit(code); } diff --git a/apps/cadcraft-cli/src/mcp.rs b/apps/cadcraft-cli/src/mcp.rs new file mode 100644 index 000000000..960c80a5e --- /dev/null +++ b/apps/cadcraft-cli/src/mcp.rs @@ -0,0 +1,17 @@ +use super::args::{McpArgs, McpTarget}; +use super::error::{CliError, Result}; + +pub(crate) fn mcp(a: &McpArgs) -> Result { + let backend: Box = match a.target() { + McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)), + McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) { + Ok(r) => Box::new(r), + Err(source) => return Err(CliError::McpConnect { addr, source }), + }, + McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()), + }; + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock())?; + Ok(()) +} diff --git a/apps/cadcraft-cli/src/output.rs b/apps/cadcraft-cli/src/output.rs new file mode 100644 index 000000000..d783267dc --- /dev/null +++ b/apps/cadcraft-cli/src/output.rs @@ -0,0 +1,177 @@ +use super::args::Framing; +use super::error::Result; +use cadcraft_engine::doc::Space; +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_engine::{Session, View}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// The process's output streams. A write to stdout that fails never stops the program: once +/// stdout has failed, later output is dropped and the work goes on, so the rest of `run` (later +/// `--cmd` results, `--save`, `--export`) still happens and `cadcraft-cli run … --save out.dxf | +/// head` keeps the drawing. A reader that stopped early (a closed pipe, `cadcraft-cli commands | +/// head`) is not an error, as in ripgrep; any other write error is reported once on stderr and +/// makes the exit status 1. +pub(crate) struct Streams<'a> { + out: &'a mut dyn Write, + err: &'a mut dyn Write, + /// Set once a write to `out` has failed; later output is dropped. + gone: bool, + /// Set when `out` failed for a reason other than a closed reader. + failed: bool, +} + +impl<'a> Streams<'a> { + pub(crate) fn new(out: &'a mut dyn Write, err: &'a mut dyn Write) -> Self { + Streams { out, err, gone: false, failed: false } + } + + /// Write a line to stdout. + pub(crate) fn outln(&mut self, args: std::fmt::Arguments) { + if !self.gone + && let Err(e) = writeln!(self.out, "{args}") + { + self.out_failed(e); + } + } + + /// Write `v` to stdout: pretty-printed, or on one line with `compact` (`--compact`). + pub(crate) fn json(&mut self, v: &serde_json::Value, compact: bool) { + if compact { + self.outln(format_args!("{v}")); + } else { + self.outln(format_args!("{v:#}")); + } + } + + /// Write a line to stderr. A failing stderr has nowhere to report to, so it is ignored. + pub(crate) fn errln(&mut self, args: std::fmt::Arguments) { + let _ = writeln!(self.err, "{args}"); + } + + /// Flush stdout (a pipe that closed during buffered output surfaces here). + pub(crate) fn finish(&mut self) { + if !self.gone + && let Err(e) = self.out.flush() + { + self.out_failed(e); + } + } + + /// Whether stdout failed for a reason other than a closed reader: the exit status is 1. + pub(crate) fn stdout_failed(&self) -> bool { + self.failed + } + + fn out_failed(&mut self, e: std::io::Error) { + if std::mem::replace(&mut self.gone, true) || e.kind() == std::io::ErrorKind::BrokenPipe { + return; + } + self.errln(format_args!("cadcraft-cli: can't write to stdout: {e}")); + self.failed = true; + } +} + +/// Why writing or framing an output failed. +#[derive(Debug, thiserror::Error)] +pub(crate) enum OutputError { + #[error("--view current: the drawing has no model-space view")] + NoModelView, + #[error("{}: {source}", path.display())] + Write { path: PathBuf, source: std::io::Error }, +} + +/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it). +pub(crate) fn model_views(s: &Session) -> Vec<(u64, Option)> { + s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect() +} + +/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio +/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view). +fn current_window(s: &Session) -> Option { + let st = s.state().ok()?; + let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?; + let (w, h) = s.viewport_px; + let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5; + Some(Bounds2::new(v.center - half, v.center + half)) +} + +/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`] +/// taken before the commands ran (`None` for `convert`, where nothing ran). +pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option)]>) -> Result, OutputError> { + match framing { + Framing::Window(w) => Ok(Some(w)), + Framing::Extents => Ok(None), + Framing::Current => current_window(s).map(Some).ok_or(OutputError::NoModelView), + Framing::Auto => { + let Some(before) = before else { return Ok(None) }; + let Ok(st) = s.state() else { return Ok(None) }; + let now = model_views(s); + let was = before.iter().find(|(uid, _)| *uid == st.uid); + let is = now.iter().find(|(uid, _)| *uid == st.uid); + // Only a view the commands changed counts; a drawing opened by the commands keeps + // the extents framing. + match (was, is) { + (Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)), + _ => Ok(None), + } + } + } +} + +/// 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> { + 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 }) +} + +pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result { + export_framed(s, out, None, io) +} + +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)?; + 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); + if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) { + io.errln(format_args!("{m}")); + } + } + Ok(()) +} + +/// The warning for the model-space entities of `path` an image can't show, by type (those kept +/// as unknown objects, `Proxy` in `info`), or `None` when there are none. +pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option { + let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for e in d.model.iter() { + if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind { + let n = types.entry(u.dxf_type.as_str()).or_insert(0); + *n = n.saturating_add(1); + } + } + let n = types.values().fold(0usize, |a, n| a.saturating_add(*n)); + if n == 0 { + return None; + } + let list: Vec = types.iter().map(|(t, n)| format!("{t}: {n}")).collect(); + let entities = if n == 1 { "entity" } else { "entities" }; + Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", "))) +} + +/// 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)); + if n == 0 { + return None; + } + let list: Vec = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect(); + let records = if n == 1 { "record" } else { "records" }; + Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", "))) +} diff --git a/apps/cadcraft-cli/src/perf.rs b/apps/cadcraft-cli/src/perf.rs new file mode 100644 index 000000000..e52b6107b --- /dev/null +++ b/apps/cadcraft-cli/src/perf.rs @@ -0,0 +1,196 @@ +use super::args::PerfTarget; +use super::error::Result; +use super::open; +use super::output::Streams; +use cadcraft_engine::Session; + +/// Why `perf` cannot run. +#[derive(Debug, thiserror::Error)] +pub(crate) enum PerfError { + #[error("perf: the drawing is empty")] + EmptyDrawing, +} + +/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + (self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (hi - lo) * self.next() + } +} + +/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread +/// over a square whose side grows with sqrt(n), so density stays constant. +fn synthetic(n: usize, side: f64) -> Result { + use cadcraft_engine::doc::{self as d, Common, EntityKind, Space}; + use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; + let mut dr = d::Drawing::new_imperial(); + let mut r = Rng(0x9E37_79B9_7F4A_7C15); + let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0); + for i in 0..n { + let (x, y) = (r.range(0.0, side), r.range(0.0, side)); + let s = r.range(0.5, 8.0); + let kind = match i % 5 { + 0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }), + 1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }), + 2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }), + 3 => EntityKind::LwPolyline(d::LwPolyline { + vertices: (0..6) + .map(|k| PolyVertex { + p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3), + bulge: if k == 2 { 0.4 } else { 0.0 }, + start_width: 0.0, + end_width: 0.0, + }) + .collect(), + closed: i % 2 == 0, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }), + _ => EntityKind::Text(d::Text { + insert: v3(x, y), + align_pt: None, + height: s * 0.2, + value: format!("T{i}"), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: d::HAlign::Left, + valign: d::VAlign::Baseline, + }), + }; + dr.add(&Space::Model, Common::default(), kind)?; + } + Ok(dr) +} + +/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the +/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at +/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use. +fn perf_file(path: &std::path::Path, io: &mut Streams) -> Result { + use cadcraft_engine::geom::{Bounds2, Vec2}; + use cadcraft_engine::render::{Options, build}; + use std::time::Instant; + let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; + let mut s = Session::empty(); + let t = Instant::now(); + open(&mut s, path)?; + let open_ms = ms(t); + let st = s.state()?; + let (d, space) = (&st.doc, &st.space); + let ext = d.extents(space); + let size = ext.width().max(ext.height()); + if ext.is_empty() || !(size.is_finite() && size > 0.0) { + return Err(PerfError::EmptyDrawing.into()); + } + io.outln(format_args!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height())); + io.outln(format_args!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims")); + let mut finest = Options::default(); + for k in 0..5 { + let zoom = 4f64.powi(k); + let h = size / zoom; + let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32); + let half = Vec2::new(h * 0.8, h * 0.5); + let visible = Bounds2::new(ext.center() - half, ext.center() + half); + let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() }; + let t = Instant::now(); + let n = build(d, space, &screen).prims.len(); + io.outln(format_args!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t))); + finest = screen; + } + // Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above). + let t = Instant::now(); + let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len(); + io.outln(format_args!("{:>8} {:>10.1} {n:>10}", "full", ms(t))); + Ok(()) +} + +pub(crate) fn perf(target: Option<&PerfTarget>, io: &mut Streams) -> Result { + use cadcraft_engine::doc::Space; + use cadcraft_engine::geom::{Bounds2, Vec2}; + use cadcraft_engine::{select, snap}; + use std::time::Instant; + let n = match target { + Some(PerfTarget::File(path)) => return perf_file(path, io), + Some(PerfTarget::Count(n)) => *n, + None => 200_000, + }; + let side = (n as f64).sqrt() * 10.0; + let mut rows: Vec<(String, f64, String)> = Vec::new(); + let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0; + + let t = Instant::now(); + let d = synthetic(n, side)?; + rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities"))); + + // Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel). + let px = side / 1600.0; + let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() }; + let t = Instant::now(); + let list = cadcraft_engine::render::build(&d, &Space::Model, &opts); + rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count()))); + + // Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px). + let ap = 0.1; + let mut r = Rng(42); + let pts: Vec = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect(); + let t = Instant::now(); + let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap); + rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}"))); + let t = Instant::now(); + let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count(); + let total = ms(t); + rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0))); + + // One edit, then a pick: only the touched chunk is re-indexed. + let mut edited = d.clone(); + if let Some(h) = edited.model.last().map(|e| e.handle) { + edited.model.modify(h, |e| e.common.layer = "0".into()); + } + let t = Instant::now(); + let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap); + rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new())); + + let mut sel = 0usize; + let t = Instant::now(); + for k in 0..20 { + let c = Vec2::new(r.range(0.0, side), r.range(0.0, side)); + let half = side * 0.05; + sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len(); + } + rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected"))); + + let t = Instant::now(); + let fence: Vec = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect(); + let fsel = select::select_fence(&d, &Space::Model, &fence).len(); + rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected"))); + + let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT; + let t = Instant::now(); + let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count(); + let total = ms(t); + rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0))); + + let t = Instant::now(); + let bytes = cadcraft_io::write(&d, "perf.dxf")?; + rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6))); + let t = Instant::now(); + let back = cadcraft_io::read(&bytes, "perf.dxf")?; + rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count()))); + + io.outln(format_args!("{:<40} {:>12} notes", "operation", "ms")); + io.outln(format_args!("{}", "-".repeat(80))); + for (name, t, note) in rows { + io.outln(format_args!("{name:<40} {t:>12.2} {note}")); + } + Ok(()) +} diff --git a/apps/cadcraft-cli/src/run.rs b/apps/cadcraft-cli/src/run.rs new file mode 100644 index 000000000..56b4626e2 --- /dev/null +++ b/apps/cadcraft-cli/src/run.rs @@ -0,0 +1,223 @@ +use super::args::{CmdStep, Drawing, RunArgs, Step}; +use super::error::Result; +use super::open; +use super::output::{Streams, export, export_framed, export_window, model_views}; +use cadcraft_engine::Session; +use cadcraft_engine::doc::Handle; +use serde_json::Value; + +/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an +/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so +/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed +/// through unchanged, and `--script` text is never rewritten. +const LAST_HANDLE: &str = "{last}"; + +/// Why a `run` step fails. +#[derive(Debug, thiserror::Error)] +pub(crate) enum RunError { + #[error("{}: {source}", .path.display())] + ReadScript { path: std::path::PathBuf, source: std::io::Error }, + #[error("--cmd {id}: {source}")] + CmdLast { id: String, source: NoLastHandle }, +} + +/// A `{last}` placeholder with no handle to fill in. +#[derive(Debug, PartialEq, thiserror::Error)] +#[error("`{{last}}` needs an earlier --cmd in this run to have returned a handle")] +pub(crate) struct NoLastHandle; + +/// How deep [`result_handles`] walks a command result (results are the engine's own JSON). +const RESULT_DEPTH: usize = 16; + +/// Result keys whose values are object references: the engine's result convention for handles. +/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`, +/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a +/// count, and is never read as a handle — a group named `100` is a name, not object `100`. +fn handle_field(key: &str) -> bool { + matches!( + key, + "handle" + | "handles" + | "viewport" + | "viewports" + | "boundary" + | "clip" + | "insert" + | "members" + | "source" + | "objects" + | "entities" + | "selection" + | "parts" + | "refs" + | "matches" + | "line" + | "arc" + | "blocks" + ) +} + +/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them, +/// or objects that carry handle fields of their own (`entities`, `parts`). +fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { + if depth > RESULT_DEPTH { + return; + } + match v { + Value::String(t) => { + if let Some(h) = Handle::parse_hex(t) + && s.doc().is_ok_and(|d| d.entity(h).is_some()) + { + out.push(h); + } + } + Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)), + Value::Object(m) => { + for (k, x) in m { + if handle_field(k) { + collect_result_handles(s, x, depth + 1, out); + } else { + find_result_handles(s, x, depth + 1, out); + } + } + } + _ => {} + } +} + +/// Find handle fields nested in containers that are not handles themselves (`groups` in +/// `groups.list`, whose members are handles): strings here are names, never references. +fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { + if depth > RESULT_DEPTH { + return; + } + match v { + Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)), + Value::Object(m) => { + for (k, x) in m { + if handle_field(k) { + collect_result_handles(s, x, depth + 1, out); + } else { + find_result_handles(s, x, depth + 1, out); + } + } + } + _ => {} + } +} + +/// The object handles a command result mentions, in the order it lists them. A handle-only result +/// (a handle string or an array of them) counts as it is; otherwise only handle fields do. +fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec) { + match v { + Value::String(_) => collect_result_handles(s, v, depth, out), + Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out), + Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out), + _ => {} + } +} + +/// The last handle `r` returned: what a later `{last}` stands for. +pub(crate) fn returned_handle(s: &Session, r: &Value) -> Option { + let mut hs = Vec::new(); + result_handles(s, r, 0, &mut hs); + hs.last().copied() +} + +/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful +/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another +/// drawing current. +fn active_drawing(s: &Session) -> Option { + s.docs.get(s.active).map(|st| st.uid) +} + +/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an +/// error, never a token reaching the command as a (bad) handle. +pub(crate) fn fill_last(p: &Value, h: Option) -> Result { + match p { + Value::String(t) if t.as_str() == LAST_HANDLE => match h { + Some(h) => Ok(Value::String(h.hex())), + None => Err(NoLastHandle), + }, + Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::, _>>().map(Value::Array), + Value::Object(m) => { + m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::, _>>().map(Value::Object) + } + other => Ok(other.clone()), + } +} + +/// A step ready to run: script text, or a command. +enum Ready<'a> { + Script(String), + Cmd(&'a CmdStep), +} + +pub(crate) fn run_cmds(a: &RunArgs, io: &mut Streams) -> Result { + let framing = a.framing.framing(); + let (save, export_to) = (a.save.as_deref(), a.export.as_deref()); + let mut s = Session::empty(); + for d in &a.drawings { + match d { + Drawing::File(f) => open(&mut s, f)?, + Drawing::Sample => { + s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); + } + Drawing::Metric => { + s.new_drawing(true); + } + } + } + let mut steps: Vec = Vec::new(); + for step in &a.steps { + steps.push(match step { + Step::Script(text) => Ready::Script(text.replace("\\n", "\n")), + Step::ScriptFile(p) => Ready::Script(std::fs::read_to_string(p).map_err(|source| RunError::ReadScript { path: p.clone(), source })?), + Step::Cmd(c) => Ready::Cmd(c), + }); + } + if s.docs.is_empty() { + s.new_drawing(false); + } + let before = model_views(&s); + let mut last_handle: Option = None; + for step in steps { + // A handle belongs to the drawing that returned it: if this step makes another drawing + // current (`new`, `open`, `document.switch`, `close`, or a script running one of them), + // the remembered handle is stale. A handle the step itself returns is taken afterwards, + // so it is a handle of the drawing it made current. + let drawing = active_drawing(&s); + match step { + Ready::Script(body) => { + s.script(&body)?; + if active_drawing(&s) != drawing { + last_handle = None; + } + } + Ready::Cmd(CmdStep { id, params }) => { + let params = fill_last(params, last_handle).map_err(|source| RunError::CmdLast { id: id.clone(), source })?; + let r = s.execute(id, ¶ms)?; + if active_drawing(&s) != drawing { + last_handle = None; + } + if let Some(h) = returned_handle(&s, &r) { + last_handle = Some(h); + } + if !r.is_null() { + io.outln(format_args!("{r}")); + } + } + } + } + for l in &s.log { + io.errln(format_args!("{l}")); + } + if let Some(p) = save { + export(&s, p, io)?; + } + if let Some(p) = export_to { + let window = if cadcraft_io::is_image(p) { export_window(&s, framing, Some(&before))? } else { None }; + export_framed(&s, p, window, io)?; + } + Ok(()) +} diff --git a/apps/cadcraft-cli/src/sample.rs b/apps/cadcraft-cli/src/sample.rs new file mode 100644 index 000000000..9b6405cb5 --- /dev/null +++ b/apps/cadcraft-cli/src/sample.rs @@ -0,0 +1,14 @@ +use super::args::{SampleArgs, SampleName}; +use super::error::Result; +use super::output::{Streams, write_atomic}; + +pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result { + let d = match a.name { + SampleName::Bracket => cadcraft_engine::sample::bracket(), + SampleName::Floorplan => cadcraft_engine::sample::floor_plan(), + }; + let bytes = cadcraft_io::write(&d, &a.output)?; + write_atomic(&a.output, &bytes)?; + io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len())); + Ok(()) +} diff --git a/apps/cadcraft-cli/tests/it/help.rs b/apps/cadcraft-cli/tests/it/help.rs index 24df988af..65f8bad97 100644 --- a/apps/cadcraft-cli/tests/it/help.rs +++ b/apps/cadcraft-cli/tests/it/help.rs @@ -1,23 +1,23 @@ //! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`. -//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1. +//! Bare `cadcraft-cli` is a usage error: the help on stderr, exit 2. use std::process::Command; #[test] -fn help_flags_print_usage_to_stdout_and_exit_zero() { +fn help_flags_print_help_to_stdout_and_exit_zero() { for args in [&["--help"][..], &["-h"], &["help"]] { let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli"); assert!(out.status.success(), "{args:?}: {:?}", out.status); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}"); + assert!(stdout.starts_with("cadcraft-cli: headless CADCraft\n"), "{args:?}: {stdout:?}"); assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty"); } } #[test] -fn bare_invocation_still_reports_on_stderr() { +fn bare_invocation_reports_on_stderr() { let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli"); - assert_eq!(out.status.code(), Some(1)); - assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr"); - assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:")); + assert_eq!(out.status.code(), Some(2)); + assert!(out.stdout.is_empty(), "bare invocation keeps its help on stderr"); + assert!(String::from_utf8_lossy(&out.stderr).starts_with("cadcraft-cli: headless CADCraft\n")); } diff --git a/apps/cadcraft-cli/tests/it/main.rs b/apps/cadcraft-cli/tests/it/main.rs index efd49c7dd..adb3f3749 100644 --- a/apps/cadcraft-cli/tests/it/main.rs +++ b/apps/cadcraft-cli/tests/it/main.rs @@ -4,3 +4,4 @@ mod help; mod mcp_connect; mod skipped_records; mod undrawn_entities; +mod usage; diff --git a/apps/cadcraft-cli/tests/it/mcp_connect.rs b/apps/cadcraft-cli/tests/it/mcp_connect.rs index c12e71c82..4211f3595 100644 --- a/apps/cadcraft-cli/tests/it/mcp_connect.rs +++ b/apps/cadcraft-cli/tests/it/mcp_connect.rs @@ -33,12 +33,15 @@ fn replies(out: &Output) -> Vec { #[test] fn connect_without_address_fails_before_serving() { let input = format!("{INIT}\n{CIRCLE}\n"); - for args in [&["--connect"][..], &["--connect", ""], &["--connect", "--foo"]] { + // clap names an unknown flag rather than the value it left missing. + for (args, named) in + [(&["--connect"][..], "--connect "), (&["--connect", ""], "--connect "), (&["--connect", "--foo"], "'--foo'")] + { let out = mcp(args, &input); - assert!(!out.status.success(), "{args:?} must exit non-zero"); + assert_eq!(out.status.code(), Some(2), "{args:?} is a usage error"); assert!(out.stdout.is_empty(), "{args:?} must not answer MCP requests: {}", String::from_utf8_lossy(&out.stdout)); let err = String::from_utf8_lossy(&out.stderr); - assert!(err.contains("--connect needs HOST:PORT"), "{args:?}: {err}"); + assert!(err.contains(named), "{args:?}: {err}"); } } diff --git a/apps/cadcraft-cli/tests/it/usage.rs b/apps/cadcraft-cli/tests/it/usage.rs new file mode 100644 index 000000000..2e9f6e907 --- /dev/null +++ b/apps/cadcraft-cli/tests/it/usage.rs @@ -0,0 +1,950 @@ +//! The command line's behaviour: exit codes, streams and messages for help requests, unknown and +//! malformed options, value flags in both spellings, repeats and arity. Every case goes through +//! `invoke`, so the table rows are the record of what each input does. Usage errors are clap's +//! (exit 2, stderr); a subcommand's own failures are `error: …` with exit 1. + +use std::path::{Path, PathBuf}; + +struct Invoke { + code: i32, + stdout: String, + stderr: String, +} + +fn invoke(args: &[&str]) -> Invoke { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = cadcraft_cli::run(args, &mut out, &mut err); + Invoke { code, stdout: String::from_utf8_lossy(&out).into_owned(), stderr: String::from_utf8_lossy(&err).into_owned() } +} + +/// What a stream holds. +#[derive(Clone, Copy)] +enum Expect { + Empty, + /// The first line equals this. + Line(&'static str), + /// The first line starts with this. + Starts(&'static str), +} + +use Expect::{Empty, Line, Starts}; + +struct Case { + name: &'static str, + args: &'static [&'static str], + code: i32, + stdout: Expect, + stderr: Expect, +} + +const fn case(name: &'static str, args: &'static [&'static str], code: i32, stdout: Expect, stderr: Expect) -> Case { + Case { name, args, code, stdout, stderr } +} + +/// A scratch directory holding `b.dxf` (the bracket sample) and `s.scr` (a one-line script). +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("cc-cli-usage-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + let b = dir.join("b.dxf"); + let r = invoke(&["sample", "bracket", b.to_str().expect("utf-8 path")]); + assert_eq!(r.code, 0, "{}", r.stderr); + std::fs::write(dir.join("s.scr"), "LINE 0,0 1,1\n").expect("write script"); + Scratch(dir) + } + + /// Replaces `{dir}` with the scratch directory and `{b}` with the sample drawing. + fn expand(&self, text: &str) -> String { + let dir = self.0.to_str().expect("utf-8 path"); + text.replace("{b}", &Path::new(dir).join("b.dxf").to_string_lossy()).replace("{dir}", dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn check(what: &str, stream: &str, got: &str, expect: Expect, scratch: &Scratch) { + let first = got.lines().next().unwrap_or(""); + match expect { + Empty => assert!(got.is_empty(), "{what}: {stream} should be empty, got {got:?}"), + Line(l) => assert_eq!(first, scratch.expand(l), "{what}: {stream}"), + Starts(p) => assert!(first.starts_with(&scratch.expand(p)), "{what}: {stream} should start with {p:?}, got {first:?}"), + } +} + +fn run_cases(tag: &str, cases: &[Case]) { + let scratch = Scratch::new(tag); + for c in cases { + let args: Vec = c.args.iter().map(|a| scratch.expand(a)).collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let what = format!("{}: {args:?}", c.name); + let r = invoke(&args); + assert_eq!(r.code, c.code, "{what}: exit code (stderr {:?})", r.stderr); + check(&what, "stdout", &r.stdout, c.stdout, &scratch); + check(&what, "stderr", &r.stderr, c.stderr, &scratch); + } +} + +#[test] +fn top_level_requests() { + run_cases( + "top", + &[ + case("--help", &["--help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty), + case("-h", &["-h"], 0, Line("cadcraft-cli: headless CADCraft"), Empty), + case("help", &["help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty), + case("--version", &["--version"], 0, Starts("cadcraft-cli "), Empty), + case("-V", &["-V"], 0, Starts("cadcraft-cli "), Empty), + case("version is not a subcommand", &["version"], 2, Empty, Line("error: unrecognized subcommand 'version'")), + case("bare", &[], 2, Empty, Line("cadcraft-cli: headless CADCraft")), + case("unknown subcommand", &["foo"], 2, Empty, Line("error: unrecognized subcommand 'foo'")), + case("unknown top-level flag", &["--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("empty subcommand", &[""], 2, Empty, Line("error: unrecognized subcommand ''")), + ], + ); +} + +/// Each subcommand's help, from `--help`, `-h` or `help `: its first line is the +/// subcommand's summary. +#[test] +fn subcommand_help_requests() { + run_cases( + "subhelp", + &[ + case("info --help", &["info", "--help"], 0, Line("Print a drawing's summary as JSON"), Empty), + case("info -h", &["info", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty), + case( + "convert --help", + &["convert", "--help"], + 0, + Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"), + Empty, + ), + case( + "convert -h", + &["convert", "-h"], + 0, + Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"), + Empty, + ), + case( + "run --help", + &["run", "--help"], + 0, + Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"), + Empty, + ), + case( + "run -h", + &["run", "-h"], + 0, + Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"), + Empty, + ), + case("commands --help", &["commands", "--help"], 0, Line("Print the command catalog as JSON"), Empty), + case( + "mcp --help", + &["mcp", "--help"], + 0, + Line("Run the MCP server on stdio: a headless drawing session, or a bridge to the running app"), + Empty, + ), + case( + "perf --help", + &["perf", "--help"], + 0, + Line( + "Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list build of a drawing, zoomed in step by step", + ), + Empty, + ), + case("sample --help", &["sample", "--help"], 0, Line("Write a built-in sample drawing"), Empty), + case( + "help run", + &["help", "run"], + 0, + Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"), + Empty, + ), + case("help info", &["help", "info"], 0, Line("Print a drawing's summary as JSON"), Empty), + case( + "help after a flag missing its value", + &["run", "--sample", "--script", "--help"], + 0, + Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"), + Empty, + ), + case( + "help after an unknown flag", + &["run", "--bogus", "--help"], + 0, + Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"), + Empty, + ), + case("help after an extra argument", &["info", "{b}", "extra", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty), + case("--help after -- is a file name", &["run", "--", "--help"], 1, Empty, Starts("error: invalid parameters for `open`: --help:")), + case("run --version", &["run", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")), + case("info --version", &["info", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")), + ], + ); +} + +#[test] +fn unknown_flags_on_every_subcommand() { + run_cases( + "unknown", + &[ + case("info", &["info", "{b}", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("info, flag first", &["info", "--bogus", "{b}"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("info, short flag", &["info", "-x"], 2, Empty, Line("error: unexpected argument '-x' found")), + case("info, a file name starting with - after --", &["info", "--", "-x"], 1, Empty, Starts("error: invalid parameters for `open`: -x:")), + case("convert", &["convert", "{b}", "{dir}/o.svg", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("run", &["run", "--sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("run, flag first", &["run", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("commands", &["commands", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("commands -- ends the options", &["commands", "--"], 0, Line("["), Empty), + case("mcp", &["mcp", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("mcp positional", &["mcp", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")), + case("perf", &["perf", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("sample", &["sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + case("sample after name", &["sample", "bracket", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")), + ], + ); +} + +/// `--x=v` is a spelling of `--x v`, and the one a value starting with `-` needs. +#[test] +fn value_flags_in_both_forms() { + run_cases( + "forms", + &[ + case("--window v", &["convert", "{b}", "{dir}/w1.svg", "--window", "0,0,100,100"], 0, Empty, Starts("wrote {dir}/w1.svg (")), + case("--window=v", &["convert", "{b}", "{dir}/w2.svg", "--window=0,0,100,100"], 0, Empty, Starts("wrote {dir}/w2.svg (")), + case( + "run --window v", + &["run", "--sample", "--window", "0,0,100,100", "--export", "{dir}/w3.svg"], + 0, + Empty, + Starts("wrote {dir}/w3.svg ("), + ), + case( + "run --window=v", + &["run", "--sample", "--window=0,0,100,100", "--export", "{dir}/w4.svg"], + 0, + Empty, + Starts("wrote {dir}/w4.svg ("), + ), + case("--view v", &["convert", "{b}", "{dir}/v1.svg", "--view", "extents"], 0, Empty, Starts("wrote {dir}/v1.svg (")), + case("--view=v", &["convert", "{b}", "{dir}/v2.svg", "--view=extents"], 0, Empty, Starts("wrote {dir}/v2.svg (")), + case("run --view v", &["run", "--sample", "--view", "current", "--export", "{dir}/v3.svg"], 0, Empty, Starts("wrote {dir}/v3.svg (")), + case("run --view=v", &["run", "--sample", "--view=extents", "--export={dir}/v4.svg"], 0, Empty, Starts("wrote {dir}/v4.svg (")), + case("--script v", &["run", "--sample", "--script", "LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")), + case("--script=v", &["run", "--sample", "--script=LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")), + case("--script-file v", &["run", "--sample", "--script-file", "{dir}/s.scr"], 0, Empty, Starts("Command: LINE")), + case("--script-file=v", &["run", "--sample", "--script-file={dir}/s.scr"], 0, Empty, Starts("Command: LINE")), + case("--cmd v", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")), + case("--cmd=v", &["run", "--sample", "--cmd=nosuch"], 1, Empty, Line("error: unknown command `nosuch`")), + case("--cmd=-v", &["run", "--sample", "--cmd=-x"], 1, Empty, Line("error: unknown command `-x`")), + case("--save v", &["run", "--sample", "--save", "{dir}/s1.dxf"], 0, Empty, Starts("wrote {dir}/s1.dxf (")), + case("--save=v", &["run", "--sample", "--save={dir}/s2.dxf"], 0, Empty, Starts("wrote {dir}/s2.dxf (")), + case("--export v", &["run", "--sample", "--export", "{dir}/e1.svg"], 0, Empty, Starts("wrote {dir}/e1.svg (")), + case("--export=v", &["run", "--sample", "--export={dir}/e2.svg"], 0, Empty, Starts("wrote {dir}/e2.svg (")), + case( + "--window=-v: negative coordinates need =", + &["convert", "{b}", "{dir}/w5.svg", "--window=-1000,-1000,1000,1000"], + 0, + Empty, + Starts("wrote {dir}/w5.svg ("), + ), + case( + "--window -v: a value starting with - is a flag", + &["convert", "{b}", "{dir}/w6.svg", "--window", "-1000,-1000,1000,1000"], + 2, + Empty, + Line("error: unexpected argument '-1' found"), + ), + case("--connect v", &["mcp", "--connect", "127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")), + case("--connect=v", &["mcp", "--connect=127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")), + ], + ); +} + +/// A value flag at the end of the arguments, or followed by another flag, is missing its value: +/// a flag is never taken as a value. +#[test] +fn flags_missing_their_value() { + run_cases( + "missing", + &[ + case( + "convert --window last", + &["convert", "{b}", "{dir}/o.svg", "--window"], + 2, + Empty, + Line("error: a value is required for '--window ' but none was supplied"), + ), + case( + "convert --view last", + &["convert", "{b}", "{dir}/o.svg", "--view"], + 2, + Empty, + Line("error: a value is required for '--view ' but none was supplied"), + ), + case( + "convert --window then flag", + &["convert", "{b}", "{dir}/o.svg", "--window", "--view", "extents"], + 2, + Empty, + Line("error: a value is required for '--window ' but none was supplied"), + ), + case( + "convert --view then flag", + &["convert", "{b}", "{dir}/o.svg", "--view", "--window", "0,0,1,1"], + 2, + Empty, + Line("error: a value is required for '--view ' but none was supplied"), + ), + case( + "run --window last", + &["run", "--sample", "--window"], + 2, + Empty, + Line("error: a value is required for '--window ' but none was supplied"), + ), + case( + "run --view last", + &["run", "--sample", "--view"], + 2, + Empty, + Line("error: a value is required for '--view ' but none was supplied"), + ), + case( + "run --script last", + &["run", "--sample", "--script"], + 2, + Empty, + Line("error: a value is required for '--script ' but none was supplied"), + ), + case( + "run --script then flag", + &["run", "--sample", "--script", "--sample"], + 2, + Empty, + Line("error: a value is required for '--script ' but none was supplied"), + ), + case( + "run --script-file last", + &["run", "--script-file"], + 2, + Empty, + Line("error: a value is required for '--script-file ' but none was supplied"), + ), + case( + "run --script-file then flag", + &["run", "--sample", "--script-file", "--sample"], + 2, + Empty, + Line("error: a value is required for '--script-file ' but none was supplied"), + ), + case( + "run --cmd last", + &["run", "--sample", "--cmd"], + 2, + Empty, + Line("error: a value is required for '--cmd ' but none was supplied"), + ), + case( + "run --cmd then flag", + &["run", "--sample", "--cmd", "--sample"], + 2, + Empty, + Line("error: a value is required for '--cmd ' but none was supplied"), + ), + case( + "run --save last", + &["run", "--sample", "--save"], + 2, + Empty, + Line("error: a value is required for '--save ' but none was supplied"), + ), + case( + "run --export last", + &["run", "--sample", "--export"], + 2, + Empty, + Line("error: a value is required for '--export ' but none was supplied"), + ), + case( + "mcp --connect last", + &["mcp", "--connect"], + 2, + Empty, + Line("error: a value is required for '--connect ' but none was supplied"), + ), + case( + "mcp --connect then flag", + &["mcp", "--connect", "--lazy"], + 2, + Empty, + Line("error: a value is required for '--connect ' but none was supplied"), + ), + case( + "mcp --connect empty", + &["mcp", "--connect", " "], + 2, + Empty, + Line("error: invalid value ' ' for '--connect ': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"), + ), + case( + "mcp --connect without a port", + &["mcp", "--connect", "example.com"], + 2, + Empty, + Line( + "error: invalid value 'example.com' for '--connect ': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)", + ), + ), + case( + "mcp --connect port out of range", + &["mcp", "--connect", "host:99999"], + 2, + Empty, + Line( + "error: invalid value 'host:99999' for '--connect ': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)", + ), + ), + case( + "mcp --connect=-v", + &["mcp", "--connect=-v"], + 2, + Empty, + Line("error: invalid value '-v' for '--connect ': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"), + ), + ], + ); +} + +#[test] +fn repeated_and_combined_flags() { + run_cases( + "repeat", + &[ + case( + "--save twice", + &["run", "--sample", "--save", "{dir}/a.dxf", "--save", "{dir}/b2.dxf"], + 2, + Empty, + Line("error: the argument '--save ' cannot be used multiple times"), + ), + case( + "--export twice", + &["run", "--sample", "--export", "{dir}/x1.svg", "--export", "{dir}/x2.svg"], + 2, + Empty, + Line("error: the argument '--export ' cannot be used multiple times"), + ), + case( + "--window twice", + &["run", "--sample", "--window", "0,0,1,1", "--window", "0,0,2,2", "--export", "{dir}/ww.svg"], + 2, + Empty, + Line("error: the argument '--window ' cannot be used multiple times"), + ), + case( + "convert --window twice", + &["convert", "{b}", "{dir}/cw.svg", "--window", "0,0,1,1", "--window", "0,0,2,2"], + 2, + Empty, + Line("error: the argument '--window ' cannot be used multiple times"), + ), + case( + "--view twice, different", + &["run", "--sample", "--view", "extents", "--view", "current", "--export", "{dir}/vv.svg"], + 2, + Empty, + Line("error: the argument '--view ' cannot be used multiple times"), + ), + case( + "--view twice, same", + &["run", "--sample", "--view", "extents", "--view", "extents", "--export", "{dir}/vs.svg"], + 2, + Empty, + Line("error: the argument '--view ' cannot be used multiple times"), + ), + case("--sample twice", &["run", "--sample", "--sample"], 2, Empty, Line("error: the argument '--sample' cannot be used multiple times")), + case("--metric twice", &["run", "--metric", "--metric"], 2, Empty, Line("error: the argument '--metric' cannot be used multiple times")), + case( + "--lazy twice", + &["mcp", "--connect", "127.0.0.1:1", "--lazy", "--lazy"], + 2, + Empty, + Line("error: the argument '--lazy' cannot be used multiple times"), + ), + case( + "--launch twice", + &["mcp", "--connect", "127.0.0.1:1", "--launch", "--launch"], + 2, + Empty, + Line("error: the argument '--launch' cannot be used multiple times"), + ), + case( + "--script, --script-file and --cmd repeat", + &[ + "run", + "--sample", + "--script", + "LINE 0,0 1,1", + "--script", + "LINE 0,0 2,2", + "--script-file", + "{dir}/s.scr", + "--script-file", + "{dir}/s.scr", + ], + 0, + Empty, + Starts("Command: LINE"), + ), + case( + "--window and --view", + &["run", "--sample", "--window", "0,0,1,1", "--view", "extents", "--export", "{dir}/wv.svg"], + 2, + Empty, + Line("error: the argument '--window ' cannot be used with '--view '"), + ), + case( + "convert --window and --view", + &["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"], + 2, + Empty, + Line("error: the argument '--window ' cannot be used with '--view '"), + ), + case( + "--view is case-insensitive", + &["run", "--sample", "--view", "EXTENTS", "--export", "{dir}/vu.svg"], + 0, + Empty, + Starts("wrote {dir}/vu.svg ("), + ), + case( + "--connect twice", + &["mcp", "--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"], + 2, + Empty, + Line("error: the argument '--connect ' cannot be used multiple times"), + ), + case("--lazy without --connect", &["mcp", "--lazy"], 2, Empty, Line("error: the following required arguments were not provided:")), + case("--launch without --connect", &["mcp", "--launch"], 2, Empty, Line("error: the following required arguments were not provided:")), + case("--sample --metric: both accepted", &["run", "--sample", "--metric"], 0, Empty, Empty), + case("--sample and a file", &["run", "--sample", "{b}"], 0, Empty, Empty), + case("two files", &["run", "{b}", "{b}"], 0, Empty, Empty), + case( + "--window without export", + &["run", "--sample", "--window", "0,0,1,1"], + 2, + Empty, + Line("error: the following required arguments were not provided:"), + ), + case( + "--view without export", + &["run", "--sample", "--view", "current"], + 2, + Empty, + Line("error: the following required arguments were not provided:"), + ), + case( + "--window with a dxf output", + &["convert", "{b}", "{dir}/o.dxf", "--window", "0,0,1,1"], + 2, + Empty, + Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"), + ), + case( + "--view with a dxf output", + &["convert", "{b}", "{dir}/o.dxf", "--view", "extents"], + 2, + Empty, + Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"), + ), + case( + "run --window with a dxf export", + &["run", "--sample", "--window", "0,0,1,1", "--export", "{dir}/o.dxf"], + 2, + Empty, + Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"), + ), + case( + "run --view with a dxf export", + &["run", "--sample", "--view", "current", "--export", "{dir}/o.dxf"], + 2, + Empty, + Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"), + ), + ], + ); +} + +#[test] +fn arity_and_positional_arguments() { + run_cases( + "arity", + &[ + case("info without a file", &["info"], 2, Empty, Line("error: the following required arguments were not provided:")), + case("info with two files", &["info", "{b}", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")), + case("info missing file", &["info", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")), + case("convert with no files", &["convert"], 2, Empty, Line("error: the following required arguments were not provided:")), + case("convert with one file", &["convert", "{b}"], 2, Empty, Line("error: the following required arguments were not provided:")), + case( + "convert with three files", + &["convert", "{b}", "{dir}/o.svg", "{dir}/x.svg"], + 2, + Empty, + Line("error: unexpected argument '{dir}/x.svg' found"), + ), + case("run missing file", &["run", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")), + case("commands, no match", &["commands", "zzzznomatch"], 0, Line("[]"), Empty), + case( + "commands with an extra argument", + &["commands", "zzzznomatch", "extra"], + 2, + Empty, + Line("error: unexpected argument 'extra' found"), + ), + case("sample without arguments", &["sample"], 2, Empty, Line("error: the following required arguments were not provided:")), + case("sample without an output", &["sample", "bracket"], 2, Empty, Line("error: the following required arguments were not provided:")), + case( + "sample with a bad name", + &["sample", "nosuch", "{dir}/o.dxf"], + 2, + Empty, + Line("error: invalid value 'nosuch' for ''"), + ), + case( + "sample names are case-sensitive", + &["sample", "Bracket", "{dir}/o.dxf"], + 2, + Empty, + Line("error: invalid value 'Bracket' for ''"), + ), + case( + "sample with an empty output", + &["sample", "bracket", ""], + 2, + Empty, + Line("error: a value is required for '' but none was supplied"), + ), + case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '' but none was supplied")), + case("perf with an empty file name", &["perf", ""], 2, Empty, Line("error: a value is required for '[N|FILE]' but none was supplied")), + case( + "sample with an extra argument", + &["sample", "bracket", "{dir}/sx.dxf", "extra"], + 2, + Empty, + Line("error: unexpected argument 'extra' found"), + ), + case("sample floor alias", &["sample", "floor", "{dir}/sf.dxf"], 0, Empty, Starts("wrote {dir}/sf.dxf (")), + case("sample with a bad extension", &["sample", "bracket", "{dir}/o.xyz"], 1, Empty, Line("error: unsupported file type `xyz`")), + case("perf with a word", &["perf", "abc"], 1, Empty, Starts("error: invalid parameters for `open`: abc:")), + case("perf with a negative number", &["perf", "-5"], 2, Empty, Line("error: unexpected argument '-5' found")), + case("perf with a missing file", &["perf", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")), + case("perf 1", &["perf", "1"], 0, Starts("operation"), Empty), + case("perf 0 is raised to 1", &["perf", "0"], 0, Starts("operation"), Empty), + case("perf with two counts", &["perf", "1", "2"], 2, Empty, Line("error: unexpected argument '2' found")), + ], + ); +} + +#[test] +fn bad_values() { + run_cases( + "values", + &[ + case( + "--view sideways", + &["convert", "{b}", "{dir}/o.svg", "--view", "sideways"], + 2, + Empty, + Line("error: invalid value 'sideways' for '--view '"), + ), + case( + "run --view sideways", + &["run", "--sample", "--view", "sideways"], + 2, + Empty, + Line("error: invalid value 'sideways' for '--view '"), + ), + case( + "--view sideways before the output check", + &["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"], + 2, + Empty, + Line("error: invalid value 'sideways' for '--view '"), + ), + case( + "--window 3 numbers", + &["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"], + 2, + Empty, + Line("error: invalid value '1,2,3' for '--window ': expected four numbers x0,y0,x1,y1"), + ), + case( + "--window 5 numbers", + &["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3,4,5"], + 2, + Empty, + Line("error: invalid value '1,2,3,4,5' for '--window ': expected four numbers x0,y0,x1,y1"), + ), + case( + "--window reversed", + &["convert", "{b}", "{dir}/o.svg", "--window", "3,3,1,1"], + 2, + Empty, + Line("error: invalid value '3,3,1,1' for '--window ': need x0 < x1 and y0 < y1 (lower-left corner first)"), + ), + case( + "--window not numbers", + &["convert", "{b}", "{dir}/o.svg", "--window", "a,b,c,d"], + 2, + Empty, + Line("error: invalid value 'a,b,c,d' for '--window ': x0 must be a finite number, got `a`"), + ), + case( + "--window NaN", + &["convert", "{b}", "{dir}/o.svg", "--window", "NaN,0,1,1"], + 2, + Empty, + Line("error: invalid value 'NaN,0,1,1' for '--window ': x0 must be a finite number, got `NaN`"), + ), + case( + "--window too large", + &["convert", "{b}", "{dir}/o.svg", "--window", "0,0,1e13,1"], + 2, + Empty, + Line("error: invalid value '0,0,1e13,1' for '--window ': export window: coordinates must lie within ±1e12"), + ), + case( + "run --window 3 numbers", + &["run", "--sample", "--window", "1,2,3", "--export", "{dir}/o.svg"], + 2, + Empty, + Line("error: invalid value '1,2,3' for '--window ': expected four numbers x0,y0,x1,y1"), + ), + case( + "run --window reversed", + &["run", "--sample", "--window", "3,3,1,1", "--export", "{dir}/o.svg"], + 2, + Empty, + Line("error: invalid value '3,3,1,1' for '--window ': need x0 < x1 and y0 < y1 (lower-left corner first)"), + ), + case( + "--cmd bad json", + &["run", "--sample", "--cmd", "line {bad json"], + 2, + Empty, + Line("error: invalid value 'line {bad json' for '--cmd ': key must be a string at line 1 column 2"), + ), + case( + "--cmd bad json after a good step: nothing runs", + &["run", "--sample", "--cmd", "line {\"points\":[[0,0],[1,1]]}", "--cmd", "line {"], + 2, + Empty, + Starts("error: invalid value 'line {' for '--cmd ': "), + ), + case( + "--cmd without an id", + &["run", "--sample", "--cmd", " {}"], + 2, + Empty, + Line("error: invalid value ' {}' for '--cmd ': expected a command id, then optionally its JSON parameters"), + ), + case( + "--cmd empty", + &["run", "--sample", "--cmd="], + 2, + Empty, + Line("error: invalid value '' for '--cmd ': expected a command id, then optionally its JSON parameters"), + ), + case("--cmd unknown id", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")), + case( + "--cmd {last} with no earlier handle", + &["run", "--sample", "--cmd", "erase {\"handles\":[\"{last}\"]}"], + 1, + Empty, + Line("error: --cmd erase: `{last}` needs an earlier --cmd in this run to have returned a handle"), + ), + case("--script-file missing", &["run", "--sample", "--script-file", "missing.scr"], 1, Empty, Starts("error: missing.scr:")), + ], + ); +} + +/// `mcp` serves on the process's real stdin and stdout, so the headless server runs through the +/// binary, with a closed stdin. +#[test] +fn mcp_headless_with_a_closed_stdin() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")) + .arg("mcp") + .stdin(std::process::Stdio::null()) + .output() + .expect("run cadcraft-cli"); + assert_eq!(out.status.code(), Some(0)); + assert!(out.stdout.is_empty() && out.stderr.is_empty()); +} + +/// A drawing to open whose name isn't valid Unicode reaches the handler as a path; the engine opens +/// files by text name, so it is refused there (exit 1), naming the file. Text that isn't Unicode is +/// a usage error. +#[cfg(unix)] +#[test] +fn non_unicode_arguments() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + let invoke_os = |args: Vec| { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = cadcraft_cli::run(args, &mut out, &mut err); + assert!(out.is_empty()); + (code, String::from_utf8_lossy(&err).into_owned()) + }; + let latin1 = || OsString::from_vec(b"caf\xe9.dxf".to_vec()); + let (code, err) = invoke_os(vec![OsString::from("info"), latin1()]); + assert_eq!(code, 1); + assert_eq!(err.lines().next(), Some("error: caf\u{FFFD}.dxf: the file name is not valid Unicode")); + let (code, err) = invoke_os(vec![OsString::from("run"), OsString::from("--sample"), OsString::from("--cmd"), latin1()]); + assert_eq!(code, 2); + assert!(err.starts_with("error: invalid UTF-8 was detected"), "{err}"); +} + +/// An output name that isn't valid Unicode is written as given: only its extension picks the format. +#[cfg(unix)] +#[test] +fn non_unicode_output_names() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + let s = Scratch::new("nonunicode-out"); + let latin1 = |ext: &str| { + let mut n = b"caf\xe9.".to_vec(); + n.extend_from_slice(ext.as_bytes()); + s.0.join(OsString::from_vec(n)) + }; + let run = |args: Vec| { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let code = cadcraft_cli::run(args, &mut out, &mut err); + (code, String::from_utf8_lossy(&err).into_owned()) + }; + let b = s.0.join("b.dxf"); + let cases: [(Vec, std::path::PathBuf); 3] = [ + (vec!["sample".into(), "bracket".into(), latin1("dxf").into()], latin1("dxf")), + (vec!["convert".into(), b.clone().into(), latin1("svg").into()], latin1("svg")), + (vec!["run".into(), "--sample".into(), "--save".into(), latin1("dwg").into()], latin1("dwg")), + ]; + for (args, path) in cases { + let (code, err) = run(args.clone()); + assert_eq!(code, 0, "{args:?}: {err}"); + assert!(path.exists(), "{path:?}"); + assert!(std::fs::metadata(&path).expect("stat output").len() > 0, "{path:?}"); + } + // An input with such a name is still refused. + let (code, err) = run(vec!["info".into(), latin1("dxf").into()]); + assert_eq!(code, 1); + assert!(err.contains("not valid Unicode"), "{err}"); +} + +/// An output that is an input file is refused before any work (exit 2, the input untouched) unless +/// `--in-place` allows it; the same file under another spelling counts, another existing file doesn't. +#[test] +fn output_that_replaces_an_input() { + let s = Scratch::new("inplace"); + let dir = s.0.to_str().expect("utf-8 path").to_string(); + let b = format!("{dir}/b.dxf"); + let original = std::fs::read(&b).expect("read input"); + std::fs::create_dir_all(format!("{dir}/sub")).expect("create sub"); + let dotted = format!("{dir}/sub/../b.dxf"); + #[allow(unused_mut)] + let mut spellings = vec![b.clone(), dotted]; + #[cfg(unix)] + { + let link = format!("{dir}/link.dxf"); + std::os::unix::fs::symlink(&b, &link).expect("symlink"); + spellings.push(link); + } + for out in &spellings { + for args in [vec!["convert", &b, out], vec!["run", &b, "--save", out], vec!["run", &b, "--export", out]] { + let r = invoke(&args); + assert_eq!(r.code, 2, "{args:?}: {}", r.stderr); + assert!(r.stdout.is_empty(), "{args:?}"); + assert!(r.stderr.starts_with(&format!("error: {out} is an input file")), "{args:?}: {}", r.stderr); + assert!(r.stderr.contains("--in-place"), "{args:?}: {}", r.stderr); + assert_eq!(std::fs::read(&b).expect("read input"), original, "{args:?}: input changed"); + } + } + + // Another existing file is replaced as before. + let other = format!("{dir}/other.dxf"); + std::fs::write(&other, "x").expect("write other"); + let r = invoke(&["convert", &b, &other]); + assert_eq!(r.code, 0, "{}", r.stderr); + assert_ne!(std::fs::read(&other).expect("read other"), b"x"); + + // --in-place allows it, for each spelling and flag: the input itself is rewritten, and a + // symlink stays a link to it. + let line = "line {\"points\":[[0,0],[1,1]]}"; + let lines = |p: &str| std::fs::read_to_string(p).expect("read input").lines().filter(|l| l.trim_end() == "LINE").count(); + let before = lines(&b); + for out in &spellings { + std::fs::write(&b, &original).expect("restore input"); + let r = invoke(&["convert", &b, out, "--in-place"]); + assert_eq!(r.code, 0, "convert {out}: {}", r.stderr); + assert_eq!(lines(&b), before, "convert {out} keeps the drawing"); + for flag in ["--save", "--export"] { + std::fs::write(&b, &original).expect("restore input"); + let r = invoke(&["run", &b, "--cmd", line, "--in-place", flag, out]); + assert_eq!(r.code, 0, "{flag} {out}: {}", r.stderr); + assert_eq!(lines(&b), before + 1, "{flag} {out}: the input gains the line"); + } + } + #[cfg(unix)] + { + let link = format!("{dir}/link.dxf"); + assert!(std::fs::symlink_metadata(&link).expect("stat link").file_type().is_symlink(), "{link} is still a symlink"); + } + + // An output that doesn't exist yet is never an input. + let new = format!("{dir}/new.dxf"); + let r = invoke(&["run", &b, "--save", &new]); + assert_eq!(r.code, 0, "{}", r.stderr); + assert!(std::path::Path::new(&new).is_file(), "{new} written"); +} + +/// JSON results are pretty-printed; `--compact` puts each on one line (RFC-0012 rule 1.3). `run` +/// prints one line per `--cmd` result either way. +#[test] +fn json_output_is_pretty_unless_compact() { + let s = Scratch::new("compact"); + let b = format!("{}/b.dxf", s.0.to_str().expect("utf-8 path")); + for args in [&["info", b.as_str()][..], &["commands", "ellipse"]] { + let help = invoke(&[args[0], "--help"]); + assert!( + help.stdout.contains("--compact") && help.stdout.contains("One line of JSON instead of pretty-printed"), + "{} --help: {}", + args[0], + help.stdout + ); + let pretty = invoke(args); + assert_eq!(pretty.code, 0, "{args:?}: {}", pretty.stderr); + assert!(pretty.stdout.lines().count() > 1, "{args:?} is pretty-printed: {}", pretty.stdout); + let compact = invoke(&[args, &["--compact"]].concat()); + assert_eq!(compact.code, 0, "{args:?} --compact: {}", compact.stderr); + assert_eq!(compact.stdout.lines().count(), 1, "{args:?} --compact is one line: {}", compact.stdout); + let a: serde_json::Value = serde_json::from_str(&pretty.stdout).expect("pretty JSON"); + let c: serde_json::Value = serde_json::from_str(&compact.stdout).expect("compact JSON"); + assert_eq!(a, c, "{args:?}: same JSON either way"); + } + let r = invoke(&["run", "--sample", "--cmd", "drawing.inspect", "--cmd", "drawing.inspect"]); + assert_eq!(r.code, 0, "{}", r.stderr); + assert_eq!(r.stdout.lines().count(), 2, "one line per --cmd result: {}", r.stdout); + assert_eq!(invoke(&["run", "--sample", "--compact"]).code, 2, "run takes no --compact"); +} diff --git a/apps/cadcraft-cli/tests/man_and_completions.rs b/apps/cadcraft-cli/tests/man_and_completions.rs new file mode 100644 index 000000000..df6dea90c --- /dev/null +++ b/apps/cadcraft-cli/tests/man_and_completions.rs @@ -0,0 +1,30 @@ +//! The clap definition is consistent, and `examples/man-and-completions.rs` writes every shell's +//! completions and every man page under the names `packaging/linux/package.sh` installs. + +// The example itself, so the test runs its code; its `main` is unused here. +#[allow(dead_code)] +#[path = "../examples/man-and-completions.rs"] +mod example; + +#[test] +fn command_is_consistent() { + cadcraft_cli::command().debug_assert(); +} + +#[test] +fn the_example_writes_the_files_the_packages_install() { + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("man-and-completions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + example::generate(&dir).unwrap(); + + let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); + written.sort(); + let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"]; + let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"]; + let mut expected: Vec = completions.map(String::from).into(); + expected.push("cadcraft-cli.1".into()); + expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1"))); + expected.sort(); + assert_eq!(written, expected); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index f69e02d8a..a25782d6e 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -14,6 +14,9 @@ pub mod download; #[cfg(target_arch = "wasm32")] mod host; +#[cfg(any(target_arch = "wasm32", test))] +mod panel_layout; + #[cfg(target_arch = "wasm32")] mod web { use cadcraft_engine::Session; @@ -59,10 +62,14 @@ mod web { self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } - /// Preferences (interface theme and language) survive reloads; egui state is not kept. + /// Preferences and validated panel placement survive reloads; egui memory is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + match crate::panel_layout::save(&self.0.ui) { + Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout), + Err(error) => log::warn!("panel layout was not saved: {error}"), + } } fn persist_egui_memory(&self) -> bool { false @@ -160,6 +167,11 @@ mod web { if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } + if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY)) + && let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout) + { + log::warn!("panel layout was not restored: {error}"); + } // `?cpu`: no render state for the app, so the canvas draws with egui shapes. if !crate::wants_cpu(&query()) && let Some(rs) = &cc.wgpu_render_state diff --git a/apps/cadcraft-web/src/panel_layout.rs b/apps/cadcraft-web/src/panel_layout.rs new file mode 100644 index 000000000..766879c83 --- /dev/null +++ b/apps/cadcraft-web/src/panel_layout.rs @@ -0,0 +1,104 @@ +//! Browser panel layout storage, independent of drawings and native workspace files. + +use cadcraft_ui_egui::{UiState, docking}; +use serde_json::{Value, json}; + +#[cfg(target_arch = "wasm32")] +pub const KEY: &str = "cadcraft.panel-layout"; +const MAX_BYTES: usize = 64 * 1024; + +/// Serialize only validated panel placement and visibility, with a bounded record size. +pub fn save(ui: &UiState) -> Result { + docking::validate(&ui.docking)?; + let saved = json!({ + "version": docking::LAYOUT_VERSION, + "docking": ui.docking, + "showToolsets": ui.show_toolsets, + "showPalettes": ui.show_palettes, + }) + .to_string(); + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + Ok(saved) +} + +/// Validate the entire optional record before changing any UI field. +pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> { + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?; + if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) { + return Err("unsupported panel layout version".into()); + } + let workspace: docking::Workspace = + serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?; + docking::validate(&workspace)?; + let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?; + let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?; + ui.docking = workspace; + ui.show_toolsets = tools; + ui.show_palettes = palettes; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + use cadcraft_ui_egui::{CadApp, Services}; + + #[test] + fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() { + let mut source = CadApp::new(Session::default(), Services::default()); + source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap(); + source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + let saved = save(&source.ui).unwrap(); + let mut destination = CadApp::new(Session::default(), Services::default()); + destination.session.start("line").unwrap(); + let before = destination.session.doc().unwrap().clone(); + restore(&mut destination.ui, &saved).unwrap(); + assert_eq!(destination.ui.docking, source.ui.docking); + assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets); + assert_eq!(destination.ui.show_palettes, source.ui.show_palettes); + assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line")); + assert_eq!(destination.session.doc().unwrap(), &before); + } + + #[test] + fn malformed_or_unsupported_storage_is_rejected_atomically() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + for (field, bad) in [ + ("version", json!(0)), + ("version", json!(docking::LAYOUT_VERSION + 1)), + ("docking", json!({})), + ("showToolsets", json!("false")), + ("showPalettes", Value::Null), + ] { + let mut invalid = valid.clone(); + invalid[field] = bad; + assert!(restore(&mut ui, &invalid.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] { + assert!(restore(&mut ui, &invalid).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + } + + #[test] + fn storage_cannot_remove_the_document_view() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let mut invalid = ui.clone(); + invalid.docking.layout.root = None; + assert!(save(&invalid).is_err()); + let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + record["docking"] = serde_json::to_value(&invalid.docking).unwrap(); + assert!(restore(&mut ui, &record.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } +} diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 48c2c2d15..d8b4ef4c5 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -21,6 +21,8 @@ eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayl egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +clap = { workspace = true } +thiserror = { workspace = true } chrono = { workspace = true } [build-dependencies] diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index 14283e6fd..d502e1408 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -7,7 +7,7 @@ //! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a //! socket may sit idle or blocked on a write. -use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; +use std::io::{BufRead, BufReader, ErrorKind, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -24,6 +24,9 @@ struct Limits { max_clients: usize, /// Longest request line in bytes (newline excluded); a longer one closes the connection. max_line: usize, + /// Request-line bytes buffered at once across all connections; a request that would pass it + /// is refused with an error reply. + total_bytes: usize, /// Requests waiting for the app at once; more are answered `busy`. queue: usize, /// How long a connection may go without sending a byte before it is closed. @@ -40,6 +43,8 @@ impl Default for Limits { max_clients: 16, // Generous, so requests that carry whole drawings or images as base64 still fit. max_line: 256 * 1024 * 1024, + // Room for one maximal request plus smaller ones, not `max_clients` maximal ones. + total_bytes: 512 * 1024 * 1024, queue: 64, idle_timeout: Duration::from_secs(600), write_timeout: Duration::from_secs(30), @@ -106,6 +111,7 @@ impl Drop for Slot { /// Accept clients within the connection limit and give each worker the same session token. fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits, token: Arc) { let open = Arc::new(AtomicUsize::new(0)); + let budget = Budget::new(limits.total_bytes); for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { let _ = stream.set_write_timeout(Some(limits.write_timeout)); if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients { @@ -117,39 +123,102 @@ fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui: let tx = tx.clone(); let ctx = ctx.clone(); let token = Arc::clone(&token); + let budget = budget.clone(); let _ = std::thread::Builder::new().spawn(move || { let _slot = slot; - serve(stream, &tx, &ctx, limits, &token); + serve(stream, &tx, &ctx, limits, &token, budget); }); } } -/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). -/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. -fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { +/// A total-bytes budget for request lines buffered across all connections, so +/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925). +#[derive(Clone, Debug)] +struct Budget { + used: Arc, + total: usize, +} + +impl Budget { + fn new(total: usize) -> Self { + Self { used: Arc::new(AtomicUsize::new(0)), total } + } + + /// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total. + fn reserve(&self, n: usize) -> bool { + self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok() + } +} + +/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the +/// next line starts or the connection ends. +struct Held { + budget: Budget, + bytes: usize, +} + +impl Held { + fn new(budget: Budget) -> Self { + Self { budget, bytes: 0 } + } + + fn release(&mut self) { + self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst); + self.bytes = 0; + } +} + +impl Drop for Held { + fn drop(&mut self) { + self.release(); + } +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging +/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long, +/// the shared budget is used up, or the read fails. +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize, held: &mut Held) -> Result { buf.clear(); - // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. - let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { - // The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock). - ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(), - _ => e.to_string(), - })?; - if n == 0 { - return Ok(false); - } - if buf.last() == Some(&b'\n') { - buf.pop(); - if buf.last() == Some(&b'\r') { + held.release(); + // A line is at most `max` bytes plus its newline; never buffer more than that. + let cap = max.saturating_add(1); + loop { + let chunk = match reader.fill_buf() { + Ok(c) => c, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => { + return Err(match e.kind() { + // The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock). + ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(), + _ => e.to_string(), + }); + } + }; + if chunk.is_empty() { + // End of stream: nothing read, or a last line without a newline. + return Ok(!buf.is_empty()); + } + let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1); + let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len())); + buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk)); + reader.consume(n); + // The chunk is already buffered (a few KiB), so it is added before it is charged: a + // refusal still leaves `buf` non-empty and the connection gets an error reply. + if !held.budget.reserve(n) { + return Err("too many large requests in flight; try again later".to_string()); + } + held.bytes += n; + if buf.last() == Some(&b'\n') { buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(true); + } + if buf.len() > max { + return Err(format!("request line longer than {max} bytes; closing the connection")); } - return Ok(true); } - if buf.len() > max { - return Err(format!("request line longer than {max} bytes; closing the connection")); - } - // Last line without a newline, then end of stream. - Ok(true) } /// One request line, parsed. @@ -204,7 +273,7 @@ fn is_http_request_line(text: &str) -> bool { /// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI. /// Invalid credentials close the connection; queue and reply limits report whether a request ran. -fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str) { +fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -212,8 +281,9 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context let mut out = stream; let mut reader = BufReader::new(read); let mut buf = Vec::new(); + let mut held = Held::new(budget); loop { - let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) { + let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) { Ok(false) => break, // Nothing of a line arrived (idle timeout, or the peer is gone): close without a word. // A line nobody asked for would be read as the reply to the client's next request. @@ -264,6 +334,7 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context #[cfg(test)] mod tests { use super::*; + use std::io::Read; /// A failed accept is followed by a wait before the next one, so a listener that keeps /// failing does not spin, and the connections around the failures still come through. @@ -281,13 +352,13 @@ mod tests { fn lines_are_read_up_to_the_limit() { let mut buf = Vec::new(); let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"bb", "CRLF is stripped"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"cc", "a last line without a newline is still read"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream"); } #[test] @@ -295,11 +366,15 @@ mod tests { let mut buf = Vec::new(); let long = [b'x'; 100]; let mut input: &[u8] = &long; - assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err()); assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len()); assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); } + fn held() -> Held { + Held::new(Budget::new(usize::MAX)) + } + fn short() -> Limits { Limits { queue: 1, @@ -414,6 +489,40 @@ mod tests { drop(rx); } + /// Connections can't together buffer more request-line bytes than the budget: a second large + /// request is refused with an error reply while the first still holds its bytes (#1925). + #[test] + fn requests_past_the_total_budget_are_refused() { + let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() }); + // The first peer sends 200 bytes of an unfinished line and keeps the connection open. + let mut first = TcpStream::connect(addr).unwrap(); + first.write_all(&[b'a'; 200]).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + // The second would take 200 more, past the 300 byte total. + let mut second = TcpStream::connect(addr).unwrap(); + second.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + second.write_all(&[b'b'; 200]).unwrap(); + let mut line = String::new(); + BufReader::new(second).read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["ok"], json!(false)); + assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}"); + drop(first); + } + + #[test] + fn the_budget_is_given_back_when_the_connection_ends() { + let budget = Budget::new(100); + { + let mut h = Held::new(budget.clone()); + let mut buf = Vec::new(); + let mut input: &[u8] = &[b'x'; 80]; + assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true)); + assert!(!budget.reserve(30), "80 of 100 bytes are held"); + } + assert!(budget.reserve(100), "all given back on drop"); + } + /// Replies on one authenticated connection preserve the ID of each request. #[test] fn requests_are_answered_with_their_ids() { diff --git a/apps/cadcraft/src/env.rs b/apps/cadcraft/src/env.rs new file mode 100644 index 000000000..76df8aa15 --- /dev/null +++ b/apps/cadcraft/src/env.rs @@ -0,0 +1,172 @@ +//! The `CADCRAFT_*` variables of the desktop app, read once at startup into [`Env`] and passed on +//! from there; no library crate reads them. `--help` lists them from [`VARS`]. `--control`'s +//! fallback, `CADCRAFT_CONTROL_PORT`, is clap's (see `launch_args`). + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::path::PathBuf; + +use cadcraft_ui_egui::switch::Switch; + +const CONFIG_DIR: &str = "CADCRAFT_CONFIG_DIR"; +const LOCALE: &str = "CADCRAFT_LOCALE"; +const VSYNC: &str = "CADCRAFT_VSYNC"; +const WAYLAND: &str = "CADCRAFT_WAYLAND"; +const NO_NATIVE_MENU: &str = "CADCRAFT_NO_NATIVE_MENU"; +const SYSTEM_CURSOR: &str = "CADCRAFT_SYSTEM_CURSOR"; +const FONTALT: &str = "CADCRAFT_FONTALT"; +const FONTFALLBACK: &str = "CADCRAFT_FONTFALLBACK"; + +/// Every variable [`Env`] reads, with its `--help` line. +pub(crate) const VARS: &[(&str, &str)] = &[ + (CONFIG_DIR, "the settings folder (logs, autosave, workspace, plotters); empty means no settings folder"), + (LOCALE, "the interface language, instead of the system's"), + (VSYNC, "1 presents with vsync, 0 without it (default: without on Linux and BSD)"), + (WAYLAND, "1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"), + (NO_NATIVE_MENU, "macOS: 1 keeps the menus inside the window; 0 or unset is off"), + (SYSTEM_CURSOR, "1 uses the system cursor instead of the drawn crosshair; 0 or unset is off"), + (FONTALT, "the font that replaces a missing one"), + (FONTFALLBACK, "the fonts tried, in order, for characters a font lacks"), +]; + +/// The process environment, read once at startup. A blank variable counts as unset, except +/// `CADCRAFT_CONFIG_DIR`. +#[derive(Debug, PartialEq)] +pub(crate) struct Env { + /// The settings folder (logs, autosave, workspace, plotters): `CADCRAFT_CONFIG_DIR`; else, when + /// that is unset, the platform's (`cadcraft_engine::settings_dir::platform`). `None`: no + /// folder, because the variable is empty or blank (the way to run without one) or the + /// platform has none. + pub(crate) config_dir: Option, + /// The interface language, instead of the system's. + pub(crate) locale: Option, + /// Present with vsync (default: off on Linux and BSD, on elsewhere). + pub(crate) vsync: Switch, + /// Keep the native Wayland backend instead of XWayland. + #[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] + pub(crate) wayland: Switch, + /// macOS: keep the menus inside the window. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + pub(crate) no_native_menu: Switch, + /// Use the system cursor instead of the drawn crosshair. + pub(crate) system_cursor: Switch, + /// FONTALT at startup. + pub(crate) font_alt: Option, + /// FONTFALLBACK at startup. + pub(crate) font_fallback: Option, +} + +impl Env { + pub(crate) fn read() -> Env { + Env::from_vars(|k| std::env::var_os(k)) + } + + /// [`Env::read`] with `var` looking a variable up. + pub(crate) fn from_vars(var: impl Fn(&str) -> Option) -> Env { + let text = |k: &str| var(k).and_then(|v| v.into_string().ok()).filter(|v| !v.trim().is_empty()); + let switch = |k: &str| Switch::parse(k, var(k).map(|v| v.to_string_lossy().into_owned()).as_deref()); + Env { + config_dir: match var(CONFIG_DIR) { + Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None, + Some(dir) => Some(PathBuf::from(dir)), + None => cadcraft_engine::settings_dir::platform(&var), + }, + locale: text(LOCALE), + vsync: switch(VSYNC), + wayland: switch(WAYLAND), + no_native_menu: switch(NO_NATIVE_MENU), + system_cursor: switch(SYSTEM_CURSOR), + font_alt: text(FONTALT), + font_fallback: text(FONTFALLBACK), + } + } +} + +/// The `--help` section that lists [`VARS`]. +pub(crate) fn help() -> String { + let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0) + 3; + VARS.iter().fold(String::from("Environment:"), |mut help, (name, line)| { + let _ = write!(help, "\n {name:(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn every_variable_read_is_in_the_help_and_every_listed_one_is_read() { + let asked = RefCell::new(Vec::new()); + Env::from_vars(|k| { + asked.borrow_mut().push(k.to_owned()); + None + }); + let ours: Vec = asked.into_inner().into_iter().filter(|k| k.starts_with("CADCRAFT_")).collect(); + let help = help(); + let unset = Env::from_vars(|_| None); + for k in &ours { + assert!(VARS.iter().any(|(name, _)| name == k), "{k} is read but not in VARS"); + } + for (name, line) in VARS { + assert!(ours.iter().any(|k| k == name), "{name} is in VARS but not read"); + assert_ne!(Env::from_vars(env(&[(name, "1")])), unset, "{name} sets no field"); + assert!(help.lines().any(|l| l.trim_start().starts_with(name) && l.ends_with(line)), "{help}"); + } + } + + #[test] + fn values_are_read_and_blank_ones_are_unset() { + let vars = [ + (CONFIG_DIR, "/private/cc"), + ("HOME", "/home/u"), + ("XDG_CONFIG_HOME", "/x"), + ("APPDATA", "C:\\a"), + (LOCALE, "cs-CZ"), + (VSYNC, " On "), + (WAYLAND, "0"), + (NO_NATIVE_MENU, "yes"), + (SYSTEM_CURSOR, "maybe"), + (FONTALT, "Noto Sans"), + (FONTFALLBACK, "Vazirmatn, msjh.ttc"), + ]; + let read = Env::from_vars(env(&vars)); + assert_eq!( + read, + Env { + config_dir: Some(PathBuf::from("/private/cc")), + locale: Some("cs-CZ".into()), + vsync: Switch::On, + wayland: Switch::Off, + no_native_menu: Switch::On, + system_cursor: Switch::Default, + font_alt: Some("Noto Sans".into()), + font_fallback: Some("Vazirmatn, msjh.ttc".into()), + } + ); + let blank = vars.map(|(k, _)| (k, if k == FONTALT { " " } else { "" })); + let read = Env::from_vars(env(&blank)); + assert_eq!(read.config_dir, None, "an empty variable means no directory, not the working directory"); + assert_eq!((read.locale, read.font_alt, read.font_fallback), (None, None, None)); + assert_eq!([read.vsync, read.wayland, read.no_native_menu, read.system_cursor], [Switch::Default; 4]); + } + + #[test] + fn config_dir_unset_is_the_platform_folder_set_is_that_folder_blank_is_none() { + let platform = [("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")]; + let expected = cadcraft_engine::settings_dir::platform(env(&platform)); + assert!(expected.is_some()); + assert_eq!(Env::from_vars(env(&platform)).config_dir, expected); + let set = [platform.as_slice(), &[(CONFIG_DIR, "/private/cc")]].concat(); + assert_eq!(Env::from_vars(env(&set)).config_dir, Some(PathBuf::from("/private/cc"))); + for blank in ["", " "] { + let vars = [platform.as_slice(), &[(CONFIG_DIR, blank)]].concat(); + assert_eq!(Env::from_vars(env(&vars)).config_dir, None, "{blank:?} means no settings folder, not the platform's"); + } + } +} diff --git a/apps/cadcraft/src/launch_args.rs b/apps/cadcraft/src/launch_args.rs new file mode 100644 index 000000000..c6329907a --- /dev/null +++ b/apps/cadcraft/src/launch_args.rs @@ -0,0 +1,158 @@ +//! The desktop app's command line, as clap types: `cadcraft --help` comes from these doc comments +//! and its Environment section from [`crate::env::VARS`], so the help and the parsers can't disagree. Anything they don't declare is a usage error naming +//! it (exit status 2), so a typo such as `--sampel` doesn't open the window and then fail to open +//! the flag as a file; so is a flag without its value, a repeated flag and a bad port, from the +//! flag or from `CADCRAFT_CONTROL_PORT`. A value never starts with `-`, and a file whose name +//! starts with `-` goes after `--`. + +use std::ffi::OsString; +use std::path::PathBuf; + +use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars}; +use clap::{Command, CommandFactory, FromArgMatches, Parser}; + +/// Opens CADCraft with the given files +#[derive(Parser)] +#[command(name = "cadcraft", version = cadcraft_engine::build_version(), after_help = crate::env::help())] +pub(crate) struct LaunchArgs { + /// Files to open. Put `--` before a name that starts with `-` + pub(crate) files: Vec, + /// Start the localhost JSON-lines control server on this port + // allow_negative_numbers makes `--control -1` a bad value rather than a missing one. + #[arg(long, value_name = "PORT", env = "CADCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)] + pub(crate) control: Option, + /// Open with the sample drawing + #[arg(long)] + pub(crate) sample: bool, +} + +/// The command line. An empty or blank variable counts as unset. +pub(crate) fn command() -> Command { + LaunchArgs::command().mut_args(|a| blank_env_is_unset(a, |k| std::env::var_os(k))) +} + +/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS +/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless +/// it comes after `--`. A bad value from a variable is a usage error naming the variable. +pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator) -> Result { + let mut options = true; + let args: Vec = args + .into_iter() + .filter(|a| { + options &= a != "--"; + !(options && a.to_str().is_some_and(|s| s.starts_with("-psn_"))) + }) + .collect(); + let argv = std::iter::once(OsString::from("cadcraft")).chain(args.iter().cloned()); + let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?; + LaunchArgs::from_arg_matches(&matches) +} + +/// A control port, for clap's `value_parser`: `--control` and `CADCRAFT_CONTROL_PORT`, which may +/// carry spaces. +fn control_port(s: &str) -> Result { + s.trim().parse().map_err(|_| NotAPort) +} + +/// A [`control_port`] value that isn't a port. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[error("expected a port, 0-65535")] +struct NotAPort; + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + /// Parse without the variables, so one exported where the tests run can't change them. + fn parse_args(args: &[&str]) -> Result { + parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from)) + } + + fn usage_error(args: &[&str]) -> String { + let Err(e) = parse_args(args) else { panic!("{args:?} parsed") }; + assert_eq!(e.exit_code(), 2, "{args:?}: {e}"); + e.to_string() + } + + #[test] + fn values_are_parsed() { + let parsed = parse_args(&["--control", " 50494 ", "--sample", "a.dxf", "b.dwg"]).unwrap(); + assert_eq!((parsed.control, parsed.sample), (Some(50494), true)); + assert_eq!(parsed.files, [PathBuf::from("a.dxf"), PathBuf::from("b.dwg")]); + let parsed = parse_args(&[]).unwrap(); + assert_eq!((parsed.control, parsed.sample, parsed.files.len()), (None, false, 0)); + } + + #[test] + fn control_port_rejects_unparseable_values_naming_them() { + for bad in ["78787", "-1", "abc", "99999999999999999999"] { + assert!(usage_error(&["--control", bad]).contains(&format!("invalid value '{bad}' for '--control '")), "{bad}"); + } + assert!(usage_error(&["--control="]).contains("invalid value '' for '--control '")); + } + + #[test] + fn usage_errors_exit_2() { + assert!(usage_error(&["--sampel"]).contains("unexpected argument '--sampel'")); + assert!(usage_error(&["--control"]).contains("a value is required for '--control '")); + // A following flag is never taken as the value. + assert!(usage_error(&["--control", "--sample"]).contains("a value is required for '--control '")); + assert!(usage_error(&["--sample", "--sample"]).contains("cannot be used multiple times")); + assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times")); + } + + #[test] + fn version_stops_parsing() { + let e = parse_args(&["-V", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0)); + assert_eq!(e.to_string(), format!("{}\n", crate::version_string())); + let e = parse_args(&["--version", "--bogus"]).err().unwrap(); + assert_eq!(e.kind(), ErrorKind::DisplayVersion); + } + + #[test] + fn help_stops_parsing_and_double_dash_ends_options() { + let e = parse_args(&["-h", "--sampel", "--control"]).err().unwrap(); + assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0)); + let help = parse_args(&["--help"]).err().unwrap().to_string(); + assert!(help.contains("--control ") && help.contains("CADCRAFT_CONFIG_DIR"), "{help}"); + + assert!(usage_error(&["--sampel", "--", "--help"]).contains("unexpected argument '--sampel'")); + let parsed = parse_args(&["a.dxf", "--", "--help", "-x"]).unwrap(); + assert_eq!(parsed.files, ["a.dxf", "--help", "-x"].map(PathBuf::from)); + } + + #[test] + fn a_finder_process_serial_number_is_dropped() { + let parsed = parse_args(&["-psn_0_12345", "a.dxf"]).unwrap(); + assert_eq!(parsed.files, [PathBuf::from("a.dxf")]); + let parsed = parse_args(&["--", "-psn_x.dxf"]).unwrap(); + assert_eq!(parsed.files, [PathBuf::from("-psn_x.dxf")]); + } + + /// A name that isn't valid Unicode is kept as given. + #[cfg(unix)] + #[test] + fn non_unicode_file_names_are_kept() { + use std::os::unix::ffi::OsStringExt as _; + let latin1 = OsString::from_vec(b"caf\xe9.dxf".to_vec()); + let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone(), OsString::from("b.dxf")]).unwrap(); + assert_eq!(parsed.files, [PathBuf::from(&latin1), PathBuf::from("b.dxf")]); + } + + /// `--control` falls back to `PATH` here instead of `CADCRAFT_CONTROL_PORT`: `PATH` is set + /// wherever the tests run and is never a port, and tests can't set a variable (`set_var` is + /// unsafe). + #[test] + fn the_flag_wins_over_its_variable_and_a_bad_value_names_the_variable() { + let Some(path) = std::env::var_os("PATH").filter(|p| !p.is_empty()) else { return }; + let path_fallback = || command().mut_args(|a| a.env(None::<&str>)).mut_arg("control", |a| a.env("PATH")); + let e = parse(path_fallback(), []).err().unwrap(); + assert_eq!(e.exit_code(), 2); + let text = e.to_string(); + assert!(text.contains(&format!("invalid value '{}' for 'PATH'", path.to_string_lossy())), "{text}"); + assert!(!text.contains("--control '"), "{text}"); + assert_eq!(parse(path_fallback(), ["--control", "7"].map(OsString::from)).unwrap().control, Some(7)); + } +} diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs index 4113de54f..724d09c1a 100644 --- a/apps/cadcraft/src/logging.rs +++ b/apps/cadcraft/src/logging.rs @@ -3,7 +3,7 @@ //! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app //! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no //! terminal, so the file is what a bug report can attach: `/logs/cadcraft.log` -//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`). +//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; empty means none; see `env.rs`). //! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a //! run that crashed survives the next launch. //! diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 1007fc0c1..4d7bdc39b 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -1,6 +1,6 @@ //! CADCraft desktop app. //! -//! Usage: `cadcraft [--control ] [--sample] [files…]` +//! Usage: `cadcraft [--control ] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`]) //! //! `--control ` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`. @@ -10,14 +10,16 @@ //! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`. //! //! `log` records go to standard error and `/logs/cadcraft.log` (`RUST_LOG` sets the -//! levels); see [`logging`] and [`config_dir`]. +//! levels); see [`logging`]. The `CADCRAFT_*` variables are read once, at startup; see [`env`]. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; mod desktop_theme; +mod env; #[cfg(any(target_os = "windows", test))] mod graphics; +mod launch_args; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -26,6 +28,7 @@ mod system_animations; mod workspace; use cadcraft_engine::Session; +use cadcraft_ui_egui::switch::Switch; use cadcraft_ui_egui::{CadApp, Services, i18n}; struct App { @@ -39,6 +42,9 @@ struct App { workspace_error: Option, #[cfg(target_os = "macos")] native_menu: Option, + /// `CADCRAFT_NO_NATIVE_MENU`: the menus stay inside the window. + #[cfg(target_os = "macos")] + menus_in_window: bool, /// The `movable` flag last set on the main window. #[cfg(target_os = "macos")] movable: Option, @@ -88,7 +94,7 @@ impl eframe::App for App { if ctx.current_pass_index() == 0 { #[cfg(target_os = "macos")] { - if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { + if self.native_menu.is_none() && !self.menus_in_window { self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx)); } if let Some(m) = &mut self.native_menu { @@ -188,30 +194,6 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] { filters } -/// The per-user settings directory (it holds `logs/` and `autosave/`): `CADCRAFT_CONFIG_DIR` if set, else -/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs, -/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows. -fn config_dir() -> Option { - config_dir_from(|k| std::env::var_os(k)) -} - -/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable -/// counts as unset, so a blank value never means the working directory. -fn config_dir_from(var: impl Fn(&str) -> Option) -> Option { - let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); - if let Some(dir) = set("CADCRAFT_CONFIG_DIR") { - return Some(dir); - } - if cfg!(target_os = "macos") { - set("HOME").map(|h| h.join("Library/Application Support/CADCraft")) - } else if cfg!(windows) { - set("APPDATA").map(|a| a.join("CADCraft")) - } else { - // The XDG spec ignores a relative XDG_CONFIG_HOME. - set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft")) - } -} - const APP_ID: &str = "ai.storyteller.cadcraft"; /// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`). @@ -227,32 +209,7 @@ fn app_icon() -> Option { } fn version_string() -> String { - let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev"); - format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION")) -} - -/// The control port from `--control ` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is -/// not a port is logged and ignored instead of silently starting no control channel. -fn control_port_from(source: &str, value: Option) -> Option { - let value = value?; - let port = value.trim().parse().ok(); - if port.is_none() { - log::warn!("{source}: {value:?} is not a port number; the control channel is off"); - } - port -} - -/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already -/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off. -fn control_flag(current: Option, value: Option) -> Option { - if value.is_some() { - return control_port_from("--control", value); - } - match current { - Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"), - None => log::warn!("--control: no port given; the control channel is off"), - } - current + format!("cadcraft {}", cadcraft_engine::build_version()) } /// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe. @@ -261,44 +218,40 @@ fn main() -> eframe::Result { let logger = logging::install(); // The update helper relaunches this binary after a self-update; it runs and exits here. #[cfg(not(target_arch = "wasm32"))] - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::CADCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("CADCraft update helper failed: {e}"); std::process::exit(1); } return Ok(()); } - // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored - let mut control_port = - control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned())); - let mut files = Vec::new(); - let mut sample = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--control" => control_port = control_flag(control_port, args.next()), - "--sample" => sample = true, - "--version" | "-V" => { - println!("{}", version_string()); - return Ok(()); - } - _ => files.push(a), - } - } + // `--help` and `--version` print to standard output and exit 0. A usage error (an unknown or + // repeated flag, a missing or bad value, a bad CADCRAFT_CONTROL_PORT) names the argument on + // standard error and exits 2 before the window opens. + let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit()); + let vars = env::Env::read(); + let (control_port, sample, files) = (launch.control, launch.sample, launch.files); // The log file lives under the settings directory; opened after the arguments, so `--version` // leaves no file behind. Records logged until now are written to it first. if let Some(logger) = logger { - match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { + match vars.config_dir.as_ref().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()), Some(Err(e)) => log::warn!("no log file: {e}"), None => { logger.stderr_only(); - log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)"); + log::warn!("no log file: no settings directory (an empty CADCRAFT_CONFIG_DIR turns it off)"); } } } install_io(); - let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos")))); + cadcraft_engine::settings_dir::set(vars.config_dir.clone()); + if let Some(name) = &vars.font_alt { + cadcraft_engine::fonts::ttf::set_font_alt(name); + } + if let Some(list) = &vars.font_fallback { + cadcraft_engine::fonts::ttf::set_fallback_fonts(list); + } + let surface = cadcraft_ui_egui::gpu::surface_config(vars.vsync, cfg!(all(unix, not(target_os = "macos")))); let mut options = eframe::NativeOptions { // 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). @@ -341,7 +294,7 @@ fn main() -> eframe::Result { // manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1 // keeps the native Wayland backend. #[cfg(all(unix, not(target_os = "macos")))] - if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() { + if std::env::var_os("DISPLAY").is_some() && vars.wayland != Switch::On { options.event_loop_builder = Some(Box::new(|b| { use winit::platform::x11::EventLoopBuilderExtX11; b.with_x11(); @@ -352,15 +305,16 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = CadApp::new(Session::empty(), services()); - let (workspace, restored, workspace_error) = workspace::Workspace::load(); + let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref()); if let Some(ui) = restored { app.ui = ui; } + app.set_system_cursor_default(vars.system_cursor == Switch::On); if let Some(error) = &workspace_error { eprintln!("CADCraft workspace: {error}"); app.set_status(error); } - app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect()); + app.system_languages = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect()); // The user profile (Options): drafting settings, selection sizes, drawing-area colours. if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) { app.load_profile(&profile); @@ -372,7 +326,7 @@ fn main() -> eframe::Result { cadcraft_ui_egui::autosave::load_prefs(&prefs); } // Autosave files (SAVEFILEPATH) default to `/autosave`. - if let Some(dir) = config_dir() { + if let Some(dir) = &vars.config_dir { app.enable_autosave(&dir.join("autosave")); app.enable_update_check(); // Startup LISP (acad.lsp, acaddoc.lsp) is read from `/support`; the @@ -419,6 +373,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] native_menu: None, #[cfg(target_os = "macos")] + menus_in_window: vars.no_native_menu == Switch::On, + #[cfg(target_os = "macos")] movable: None, })) }), @@ -427,13 +383,7 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { - use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop}; - use std::ffi::OsString; - use std::path::PathBuf; - - fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { - move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) - } + use super::{filters_for_save, follow_desktop}; #[test] fn save_dialog_offers_dwg_with_the_drawing_format_first() { @@ -451,59 +401,6 @@ mod tests { } } - #[test] - fn control_port_values_that_are_not_ports_are_ignored() { - assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979)); - assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979)); - assert_eq!(control_port_from("--control", None), None); - for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] { - assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}"); - } - } - - #[test] - fn control_without_a_port_keeps_the_environment_port() { - // `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844) - assert_eq!(control_flag(Some(47321), None), Some(47321)); - assert_eq!(control_flag(None, None), None); - // A port given on the command line still wins over the variable. - assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979)); - assert_eq!(control_flag(Some(47321), Some("abc".into())), None); - } - - #[test] - fn the_override_variable_wins_and_empty_values_are_unset() { - let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")]; - assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc"))); - let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")]; - assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory"); - assert_eq!(config_dir_from(env(&[])), None); - } - - #[cfg(all(unix, not(target_os = "macos")))] - #[test] - fn linux_and_bsd_follow_xdg_config_home() { - assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft"))); - assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); - // The XDG spec says a relative XDG_CONFIG_HOME is ignored. - assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_uses_application_support() { - assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft"))); - } - - #[cfg(windows)] - #[test] - fn windows_uses_appdata() { - assert_eq!( - config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])), - Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft")) - ); - } - /// 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/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index b8e541ed3..b75c9bc70 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -1,6 +1,5 @@ //! Native workspace preferences. Drawing data and transient dialogs never enter this file. -use std::ffi::OsString; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -24,18 +23,6 @@ pub struct Workspace { last_write: Option, } -fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option { - if let Some(root) = get("CADCRAFT_CONFIG_DIR") { - return (!root.is_empty()).then(|| PathBuf::from(root)); - } - let nonempty = |key| get(key).filter(|value| !value.is_empty()).map(PathBuf::from); - match platform { - "macos" => nonempty("HOME").map(|home| home.join("Library/Application Support/CADCraft")), - "windows" => nonempty("APPDATA").map(|root| root.join("CADCraft")), - _ => nonempty("XDG_CONFIG_HOME").map(|root| root.join("cadcraft")).or_else(|| nonempty("HOME").map(|home| home.join(".config/cadcraft"))), - } -} - fn snapshot(ui: &UiState) -> Result { cadcraft_ui_egui::docking::validate(&ui.docking)?; if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { @@ -139,8 +126,9 @@ fn write(path: &Path, workspace: &Value) -> Result<(), String> { } impl Workspace { - pub fn load() -> (Self, Option, Option) { - Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json"))) + /// The workspace file in the settings directory `config_dir`; without one nothing is kept. + pub fn load(config_dir: Option<&Path>) -> (Self, Option, Option) { + Self::at(config_dir.map(|root| root.join("workspace.json"))) } fn at(path: Option) -> (Self, Option, Option) { @@ -195,6 +183,7 @@ impl Workspace { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; fn temp_dir() -> PathBuf { let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); @@ -205,20 +194,18 @@ mod tests { #[test] fn profile_override_isolated_or_explicitly_disabled() { - let get = |key: &str| match key { - "CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")), - "HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")), - _ => None, + let vars = |config: Option<&'static str>| { + move |key: &str| match key { + "CADCRAFT_CONFIG_DIR" => config.map(OsString::from), + "HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")), + _ => None, + } }; - for platform in ["macos", "windows", "linux", "freebsd"] { - assert_eq!(config_dir_from(platform, get), Some(PathBuf::from("/private/test-state"))); - assert_eq!(config_dir_from(platform, |key| if key == "CADCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { get(key) }), None); - } - assert_eq!( - config_dir_from("linux", |key| (key == "XDG_CONFIG_HOME").then(|| OsString::from("/config"))), - Some(PathBuf::from("/config/cadcraft")) - ); - assert_eq!(config_dir_from("macos", |_| None), None); + let dir = |config| crate::env::Env::from_vars(vars(config)).config_dir; + assert_eq!(dir(Some("/private/test-state")), Some(PathBuf::from("/private/test-state"))); + assert_eq!(dir(Some("")), None); + let (_, restored, error) = Workspace::load(dir(Some("")).as_deref()); + assert!(restored.is_none() && error.is_none(), "no settings folder, no workspace file"); } #[test] diff --git a/clippy.toml b/clippy.toml index 287e14edc..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 40d6ba823..28564415e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -91,3 +91,11 @@ sync_github_name = false real_name = "Robert Lee" sync_github_name = true +[people.2ez4gcx] +real_name = "Doan Huynh Huu Khuong" +display_name = "Doan Huynh Huu Khuong" +sync_github_name = false + + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/constraints/src/auto.rs b/crates/constraints/src/auto.rs index fe7764205..211be6b93 100644 --- a/crates/constraints/src/auto.rs +++ b/crates/constraints/src/auto.rs @@ -52,15 +52,11 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo { g.curves.push((GeomRef::whole(h), ga.center, a.radius)); } } - EntityKind::Circle(c) => { - if c.center.is_finite() && c.radius.is_finite() { - g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius)); - } + EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => { + g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius)); } - EntityKind::Point(p) => { - if p.p.is_finite() { - g.points.push((GeomRef::whole(h), p.p.xy())); - } + EntityKind::Point(p) if p.p.is_finite() => { + g.points.push((GeomRef::whole(h), p.p.xy())); } EntityKind::LwPolyline(p) => { let n = p.vertices.len(); diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs index be5723fec..05794b720 100644 --- a/crates/constraints/src/residual.rs +++ b/crates/constraints/src/residual.rs @@ -3,7 +3,7 @@ use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef}; use cadcraft_geom::Vec2; -use crate::curves::circle_at; +use crate::curves::{Ell, circle_at}; use crate::model::Model; /// Contact parameter samples per curve when guessing where two curves touch. @@ -205,42 +205,7 @@ impl Built { }; b.rows = 1; } - K::Tangent | K::Smooth => { - if n != 2 { - return need("two objects"); - } - if c.refs.iter().any(|r| m.is_spline(r)) { - return Self::spline_form(b, m, x); - } - b.rows = 1; - if let (Some((a, bb)), Some((cc, _))) = (ln(0), ci(1)) { - b.form = Form::LC { swap: false }; - b.sign = sgn(dir_n(a, bb).cross(cc - a)); - } else if let (Some((a, bb)), Some((cc, _))) = (ln(1), ci(0)) { - b.form = Form::LC { swap: true }; - b.sign = sgn(dir_n(a, bb).cross(cc - a)); - } else if let (Some((c1, r1)), Some((c2, r2))) = (ci(0), ci(1)) { - b.form = Form::CC; - // sign > 0: external tangency; < 0: internal. - let d = c1.dist(c2); - b.sign = if d >= r1.abs().max(r2.abs()) { 1.0 } else { -1.0 }; - b.target = sgn(r1 - r2); - } else if let (Some((a, bb)), Some(e)) = (ln(0), el(1)) { - b.form = Form::LE { swap: false }; - b.sign = sgn(dir_n(a, bb).cross(e.c - a)); - } else if let (Some((a, bb)), Some(e)) = (ln(1), el(0)) { - b.form = Form::LE { swap: true }; - b.sign = sgn(dir_n(a, bb).cross(e.c - a)); - } else if (ci(0).is_some() || el(0).is_some()) && (ci(1).is_some() || el(1).is_some()) { - b.form = Form::Contact; - b.rows = 3; - b.aux_init = b.contact_guess(m, x).ok_or_else(|| format!("{} could not find where the curves touch", c.kind.name()))?; - } else if ln(0).is_some() && ln(1).is_some() && c.kind == K::Smooth { - b.form = Form::LL; - } else { - return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve"); - } - } + K::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x), K::Symmetric => { if n != 3 || ln(2).is_none() { return need("two objects and a symmetry line"); @@ -347,154 +312,8 @@ impl Built { out.resize(start + self.rows, 0.0); } - fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec) -> Option<()> { - use ConstraintKind as K; - let r = |i: usize| self.refs.get(i); - let pt = |i: usize| m.point(x, r(i)?); - let ln = |i: usize| m.line(x, r(i)?); - let ci = |i: usize| m.circle(x, r(i)?); - let el = |i: usize| m.ellipse(x, r(i)?); - match (self.kind, self.form) { - (_, Form::Lock) => {} - (_, Form::PE { swap }) => { - let (p, e) = if swap { (pt(1)?, el(0)?) } else { (pt(0)?, el(1)?) }; - out.push(e.on(p)); - } - (_, Form::LE { swap }) => { - let ((a, b), e) = if swap { (ln(1)?, el(0)?) } else { (ln(0)?, el(1)?) }; - let d = dir_n(a, b); - out.push(self.sign * d.cross(e.c - a) - e.support(d.perp())); - } - (_, Form::Contact) => { - let (p0, d0) = self.curve_at(m, x, 0, *x.get(self.aux)?)?; - let (p1, d1) = self.curve_at(m, x, 1, *x.get(self.aux + 1)?)?; - out.extend([p0.x - p1.x, p0.y - p1.y, d0.normalized().cross(d1.normalized())]); - } - (_, Form::EndOn { spline, at_end }) => { - let f = m.end_frame(x, r(spline)?, at_end)?; - let o = 1 - spline.min(1); - if let Some((a, b)) = ln(o) { - let d = dir_n(a, b); - out.extend([d.cross(f.p - a), f.t.cross(d)]); - } else if let Some((c, rad)) = ci(o) { - out.extend([f.p.dist(c) - rad, f.t.dot(dir_n(c, f.p))]); - } else { - let e = el(o)?; - out.extend([e.on(f.p), f.t.dot(e.normal(f.p))]); - } - } - (_, Form::Join { a_end, b_end, g2 }) => { - // Orient both so the first curve runs into the joint and the second out of it. - let fa = m.end_frame(x, r(0)?, a_end)?; - let fb = m.end_frame(x, r(1)?, b_end)?; - let fa = if a_end { fa } else { fa.reversed() }; - let fb = if b_end { fb.reversed() } else { fb }; - out.extend([fa.p.x - fb.p.x, fa.p.y - fb.p.y, fa.t.cross(fb.t)]); - if g2 { - out.push(fa.k - fb.k); - } - } - (_, Form::Anchor(a)) => { - let p = pt(0)?; - out.extend([p.x - a.x, p.y - a.y]); - } - (K::Coincident, Form::PP) => { - let (p, q) = (pt(0)?, pt(1)?); - out.extend([p.x - q.x, p.y - q.y]); - } - (K::Coincident, Form::PL { swap }) => { - let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) }; - out.push(dir_n(a, b).cross(p - a)); - } - (K::Coincident, Form::PC { swap }) => { - let (p, (c, rad)) = if swap { (pt(1)?, ci(0)?) } else { (pt(0)?, ci(1)?) }; - out.push(p.dist(c) - rad); - } - (K::Horizontal | K::Vertical, f) => { - let (a, b) = if f == Form::L1 { ln(0).or_else(|| el(0).map(|e| (e.c - e.mj, e.c + e.mj)))? } else { (pt(0)?, pt(1)?) }; - out.push(if self.kind == K::Horizontal { a.y - b.y } else { a.x - b.x }); - } - (K::Parallel | K::Smooth, Form::LL) => { - let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - out.push(dir_n(a1, b1).cross(dir_n(a2, b2))); - } - (K::Perpendicular, _) => { - let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - out.push(dir_n(a1, b1).dot(dir_n(a2, b2))); - } - (K::Collinear, _) => { - let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - let d = dir_n(a1, b1); - out.extend([d.cross(a2 - a1), d.cross(b2 - a1)]); - } - (K::Concentric, _) => { - let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| el(i).map(|e| e.c)).or_else(|| pt(i)); - let (p, q) = (center(0)?, center(1)?); - out.extend([p.x - q.x, p.y - q.y]); - } - (K::Equal, Form::LL) => { - let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - out.push(a1.dist(b1) - a2.dist(b2)); - } - (K::Equal, _) => { - let ((_, r1), (_, r2)) = (ci(0)?, ci(1)?); - out.push(r1 - r2); - } - (K::Tangent | K::Smooth, Form::LC { swap }) => { - let ((a, b), (c, rad)) = if swap { (ln(1)?, ci(0)?) } else { (ln(0)?, ci(1)?) }; - out.push(self.sign * dir_n(a, b).cross(c - a) - rad); - } - (K::Tangent | K::Smooth, _) => { - let ((c1, r1), (c2, r2)) = (ci(0)?, ci(1)?); - let d = c1.dist(c2); - out.push(if self.sign > 0.0 { d - (r1 + r2) } else { d - self.target * (r1 - r2) }); - } - (K::Symmetric, Form::PP) => { - let (p, q, (a, b)) = (pt(0)?, pt(1)?, ln(2)?); - let d = dir_n(a, b); - out.extend([d.cross(p.mid(q) - a), d.dot(q - p)]); - } - (K::Symmetric, Form::SymLines { crossed }) => { - let ((a1, b1), (a2, b2), (a, b)) = (ln(0)?, ln(1)?, ln(2)?); - let (a2, b2) = if crossed { (b2, a2) } else { (a2, b2) }; - let d = dir_n(a, b); - out.extend([d.cross(a1.mid(a2) - a), d.dot(a2 - a1), d.cross(b1.mid(b2) - a), d.dot(b2 - b1)]); - } - (K::Symmetric, _) => { - let ((p, r1), (q, r2), (a, b)) = (ci(0)?, ci(1)?, ln(2)?); - let d = dir_n(a, b); - out.extend([d.cross(p.mid(q) - a), d.dot(q - p), r1 - r2]); - } - (K::Distance(axis), f) => { - let v = match (axis, f) { - (_, Form::PL { swap }) => { - let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) }; - dir_n(a, b).cross(p - a) - } - _ => { - let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) }; - match axis { - DistAxis::Aligned => a.dist(b), - DistAxis::Horizontal => b.x - a.x, - DistAxis::Vertical => b.y - a.y, - } - } - }; - out.push(self.sign * v - self.target); - } - (K::Angular, Form::ArcSweep) => { - out.push(m.arc_sweep(x, r(0)?)? - self.target); - } - (K::Angular, Form::Rays { rev }) => { - let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?); - let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1])); - out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target)); - } - (K::Radius, _) => out.push(ci(0)?.1 - self.target), - (K::Diameter, _) => out.push(2.0 * ci(0)?.1 - self.target), - _ => {} - } - Some(()) + fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec) -> Option<()> { + ResidualEval { built: self, model, values, out }.evaluate() } /// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`. @@ -527,6 +346,54 @@ impl Built { /// Tangent or Smooth with a spline: the spline end and the other curve's nearest end are /// joined (Smooth: G2; Tangent between splines: G1), or the spline's end touches a line, /// circle, arc or ellipse tangentially. A `start`/`end` reference picks the end. + fn tangent_form(mut built: Built, constraint: &Constraint, model: &Model, values: &[f64]) -> Result { + use ConstraintKind as K; + + let need = |what: &str| Err(format!("{} needs {what}", constraint.kind.name())); + if built.refs.len() != 2 { + return need("two objects"); + } + if constraint.refs.iter().any(|reference| model.is_spline(reference)) { + return Self::spline_form(built, model, values); + } + + let line = |index| r_at(constraint, index).and_then(|reference| model.line(values, reference)); + let circle = |index| r_at(constraint, index).and_then(|reference| model.circle(values, reference)); + let ellipse = |index| r_at(constraint, index).and_then(|reference| model.ellipse(values, reference)); + built.rows = 1; + + if let (Some((start, end)), Some((center, _))) = (line(0), circle(1)) { + built.form = Form::LC { swap: false }; + built.sign = sgn(dir_n(start, end).cross(center - start)); + } else if let (Some((start, end)), Some((center, _))) = (line(1), circle(0)) { + built.form = Form::LC { swap: true }; + built.sign = sgn(dir_n(start, end).cross(center - start)); + } else if let (Some((first_center, first_radius)), Some((second_center, second_radius))) = (circle(0), circle(1)) { + built.form = Form::CC; + // sign > 0: external tangency; < 0: internal. + let distance = first_center.dist(second_center); + built.sign = if distance >= first_radius.abs().max(second_radius.abs()) { 1.0 } else { -1.0 }; + built.target = sgn(first_radius - second_radius); + } else if let (Some((start, end)), Some(ellipse)) = (line(0), ellipse(1)) { + built.form = Form::LE { swap: false }; + built.sign = sgn(dir_n(start, end).cross(ellipse.c - start)); + } else if let (Some((start, end)), Some(ellipse)) = (line(1), ellipse(0)) { + built.form = Form::LE { swap: true }; + built.sign = sgn(dir_n(start, end).cross(ellipse.c - start)); + } else if (circle(0).is_some() || ellipse(0).is_some()) && (circle(1).is_some() || ellipse(1).is_some()) { + built.form = Form::Contact; + built.rows = 3; + built.aux_init = + built.contact_guess(model, values).ok_or_else(|| format!("{} could not find where the curves touch", constraint.kind.name()))?; + } else if line(0).is_some() && line(1).is_some() && constraint.kind == K::Smooth { + built.form = Form::LL; + } else { + return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve"); + } + + Ok(built) + } + fn spline_form(mut b: Built, m: &Model, x: &[f64]) -> Result { let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else { return Err(format!("{} needs two objects", b.kind.name())); @@ -596,3 +463,292 @@ impl Built { if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v } } } + +/// Shared access to a constraint's geometry, values, and output while evaluating one residual. +struct ResidualEval<'a> { + built: &'a Built, + model: &'a Model, + values: &'a [f64], + out: &'a mut Vec, +} + +impl ResidualEval<'_> { + fn evaluate(&mut self) -> Option<()> { + use ConstraintKind as K; + + match (self.built.kind, self.built.form) { + (_, Form::Lock) => Some(()), + (_, Form::PE { swap }) => self.eval_point_ellipse(swap), + (_, Form::LE { swap }) => self.eval_line_ellipse(swap), + (_, Form::Contact) => self.eval_contact(), + (_, Form::EndOn { spline, at_end }) => self.eval_end_on(spline, at_end), + (_, Form::Join { a_end, b_end, g2 }) => self.eval_join(a_end, b_end, g2), + (_, Form::Anchor(anchor)) => self.eval_anchor(anchor), + (K::Coincident, Form::PP) => self.eval_coincident_points(), + (K::Coincident, Form::PL { swap }) => self.eval_point_line(swap), + (K::Coincident, Form::PC { swap }) => self.eval_point_circle(swap), + (K::Horizontal | K::Vertical, form) => self.eval_horizontal_vertical(form), + (K::Parallel | K::Smooth, Form::LL) => self.eval_parallel(), + (K::Perpendicular, _) => self.eval_perpendicular(), + (K::Collinear, _) => self.eval_collinear(), + (K::Concentric, _) => self.eval_concentric(), + (K::Equal, Form::LL) => self.eval_equal_lines(), + (K::Equal, _) => self.eval_equal_circles(), + (K::Tangent | K::Smooth, Form::LC { swap }) => self.eval_tangent_line_circle(swap), + (K::Tangent | K::Smooth, _) => self.eval_tangent_circles(), + (K::Symmetric, Form::PP) => self.eval_symmetric_points(), + (K::Symmetric, Form::SymLines { crossed }) => self.eval_symmetric_lines(crossed), + (K::Symmetric, _) => self.eval_symmetric_circles(), + (K::Distance(axis), form) => self.eval_distance(axis, form), + (K::Angular, Form::ArcSweep) => self.eval_angular_arc(), + (K::Angular, Form::Rays { rev }) => self.eval_angular_rays(rev), + (K::Radius, _) => self.eval_radius(), + (K::Diameter, _) => self.eval_diameter(), + _ => Some(()), + } + } + + fn reference(&self, index: usize) -> Option<&GeomRef> { + self.built.refs.get(index) + } + + fn point(&self, index: usize) -> Option { + self.model.point(self.values, self.reference(index)?) + } + + fn line(&self, index: usize) -> Option<(Vec2, Vec2)> { + self.model.line(self.values, self.reference(index)?) + } + + fn circle(&self, index: usize) -> Option<(Vec2, f64)> { + self.model.circle(self.values, self.reference(index)?) + } + + fn ellipse(&self, index: usize) -> Option { + self.model.ellipse(self.values, self.reference(index)?) + } + + fn eval_point_ellipse(&mut self, swap: bool) -> Option<()> { + let (point, ellipse) = if swap { (self.point(1)?, self.ellipse(0)?) } else { (self.point(0)?, self.ellipse(1)?) }; + self.out.push(ellipse.on(point)); + Some(()) + } + + fn eval_line_ellipse(&mut self, swap: bool) -> Option<()> { + let (line, ellipse) = if swap { (self.line(1)?, self.ellipse(0)?) } else { (self.line(0)?, self.ellipse(1)?) }; + let (start, end) = line; + let direction = dir_n(start, end); + self.out.push(self.built.sign * direction.cross(ellipse.c - start) - ellipse.support(direction.perp())); + Some(()) + } + + fn eval_contact(&mut self) -> Option<()> { + let first_parameter = *self.values.get(self.built.aux)?; + let second_parameter = *self.values.get(self.built.aux + 1)?; + let (first_point, first_tangent) = self.built.curve_at(self.model, self.values, 0, first_parameter)?; + let (second_point, second_tangent) = self.built.curve_at(self.model, self.values, 1, second_parameter)?; + self.out.extend([ + first_point.x - second_point.x, + first_point.y - second_point.y, + first_tangent.normalized().cross(second_tangent.normalized()), + ]); + Some(()) + } + + fn eval_end_on(&mut self, spline: usize, at_end: bool) -> Option<()> { + let frame = self.model.end_frame(self.values, self.reference(spline)?, at_end)?; + let other = 1 - spline.min(1); + if let Some((start, end)) = self.line(other) { + let direction = dir_n(start, end); + self.out.extend([direction.cross(frame.p - start), frame.t.cross(direction)]); + } else if let Some((center, radius)) = self.circle(other) { + self.out.extend([frame.p.dist(center) - radius, frame.t.dot(dir_n(center, frame.p))]); + } else { + let ellipse = self.ellipse(other)?; + self.out.extend([ellipse.on(frame.p), frame.t.dot(ellipse.normal(frame.p))]); + } + Some(()) + } + + fn eval_join(&mut self, a_end: bool, b_end: bool, g2: bool) -> Option<()> { + let first = self.model.end_frame(self.values, self.reference(0)?, a_end)?; + let second = self.model.end_frame(self.values, self.reference(1)?, b_end)?; + let first = if a_end { first } else { first.reversed() }; + let second = if b_end { second.reversed() } else { second }; + self.out.extend([first.p.x - second.p.x, first.p.y - second.p.y, first.t.cross(second.t)]); + if g2 { + self.out.push(first.k - second.k); + } + Some(()) + } + + fn eval_anchor(&mut self, anchor: Vec2) -> Option<()> { + let point = self.point(0)?; + self.out.extend([point.x - anchor.x, point.y - anchor.y]); + Some(()) + } + + fn eval_coincident_points(&mut self) -> Option<()> { + let (first, second) = (self.point(0)?, self.point(1)?); + self.out.extend([first.x - second.x, first.y - second.y]); + Some(()) + } + + fn eval_point_line(&mut self, swap: bool) -> Option<()> { + let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) }; + self.out.push(dir_n(start, end).cross(point - start)); + Some(()) + } + + fn eval_point_circle(&mut self, swap: bool) -> Option<()> { + let (point, (center, radius)) = if swap { (self.point(1)?, self.circle(0)?) } else { (self.point(0)?, self.circle(1)?) }; + self.out.push(point.dist(center) - radius); + Some(()) + } + + fn eval_horizontal_vertical(&mut self, form: Form) -> Option<()> { + use ConstraintKind as K; + + let (first, second) = if form == Form::L1 { + self.line(0).or_else(|| self.ellipse(0).map(|ellipse| (ellipse.c - ellipse.mj, ellipse.c + ellipse.mj)))? + } else { + (self.point(0)?, self.point(1)?) + }; + let value = if self.built.kind == K::Horizontal { first.y - second.y } else { first.x - second.x }; + self.out.push(value); + Some(()) + } + + fn eval_parallel(&mut self) -> Option<()> { + let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?); + self.out.push(dir_n(first_start, first_end).cross(dir_n(second_start, second_end))); + Some(()) + } + + fn eval_perpendicular(&mut self) -> Option<()> { + let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?); + self.out.push(dir_n(first_start, first_end).dot(dir_n(second_start, second_end))); + Some(()) + } + + fn eval_collinear(&mut self) -> Option<()> { + let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?); + let direction = dir_n(first_start, first_end); + self.out.extend([direction.cross(second_start - first_start), direction.cross(second_end - first_start)]); + Some(()) + } + + fn eval_concentric(&mut self) -> Option<()> { + let center = |index| { + self.circle(index).map(|(center, _)| center).or_else(|| self.ellipse(index).map(|ellipse| ellipse.c)).or_else(|| self.point(index)) + }; + let (first, second) = (center(0)?, center(1)?); + self.out.extend([first.x - second.x, first.y - second.y]); + Some(()) + } + + fn eval_equal_lines(&mut self) -> Option<()> { + let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?); + self.out.push(first_start.dist(first_end) - second_start.dist(second_end)); + Some(()) + } + + fn eval_equal_circles(&mut self) -> Option<()> { + let ((_, first_radius), (_, second_radius)) = (self.circle(0)?, self.circle(1)?); + self.out.push(first_radius - second_radius); + Some(()) + } + + fn eval_tangent_line_circle(&mut self, swap: bool) -> Option<()> { + let ((start, end), (center, radius)) = if swap { (self.line(1)?, self.circle(0)?) } else { (self.line(0)?, self.circle(1)?) }; + self.out.push(self.built.sign * dir_n(start, end).cross(center - start) - radius); + Some(()) + } + + fn eval_tangent_circles(&mut self) -> Option<()> { + let ((first_center, first_radius), (second_center, second_radius)) = (self.circle(0)?, self.circle(1)?); + let distance = first_center.dist(second_center); + let residual = if self.built.sign > 0.0 { + distance - (first_radius + second_radius) + } else { + distance - self.built.target * (first_radius - second_radius) + }; + self.out.push(residual); + Some(()) + } + + fn eval_symmetric_points(&mut self) -> Option<()> { + let (first, second, (line_start, line_end)) = (self.point(0)?, self.point(1)?, self.line(2)?); + let direction = dir_n(line_start, line_end); + self.out.extend([direction.cross(first.mid(second) - line_start), direction.dot(second - first)]); + Some(()) + } + + fn eval_symmetric_lines(&mut self, crossed: bool) -> Option<()> { + let ((first_start, first_end), (second_start, second_end), (line_start, line_end)) = (self.line(0)?, self.line(1)?, self.line(2)?); + let (second_start, second_end) = if crossed { (second_end, second_start) } else { (second_start, second_end) }; + let direction = dir_n(line_start, line_end); + self.out.extend([ + direction.cross(first_start.mid(second_start) - line_start), + direction.dot(second_start - first_start), + direction.cross(first_end.mid(second_end) - line_start), + direction.dot(second_end - first_end), + ]); + Some(()) + } + + fn eval_symmetric_circles(&mut self) -> Option<()> { + let ((first_center, first_radius), (second_center, second_radius), (line_start, line_end)) = + (self.circle(0)?, self.circle(1)?, self.line(2)?); + let direction = dir_n(line_start, line_end); + self.out.extend([ + direction.cross(first_center.mid(second_center) - line_start), + direction.dot(second_center - first_center), + first_radius - second_radius, + ]); + Some(()) + } + + fn eval_distance(&mut self, axis: DistAxis, form: Form) -> Option<()> { + let value = match (axis, form) { + (_, Form::PL { swap }) => { + let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) }; + dir_n(start, end).cross(point - start) + } + _ => { + let (first, second) = if form == Form::L1 { self.line(0)? } else { (self.point(0)?, self.point(1)?) }; + match axis { + DistAxis::Aligned => first.dist(second), + DistAxis::Horizontal => second.x - first.x, + DistAxis::Vertical => second.y - first.y, + } + } + }; + self.out.push(self.built.sign * value - self.built.target); + Some(()) + } + + fn eval_angular_arc(&mut self) -> Option<()> { + let reference = self.reference(0)?; + self.out.push(self.model.arc_sweep(self.values, reference)? - self.built.target); + Some(()) + } + + fn eval_angular_rays(&mut self, reversed: [bool; 2]) -> Option<()> { + let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?); + let (first, second) = (ray(first_start, first_end, reversed[0]), ray(second_start, second_end, reversed[1])); + let residual = wrap(first.cross(second).atan2(first.dot(second)) - self.built.sign * self.built.target); + self.out.push(residual); + Some(()) + } + + fn eval_radius(&mut self) -> Option<()> { + self.out.push(self.circle(0)?.1 - self.built.target); + Some(()) + } + + fn eval_diameter(&mut self) -> Option<()> { + self.out.push(2.0 * self.circle(0)?.1 - self.built.target); + Some(()) + } +} diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index 6887459a4..ef0a88be2 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -5,7 +5,7 @@ //! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the //! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and //! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…). -//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing +//! [`dynamic_block`](crate::dynblock::dynamic_block) reads them into the model below without changing the kept data, so a drawing //! saved again still carries them exactly as read. //! //! Every count and index here comes from the file: they are capped by the data actually present diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 089904109..d13b82722 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -44,14 +44,16 @@ mod dim; mod fill; mod layout; mod text; +mod wipeout; pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal}; pub use common::{Common, Handle, Lineweight, RawTag, Transparency}; pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid}; pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader}; -pub use fill::{Gradient, Hatch, HatchLoop, Wipeout}; +pub use fill::{Gradient, Hatch, HatchLoop}; pub use layout::{Image, Table, TableCell, Viewport}; pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign}; +pub use wipeout::Wipeout; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Unknown { pub dxf_type: String, @@ -226,11 +228,7 @@ impl EntityKind { h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect() } EntityKind::Wipeout(w) => { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); - } - pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect() + w.closed_boundary().windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect() } EntityKind::Viewport(v) => { let c = v.center.xy(); @@ -535,7 +533,7 @@ impl EntityKind { Bounds2::new(c - h, c + h).corners().to_vec() } EntityKind::Image(i) => vec![i.insert.xy()], - EntityKind::Wipeout(w) => w.boundary.clone(), + EntityKind::Wipeout(w) => w.vertices().to_vec(), EntityKind::Table(t) => vec![t.insert.xy()], EntityKind::Tolerance(t) => vec![t.insert.xy()], EntityKind::Unknown(_) => Vec::new(), diff --git a/crates/doc/src/entity/block.rs b/crates/doc/src/entity/block.rs index 166afee27..3d6665e0b 100644 --- a/crates/doc/src/entity/block.rs +++ b/crates/doc/src/entity/block.rs @@ -1,6 +1,6 @@ use super::common::{Common, Lineweight}; use super::text::{MText, Text}; -use super::*; +use super::{Color, Deserialize, EntityKind, Mat3, Serialize, Vec2, Vec3}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/entity/common.rs b/crates/doc/src/entity/common.rs index 6f7867ab3..45cf04e12 100644 --- a/crates/doc/src/entity/common.rs +++ b/crates/doc/src/entity/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color, Deserialize, Serialize, Vec3}; /// A database handle (DXF group 5), unique per drawing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] @@ -130,3 +130,20 @@ pub struct RawTag { pub code: i32, pub value: String, } + +impl Common { + /// Whether "Match orientation to layout" is on for this annotative object (AutoCAD's + /// `AcadAnnoPO` xdata = 1). AutoCAD draws such text upright and ignores its stored rotation. + /// The object's own `AcadAnnotative` xdata never reaches [`Common::xdata`] (the reader drops + /// it), so the `AcadAnnoPO` flag alone decides. + pub fn matches_layout_orientation(&self) -> bool { + let app = self.xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("AcadAnnoPO")); + let Some(at) = app else { return false }; + self.xdata + .iter() + .skip(at + 1) + .take_while(|t| t.code != 1001) + .find(|t| t.code == 1070) + .is_some_and(|t| t.value.trim().parse::().is_ok_and(|v| v != 0)) + } +} diff --git a/crates/doc/src/entity/curves.rs b/crates/doc/src/entity/curves.rs index 770b22559..8eeb556c4 100644 --- a/crates/doc/src/entity/curves.rs +++ b/crates/doc/src/entity/curves.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, PolyVertex, Serialize, Vec3}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Line { diff --git a/crates/doc/src/entity/dim.rs b/crates/doc/src/entity/dim.rs index 396e74c6f..d10237b26 100644 --- a/crates/doc/src/entity/dim.rs +++ b/crates/doc/src/entity/dim.rs @@ -1,7 +1,7 @@ use super::block::Insert; use super::common::Handle; use super::text::MText; -use super::*; +use super::{Deserialize, GSpline, Serialize, Vec2, Vec3, one, standard, yes}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,7 +92,7 @@ impl DimMarks { *self == DimMarks::default() } - /// Transform the points and angles (part of [`EntityKind::transform`]). + /// Transform the points and angles (part of [`super::EntityKind::transform`]). pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) { if let Some(a) = &mut self.oblique { *a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot }); diff --git a/crates/doc/src/entity/fill.rs b/crates/doc/src/entity/fill.rs index 1b69108a0..cff172a71 100644 --- a/crates/doc/src/entity/fill.rs +++ b/crates/doc/src/entity/fill.rs @@ -1,5 +1,5 @@ use super::common::Handle; -use super::*; +use super::{Color, Deserialize, PolyVertex, Serialize, Vec2, one}; /// A hatch boundary loop: closed polyline with bulges. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -54,8 +54,3 @@ pub struct Gradient { #[serde(default)] pub centered: bool, } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Wipeout { - pub boundary: Vec, -} diff --git a/crates/doc/src/entity/layout.rs b/crates/doc/src/entity/layout.rs index 7ebe49eee..be1b9584d 100644 --- a/crates/doc/src/entity/layout.rs +++ b/crates/doc/src/entity/layout.rs @@ -1,5 +1,5 @@ use super::common::Handle; -use super::*; +use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/entity/text.rs b/crates/doc/src/entity/text.rs index a4eff0427..75857eb30 100644 --- a/crates/doc/src/entity/text.rs +++ b/crates/doc/src/entity/text.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize, Vec2, Vec3, one, standard}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/entity/wipeout.rs b/crates/doc/src/entity/wipeout.rs new file mode 100644 index 000000000..c79e93275 --- /dev/null +++ b/crates/doc/src/entity/wipeout.rs @@ -0,0 +1,162 @@ +//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag. +//! One exact terminal copy of the first point is a closure marker, not an editable corner. +//! Preserve logical corners when editing, adding a separate marker if the endpoints meet. +//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified. + +use std::borrow::Cow; + +use cadcraft_geom::Vec2; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Wipeout { + /// Ordered boundary, implicitly closed or ending in a repeated first point. + pub boundary: Vec, +} + +impl Wipeout { + /// Editable vertices, excluding one exactly repeated closing point, without changing storage. + pub fn vertices(&self) -> &[Vec2] { + match self.boundary.split_last() { + Some((last, vertices)) if vertices.first() == Some(last) => vertices, + _ => &self.boundary, + } + } + + /// An explicitly closed outline for geometry, rendering and export. Already closed or + /// empty/single-point boundaries are borrowed; otherwise one closing point is appended. + pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> { + if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 { + Cow::Borrowed(&self.boundary) + } else { + let mut points = self.boundary.clone(); + points.extend(self.boundary.first()); + Cow::Owned(points) + } + } + + /// Move an editable vertex, carrying an existing closing marker with vertex zero. If an + /// implicit boundary's endpoints meet, add a marker so both corners remain addressable. + /// Invalid indices/non-finite points leave the boundary unchanged. + pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> { + let count = self.vertices().len(); + if index >= count || !to.is_finite() { + return None; + } + let explicit = count < self.boundary.len(); + *self.boundary.get_mut(index)? = to; + if explicit + && index == 0 + && let Some(last) = self.boundary.last_mut() + { + *last = to; + } + if !explicit && self.vertices().len() < count { + // The moved endpoint is a logical corner, not the newly implied closing marker. + if let Some(first) = self.boundary.first().copied() { + self.boundary.push(first); + } + } + Some(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EntityKind; + + #[test] + fn vertices_and_grips_exclude_only_the_exact_closing_point() { + let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)); + let near_a = Vec2::new(1e-13, 0.0); + for (boundary, expected) in [ + (vec![], vec![]), + (vec![a], vec![a]), + (vec![a, a], vec![a]), + (vec![a, b, c], vec![a, b, c]), + (vec![a, b, c, a], vec![a, b, c]), + (vec![a, b, a, c, a], vec![a, b, a, c]), + (vec![a, b, c, a, a], vec![a, b, c, a]), + (vec![a, b, c, near_a], vec![a, b, c, near_a]), + ] { + let wipeout = Wipeout { boundary: boundary.clone() }; + assert_eq!(wipeout.vertices(), expected); + assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected); + assert_eq!(wipeout.boundary, boundary); + } + } + + #[test] + fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() { + let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)]; + let implicit = Wipeout { boundary: points.clone() }; + let mut expected = points.clone(); + expected.push(points[0]); + assert!(matches!(implicit.closed_boundary(), Cow::Owned(_))); + assert_eq!(implicit.closed_boundary().as_ref(), expected); + assert_eq!(implicit.boundary, points); + let explicit = Wipeout { boundary: expected.clone() }; + assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_))); + assert_eq!(explicit.closed_boundary().as_ref(), expected); + let a = EntityKind::Wipeout(implicit).prims(); + let b = EntityKind::Wipeout(explicit).prims(); + assert_eq!(a, b); + assert_eq!(a.len(), 3); + for boundary in [vec![], vec![Vec2::ZERO]] { + let wipeout = Wipeout { boundary: boundary.clone() }; + assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_))); + assert_eq!(wipeout.closed_boundary().as_ref(), boundary); + assert!(EntityKind::Wipeout(wipeout).prims().is_empty()); + } + } + + #[test] + fn moving_endpoints_together_keeps_both_corners_editable() { + let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)]; + for explicit in [false, true] { + for index in [0, 3] { + let other = 3 - index; + let mut boundary = points.to_vec(); + if explicit { + boundary.push(points[0]); + } + let mut wipeout = Wipeout { boundary }; + let mut expected = points; + expected[index] = points[other]; + for _ in 0..2 { + assert_eq!(wipeout.set_vertex(index, points[other]), Some(())); + assert_eq!(wipeout.vertices(), expected); + assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth"); + } + expected[other] = Vec2::new(-2.0, 4.0); + assert_eq!(wipeout.set_vertex(other, expected[other]), Some(())); + assert_eq!(wipeout.vertices(), expected); + assert_eq!(wipeout.boundary.len(), 5); + assert_eq!(wipeout.boundary.first(), wipeout.boundary.last()); + } + } + } + + #[test] + fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() { + let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0)); + for explicit in [false, true] { + let mut boundary = vec![a, b, a, c]; + if explicit { + boundary.push(a); + } + let mut wipeout = Wipeout { boundary }; + assert_eq!(wipeout.set_vertex(0, to), Some(())); + let mut expected = vec![to, b, a, c]; + if explicit { + expected.push(to); + } + assert_eq!(wipeout.boundary, expected); + for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] { + assert_eq!(wipeout.set_vertex(index, point), None); + assert_eq!(wipeout.boundary, expected); + } + } + } +} diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index d7c7c7a8a..353a42b53 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -36,11 +36,26 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 { /// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the /// entities a visibility state hides are invisible this way, and they sit far from the block's /// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked. -fn in_block_visible(d: &Drawing, e: &Entity) -> bool { +/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]). +fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool { // Attribute definitions are not drawn in an insert (its attribs are, see below). - !matches!(e.kind, EntityKind::AttDef(_)) - && e.common.visible - && (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible)) + !matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts) +} + +/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which +/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`. +fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool { + layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts)) +} + +/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its +/// definition's (layer "0" being the insert's). +fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str { + a.props.layer.as_deref().unwrap_or_else(|| { + d.block(&ins.block) + .and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag)))) + .map_or("0", |be| be.common.layer.as_str()) + }) } /// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room. @@ -52,7 +67,9 @@ fn blank(s: &str) -> bool { fn mtext_box(t: &crate::MText) -> Bounds2 { let lines = t.contents.split("\\P").count().max(1); let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1); - let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 }; + let estimate = t.height * 0.8 * longest as f64; + // The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465). + let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate }; let mut h = t.height * 1.66 * lines as f64; if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) { // Columns side by side, the text flowing down each in turn. @@ -75,16 +92,27 @@ fn mtext_box(t: &crate::MText) -> Bounds2 { Bounds2::from_points(pts) } +/// The box of what `e` draws (block contents on layers turned off left out). pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { + bounds_of(d, e, depth, false) +} + +/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer +/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not). +pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 { + bounds_of(d, e, 0, true) +} + +fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 { // A top-level annotative object measures as it draws at the current annotation scale. if depth == 0 { match d.anno_draw(e) { crate::AnnoDraw::Plain => {} crate::AnnoDraw::Hidden => return Bounds2::EMPTY, - crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()), + crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts), } } - bounds_in(d, e, depth, &mut HashMap::new()) + bounds_in(d, e, depth, &mut HashMap::new(), off_counts) } /// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed, @@ -93,11 +121,16 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { /// definition per depth instead of one per reference (which grows exponentially with the nesting /// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its /// bounds must not stand in for a shallower reference to the same block (#706). -fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 { +fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 { match &e.kind { EntityKind::Table(t) if t.is_broken() => t.bounds(), EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY, EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY, + // Text that matches the layout's orientation is drawn upright (#2498). + EntityKind::Text(t) if e.common.matches_layout_orientation() => { + text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count()) + } + EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }), EntityKind::Text(t) => text_box(t, t.value.chars().count()), EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()), EntityKind::MText(t) => mtext_box(t), @@ -115,8 +148,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< Some(b) => *b, None => { let mut inner = Bounds2::EMPTY; - for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { - inner = inner.union(&bounds_in(d, be, depth + 1, blocks)); + for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) { + inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts)); } blocks.insert((blk.name.as_str(), depth), inner); inner @@ -131,7 +164,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< } } for a in &ins.attribs { - if !a.invisible { + if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) { b = b.union(&match a.multiline_text() { Some(m) => mtext_box(&m), None => text_box(&a.text, a.text.value.chars().count()), @@ -172,8 +205,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< Some(cached) => *cached, None => { let mut inner = Bounds2::EMPTY; - for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { - inner = inner.union(&bounds_in(d, be, depth + 1, blocks)); + for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) { + inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts)); } blocks.insert((blk.name.as_str(), depth), inner); inner @@ -193,7 +226,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< } if let Some(ins) = &m.block { let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) }; - b = b.union(&bounds_in(d, &be, depth, blocks)); + b = b.union(&bounds_in(d, &be, depth, blocks, off_counts)); } b } @@ -309,6 +342,37 @@ mod tests { entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0) } + fn mtext(contents: &str, width: f64) -> crate::MText { + crate::MText { + insert: Vec3::new(10.0, 10.0, 0.0), + height: 2.5, + width, + attach: 1, + rotation: 0.0, + style: "Standard".into(), + contents: contents.into(), + line_spacing: 1.0, + line_spacing_exact: false, + columns: None, + } + } + + /// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not + /// stretch the drawing extents (#2465). + #[test] + fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() { + let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0))); + // "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide. + assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x); + assert!(b.min.x >= 10.0 - 1e-9); + } + + /// A wrap width narrower than the text still bounds the box to that width. + #[test] + fn a_narrow_mtext_wrap_width_still_sets_the_box_width() { + let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0))); + assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x); + } #[test] fn right_justified_text_is_left_of_alignment_point() { let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline))); diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs new file mode 100644 index 000000000..132a8c1aa --- /dev/null +++ b/crates/doc/src/format.rs @@ -0,0 +1,100 @@ +use std::path::Path; + +/// A file format, chosen by the file name's extension (ASCII case-insensitive). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Dxf, + Dwg, + /// A drawing template: DWG content. + Dwt, + Svg, + /// Gzip-compressed SVG. + Svgz, + Png, + Pdf, +} + +impl Format { + /// The formats a drawing can be read from by name. DXF and DWG are also recognised by content. + pub const READ: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Svg, Format::Svgz]; + /// The formats a drawing can be written to. + pub const WRITE: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Png, Format::Pdf]; + + /// The format `path`'s extension names. A name without an extension is DXF; an unknown + /// extension is `None`. + pub fn from_path(p: impl AsRef) -> Option { + let ext = p.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + Some(match ext.as_str() { + "dxf" | "" => Format::Dxf, + "dwg" => Format::Dwg, + "dwt" => Format::Dwt, + "svg" => Format::Svg, + "svgz" => Format::Svgz, + "png" => Format::Png, + "pdf" => Format::Pdf, + _ => return None, + }) + } + + /// The extension, lower case and without the dot. + pub fn extension(self) -> &'static str { + match self { + Format::Dxf => "dxf", + Format::Dwg => "dwg", + Format::Dwt => "dwt", + Format::Svg => "svg", + Format::Svgz => "svgz", + Format::Png => "png", + Format::Pdf => "pdf", + } + } + + /// Written as an image (model space fitted to a frame), so framing applies. SVGZ is read only. + pub fn is_image(self) -> bool { + matches!(self, Format::Svg | Format::Png | Format::Pdf) + } + + /// A drawing file (DXF, DWG, template) rather than an image. + pub fn is_drawing(self) -> bool { + matches!(self, Format::Dxf | Format::Dwg | Format::Dwt) + } + + /// Written as DWG, which drops the object types the DWG writer doesn't know. + pub fn drops_unknown(self) -> bool { + matches!(self, Format::Dwg | Format::Dwt) + } + + /// `formats` as "dxf, dwg, svg": the list help text shows. + pub fn list(formats: &[Format]) -> String { + formats.iter().map(|f| f.extension()).collect::>().join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_path_by_extension() { + for f in [Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Svgz, Format::Png, Format::Pdf] { + assert_eq!(Format::from_path(format!("a/b.{}", f.extension())), Some(f)); + assert_eq!(Format::from_path(format!("B.{}", f.extension().to_ascii_uppercase())), Some(f)); + } + assert_eq!(Format::from_path("Drawing1"), Some(Format::Dxf), "no extension is DXF"); + assert_eq!(Format::from_path("dir.dwg/Drawing1"), Some(Format::Dxf)); + assert_eq!(Format::from_path("a."), Some(Format::Dxf)); + assert_eq!(Format::from_path("a.txt"), None); + assert_eq!(Format::from_path("a.dxf.bak"), None); + assert_eq!(Format::from_path(std::path::Path::new("x.PnG")), Some(Format::Png)); + } + + #[test] + fn classes() { + assert!(Format::READ.contains(&Format::Svgz) && !Format::WRITE.contains(&Format::Svgz)); + assert!(Format::WRITE.contains(&Format::Dwt) && !Format::READ.contains(&Format::Dwt)); + assert!(Format::WRITE.iter().filter(|f| f.is_image()).count() == 3 && !Format::Svgz.is_image()); + assert!(Format::Dwt.drops_unknown() && Format::Dwg.drops_unknown() && !Format::Dxf.drops_unknown()); + assert!(Format::Dwt.is_drawing() && !Format::Pdf.is_drawing()); + assert_eq!(Format::list(Format::READ), "dxf, dwg, svg, svgz"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index eafea9e27..e07a08609 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ mod array; mod constraint; mod entity; mod extents; +mod format; mod gdt; mod header; pub mod library; @@ -26,7 +27,8 @@ pub use cadcraft_color as color; pub use cadcraft_geom as geom; pub use constraint::*; pub use entity::*; -pub use extents::{MAX_BLOCK_DEPTH, entity_bounds}; +pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds}; +pub use format::Format; pub use gdt::*; pub use header::{HVal, Header}; pub use plotstyle::*; @@ -367,13 +369,19 @@ impl Drawing { !self.hidden_objects.is_empty() && self.hidden_objects.contains(&h) } - /// Bounds of all visible entities in a space. + /// Whether an entity counts in the extents: as AutoCAD 2024 counts it for ZOOM Extents and + /// EXTMIN/EXTMAX, on a layer that is off too, but not on a frozen one. + pub fn in_extents(&self, e: &Entity) -> bool { + e.common.visible && self.layer(&e.common.layer).is_none_or(|l| !l.frozen) && !self.is_hidden_object(e.handle) + } + + /// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]). pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 { let mut b = cadcraft_geom::Bounds2::EMPTY; if let Some(store) = self.space(space) { for e in store.iter() { - if self.is_visible(e) { - b = b.union(&entity_bounds(self, e, 0)); + if self.in_extents(e) { + b = b.union(&extent_bounds(self, e)); } } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 067d42ac0..f0f979f12 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -44,15 +44,85 @@ fn add_and_extents() { } #[test] -fn hidden_layers_excluded_from_extents() { +fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() { let mut d = Drawing::new_imperial(); let c = Common { layer: "Hidden".into(), ..Default::default() }; d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap(); d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap(); d.layer_mut("Hidden").unwrap().on = false; + assert_eq!(d.extents(&Space::Model).max, Vec2::new(200.0, 200.0)); + d.layer_mut("Hidden").unwrap().frozen = true; assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0)); } +/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a +/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an +/// entity inside a block, and for a block reference itself. +#[test] +fn extents_count_layers_turned_off_as_autocad() { + let mut d = Drawing::new_imperial(); + for (name, on, frozen) in [("OFFL", false, false), ("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] { + let mut l = Layer::new(name); + (l.on, l.frozen) = (on, frozen); + d.layers.push(l); + } + for (layer, b) in [("0", (10.0, 10.0)), ("OFFL", (100.0, 0.0)), ("FRZ", (0.0, 200.0))] { + d.add(&Space::Model, Common { layer: layer.into(), ..Default::default() }, line((0.0, 0.0), b)).unwrap(); + } + // AutoCAD: (-0.013, -0.009)…(100.027, 10.006). + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(100.0, 10.0))); + + let mut d = Drawing::new_imperial(); + for (name, on, frozen) in [("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] { + let mut l = Layer::new(name); + (l.on, l.frozen) = (on, frozen); + d.layers.push(l); + } + let mut blk = Block::new("BX"); + blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1)); + blk.entities.push(on_layer("BOFF", line((0.0, 0.0), (50.0, 0.0)), 2)); + blk.entities.push(on_layer("BFRZ", line((0.0, 0.0), (0.0, 70.0)), 3)); + d.blocks.insert("BX".into(), std::sync::Arc::new(blk)); + let at = |x: f64| match plain_insert("BX") { + EntityKind::Insert(i) => EntityKind::Insert(Insert { insert: Vec3::new(x, x, 0.0), ..i }), + k => k, + }; + d.add(&Space::Model, Common::default(), at(1000.0)).unwrap(); + d.add(&Space::Model, Common { layer: "FRZ".into(), ..Default::default() }, at(-500.0)).unwrap(); + // AutoCAD: (1000, 1000)…(1050, 1001). + assert_eq!(extents_pair(&d), (Vec2::new(1000.0, 1000.0), Vec2::new(1050.0, 1001.0))); + + // An insert's attributes lie on their own layer, else on their definition's: one on a frozen + // layer either way does not count, one on a layer turned off does. + let mut d = Drawing::new_imperial(); + for (name, on, frozen) in [("IOFF", false, false), ("AOFF", false, false), ("AFRZ", true, true)] { + let mut l = Layer::new(name); + (l.on, l.frozen) = (on, frozen); + d.layers.push(l); + } + let attrib = |tag: &str, at: (f64, f64), layer: Option<&str>| { + let mut a = plain_attrib(tag); + a.text.insert = Vec3::new(at.0, at.1, 0.0); + a.props.layer = layer.map(str::to_string); + a + }; + let mut blk = Block::new("BA"); + blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1)); + blk.entities.push(on_layer("AFRZ", EntityKind::AttDef(plain_attrib("DEF")), 2)); + d.blocks.insert("BA".into(), std::sync::Arc::new(blk)); + let ins = match plain_insert("BA") { + EntityKind::Insert(i) => { + let attribs = + vec![attrib("OWN", (0.0, 300.0), Some("AFRZ")), attrib("DEF", (0.0, -400.0), None), attrib("OFF", (40.0, 0.0), Some("AOFF"))]; + EntityKind::Insert(Insert { attribs, ..i }) + } + k => k, + }; + d.add(&Space::Model, Common { layer: "IOFF".into(), ..Default::default() }, ins).unwrap(); + let (min, max) = extents_pair(&d); + assert!(min.y > -1.0 && max.y < 50.0 && max.x > 40.0, "{min:?} {max:?}"); +} + #[test] fn rays_and_xlines_do_not_stretch_extents() { // ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away. @@ -315,6 +385,31 @@ fn plain_insert(block: &str) -> EntityKind { }) } +/// An attribute (or its definition) `tag` at the origin, one unit high, reading "x". +fn plain_attrib(tag: &str) -> Attrib { + Attrib { + tag: tag.into(), + text: Text { + insert: Vec3::ZERO, + align_pt: None, + height: 1.0, + value: "x".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }, + invisible: false, + constant: false, + prompt: String::new(), + props: Default::default(), + multiline: None, + modes: Default::default(), + } +} + fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity { Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind } } @@ -383,24 +478,18 @@ fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() { } #[test] -fn block_entity_on_off_or_frozen_layer_is_ignored() { - for frozen in [false, true] { - let mut d = drawing_with_block(far_line("Hidden")); - assert!(d.layer("Hidden").is_none(), "block contents do not create layers"); - let mut layer = Layer::new("Hidden"); - if frozen { - layer.frozen = true; - } else { - layer.on = false; - } - d.layers.push(layer); - assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}"); - // Turned back on, it counts again. - let l = d.layer_mut("Hidden").unwrap(); - l.on = true; - l.frozen = false; - assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); - } +fn block_entity_on_frozen_layer_is_ignored_and_on_a_layer_turned_off_counts() { + let mut d = drawing_with_block(far_line("Hidden")); + assert!(d.layer("Hidden").is_none(), "block contents do not create layers"); + let mut layer = Layer::new("Hidden"); + layer.frozen = true; + d.layers.push(layer); + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0))); + // Thawed but turned off, it counts. + let l = d.layer_mut("Hidden").unwrap(); + l.frozen = false; + l.on = false; + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); } #[test] @@ -558,10 +647,10 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() { hidden.common.visible = false; dim(&mut d, hidden); assert!(d.extents(&Space::Model).max.x < 100.0); - // Hidden by its layer state. + // Hidden by its layer state (frozen; a layer turned off still counts). let mut d = Drawing::new_imperial(); let mut l = Layer::new("*ADSK_CONSTRAINTS"); - l.on = false; + l.frozen = true; d.layers.push(l); dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2)); assert!(d.extents(&Space::Model).max.x < 100.0); diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index b81bd00d1..f7bf13dd6 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -104,10 +104,7 @@ mod native { } let mut notes = read_notes(&outcome.stats); let mut doc = outcome.document; - current_style_names(&mut doc); - table_merges(&mut doc); - table_style_rows(&mut doc, false); - let orphans = drop_orphaned_dgn_components(&mut doc); + let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?; if orphans > 0 { let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out")); @@ -237,6 +234,33 @@ mod native { /// cells already carry merges are left alone. Each range's first cell gets the span; the /// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does. fn table_merges(doc: &mut acadrust::CadDocument) { + table_merges_within(doc, MAX_MERGE_CELLS); + } + + /// The most table cells [`table_merges`] visits in one document: each range is capped, but + /// can cover rows × cols cells, so the total is bounded too. + const MAX_MERGE_CELLS: usize = 1 << 24; + + /// The fix-ups made to a document after the DWG read, returning how many orphaned DGN + /// components were dropped. The document comes from a third-party reader and is hostile. + fn post_process(doc: &mut acadrust::CadDocument) -> usize { + super::fail_in_test("post_process"); + current_style_names(doc); + table_merges(doc); + table_style_rows(doc, false); + keep_sort_handles(doc); + drop_orphaned_dgn_components(doc) + } + + /// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way). + fn guarded(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result { + std::panic::catch_unwind(f).map_err(|_| ()) + } + + /// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is + /// left unmerged. Returns the cells visited. + fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize { + let mut left = budget; for e in doc.entities_mut() { let acadrust::entities::EntityType::Table(t) = e else { continue }; if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) { @@ -252,6 +276,11 @@ mod native { if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) { continue; } + let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1); + if area > left { + continue; + } + left -= area; let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX); for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) { for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) { @@ -263,6 +292,28 @@ mod native { } } } + budget - left + } + + /// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF + /// writer renumbers them past every entity handle, but an entity left out of a table sorts by + /// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving + /// the table its codes verbatim makes the writer emit them unchanged. + fn keep_sort_handles(doc: &mut acadrust::CadDocument) { + use acadrust::objects::ObjectType; + let version = doc.version; + for o in doc.objects.values_mut() { + let ObjectType::SortEntitiesTable(t) = o else { continue }; + let hex = |h: acadrust::types::Handle| format!("{:X}", h.value()); + let mut codes = + vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))]; + for e in t.entries() { + codes.push((331, hex(e.entity_handle))); + codes.push((5, hex(e.sort_handle))); + } + t.raw_dxf_codes = Some(codes); + t.raw_dxf_version = Some(version); + } } /// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps @@ -512,6 +563,17 @@ mod native { } } + /// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG + /// stores the handle of the layer, and the one read with the DXF needn't be the layer's in + /// the document, so the drawing reopened with layer "0" current (#2252). + fn current_layer(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_layer_name.clone(); + // The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä"). + if let Some(layer) = doc.layers.get(&name) { + doc.header.current_layer_handle = layer.handle; + } + } + fn convert(dxf: &[u8], version: Option) -> Result, String> { // Reject a pathologically large DXF before handing it to the third-party decoder, which // allocates tables and section buffers from the input's declared sizes and cannot be @@ -536,13 +598,19 @@ mod native { None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION, None => {} } - other_layout_owners(&mut doc); - table_style_rows(&mut doc, true); - dimension_flags(&mut doc); - drop_unwritable(&mut doc); - current_dimstyle(&mut doc); - add_variable_dictionary(&mut doc); - current_text_style(&mut doc); + // The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash. + guarded(AssertUnwindSafe(|| { + super::fail_in_test("convert"); + other_layout_owners(&mut doc); + table_style_rows(&mut doc, true); + dimension_flags(&mut doc); + drop_unwritable(&mut doc); + current_dimstyle(&mut doc); + add_variable_dictionary(&mut doc); + current_text_style(&mut doc); + current_layer(&mut doc); + })) + .map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?; std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) @@ -639,11 +707,60 @@ mod native { } } } + + #[cfg(test)] + mod post_tests { + use super::*; + use acadrust::entities::{CellRange, EntityType, Table}; + + fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument { + let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols); + t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect(); + let mut doc = acadrust::CadDocument::new(); + assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok()); + doc + } + + /// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934). + #[test] + fn merge_work_is_bounded_by_the_cell_budget() { + let mut doc = doc_with_table(10, 10, 50); + assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped"); + let mut doc = doc_with_table(10, 10, 1); + assert_eq!(table_merges_within(&mut doc, usize::MAX), 100); + } + + /// A panic in the post-read fix-ups is an error, not a crash (#1934). + #[test] + fn a_panic_in_post_processing_is_an_error() { + #[allow(clippy::panic)] + let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") })); + assert!(r.is_err()); + assert_eq!(guarded(|| 3), Ok(3)); + } + } } #[cfg(not(target_arch = "wasm32"))] 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")))] +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"))] +#[allow(clippy::panic)] +fn fail_in_test(_stage: &str) { + #[cfg(test)] + if PANIC_IN.with(|p| p.get()) == Some(_stage) { + panic!("test-forced panic in {_stage}"); + } +} + /// The error for a DWG version CADCraft can't write. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn unsupported(version: &str) -> String { @@ -676,6 +793,50 @@ pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { mod tests { use super::*; + /// The DXF of an empty drawing, enough for the bridge to make a small DWG. + #[cfg(not(target_arch = "wasm32"))] + 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"))] + struct PanicIn; + + #[cfg(not(target_arch = "wasm32"))] + impl PanicIn { + fn at(stage: &'static str) -> Self { + PANIC_IN.with(|p| p.set(Some(stage))); + Self + } + } + + #[cfg(not(target_arch = "wasm32"))] + impl Drop for PanicIn { + fn drop(&mut self) { + PANIC_IN.with(|p| p.set(None)); + } + } + + /// The public read reports a panic in the post-read fix-ups as an error: this fails if the + /// `guarded` call around `post_process` is taken out (#2402). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn reading_a_dwg_survives_a_panic_in_post_processing() { + let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap(); + assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic"); + let _panic = PanicIn::at("post_process"); + let err = dwg_to_dxf(&dwg).unwrap_err(); + assert!(err.contains("the DWG post-processing failed on this file"), "{err}"); + } + + /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { + let _panic = PanicIn::at("convert"); + let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err(); + assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}"); + } + #[test] fn detects_magic() { assert!(is_dwg(b"AC1032\0\0\0\0")); diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 7f40c8f32..953043cb9 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -182,8 +182,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) { return parse_binary_with_limit(body, max_tags); } - // Every group code takes two lines (code, value). - let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2); + // Count nonblank group-code/value pairs, stopping at EOF just as the parser does. + let mut lines = bytes.split(|&b| b == b'\n'); + let mut tags = 0; + while let Some(code) = lines.next() { + let code = code.trim_ascii(); + if code.is_empty() { + continue; + } + let Some(value) = lines.next() else { break }; + tags += 1; + if code == b"0" && value.trim_ascii() == b"EOF" { + break; + } + } if tags > max_tags { return Err(DxfError::TooLarge { tags, limit: max_tags }); } @@ -265,7 +277,7 @@ pub fn parse_ascii(text: &str) -> Result> { #[allow(clippy::while_let_loop)] fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result> { let mut tags = Vec::new(); - let mut lines = text.lines().enumerate(); + let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate(); loop { let Some((ln, code_line)) = lines.next() else { break }; let ct = code_line.trim(); @@ -645,6 +657,12 @@ mod tests { } } + #[test] + fn blank_lines_do_not_consume_group_code_limit() { + let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one"); + assert_eq!(tags, vec![Tag::s(0, "EOF")]); + } + #[test] fn ascii_roundtrip() { let tags = vec![ diff --git a/crates/dxf/tests/ascii_bom.rs b/crates/dxf/tests/ascii_bom.rs new file mode 100644 index 000000000..e39b9b84c --- /dev/null +++ b/crates/dxf/tests/ascii_bom.rs @@ -0,0 +1,19 @@ +//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped. + +const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + +#[test] +fn utf8_bom_bytes_are_skipped() { + let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses"); + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(BODY.as_bytes()); + let with_bom = cadcraft_dxf::parse(&bytes); + assert!(with_bom.is_ok(), "{with_bom:?}"); + assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len()); +} + +#[test] +fn bom_text_is_skipped() { + let text = format!("\u{feff}{BODY}"); + assert!(cadcraft_dxf::parse_ascii(&text).is_ok()); +} diff --git a/crates/engine/src/atomic_write.rs b/crates/engine/src/atomic_write.rs index ff9e141d3..0d7459b1b 100644 --- a/crates/engine/src/atomic_write.rs +++ b/crates/engine/src/atomic_write.rs @@ -36,13 +36,20 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { Err(e) if e.kind() == io::ErrorKind::NotFound => None, Err(e) => return Err(e), }; + write_atomic_with(path, permissions, |file| file.write_all(bytes)) +} + +/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer), +/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink +/// at `path` is replaced by the new file, never written through. +pub fn write_atomic_with(path: &Path, permissions: Option, 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 +62,22 @@ 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; +} + /// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path. /// /// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing @@ -116,6 +136,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() { diff --git a/crates/engine/src/audit.rs b/crates/engine/src/audit.rs index f78de7efe..c055bff25 100644 --- a/crates/engine/src/audit.rs +++ b/crates/engine/src/audit.rs @@ -460,17 +460,13 @@ fn references(d: &mut Drawing, out: &mut Vec) { m.style = "Standard".into(); } } - EntityKind::Table(t) => { - if !has_name(&d.table_styles, &t.style, |s| &s.name) { - note("table style", &t.style); - t.style = "Standard".into(); - } + EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => { + note("table style", &t.style); + t.style = "Standard".into(); } - EntityKind::Leader(l) => { - if !ok_dim(d, &l.style) { - note("dimension style", &l.style); - l.style = "Standard".into(); - } + EntityKind::Leader(l) if !ok_dim(d, &l.style) => { + note("dimension style", &l.style); + l.style = "Standard".into(); } EntityKind::Dimension(dim) => { if !ok_dim(d, &dim.style) { diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 4da107449..c6b41cce4 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60; const HEARTBEAT_SECS: f64 = 60.0; static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME); +static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +/// ISAVEBAK: whether saving over a drawing keeps the previous file as `.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. @@ -59,10 +71,22 @@ pub fn set_savefilepath(path: &str) { /// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`. pub fn file_name(title: &str, instance: &str, uid: u64) -> String { - let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect(); + // File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary. + let suffix = format!("_{instance}_{uid}.{EXT}"); + let budget = 255usize.saturating_sub(suffix.len()); + let mut used = 0; + let stem: String = title + .chars() + .map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }) + .take(80) + .take_while(|c| { + used += c.len_utf8(); + used <= budget + }) + .collect(); let stem = stem.trim().trim_matches('.'); let stem = if stem.is_empty() { "Drawing" } else { stem }; - format!("{stem}_{instance}_{uid}.{EXT}") + format!("{stem}{suffix}") } /// Whether `s` has the form of an app instance id (`-`, see @@ -256,7 +280,10 @@ mod native { use std::path::Path; use std::time::{Duration, SystemTime}; - use super::*; + use super::{ + Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, 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. pub fn instance_id() -> String { @@ -475,3 +502,20 @@ mod native { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_name_fits_255_bytes_for_multibyte_titles() { + let instance = "12345-abcdef0123456789"; + for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] { + let name = file_name(&title, instance, u64::MAX); + assert!(name.len() <= 255, "{} bytes", name.len()); + let (t, i, u) = parse_file_name(&name).expect("round trips"); + assert_eq!((i, u), (instance, u64::MAX)); + assert!(!t.is_empty()); + } + } +} diff --git a/crates/engine/src/blockedit.rs b/crates/engine/src/blockedit.rs index 4dab97f1f..fae52757e 100644 --- a/crates/engine/src/blockedit.rs +++ b/crates/engine/src/blockedit.rs @@ -144,6 +144,11 @@ impl BlockEdit { } let host = &self.host.doc; let mut d = editor.clone(); + // When the block is saved under another name, the edited block keeps the drawing's + // description and block `name` gets the description changed in the editor. + if !name.eq_ignore_ascii_case(&self.name) { + keep_description(&mut d, host, &self.name); + } d.model = host.model.clone(); d.layouts = host.layouts.clone(); d.groups = host.groups.clone(); @@ -171,6 +176,21 @@ impl BlockEdit { } } +/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block, +/// drop the empty definition a description gives a block the editor started and never saved. +fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) { + match host.block(name) { + Some(h) => { + if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) + && b.description != h.description + { + Arc::make_mut(b).description = h.description.clone(); + } + } + None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())), + } +} + impl DocState { /// The drawing as a file saves it: outside the Block Editor's changes while it is open. pub fn drawing(&self) -> &Arc { @@ -272,6 +292,24 @@ impl DocState { let d = be.compose(&self.doc, &name)?; let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default(); let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name); + // When BSAVEAS saves the block under another name, the editor's drawing and each undo and + // redo step get the block as saved and the edited block as the drawing has it, so a later + // save and the description shown start from them after an undo too. + if !name.eq_ignore_ascii_case(&be.name) + && let Some(b) = d.block(&name).cloned() + { + let was = be.name.clone(); + let reconcile = |doc: &mut Arc| { + let editor = Arc::make_mut(doc); + keep_description(editor, &d, &was); + editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name)); + editor.blocks.insert(name.clone(), b.clone()); + }; + reconcile(&mut self.doc); + for step in self.undo.iter_mut().chain(self.redo.iter_mut()) { + reconcile(&mut step.doc); + } + } let editor = self.doc.clone(); let Some(be) = &mut self.block_edit else { return Ok(()) }; let before = be.host.doc.clone(); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1856c6a15..172e416d1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -33,6 +33,7 @@ pub mod plotters; mod props; pub use draw2::breakline::BreaklineOptions; pub use props::MatchProps; +pub(crate) use props::UNIT_RANGES; pub(crate) use props::{current_color_value, set_current_color_value}; mod qselect; mod selecttools; diff --git a/crates/engine/src/cmd/annotate/angular.rs b/crates/engine/src/cmd/annotate/angular.rs index e576798ca..98ee88c79 100644 --- a/crates/engine/src/cmd/annotate/angular.rs +++ b/crates/engine/src/cmd/annotate/angular.rs @@ -1,5 +1,8 @@ use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc}; -use super::*; +use super::{ + Accept, AssocSnap, DimAssoc, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, Segment, Session, Step, Value, Vec2, bad, json, + point_req, targets, v3, +}; /// Two-line angular dimension (type 2) between two lines, attached to them. fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option { diff --git a/crates/engine/src/cmd/annotate/common.rs b/crates/engine/src/cmd/annotate/common.rs index 2dec75558..398102797 100644 --- a/crates/engine/src/cmd/annotate/common.rs +++ b/crates/engine/src/cmd/annotate/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Accept, DimAssoc, DimKind, Dimension, EntityKind, Handle, Input, Prompt, Result, Segment, Session, Value, Vec2, Vec3, v3}; pub(super) fn style(s: &Session) -> String { s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into()) @@ -48,6 +48,19 @@ pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result { { dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm); } + // The measurement is what the DXF/DWG writer computes from the geometry (group 42); filling it + // in now means queries show it before the drawing is saved and reopened (#2421). + if let EntityKind::Dimension(dm) = &mut k + && let Ok(d) = s.doc() + { + let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); + let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); + dm.measurement = g.measurement; + // The text position the writer puts in group 11 when the user has not placed the text. + if !dm.user_text_pos { + dm.text_mid = g.text_pos.to3(0.0); + } + } let h = s.add_entity(k)?; s.last_dim = Some(h); Ok(h) @@ -94,7 +107,7 @@ pub(super) fn hex_param(p: &Value, key: &str) -> Option { pub(super) fn pick_at(s: &Session, p: Vec2) -> Option { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - crate::select::pick(s.doc().ok()?, &s.space(), p, ap) + s.pick_object(&s.space(), p, ap).ok().flatten() } /// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. diff --git a/crates/engine/src/cmd/annotate/edit.rs b/crates/engine/src/cmd/annotate/edit.rs index 74292bc81..38f02005f 100644 --- a/crates/engine/src/cmd/annotate/edit.rs +++ b/crates/engine/src/cmd/annotate/edit.rs @@ -1,5 +1,8 @@ use super::common::{hex_param, style}; -use super::*; +use super::{ + Accept, DimKind, EntityKind, Handle, Input, Interactive, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, Vec2, bad, json, + point_param, str_param, targets, v3, +}; pub(super) fn run_update(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; diff --git a/crates/engine/src/cmd/annotate/linear.rs b/crates/engine/src/cmd/annotate/linear.rs index ab0a14214..6bcf8fbdb 100644 --- a/crates/engine/src/cmd/annotate/linear.rs +++ b/crates/engine/src/cmd/annotate/linear.rs @@ -1,5 +1,8 @@ use super::common::{DimText, add_dim, assoc_enabled, circle_of, dim, linear_kind, pick_at}; -use super::*; +use super::{ + Accept, DimKind, Dimension, EngineError, EntityKind, Handle, Input, Interactive, Prim, Prompt, Result, Segment, Session, Step, Value, Vec2, bad, + json, point_req, points_param, str_param, v3, +}; pub(super) fn run_linear(s: &mut Session, p: &Value) -> Result { let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { diff --git a/crates/engine/src/cmd/annotate/mleader.rs b/crates/engine/src/cmd/annotate/mleader.rs index c2e0a63df..536e83b8c 100644 --- a/crates/engine/src/cmd/annotate/mleader.rs +++ b/crates/engine/src/cmd/annotate/mleader.rs @@ -1,5 +1,8 @@ use super::common::style; -use super::*; +use super::{ + Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2, + bad, json, points_param, str_param, v3, +}; /// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg). fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option { diff --git a/crates/engine/src/cmd/annotate/radial.rs b/crates/engine/src/cmd/annotate/radial.rs index a83f7dfd2..cee5dcc01 100644 --- a/crates/engine/src/cmd/annotate/radial.rs +++ b/crates/engine/src/cmd/annotate/radial.rs @@ -1,5 +1,5 @@ use super::common::{DimText, add_dim, circle_of, dim, pick_at, text_at}; -use super::*; +use super::{Accept, DimKind, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, point_param, targets}; fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { let id = if diameter { "dimdiameter" } else { "dimradius" }; diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 479b7873a..9d4a2fd9c 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -328,16 +328,19 @@ fn run_array(s: &mut Session, p: &Value) -> Result { (spec.run)(s, p) } -/// A count parameter (at least 1). -fn count_or(p: &Value, key: &str, default: u64) -> u64 { - p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1) +/// A count parameter (at least 1); a missing key gives the default, an invalid value is refused. +fn count_or(cmd: &str, p: &Value, key: &str, default: u64) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_u64().filter(|n| *n >= 1).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number of at least 1"))), + } } fn run_arrayrect(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; - let rows = count_or(p, "rows", 3); - let cols = count_or(p, "cols", 4); - let levels = count_or(p, "levels", 1); + let rows = count_or("arrayrect", p, "rows", 3)?; + let cols = count_or("arrayrect", p, "cols", 4)?; + let levels = count_or("arrayrect", p, "levels", 1)?; check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?; let kind = ArrayKind::Rect(RectArray { cols: count32(cols), @@ -360,9 +363,9 @@ fn run_arrayrect(s: &mut Session, p: &Value) -> Result { fn run_arraypolar(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let c = point_req("arraypolar", p, "center")?; - let n = count_or(p, "count", 6); - let rows = count_or(p, "rows", 1); - let levels = count_or(p, "levels", 1); + let n = count_or("arraypolar", p, "count", 6)?; + let rows = count_or("arraypolar", p, "rows", 1)?; + let levels = count_or("arraypolar", p, "levels", 1)?; check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?; let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c); let row_spacing = f64_or(p, "rowSpacing", 1.0); diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 15d75da14..8cc248685 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -19,6 +19,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St mod attedit; mod burst; +mod description; mod dyngrip; mod extract; pub(crate) mod refedit; @@ -28,12 +29,12 @@ pub fn specs() -> Vec { CommandSpec::new("block", "Make...", run_block) .menu(&["Draw", "Block", "Make..."]) .alias(&["b", "bmake"]) - .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}") + .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description? (up to 255 characters, one line), annotative?}") .interactive(|_| Ok(Box::new(BlockM::default()))), // -BLOCK erases the selected objects once the block is defined. CommandSpec::new("-block", "Create Block", run_dash_block) .alias(&["-b"]) - .params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}") + .params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description? (up to 255 characters, one line)}") .interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))), CommandSpec::new("insert", "Block...", run_insert) .menu(&["Insert", "Block..."]) @@ -79,6 +80,7 @@ pub fn specs() -> Vec { blockreplace::blockreplace_spec(), extract::dataextraction_spec(), extract::eattext_spec(), + description::description_spec(), ] .into_iter() .chain(attribs::attdisp_menu_specs()) @@ -200,13 +202,13 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values) } -/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block +/// The block reference INSERT places: X/Y/Z scale factors, and an optional base point (in block /// coordinates) that lands on `at` instead of the block's own base point. -fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option) -> Insert { +fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option) -> Insert { let mut ins = Insert { block: blk.name.clone(), insert: v3(at), - scale: Vec3::new(scale.x, scale.y, scale.x), + scale, rotation, attribs: Vec::new(), cols: 1, @@ -221,7 +223,8 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option ins } -/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]). +/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see +/// [`insert_entity`]). pub(crate) fn insert_with( s: &mut Session, name: &str, @@ -230,6 +233,19 @@ pub(crate) fn insert_with( rotation: f64, base: Option, values: &serde_json::Map, +) -> Result { + insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values) +} + +/// [`insert_with`] with a Z scale factor of its own. +fn insert_scaled( + s: &mut Session, + name: &str, + at: Vec2, + scale: Vec3, + rotation: f64, + base: Option, + values: &serde_json::Map, ) -> Result { let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; let mut ins = insert_entity(&blk, at, scale, rotation, base); @@ -266,7 +282,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result { let base = point_param(p, "base").unwrap_or(Vec2::ZERO); let hs = targets(s, p)?; let keep = keep_param("block", p, Keep::Convert)?; - let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?; + let description = str_param(p, "description").unwrap_or(""); + description::check_text("block", description)?; + let r = make_block(s, &name, base, &hs, keep, description)?; // An annotative block: its references (the converted selection too) are annotative. if bool_or(p, "annotative", false) { let d = s.doc_mut()?; @@ -562,15 +580,19 @@ enum InsStage { /// A file whose block already exists: redefine it from the file? Redefine, Point, - /// Basepoint: a new base point, picked on the block shown at its definition position. + /// Basepoint: a new base point, picked on the block dropped where it was (at the cursor). BasePoint, /// Scale preset before the insertion point. PresetScale, + /// X, Y or Z scale preset (0, 1, 2) before the insertion point: that axis alone. + PresetAxis(usize), /// Rotate preset before the insertion point. PresetRotation, XScale, Corner, YScale, + /// XYZ: the Z scale factor, after X and Y. + ZScale, Rotation, Attribs, } @@ -583,6 +605,15 @@ struct InsertM { base: Option, x_scale: Option, scale: Option, + /// The Z scale factor when given (a preset or XYZ); otherwise the X factor's size. AutoCAD + /// keeps a Z factor typed at XYZ's Z prompt as given, and stores the size of any other. + z_scale: Option, + /// X/Y/Z presets given so far. + axes: [Option; 3], + /// XYZ at the X scale prompt: X, Y and Z are asked in turn. + xyz: bool, + /// Where the block was dropped to pick a Basepoint on it. + dropped: Option, rotation: Option, attdefs: Vec<(String, String, String)>, values: serde_json::Map, @@ -606,7 +637,7 @@ impl InsertM { && self.values.len() >= self.attdefs.len() && let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) { - let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?; + let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?; if self.explode { insert_file::explode_insert(s, h)?; } @@ -652,6 +683,23 @@ impl InsertM { } } + /// X/Y scale factors with Z: as given, else the X factor's size (AutoCAD 2024: X -1 with Y + /// as X stores Z +1). + /// X and Y from a corner: XYZ still asks Z, which defaults to the size of X. + fn corner_scale(&mut self, s: &mut Session, sc: Vec2) -> Result { + self.scale = Some(sc); + if self.xyz { + self.x_scale = Some(sc.x); + self.stage = InsStage::ZScale; + return Ok(Step::Continue); + } + self.advance(s) + } + + fn scale3(&self, sc: Vec2) -> Vec3 { + Vec3::new(sc.x, sc.y, self.z_scale.unwrap_or(sc.x.abs())) + } + /// Scale factors from an opposite corner: the X and Y distances from the insertion point. fn corner(a: Vec2, p: Vec2) -> Result { let d = p - a; @@ -682,15 +730,18 @@ impl Interactive for InsertM { let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default(); Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N") } - InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]), + InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]), InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT), InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"), + InsStage::PresetAxis(i) => Prompt::new(format!("Specify {} scale factor", ["X", "Y", "Z"][i.min(2)]), Accept::NUMBER).default("1"), InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"), + InsStage::XScale if self.xyz => Prompt::new("Specify X scale factor", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at), InsStage::XScale => { - Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at) + Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner", "XYZ"]).default("1").base(at) } InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at), InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"), + InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"), InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at), InsStage::Attribs => { let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); @@ -738,14 +789,25 @@ impl Interactive for InsertM { self.at = Some(p); return self.advance(s); } - Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint, + Input::Keyword(k) if k == "Basepoint" => { + // AutoCAD drops the block where it is being dragged, at the cursor (the origin + // in a script: 2024 inserted (15, 5) for base point (612350.678, 2398770.432) + // and insertion point (612365.678, 2398775.432)). + self.dropped = Some(s.cursor); + self.stage = InsStage::BasePoint; + } Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale, + Input::Keyword(k) if k == "X" => self.stage = InsStage::PresetAxis(0), + Input::Keyword(k) if k == "Y" => self.stage = InsStage::PresetAxis(1), + Input::Keyword(k) if k == "Z" => self.stage = InsStage::PresetAxis(2), Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation, _ => {} }, InsStage::BasePoint => { if let Input::Point(p) = i { - self.base = Some(p); + // From the dropped block to its own coordinates. + let def = s.doc()?.block(self.name.as_deref().unwrap_or_default()).map_or(Vec2::ZERO, |b| b.base.xy()); + self.base = Some(def + (p - self.dropped.unwrap_or(def))); self.stage = InsStage::Point; } } @@ -756,12 +818,30 @@ impl Interactive for InsertM { _ => return Ok(Step::Continue), }; self.scale = Some(Vec2::new(f, f)); + self.z_scale = Some(f.abs()); + // An axis preset after it changes that axis alone (Scale -3 then X 2: 2/-3/3). + self.axes = [Some(f), Some(f), Some(f.abs())]; // Exploded insertion asks its one scale factor after the insertion point. if self.at.is_some() { return self.advance(s); } self.stage = InsStage::Point; } + // One axis preset: the others are 1, and no scale factors are asked after the point. + InsStage::PresetAxis(ax) => { + let f = match i { + Input::Text(t) => nonzero(&t)?, + Input::Enter => 1.0, + _ => return Ok(Step::Continue), + }; + if let Some(a) = self.axes.get_mut(ax) { + *a = Some(f); + } + let [x, y, z] = self.axes.map(|a| a.unwrap_or(1.0)); + self.scale = Some(Vec2::new(x, y)); + self.z_scale = Some(z.abs()); + self.stage = InsStage::Point; + } InsStage::PresetRotation => { self.rotation = Some(match i { Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, @@ -772,9 +852,10 @@ impl Interactive for InsertM { } InsStage::XScale => match i { Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner, + Input::Keyword(k) if k == "XYZ" => self.xyz = true, Input::Point(p) => { - self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?); - return self.advance(s); + let sc = Self::corner(self.at.unwrap_or_default(), p)?; + return self.corner_scale(s, sc); } Input::Text(t) => { self.x_scale = Some(nonzero(&t)?); @@ -788,8 +869,8 @@ impl Interactive for InsertM { }, InsStage::Corner => { if let Input::Point(p) = i { - self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?); - return self.advance(s); + let sc = Self::corner(self.at.unwrap_or_default(), p)?; + return self.corner_scale(s, sc); } } InsStage::YScale => { @@ -800,6 +881,18 @@ impl Interactive for InsertM { _ => return Ok(Step::Continue), }; self.scale = Some(Vec2::new(x, y)); + if self.xyz { + self.stage = InsStage::ZScale; + return Ok(Step::Continue); + } + return self.advance(s); + } + InsStage::ZScale => { + self.z_scale = Some(match i { + Input::Text(t) => nonzero(&t)?, + Input::Enter => self.x_scale.unwrap_or(1.0).abs(), + _ => return Ok(Step::Continue), + }); return self.advance(s); } InsStage::Rotation => { @@ -828,7 +921,7 @@ impl Interactive for InsertM { let Some(b) = d.block(n) else { return Vec::new() }; // Picking a new base point: the block sits at its definition position. if self.stage == InsStage::BasePoint { - return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))]; + return vec![EntityKind::Insert(insert_entity(b, self.dropped.unwrap_or(b.base.xy()), Vec3::new(1.0, 1.0, 1.0), 0.0, None))]; } let at = self.at.unwrap_or(c); let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) { @@ -837,7 +930,7 @@ impl Interactive for InsertM { _ => Vec2::new(1.0, 1.0), }); let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 }); - vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))] + vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))] } } diff --git a/crates/engine/src/cmd/blocks/attedit.rs b/crates/engine/src/cmd/blocks/attedit.rs index ad123f0b3..be59474c5 100644 --- a/crates/engine/src/cmd/blocks/attedit.rs +++ b/crates/engine/src/cmd/blocks/attedit.rs @@ -3,7 +3,7 @@ //! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD. use cadcraft_doc::color::Color; -use cadcraft_doc::{AttribProps, Common, EntityKind, Handle}; +use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign}; use serde_json::{Map, Value, json}; use crate::cmd::curves::KW; @@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result { if let EntityKind::Insert(i) = &mut e.kind { for a in &mut i.attribs { if let Some(v) = value_for(&vals, &a.tag) { - a.text.value = v; + set_value(&mut a.text, v); n += 1; } } @@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option && let Some(a) = ins.attribs.get_mut(k) && let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value) { - a.text.value = v; + set_value(&mut a.text, v); changed = true; } })?; Ok(changed) } +/// Refresh the derived baseline after a value change, even one within the writer's +/// imported-font tolerance. Equal XY points request recomputation on export; retain +/// both elevations and the independent endpoints used by Aligned/Fit text. +fn set_value(text: &mut Text, value: String) { + if text.value == value { + return; + } + text.value = value; + if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _)) + && let Some(anchor) = text.align_pt + { + text.insert.x = anchor.x; + text.insert.y = anchor.y; + } +} + /// Change the properties set on attribute `k` of block reference `h`. fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> { s.doc_mut()?.modify_entity(h, |e| { diff --git a/crates/engine/src/cmd/blocks/bedit.rs b/crates/engine/src/cmd/blocks/bedit.rs index f11bb3091..c570fa107 100644 --- a/crates/engine/src/cmd/blocks/bedit.rs +++ b/crates/engine/src/cmd/blocks/bedit.rs @@ -2,6 +2,8 @@ //! drawing's own tab, its base point at the origin; the block's objects are edited with the //! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the //! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved. +//! BEDIT while the Block Editor is open switches it to another block: changes not saved yet are +//! saved or discarded first (asked at the command line; `save` in JSON). //! The state is [`crate::blockedit`]. use cadcraft_doc::{Drawing, EntityKind, Handle}; @@ -16,10 +18,9 @@ pub(super) fn specs() -> Vec { CommandSpec::new("bedit", "Block Editor", run_bedit) .menu(&["Tools", "Block Editor"]) .alias(&["be", "-bedit"]) - .params("{name? (\"\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}") - .enabled(not_editing) + .params("{name? (\"\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it), save?: bool (in the Block Editor, switching to another block: save (true) or discard (false) the changes not saved yet; required when there are some), check?: bool (in the Block Editor: only check that the block can be switched to)} → {block, new, saved?}") .noundo() - .interactive(|_| Ok(Box::new(BeditM))), + .interactive(|_| Ok(Box::new(BeditM::default()))), CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(), CommandSpec::new("bsaveas", "Save Block As", run_bsaveas) .params("{name, replace?: bool (redefine an existing block of that name)} → {block}") @@ -53,16 +54,6 @@ fn editing_a_block(s: &Session) -> std::result::Result<(), String> { } } -fn not_editing(s: &Session) -> std::result::Result<(), String> { - match s.state() { - Ok(st) => match st.block_editor() { - Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")), - None => Ok(()), - }, - Err(e) => Err(e.to_string()), - } -} - /// Commands that leave model space, which the Block Editor is: not available while it is open. const NOT_IN_EDITOR: &[&str] = &[ "layout", @@ -85,7 +76,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option { } /// The block a block reference refers to. -fn block_of(d: &Drawing, h: Handle) -> Option { +pub(super) fn block_of(d: &Drawing, h: Handle) -> Option { match d.entity(h).map(|e| &e.kind) { Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())), _ => None, @@ -106,8 +97,53 @@ fn dynamic_definition(d: &Drawing, name: &str) -> Option { (kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten() } -/// Open the Block Editor on block `name` and frame its objects. -fn open(s: &mut Session, name: &str, create: bool) -> Result { +/// Whether the open Block Editor can switch to block `name`: the block is there (or `create` makes +/// it) and isn't one the editor refuses. Checked before anything is saved or closed, or asked. +fn check_target(s: &Session, name: &str, create: bool) -> Result<()> { + let host = s.state()?.block_edit.as_ref().map(|b| b.host.doc.clone()).ok_or_else(|| bad("bedit", "the Block Editor is not open"))?; + if name.trim().eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) { + return Ok(()); + } + match host.block(name) { + None if !create => Err(bad("bedit", format!("Block \"{name}\" not found."))), + None => Ok(()), + Some(_) => crate::blockedit::refusal(&host, name).map_or(Ok(()), |why| Err(bad("bedit", why))), + } +} + +/// Open the Block Editor on block `name` and frame its objects. While it is open on another +/// block it switches: the changes not saved yet are saved first with `save` Some(true), +/// discarded with Some(false); with None there must be none. The block to switch to is checked +/// before anything is saved or closed, so a refused switch leaves the editor as it was. +fn open(s: &mut Session, name: &str, create: bool, save: Option) -> Result { + let mut saved = None; + if let Some(current) = s.state()?.block_editor().map(str::to_string) { + if current.eq_ignore_ascii_case(name.trim()) { + s.echo(format!("The Block Editor is already editing \"{current}\".")); + return Ok(json!({ "block": current, "new": false })); + } + check_target(s, name, create)?; + let save = match (s.state()?.block_unsaved(), save) { + (false, _) => false, + (true, Some(save)) => save, + (true, None) => { + return Err(bad( + "bedit", + format!("Block \"{current}\" has changes not saved; pass `save: true` to save them or `save: false` to discard them"), + )); + } + }; + saved = close(s, save)?.get("saved").cloned(); + } + let mut r = open_closed(s, name, create)?; + if let (Some(v), Some(o)) = (saved, r.as_object_mut()) { + o.insert("saved".into(), v); + } + Ok(r) +} + +/// [`open`] with the Block Editor closed. +fn open_closed(s: &mut Session, name: &str, create: bool) -> Result { let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none(); s.state_mut()?.open_block_editor(name, create)?; let name = s.state()?.block_editor().unwrap_or(name).to_string(); @@ -129,7 +165,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result { } /// The one block reference selected, if exactly one is. -fn selected_reference(s: &Session) -> Option { +pub(super) fn selected_reference(s: &Session) -> Option { let d = s.doc().ok()?; let refs: Vec = s.selection().iter().filter_map(|h| block_of(d, *h)).collect(); match refs.as_slice() { @@ -153,7 +189,15 @@ fn run_bedit(s: &mut Session, p: &Value) -> Result { if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) { return Err(bad("bedit", format!("\"{name}\" is not a valid block name"))); } - open(s, name, create) + if p.get("save").is_some_and(|v| !v.is_null() && !v.is_boolean()) { + return Err(bad("bedit", "`save` must be true or false")); + } + // Only whether the Block Editor, when open, can switch to the block: nothing opens or closes. + if bool_or(p, "check", false) && s.state()?.block_editor().is_some() { + check_target(s, name, create)?; + return Ok(json!({ "block": name, "checked": true })); + } + open(s, name, create, p.get("save").and_then(Value::as_bool)) } fn run_bsave(s: &mut Session, _p: &Value) -> Result { @@ -206,7 +250,27 @@ fn run_bclose(s: &mut Session, p: &Value) -> Result { // ---------------- interactive ---------------- /// BEDIT: the selected block reference's block, else a block name typed or a reference picked. -struct BeditM; +/// In the Block Editor with changes not saved yet, it asks whether to save them before switching. +#[derive(Default)] +struct BeditM { + /// The block to switch to, waiting for the answer to "Save changes?". + pending: Option, +} + +impl BeditM { + /// Go to block `name`: at once, or after asking about the changes not saved yet. + fn go(&mut self, s: &mut Session, name: &str) -> Result { + let st = s.state()?; + let other = st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(name.trim())); + if other && st.block_unsaved() { + // A block it can't switch to is refused before the question, which would trap the user. + check_target(s, name.trim(), false)?; + self.pending = Some(name.trim().to_string()); + return Ok(Step::Continue); + } + open(s, name, false, Some(false)).map(|_| Step::Done) + } +} impl Interactive for BeditM { fn name(&self) -> &'static str { @@ -214,14 +278,30 @@ impl Interactive for BeditM { } fn begin(&mut self, s: &mut Session) -> Result { match selected_reference(s) { - Some(name) => open(s, &name, false).map(|_| Step::Done), + Some(name) => self.go(s, &name), None => Ok(Step::Continue), } } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { + if self.pending.is_some() { + let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default(); + return Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y"); + } Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true }) } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(name) = self.pending.clone() { + let answer = match i { + Input::Enter => "Y".to_string(), + Input::Keyword(k) | Input::Text(k) => k, + _ => return Ok(Step::Continue), + }; + return match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) { + Some('Y') => open(s, &name, false, Some(true)).map(|_| Step::Done), + Some('N') => open(s, &name, false, Some(false)).map(|_| Step::Done), + _ => Err(EngineError::Other("Enter Yes or No.".into())), + }; + } match i { Input::Text(t) if t.trim() == "?" => { let names: Vec = { @@ -231,14 +311,14 @@ impl Interactive for BeditM { s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") }); Ok(Step::Continue) } - Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done), + Input::Text(t) | Input::Keyword(t) => self.go(s, t.trim()), Input::Pick(hs) => { let name = { let d = s.doc()?; hs.iter().find_map(|h| block_of(d, *h)) }; match name { - Some(n) => open(s, &n, false).map(|_| Step::Done), + Some(n) => self.go(s, &n), None => Err(EngineError::Other("Select a block reference.".into())), } } diff --git a/crates/engine/src/cmd/blocks/description.rs b/crates/engine/src/cmd/blocks/description.rs new file mode 100644 index 000000000..96ad4df49 --- /dev/null +++ b/crates/engine/src/cmd/blocks/description.rs @@ -0,0 +1,188 @@ +//! Block Description (Modify > Object > Block Description...): changes the description of a +//! block definition. In the Block Editor it changes the edited block's description, which BSAVE +//! saves into the drawing as it saves the block's objects. + +use std::sync::Arc; + +use cadcraft_doc::Block; +use serde_json::{Value, json}; + +use super::bedit::selected_reference; +use crate::cmd::{CommandSpec, bad, has_doc, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// The longest description a block takes, in characters. +const MAX_LEN: usize = 255; + +pub(super) fn description_spec() -> CommandSpec { + CommandSpec::new("blocks.description", "Block Description...", run) + .menu(&["Modify", "Object", "Block Description..."]) + .params("{name? (default the block the Block Editor is editing, else the one selected block reference's block), description (up to 255 characters, one line; \"\" clears it)} → {block, description}") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(DescriptionM::default()))) +} + +/// The block the Block Editor is editing, when it is open on a block (not on the drawing itself). +fn edited_block(s: &Session) -> Option { + s.state().ok()?.block_editor().filter(|n| !n.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING)).map(str::to_string) +} + +/// The block that a call without a name is about: the edited block, else the selected reference's. +fn default_block(s: &Session) -> Option { + edited_block(s).or_else(|| selected_reference(s)) +} + +/// Block `name` as the drawing spells it, when its description can be changed: while the Block +/// Editor edits a block, only that block's. The block the editor has started and not saved yet has +/// no definition, and is named as the editor names it. +fn check_block(s: &Session, name: &str) -> Result { + let name = name.trim(); + if let Some(edited) = edited_block(s) + && !edited.eq_ignore_ascii_case(name) + { + return Err(bad( + "blocks.description", + format!("The Block Editor is editing \"{edited}\"; close it (BCLOSE) to change another block's description."), + )); + } + let d = s.doc()?; + match d.block(name) { + Some(b) if b.xref_path.is_some() => { + Err(bad("blocks.description", format!("\"{}\" is an external reference; its description comes from its drawing.", b.name))) + } + Some(b) if b.anonymous || b.name.starts_with('*') => { + Err(bad("blocks.description", format!("\"{}\" is an anonymous block made by another object; it has no description to change.", b.name))) + } + Some(b) => Ok(b.name.clone()), + None => match edited_block(s) { + Some(e) if e.eq_ignore_ascii_case(name) => Ok(e), + _ => Err(bad("blocks.description", format!("Block \"{name}\" not found."))), + }, + } +} + +/// A description a block can take (command `cmd`'s): one line of up to [`MAX_LEN`] characters. +pub(super) fn check_text(cmd: &str, text: &str) -> Result<()> { + if text.contains(['\n', '\r']) { + return Err(bad(cmd, "the description must be one line")); + } + if text.chars().count() > MAX_LEN { + return Err(bad(cmd, format!("the description can be up to {MAX_LEN} characters"))); + } + Ok(()) +} + +/// Block `name`'s definition: the drawing's, else, for the block the Block Editor edits, the one +/// outside the editor (a block saved under a new name with BSAVEAS is not in the editor's tables). +fn definition(s: &Session, name: &str) -> Option> { + let st = s.state().ok()?; + st.doc.block(name).or_else(|| st.block_edit.as_ref().and_then(|be| be.host.doc.block(name))).cloned() +} + +/// The description of block `name` (as [`check_block`] gave it), or "" for one not yet saved. +fn current(s: &Session, name: &str) -> String { + definition(s, name).map(|b| b.description.clone()).unwrap_or_default() +} + +/// Give block `name` (as [`check_block`] gave it) the description `text`. +fn set(s: &mut Session, name: &str, text: &str) -> Result { + check_text("blocks.description", text)?; + let outside = definition(s, name); + let doc = s.doc_mut()?; + match doc.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name)) { + Some((_, b)) => { + if b.description != text { + Arc::make_mut(b).description = text.to_string(); + } + } + // The block the Block Editor edits and the editor's tables don't hold (one it started, or + // one BSAVEAS made): BSAVE saves it with this description. + None => { + let mut b = outside.map(|b| (*b).clone()).unwrap_or_else(|| Block::new(name)); + b.description = text.to_string(); + doc.blocks.insert(name.to_string(), Arc::new(b)); + } + } + if s.state()?.block_editor().is_some() { + s.echo(format!("Description of block \"{name}\" changed; BSAVE saves it into the drawing.")); + } else { + s.echo(format!("Description of block \"{name}\" changed.")); + } + Ok(json!({ "block": name, "description": text })) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => default_block(s) + .ok_or_else(|| bad("blocks.description", "`name` is required (or open the block in the Block Editor, or select one block reference)"))?, + }; + let text = str_param(p, "description").ok_or_else(|| bad("blocks.description", "`description` is required"))?; + let name = check_block(s, &name)?; + set(s, &name, text) +} + +// ---------------- interactive ---------------- + +/// Block Description: the block (the edited one, the selected reference's, or a name typed or a +/// reference picked), then its new description; Enter keeps the description it has. +#[derive(Default)] +struct DescriptionM { + /// The block whose description is asked for, once known. + block: Option, +} + +impl Interactive for DescriptionM { + fn name(&self) -> &'static str { + "BLOCKS.DESCRIPTION" + } + fn begin(&mut self, s: &mut Session) -> Result { + if let Some(name) = default_block(s) { + self.block = Some(check_block(s, &name)?); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.block { + Some(name) => { + let cur = current(s, name); + let p = Prompt::new(format!("Enter description for block \"{name}\""), Accept::TEXT); + if cur.is_empty() { p } else { p.default(cur) } + } + None => Prompt::new( + "Enter block name or select a block reference", + Accept { point: true, number: false, text: true, select: true, enter: true }, + ), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(name) = self.block.clone() { + return match i { + Input::Text(t) => set(s, &name, t.trim()).map(|_| Step::Done), + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + }; + } + match i { + Input::Text(t) | Input::Keyword(t) => { + self.block = Some(check_block(s, &t)?); + Ok(Step::Continue) + } + Input::Pick(hs) => { + let picked = { + let d = s.doc()?; + hs.iter().find_map(|h| super::bedit::block_of(d, *h)) + }; + match picked { + Some(n) => { + self.block = Some(check_block(s, &n)?); + Ok(Step::Continue) + } + None => Err(EngineError::Other("Select a block reference.".into())), + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} diff --git a/crates/engine/src/cmd/constraints.rs b/crates/engine/src/cmd/constraints.rs index 5b685dd45..9f21dca6a 100644 --- a/crates/engine/src/cmd/constraints.rs +++ b/crates/engine/src/cmd/constraints.rs @@ -461,7 +461,7 @@ impl Interactive for GcM { } Input::Point(p) => { let space = s.space(); - let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else { + let Some(h) = s.pick_object(&space, p, aperture(s))? else { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; @@ -706,11 +706,11 @@ impl Interactive for DcM { Input::Point(p) => { let space = s.space(); let ap = aperture(s); - let d = s.doc()?; - let Some(h) = crate::select::pick(d, &space, p, ap) else { + let Some(h) = s.pick_object(&space, p, ap)? else { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; + let d = s.doc()?; let want = match dc { Dc::Radius | Dc::Diameter => Want::Curve, Dc::Angular if self.refs.is_empty() => { diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 7fe05239d..ca918834a 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -18,10 +18,9 @@ pub fn pick_aperture(s: &Session) -> f64 { s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5 } -/// The object under `p` in the current space. +/// The object under `p` in the current space (or the one named for `p`: [`Session::pick_object`]). pub fn pick_at(s: &Session, p: Vec2) -> Option { - let space = s.space(); - crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s)) + s.pick_object(&s.space(), p, pick_aperture(s)).ok().flatten() } pub fn entity(s: &Session, h: Handle) -> Result { @@ -479,6 +478,19 @@ impl Chain { } best.1 } + /// The curve parameter of the point on the chain nearest `p`: the index of its segment plus + /// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc). + pub fn param_of(&self, p: Vec2) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, s) in self.segs.iter().enumerate() { + let c = s.closest(p); + let dd = c.dist(p); + if dd < best.0 { + best = (dd, i as f64 + seg_param(s, c)); + } + } + best.1 + } /// Reversed copy. pub fn reversed(&self) -> Chain { Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed } @@ -506,6 +518,29 @@ impl Chain { } } +/// The polyline parameter of the point nearest `p` on spans `(start, end, bulge)` taken from the +/// source vertices: the span's index plus the fraction along it. Unlike [`Chain::param_of`] it +/// counts zero-length spans, which a polyline's parameter range includes (one per vertex) but its +/// chain drops. +pub fn vertex_param_of(spans: &[(Vec2, Vec2, f64)], p: Vec2) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, &(a, b, bulge)) in spans.iter().enumerate() { + if a.near(b, EPS) { + continue; + } + let seg = match cadcraft_geom::bulge_to_arc(a, b, bulge) { + Some((arc, ccw)) => Segment::Arc { arc, ccw }, + None => Segment::Line(Line::new(a, b)), + }; + let c = seg.closest(p); + let dd = c.dist(p); + if dd < best.0 { + best = (dd, i as f64 + seg_param(&seg, c)); + } + } + best.1 +} + /// Parameter (0..1 along travel) of a point on a segment. pub fn seg_param(s: &Segment, p: Vec2) -> f64 { match s { diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 52bf23ace..1b0e0c1cf 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,5 +1,5 @@ //! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY, -//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence. +//! SPLINE, DONUT, SOLID, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence. use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign}; use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc}; @@ -21,6 +21,7 @@ mod ellipse; mod line_pline; mod point; mod polygon; +mod solid; mod spline; mod text; @@ -30,6 +31,7 @@ use ellipse::{EllipseM, run_ellipse}; use line_pline::{LineM, PlineM, run_line, run_pline}; use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray}; use polygon::{PolygonM, run_polygon}; +use solid::{SolidM, run_solid}; use spline::{SplineM, run_spline}; use text::{MTextM, TextM, run_mtext, run_text}; @@ -100,6 +102,11 @@ pub fn specs() -> Vec { .alias(&["do", "doughnut"]) .params("{center, inside, outside}") .interactive(|_| Ok(Box::new(DonutM::default()))), + CommandSpec::new("solid", "2D Solid", run_solid) + .menu(&["Draw", "2D Solid"]) + .alias(&["so"]) + .params("{points: [[x,y],...]} (three or four for the first solid, two more for each next one, chained on its last two corners)") + .interactive(|_| Ok(Box::new(SolidM::default()))), CommandSpec::new("text", "Single Line Text", run_text) .menu(&["Draw", "Text", "Single Line Text"]) .alias(&["dt", "dtext"]) diff --git a/crates/engine/src/cmd/draw/circle.rs b/crates/engine/src/cmd/draw/circle.rs index 146b4de50..789cb5366 100644 --- a/crates/engine/src/cmd/draw/circle.rs +++ b/crates/engine/src/cmd/draw/circle.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Arc, Circle, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, arc, bad, circle, f64_req, line, number, + point_param, size_param, +}; pub(super) fn run_circle(s: &mut Session, p: &Value) -> Result { let c = if let Some(center) = point_param(p, "center") { diff --git a/crates/engine/src/cmd/draw/ellipse.rs b/crates/engine/src/cmd/draw/ellipse.rs index 87391c263..b1cd56927 100644 --- a/crates/engine/src/cmd/draw/ellipse.rs +++ b/crates/engine/src/cmd/draw/ellipse.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Ellipse, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, f64_or, f64_req, line, number, + point_req, v3, +}; pub(super) fn run_ellipse(s: &mut Session, p: &Value) -> Result { if p.get("isocircle").is_some() { @@ -31,19 +34,24 @@ pub(super) struct EllipseM { /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. param: bool, start: Option, + /// The `Included angle` option: the next value is the arc's angle from its start angle. + included: bool, /// The Isocircle option (isometric snap style) in progress. iso: Option, } impl EllipseM { fn axis(&self) -> Option<(Vec2, Vec2)> { - // (center, major vector) + // (center, major vector): towards the first axis endpoint, as AutoCAD stores it. match (self.center_mode, self.pts.as_slice()) { (true, [c, e, ..]) => Some((*c, *e - *c)), - (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))), + (false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))), _ => None, } } + /// The other half axis reaches as far as `other` is from the center (AutoCAD measures the + /// picked point's distance from the center, not from the first axis). Longer than the + /// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise. fn build(&self, other: Vec2) -> Option { let (c, m) = self.axis()?; let ml = m.len(); @@ -51,7 +59,7 @@ impl EllipseM { return None; } let u = m / ml; - let minor = ((other - c).dot(u.perp())).abs(); + let minor = (other - c).len(); let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) }; (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0))) } @@ -85,13 +93,16 @@ impl Interactive for EllipseM { } fn prompt(&self, s: &Session) -> Prompt { if let Some(e) = &self.ellipse { - let msg = match (self.start.is_none(), self.param) { - (true, false) => "Specify start angle", - (false, false) => "Specify end angle", - (true, true) => "Specify start parameter", - (false, true) => "Specify end parameter", + if self.included { + return Prompt::new("Specify included angle for arc", Accept::POINT_OR_NUMBER).default("180").base(e.center); + } + let (msg, kw): (&str, &[&str]) = match (self.start.is_none(), self.param) { + (true, false) => ("Specify start angle", &["Parameter"]), + (false, false) => ("Specify end angle", &["Parameter", "Included angle"]), + (true, true) => ("Specify start parameter", &["Angle"]), + (false, true) => ("Specify end parameter", &["Angle"]), }; - return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); + return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(kw).base(e.center); } if let Some(iso) = &self.iso { return iso.prompt(); @@ -109,11 +120,32 @@ impl Interactive for EllipseM { fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(e) = self.ellipse { let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; + if self.included { + let incl = match i { + Input::Point(p) => (p - e.center).angle(), + Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?, + Input::Enter => std::f64::consts::PI, + _ => return Ok(Step::Continue), + }; + // A true angle added to the start angle (AutoCAD: start 214.659°, included + // 232.716° ends the arc at 87.375° from the major axis). + let st = self.start.unwrap_or(0.0); + let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl)); + s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: st, + end, + }))?; + return Ok(Step::Done); + } let ang = match i { Input::Keyword(k) => { match k.as_str() { "Parameter" => self.param = true, "Angle" => self.param = false, + "Included angle" if self.start.is_some() && !self.param => self.included = true, _ => {} } return Ok(Step::Continue); diff --git a/crates/engine/src/cmd/draw/line_pline.rs b/crates/engine/src/cmd/draw/line_pline.rs index b7ad2455c..9f6df5fa6 100644 --- a/crates/engine/src/cmd/draw/line_pline.rs +++ b/crates/engine/src/cmd/draw/line_pline.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Input, Interactive, Outcome, PlineAsk, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, added, arc_to_bulge, bad, + bool_or, bulge_to_arc, distinct_points, f64_or, json, line, lwpoly, number, point_value, points_param, size_param, snap, +}; // ---------------- JSON forms ---------------- diff --git a/crates/engine/src/cmd/draw/point.rs b/crates/engine/src/cmd/draw/point.rs index 507b0b10d..17f486b76 100644 --- a/crates/engine/src/cmd/draw/point.rs +++ b/crates/engine/src/cmd/draw/point.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Input, Interactive, Point, PolyVertex, Prompt, RayLine, Result, Session, Step, Value, Vec2, added, bad, json, lwpoly, number, + point_param, point_req, points_param, size_param, v3, +}; pub(super) fn run_point(s: &mut Session, p: &Value) -> Result { let pts = match point_param(p, "at") { diff --git a/crates/engine/src/cmd/draw/polygon.rs b/crates/engine/src/cmd/draw/polygon.rs index d67aa62e6..d13618683 100644 --- a/crates/engine/src/cmd/draw/polygon.rs +++ b/crates/engine/src/cmd/draw/polygon.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, bool_or, lwpoly, number, point_req, points_param, + polygon_from_edge, polygon_typed_radius_point, polygon_vertices, size_param, +}; pub(super) fn run_polygon(s: &mut Session, p: &Value) -> Result { // A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4. diff --git a/crates/engine/src/cmd/draw/solid.rs b/crates/engine/src/cmd/draw/solid.rs new file mode 100644 index 000000000..835772a45 --- /dev/null +++ b/crates/engine/src/cmd/draw/solid.rs @@ -0,0 +1,79 @@ +//! SOLID: filled triangles and quadrilaterals (2D solids), chained as AutoCAD 2024 chains them. +//! +//! The prompts are "Specify first point", "Specify second point", "Specify third point" and +//! "Specify fourth point or "; Enter there makes a triangle (the fourth corner repeats the +//! third). Then "Specify third point" and "Specify fourth point or " come again for the next +//! solid, whose first two corners are the previous one's last two, until Enter at "Specify third +//! point". Enter there right after the second point makes nothing. + +use cadcraft_doc::Solid; + +use super::{Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, v3}; + +/// A 2D solid from its corners in AutoCAD's order (the fourth is drawn opposite the first). +fn solid(c: [Vec2; 4]) -> EntityKind { + EntityKind::Solid(Solid { corners: c.map(v3) }) +} + +/// `{points}`: the first solid takes three or four points, each further one two more, its first +/// two corners being the previous one's last two; a solid given three points is a triangle. +pub(super) fn run_solid(s: &mut Session, p: &Value) -> Result { + let pts = points_param(p, "points").ok_or_else(|| bad("solid", "`points` is required"))?; + let mut it = pts.into_iter(); + let (Some(mut a), Some(mut b), Some(_)) = (it.next(), it.next(), it.clone().next()) else { + return Err(bad("solid", "`points` needs at least three points")); + }; + let mut hs = Vec::new(); + while let Some(c) = it.next() { + let d = it.next().unwrap_or(c); + hs.push(s.add_entity(solid([a, b, c, d]))?.hex()); + (a, b) = (c, d); + } + Ok(json!({ "handles": hs })) +} + +#[derive(Default)] +pub(super) struct SolidM { + /// The corners of the solid being drawn (the first two from the previous one after it). + pts: Vec, +} + +impl Interactive for SolidM { + fn name(&self) -> &'static str { + "SOLID" + } + fn prompt(&self, _s: &Session) -> Prompt { + let last = self.pts.last().copied(); + match self.pts.len() { + 0 => Prompt::new("Specify first point", Accept::POINT), + 1 => Prompt::new("Specify second point", Accept::POINT).base_opt(last), + 2 => Prompt::new("Specify third point", Accept::POINT).base_opt(last), + _ => Prompt::new("Specify fourth point or", Accept::POINT).default("exit").base_opt(last), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (i, self.pts.as_slice()) { + (Input::Point(p), &[a, b, c]) => self.make(s, [a, b, c, p])?, + (Input::Point(p), _) => self.pts.push(p), + (Input::Enter, &[a, b, c]) => self.make(s, [a, b, c, c])?, + (Input::Enter, _) => return Ok(Step::Done), + _ => {} + } + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, cursor: Vec2) -> Vec { + match *self.pts.as_slice() { + [a, b] => vec![solid([a, b, cursor, cursor])], + [a, b, c] => vec![solid([a, b, c, cursor])], + _ => Vec::new(), + } + } +} + +impl SolidM { + fn make(&mut self, s: &mut Session, c: [Vec2; 4]) -> Result<()> { + s.add_entity(solid(c))?; + self.pts = vec![c[2], c[3]]; + Ok(()) + } +} diff --git a/crates/engine/src/cmd/draw/spline.rs b/crates/engine/src/cmd/draw/spline.rs index f10a0dd80..498f1a9f2 100644 --- a/crates/engine/src/cmd/draw/spline.rs +++ b/crates/engine/src/cmd/draw/spline.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Spline, Step, Value, Vec2, added, bad, bool_or, distinct_points, find_command, + points_param, +}; pub(super) fn run_spline(s: &mut Session, p: &Value) -> Result { if p.get("object").is_some() { diff --git a/crates/engine/src/cmd/draw/text.rs b/crates/engine/src/cmd/draw/text.rs index 2ca220a9b..21962c469 100644 --- a/crates/engine/src/cmd/draw/text.rs +++ b/crates/engine/src/cmd/draw/text.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, HAlign, Input, Interactive, MText, PolyVertex, Prompt, Result, Session, Step, Text, VAlign, Value, Vec2, added, bad, bool_or, + f64_or, lwpoly, number, point_req, str_param, v3, +}; /// Justification code → (halign, valign). pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> { diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index b37ebbc68..003edf2a8 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(TanCircleM::new(false)))), CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis) .menu(&["Draw", "Ellipse", "Axis, End"]) - .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)") + .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)") .interactive(|_| Ok(super::draw::ellipse_machine(false, false))), CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc) .menu(&["Draw", "Ellipse", "Arc"]) diff --git a/crates/engine/src/cmd/draw2/arc.rs b/crates/engine/src/cmd/draw2/arc.rs index f219348df..332f22653 100644 --- a/crates/engine/src/cmd/draw2/arc.rs +++ b/crates/engine/src/cmd/draw2/arc.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Arc, Circle, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, arc, bad, circle, curves, line, + norm_angle, number, other, point_param, point_req, point_value, +}; // ===================================================================================== // Generic point/value sequences (arc and circle variants) diff --git a/crates/engine/src/cmd/draw2/centermark.rs b/crates/engine/src/cmd/draw2/centermark.rs index 00edfbcc3..2a13a8e02 100644 --- a/crates/engine/src/cmd/draw2/centermark.rs +++ b/crates/engine/src/cmd/draw2/centermark.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, added, bad, curves, json, line, other, targets, +}; // ===================================================================================== // CENTERMARK / CENTERLINE diff --git a/crates/engine/src/cmd/draw2/curves3d.rs b/crates/engine/src/cmd/draw2/curves3d.rs index 70af51c53..005c49f07 100644 --- a/crates/engine/src/cmd/draw2/curves3d.rs +++ b/crates/engine/src/cmd/draw2/curves3d.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Helix, HelixConstrain, Input, Interactive, MAX_GEN, Polyline3d, Prompt, Result, Session, Spline, Step, Value, Vec2, + Vec3, added, bad, bool_or, circle, curves, f64_or, f64_req, line, number, other, point_req, points_param, v3, +}; // ===================================================================================== // 3DPOLY diff --git a/crates/engine/src/cmd/draw2/divide.rs b/crates/engine/src/cmd/draw2/divide.rs index 1ba624c15..4e92f561f 100644 --- a/crates/engine/src/cmd/draw2/divide.rs +++ b/crates/engine/src/cmd/draw2/divide.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Chain, EntityKind, Handle, Input, Interactive, MAX_GEN, Point, Prompt, Result, Session, Step, Value, Vec2, bad, bool_or, curves, f64_req, + json, number, other, point_param, str_param, targets, v3, +}; // ===================================================================================== // DIVIDE / MEASURE @@ -71,12 +74,43 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result { } let block = str_param(p, "block").map(str::to_string); check_block(s, block.as_deref())?; - if let Some(from) = point_param(p, "from") - && !c.closed - && let (Some(a), Some(b)) = (c.start(), c.end()) - && from.dist(b) < from.dist(a) - { - c = c.reversed(); + // AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index + // plus the fraction along it), not by distance: an open curve from its end once the pick is + // halfway or further, a closed one from its first vertex, backwards from halfway on. A + // circle always runs counterclockwise from angle 0. + if let Some(from) = point_param(p, "from") { + let past_half = match curves::entity(s, h)?.kind { + EntityKind::Circle(_) => false, + // At an arc's centre, where a bare name given from AutoLISP stands (its DXF group 10 + // point), every point of it is as near: AutoCAD measures from its end. + EntityKind::Arc(a) if from.dist(a.center.xy()) <= a.radius * 1e-9 => true, + // A spline by its own parameter: its tessellation drops zero-length pieces, so the + // segment count is not the spline's parameter range. + EntityKind::Spline(sp) => { + let (lo, hi) = sp.domain(); + curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0 + } + // Polylines by their vertices, coincident ones included (one parameter unit each, + // as in AutoCAD), which the chain drops. + EntityKind::LwPolyline(pl) => { + let n = pl.vertices.len(); + let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) }) + .filter_map(|i| Some((pl.vertices.get(i)?.p, pl.vertices.get((i + 1) % n)?.p, pl.vertices.get(i)?.bulge))) + .collect(); + curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0 + } + EntityKind::Polyline3d(pl) => { + let n = pl.points.len(); + let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) }) + .filter_map(|i| Some((pl.points.get(i)?.xy(), pl.points.get((i + 1) % n)?.xy(), 0.0))) + .collect(); + curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0 + } + _ => c.param_of(from) >= c.segs.len() as f64 / 2.0, + }; + if past_half { + c = c.reversed(); + } } let pts = measure_points(&c, step); let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; diff --git a/crates/engine/src/cmd/draw2/ellipse.rs b/crates/engine/src/cmd/draw2/ellipse.rs index ca3f9c6e2..430effa6b 100644 --- a/crates/engine/src/cmd/draw2/ellipse.rs +++ b/crates/engine/src/cmd/draw2/ellipse.rs @@ -1,10 +1,11 @@ -use super::*; +use super::{Ellipse, EntityKind, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3}; // ---------------- ellipse entries ---------------- fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option { let c = p1.mid(p2); - let m = p2 - c; + // The major axis points at the first endpoint, as AutoCAD stores it. + let m = p1 - c; let ml = m.len(); let half = half.abs(); if ml < 1e-12 || half < 1e-12 || !half.is_finite() { @@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result { let p1 = point_req(cmd, p, "p1")?; let p2 = point_req(cmd, p, "p2")?; let half = match point_param(p, "p3") { - Some(p3) => { - let ax = Line::new(p1, p2); - ax.project(p3).dist(p3) - } + // As at the prompt: the point's distance from the center, not from the first axis. + Some(p3) => p3.dist(p1.mid(p2)), None => f64_req(cmd, p, "distance")?, }; ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse")) diff --git a/crates/engine/src/cmd/draw2/rect.rs b/crates/engine/src/cmd/draw2/rect.rs index 84db5093a..4b19dc703 100644 --- a/crates/engine/src/cmd/draw2/rect.rs +++ b/crates/engine/src/cmd/draw2/rect.rs @@ -1,4 +1,7 @@ -use super::*; +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, +}; // ===================================================================================== // RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness diff --git a/crates/engine/src/cmd/draw2/revcloud.rs b/crates/engine/src/cmd/draw2/revcloud.rs index 83d8cbb75..634456b05 100644 --- a/crates/engine/src/cmd/draw2/revcloud.rs +++ b/crates/engine/src/cmd/draw2/revcloud.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Chain, Circle, Ellipse, EntityKind, Handle, Input, Interactive, Line, MAX_GEN, PolyVertex, Polyline, Prompt, Result, Session, Step, + Value, Vec2, added, bad, bool_or, curves, lwpoly, number, other, point_param, points_param, rect_vertices, shoelace, str_param, +}; // ===================================================================================== // REVCLOUD diff --git a/crates/engine/src/cmd/draw2/tancircle.rs b/crates/engine/src/cmd/draw2/tancircle.rs index 48268987a..0d4cd9ca7 100644 --- a/crates/engine/src/cmd/draw2/tancircle.rs +++ b/crates/engine/src/cmd/draw2/tancircle.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Circle, Handle, Input, Interactive, Prompt, Result, Session, Step, TanObj, Value, Vec2, added, bad, circle, curves, f64_req, number, + other, point_param, point_req, +}; pub(super) fn run_circle_variant(s: &mut Session, p: &Value) -> Result { let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) { diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs index a28d92b4d..8eedd3772 100644 --- a/crates/engine/src/cmd/draw2/tests.rs +++ b/crates/engine/src/cmd/draw2/tests.rs @@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() { } } +/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic +/// input in accoreconsole, read back with `entget`). The major axis points at the first axis +/// endpoint; a point at the distance prompt gives its distance from the center; an other axis +/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the +/// Included angle is added to the start angle. +#[test] +fn ellipse_axis_endpoints_match_autocad() { + type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>); + // (input after ELLIPSE, center, major axis, ratio, arc start/end parameters) + let cases: [Case; 10] = [ + // The other axis (27.02) is longer than the first half axis (26.42). + ( + "16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268", + [5.712514913615239, 3.985817249587547], + [-24.6850622596004, 10.99222700689644], + 0.9776866627982613, + None, + ), + ( + "33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949", + [17.27018583472502, -18.93707805189049], + [16.1799716942092, -9.060587202842658], + 0.8052876303433125, + None, + ), + ( + "0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256", + [0.5074363325764704, 0.2533737894427027], + [0.00868846668462886, 0.02043019027014691], + 0.1200276615332929, + None, + ), + // Arcs: the angles are taken from the major axis, so a reversed axis draws the arc + // on the opposite side of the center. + ( + "_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204", + [612359.8301610444, 2398774.152511537], + [-5.160876934416592, 22.44292051764205], + 0.1535548572193783, + Some([4.683678586016272, 2.014340486820176]), + ), + ( + "_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654", + [-8.068629780716847, 9.577702534161936], + [-24.42926629106581, 16.24859713260318], + 0.317871961092373, + Some([4.107654908771007, 5.952182383669234]), + ), + ( + "_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933", + [15.89513193856356, -16.13523652037361], + [2.53204749973717, -14.23341561039267], + 0.9785367111972643, + Some([3.756694588158375, 1.5259701966906878]), + ), + ( + "_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075", + [1.882264537326193, -7.920292708078534], + [19.15838502833398, 33.68853210335513], + 0.8017236937148501, + Some([2.267136066480607, 4.657903136501946]), + ), + // The other axis given by a point. + ( + "-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656", + [-15.97844303459893, 15.52013568457562], + [-0.3990375230131046, 39.92686201604709], + 0.7411292927991227, + None, + ), + ( + "8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266", + [2.843616795535609, 19.31530728820197], + [4.602217644463162, 7.450176331143755], + 0.7105482899194384, + None, + ), + ( + "_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446", + [15.41818958649589, -18.74113376930122], + [27.87548765369622, 24.07822440243505], + 0.3302549104883693, + None, + ), + ]; + use std::f64::consts::{PI, TAU}; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + let same_angle = |a: f64, b: f64| { + let d = (a - b).rem_euclid(TAU); + d < 1e-9 || TAU - d < 1e-9 + }; + for (input, center, major, ratio, arc) in cases { + let mut s = Session::new(); + s.cmdline(&format!("ellipse {input}")).unwrap(); + assert!(s.running.is_none(), "{input}: {}", s.prompt_text()); + let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") }; + assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center); + assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major); + assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio); + let [start, end] = arc.unwrap_or([0.0, TAU]); + assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end); + } + // Enter takes the default included angle, 180°: half the ellipse. + let mut s = Session::new(); + s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap(); + assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text()); + s.input(Input::Enter).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") }; + assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end); +} + // ---------------- DIVIDE / MEASURE ---------------- #[test] @@ -302,6 +414,279 @@ fn divide_and_measure_json() { assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err()); } +/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick +/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`, +/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not +/// by distance; a closed polyline runs from its first vertex, a circle from angle 0. +#[test] +fn divide_and_measure_match_autocad() { + type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]); + // ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points) + let cases: Vec = vec![ + // DIVIDE-001: (list ename point). + ( + ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})), + "divide", + 5.0, + [-2.090569265353071, 19.890437907365467], + 4, + &[ + [-19.021130325903, 6.180339887499], + [-17.975880925983, -8.767422935782], + [-9.389431255718, 17.658951857179], + [5.51274711634, 19.225233918766], + ], + ), + // DIVIDE-002: (list ename point). + ( + ("circle", json!({"center": [0.0, 0.0], "radius": 15.0})), + "divide", + 5.0, + [-4.63525491562421, 14.265847744427305], + 5, + &[ + [-12.135254915624, -8.816778784387], + [-12.135254915624, 8.816778784387], + [4.635254915624, -14.265847744427], + [4.635254915624, 14.265847744427], + [15.0, 0.0], + ], + ), + // MEASURE-016: (list ename point). + ( + ("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})), + "measure", + 7.688652713279647, + [25.9, 9.1], + 5, + &[ + [0.730313187001, 0.256596525162], + [7.984250549601, 2.80527722013], + [15.2381879122, 5.353957915097], + [22.4921252748, 7.902638610065], + [29.7460626374, 10.451319305032], + ], + ), + // MEASURE-024: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}), + ), + "measure", + 7.0, + [90.0, 0.0], + 29, + &[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]], + ), + // MEASURE-026: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}), + ), + "measure", + 7.0, + [10.0, 30.0], + 15, + &[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]], + ), + // MEASURE-030: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [5.0, 17.5], + 17, + &[ + [2.06040845923, 7.211429607306], + [4.120816918461, 14.422859214612], + [5.957390538273, 0.0], + [41.854257779673, 5.445746712402], + [45.137412221497, 19.497902817843], + [45.215233654661, 12.07010340037], + ], + ), + // DIVIDE-028: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "divide", + 5.0, + [37.5, 10.0], + 5, + &[ + [0.0, 0.0], + [7.332712970983, 25.664495398441], + [25.609294779385, 28.310302237406], + [26.691478107655, 0.0], + [45.58190593662, 14.465330916891], + ], + ), + // MEASURE-041: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [45.54951565955276, 17.15901034980981], + 17, + &[ + [1.636621047999, 5.728173667998], + [3.69702950723, 12.939603275304], + [5.75743796646, 20.15103288261], + [42.799868254703, 6.663750217618], + [44.060611317761, 20.402595149531], + [45.491536690495, 13.587129550966], + ], + ), + // MEASURE-042: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [44.0, 20.5], + 17, + &[ + [2.06040845923, 7.211429607306], + [4.120816918461, 14.422859214612], + [5.957390538273, 0.0], + [41.854257779673, 5.445746712402], + [45.137412221497, 19.497902817843], + [45.215233654661, 12.07010340037], + ], + ), + ]; + for ((seed, params), what, value, pick, count, want) in cases { + let mut s = Session::new(); + let r = s.execute(seed, ¶ms).unwrap(); + let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string(); + // AutoLISP's (list ename point): the object and where it was picked. + let params = if what == "divide" { + json!({"handle": handle, "segments": value as u64, "from": pick}) + } else { + json!({"handle": handle, "length": value, "from": pick}) + }; + s.execute(what, ¶ms).unwrap(); + let pts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect(); + assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}"); + for w in want { + let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0); + assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}"); + } + } +} + +/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a +/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and +/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length. +#[test] +fn measure_spline_by_its_own_parameter() { + let mut s = Session::new(); + let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap()); + s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap(); + let pts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect(); + assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}"); +} + +/// MEASURE counts every vertex in a polyline's parameter, coincident ones too (AutoCAD's polyline +/// parameter is the vertex index; the chain drops zero-length spans), and picks an ellipse's end +/// by the ellipse parameter like any other curve: AutoCAD 2024 runs both measured cases below from +/// the end (black-box, accoreconsole, POINTs read back with `entget`). +#[test] +fn measure_counts_coincident_vertices_and_runs_ellipses_by_parameter() { + let pts = |s: &Session| -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect() + }; + // (0,0) (1,0) (101,0) (101,0) picked at x = 1.1: parameter 1.001 of 3, before the middle, so + // from the start (the chain alone, 1.001 of 2, ran it from the end). + let mut s = Session::new(); + let pl = h(&s.execute("pline", &json!({"vertices": [[0, 0], [1, 0], [101, 0], [101, 0]]})).unwrap()); + s.execute("measure", &json!({"handle": pl, "length": 30, "from": [1.1, 0]})).unwrap(); + assert!(pts(&s).iter().any(|p| near(*p, 30.0, 0.0)), "{:?}", pts(&s)); + // (0,0) (0,0) (10,0) (20,0) picked at x = 7: parameter 1.7 of 3, past the middle, so from the + // end; the same as a 3D polyline. + for (cmd, params) in [ + ("pline", json!({"vertices": [[0, 0], [0, 0], [10, 0], [20, 0]]})), + ("3dpoly", json!({"points": [[0, 0, 0], [0, 0, 0], [10, 0, 0], [20, 0, 0]]})), + ] { + let mut s = Session::new(); + let pl = h(&s.execute(cmd, ¶ms).unwrap()); + s.execute("measure", &json!({"handle": pl, "length": 3, "from": [7, 0]})).unwrap(); + assert!(pts(&s).iter().any(|p| near(*p, 17.0, 0.0)), "{cmd}: {:?}", pts(&s)); + } + // AutoCAD's points. CADCraft measures along a tessellation (1e-4 of the major axis), so its + // points sit within 2e-4 of the axis of AutoCAD's; the other end is 4 to 200 units away. + type Ellipse = ([f64; 2], [f64; 2], f64, f64, f64); + let cases: [((Ellipse, f64, [f64; 2]), &[[f64; 2]]); 2] = [ + // MEASURE-032-TO: a full ellipse picked at t = pi. + ( + (([-35000.0, 81000.0], [1000.0, 250.0], 0.5, 0.0, std::f64::consts::TAU), 325.0, [-36000.0, 80750.0]), + &[ + [-36005.13895764338, 80852.59066530415], + [-35871.99367437639, 80574.74515309861], + [-35846.48143804769, 81129.79467385833], + [-35585.0606965763, 81321.20806711384], + [-35570.69066464184, 80460.16118260568], + [-35288.01208649818, 81451.990340526], + [-35246.80459288685, 80443.34080344986], + [-34973.3365764063, 81531.8855660685], + [-34925.28109250207, 80488.09750605426], + [-34649.89308676404, 81558.94977503068], + [-34615.08169799787, 80583.88008545575], + [-34328.55263503237, 81517.06267451325], + [-34326.49236487653, 80732.20222770158], + [-34085.24055588816, 80947.67206458953], + [-34052.78599900089, 81354.8654746607], + ], + ), + // MEASURE-033: an elliptical arc picked past its middle. + ( + (([0.0, 0.0], [20.0, 5.0], 0.5, 0.5, 4.0), 4.25, [-14.28970951093154, -10.073417468999274]), + &[ + [-20.14815072114992, -3.432193431111516], + [-18.74987859459168, -7.329883069345779], + [-18.68559214389327, 0.5029121733397516], + [-15.81338990685036, 3.617741906558092], + [-15.26656288688172, -9.70206428611582], + [-12.33992351852055, 6.057945492888583], + [-8.547503008323163, 7.9699830580747], + [-4.557798848614094, 9.428419587410612], + [-0.4368018715850841, 10.45983604769949], + [3.769519107506058, 11.05284388252232], + [8.015496424961295, 11.15319778065308], + [12.22912105170455, 10.6383262710487], + [16.23125506912425, 9.247174221779819], + ], + ), + ]; + for (((center, major, ratio, t0, t1), length, from), want) in cases { + let mut s = Session::new(); + let e = h(&s + .execute("ellipse", &json!({"center": center, "major": major, "ratio": ratio, "start": t0.to_degrees(), "end": t1.to_degrees()})) + .unwrap()); + s.execute("measure", &json!({"handle": e, "length": length, "from": from})).unwrap(); + let got = pts(&s); + let tol = 2e-4 * major[0].hypot(major[1]); + assert_eq!(got.len(), want.len(), "{got:?}"); + for w in want { + assert!(got.iter().any(|p| p.dist(Vec2::new(w[0], w[1])) < tol), "no point near {w:?} in {got:?}"); + } + } +} + #[test] fn divide_and_measure_command_line() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/draw2/wipeout.rs b/crates/engine/src/cmd/draw2/wipeout.rs index 212b645f3..e5ec3387f 100644 --- a/crates/engine/src/cmd/draw2/wipeout.rs +++ b/crates/engine/src/cmd/draw2/wipeout.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Input, Interactive, MAX_GEN, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, Wipeout, added, bad, bool_or, + curves, json, lwpoly, other, points_param, shoelace, str_param, +}; // ===================================================================================== // WIPEOUT diff --git a/crates/engine/src/cmd/draw2/xline.rs b/crates/engine/src/cmd/draw2/xline.rs index 1e4b6cdae..31f152d16 100644 --- a/crates/engine/src/cmd/draw2/xline.rs +++ b/crates/engine/src/cmd/draw2/xline.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Input, Interactive, Polyline, Prompt, RayLine, Result, Session, Step, Value, Vec2, added, bad, bool_or, curves, + f64_req, number, other, point_param, point_req, v3, +}; // ===================================================================================== // XLINE with Hor/Ver/Ang/Bisect/Offset diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index f9acc909e..e28de89fc 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -754,19 +754,20 @@ mod tests { s.cmdline("").unwrap(); s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap(); s.cmdline("").unwrap(); - let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() })); + // `a^b.pdf` names one book per row, both in the folder of the path. + let dir = crate::temp_dir::TempDir::new("pdfbook"); + let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") })); let r = r.unwrap(); let books = r["books"].as_array().unwrap(); assert_eq!(books.len(), 2); assert_eq!(books[0]["pages"], 3); assert_eq!(books[1]["pages"], 1); - let first = temp("a.pdf"); + let first = dir.join("a.pdf"); let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap(); let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap()); assert!(a4 < a3, "frames go left to right: {text}"); assert!(text.contains("\"landscape\":true")); - let _ = std::fs::remove_file(first); - let _ = std::fs::remove_file(temp("b.pdf")); + assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first"); // No frames: an error, not a panic. assert!(Session::new().execute("pdfbook", &json!({})).is_err()); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 95975a4e2..9aaff4631 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -7,7 +7,7 @@ mod template; use std::sync::OnceLock; -use cadcraft_doc::Drawing; +use cadcraft_doc::{Drawing, Format}; pub use cadcraft_dxf::Version; use serde_json::{Value, json}; @@ -136,8 +136,7 @@ fn save_options(cmd: &str, p: &Value) -> Result { /// Whether `path` names a DXF file (or has no extension, which saves as DXF). fn is_dxf(path: &str) -> bool { - let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - matches!(ext.as_str(), "dxf" | "") + Format::from_path(path) == Some(Format::Dxf) } /// The drawing written to `path` with `opts`: through [`IoHooks::write`] when no option is set, @@ -273,17 +272,21 @@ pub struct ReadFile { } /// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere. -pub fn read_file(path: &str) -> std::result::Result { +pub fn read_file(path: &std::path::Path) -> std::result::Result { #[cfg(not(target_arch = "wasm32"))] { let r = std::panic::catch_unwind(|| { - let bytes = - crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?; - read_bytes(&bytes, path, Some(path.to_string())) + let shown = path.display(); + // The session keeps paths as text; a name that isn't Unicode could never be saved back to. + let Some(text_path) = path.to_str() else { + return Err(bad("open", format!("{shown}: the file name is not valid Unicode, so CADCraft can't save back to it"))); + }; + let bytes = crate::capped_read::read_capped(path, MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{shown}: {e}")))?; + read_bytes(&bytes, text_path, Some(text_path.to_string())) }); match r { Ok(r) => r.map_err(|e| e.to_string()), - Err(_) => Err(bad("open", format!("{path}: the file could not be read")).to_string()), + Err(_) => Err(bad("open", format!("{}: the file could not be read", path.display())).to_string()), } } #[cfg(target_arch = "wasm32")] @@ -417,11 +420,20 @@ 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"))] { + if is_drawing_file(path) && crate::autosave::isavebak() { + backup(std::path::Path::new(path)) + .map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?; + } // Exclusively create a fresh temporary file and rename it over `path`, so a planted // `.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}"))) @@ -434,6 +446,180 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> { } } +/// The backup of the drawing file `path`: `.bak` beside it, as AutoCAD names it. +pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf { + path.with_extension("bak") +} + +/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as +/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a +/// good `.bak`. +pub fn looks_like_drawing(bytes: &[u8]) -> bool { + // DWG: a version tag like `AC1032` (R12 is `AC1009`). + let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit()); + if dwg || cadcraft_dxf::is_binary(bytes) { + return true; + } + // ASCII DXF: optional comments (999), then group 0 SECTION. + let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]); + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + loop { + match (lines.next(), lines.next()) { + (Some("999"), Some(_)) => continue, + (Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"), + _ => return false, + } + } +} + +/// How much of a drawing's end [`looks_complete`] reads. +#[cfg(not(target_arch = "wasm32"))] +const TAIL_BYTES: u64 = 256; + +/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them +/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it +/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its +/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut +/// off by a failed save or another program doesn't replace a good backup. +pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option { + 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 @@ -515,7 +701,7 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result { let lost = not_saved_as(s.doc()?, version); let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?; let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }); - if drops_unknown(&format!("drawing.{fmt}")) { + if drops_unknown(format!("drawing.{fmt}")) { report_not_saved(s, &mut r, &lost); } Ok(r) @@ -561,17 +747,26 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Op Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", "))) } +/// What saving the active drawing to `path` would leave out, before anything is written: empty +/// when the format keeps everything. The interactive save asks with this; [`save_reporting`] +/// reports the same list afterwards to callers that never get a dialog. +pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap { + if !drops_unknown(path) { + return Default::default(); + } + let version = drawing_version(s, path); + s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default() +} + /// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG /// bridge drops types it doesn't know); DXF writes them back as read. -pub fn drops_unknown(path: &str) -> bool { - // Templates (.dwt) are written as DWG too. - std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("dwg") || e.eq_ignore_ascii_case("dwt")) +pub fn drops_unknown(path: impl AsRef) -> bool { + Format::from_path(path).is_some_and(Format::drops_unknown) } /// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image. -fn is_drawing_file(path: &str) -> bool { - let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - matches!(ext.as_str(), "dxf" | "dwg" | "dwt" | "") +fn is_drawing_file(path: impl AsRef) -> bool { + Format::from_path(path).is_some_and(Format::is_drawing) } /// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`. @@ -651,20 +846,16 @@ mod tests { use serde_json::json; use crate::Session; + use crate::temp_dir::TempDir; fn active_title(s: &Session) -> String { s.state().unwrap().title.clone() } - fn temp_script(name: &str, text: &str) -> String { - let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id())); - std::fs::write(&path, text).unwrap(); - path.to_string_lossy().into_owned() - } - #[test] fn script_command_runs_a_scr_file() { - let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n"); + let dir = TempDir::new("script"); + let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n"); let mut s = Session::new(); let r = s.execute("script", &json!({ "path": path })).unwrap(); assert_eq!(r["lines"], 3); @@ -682,8 +873,9 @@ mod tests { /// A script that runs itself must be refused, not recurse until the stack dies. #[test] fn a_script_cannot_start_a_script() { - let inner = temp_script("inner.scr", "circle 0,0 1\n"); - let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner }))); + let dir = TempDir::new("script"); + let inner = dir.file("inner.scr", "circle 0,0 1\n"); + let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner }))); let mut s = Session::new(); let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string(); assert!(e.contains("already running"), "{e}"); @@ -691,7 +883,8 @@ mod tests { #[test] fn typed_script_prompts_for_the_file_then_runs_or_reports_it() { - let good = temp_script("typed.scr", "circle 0,0 1\n"); + let dir = TempDir::new("script"); + let good = dir.file("typed.scr", "circle 0,0 1\n"); let mut s = Session::new(); s.cmdline("script").unwrap(); assert!(s.running.is_some(), "SCRIPT asks for the file name"); diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index d0221a00a..ca7defed8 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -10,7 +10,7 @@ use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon}; use serde_json::{Value, json}; use super::helpers::lwpoly; -use super::*; +use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_param, str_param, targets}; use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; @@ -550,6 +550,9 @@ struct HatchM { /// Loops by pick (one hatch each when separate). loops: Vec>, asking: Option, + /// The pattern was asked by the Pattern option: its scale and angle follow, as at AutoCAD's + /// -HATCH Properties. + properties: bool, /// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again. selecting: bool, } @@ -561,6 +564,7 @@ impl HatchM { settings: HatchSettings::from(s, &Value::Null, gradient), loops: Vec::new(), asking: None, + properties: false, selecting: s.last_used.hatch_select, } } @@ -582,11 +586,13 @@ impl HatchM { h.set_i64("HPORIGINMODE", mode.index()); Ok(()) } - fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> { + /// Take an answer to sub-prompt `a`; false when it was refused. + fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result { match a { HatchAsk::Pattern => { let up = match patfile::typed_file(s, t.trim()) { - Some(n) if n.is_empty() => return Ok(()), + // Nothing loaded from it: the pattern is asked again. + Some(n) if n.is_empty() => return Ok(false), Some(n) => n, None => t.trim().to_ascii_uppercase(), }; @@ -595,20 +601,19 @@ impl HatchM { s.doc_mut()?.header.set_str("HPNAME", &up); } else { s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", "))); + return Ok(false); } } HatchAsk::Scale => { - if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) { - self.settings.scale = v; - s.doc_mut()?.header.set_f64("HPSCALE", v); - } + let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) else { return Ok(false) }; + self.settings.scale = v; + s.doc_mut()?.header.set_f64("HPSCALE", v); } HatchAsk::Angle => { // Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1. - if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) { - self.settings.angle = a; - s.doc_mut()?.header.set_f64("HPANG", a); - } + let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) else { return Ok(false) }; + self.settings.angle = a; + s.doc_mut()?.header.set_f64("HPANG", a); } HatchAsk::Origin => { let mode = match t { @@ -622,7 +627,22 @@ impl HatchM { self.set_origin(s, None, mode)?; } } - Ok(()) + Ok(true) + } + + /// The sub-prompt that follows `a` (`taken`: its answer was taken). The Pattern option goes + /// on, as AutoCAD 2024's -HATCH Properties does, from the pattern name to its scale and then + /// its angle (a solid fill has neither), and asks again for a pattern or a value it refused. + fn after(&self, a: HatchAsk, taken: bool) -> Option { + if !self.properties { + return None; + } + match a { + _ if !taken => Some(a), + HatchAsk::Pattern if self.settings.pattern != "SOLID" => Some(HatchAsk::Scale), + HatchAsk::Scale => Some(HatchAsk::Angle), + _ => None, + } } } @@ -643,9 +663,10 @@ impl Interactive for HatchM { // The sub-prompts offer the current value: Enter keeps it. match self.asking { Some(HatchAsk::Pattern) => { - return Prompt::new("Enter a pattern name", Accept::TEXT) + let p = Prompt::new("Enter a pattern name", Accept::TEXT) .default(self.settings.pattern.clone()) .picker(Picker::HatchPattern(self.settings.pattern.clone())); + return if self.gradient { p } else { p.kw(&["Solid"]) }; } Some(HatchAsk::Scale) => { return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale)); @@ -686,11 +707,19 @@ impl Interactive for HatchM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(a) = self.asking.take() { - match &i { - Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?, + let taken = match &i { + Input::Point(p) if a == HatchAsk::Origin => { + self.set_origin(s, Some(*p), OriginMode::Current)?; + true + } + Input::Keyword(k) if a == HatchAsk::Pattern && k == "Solid" => self.answer(s, a, "SOLID")?, Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?, - _ => {} - } + // Enter keeps the current value; a point or a pick answers none of these prompts. + Input::Enter => true, + _ => false, + }; + self.asking = self.after(a, taken); + self.properties &= self.asking.is_some(); return Ok(Step::Continue); } match i { @@ -723,7 +752,10 @@ impl Interactive for HatchM { "Select objects" => self.set_mode(s, true), "picK internal point" => self.set_mode(s, false), "Undo" => self.loops.clear(), - "Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern), + "Pattern" | "seTtings" => { + self.asking = Some(HatchAsk::Pattern); + self.properties = !self.gradient; + } "sCale" => self.asking = Some(HatchAsk::Scale), "Angle" => self.asking = Some(HatchAsk::Angle), "Origin" => self.asking = Some(HatchAsk::Origin), @@ -738,7 +770,7 @@ impl Interactive for HatchM { } // A pattern chosen in the picker while boundaries are picked. Input::Text(t) if !self.gradient => { - self.answer(s, HatchAsk::Pattern, &t)?; + let _ = self.answer(s, HatchAsk::Pattern, &t)?; Ok(Step::Continue) } // Enter ends boundary picking in either mode and creates the hatch. @@ -838,6 +870,9 @@ mod tests { s.input(Input::Keyword("Pattern".into())).unwrap(); assert!(s.current_prompt().unwrap().picker.is_some()); s.input(Input::Text("BRICK".into())).unwrap(); + // Its scale and angle follow; Enter keeps them. + s.input(Input::Enter).unwrap(); + s.input(Input::Enter).unwrap(); s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap(); s.input(Input::Enter).unwrap(); assert_eq!(only_hatch(&s).1.pattern, "BRICK"); @@ -851,6 +886,53 @@ mod tests { assert!(s.current_prompt().unwrap().picker.is_none()); } + /// -HATCH's Properties as AutoCAD 2024 takes them from AutoLISP's `command` (accoreconsole, the + /// hatch read back with `entget`): a pattern name, then its scale and angle (Enter keeps them); + /// Solid (`S`, `_S` or SOLID) asks neither; an unknown name is asked again. + #[test] + fn hatch_properties_ask_pattern_scale_and_angle_as_autocad() { + // (properties given, pattern, solid, scale, angle in degrees) + let cases: [(&str, &str, bool, f64, f64); 7] = [ + ("\"ANSI31\" 2 45 \"_S\" (entlast) \"\"", "ANSI31", false, 2.0, 45.0), + ("\"_S\" \"_S\" (entlast) \"\"", "SOLID", true, 1.0, 0.0), + ("\"ANSI37\" 0.5 30 '(5.0 5.0)", "ANSI37", false, 0.5, 30.0), + ("\"ANSI31\" \"\" \"\" '(5.0 5.0)", "ANSI31", false, 1.0, 0.0), + ("\"S\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0), + ("\"SOLID\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0), + ("\"XYZ\" \"ANSI31\" 1 0 '(5.0 5.0)", "ANSI31", false, 1.0, 0.0), + ]; + for (props, pattern, solid, scale, angle) in cases { + let mut s = Session::new(); + s.execute("circle", &json!({ "center": [20, 15], "radius": 8 })).unwrap(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap(); + s.cmdline("(setvar \"HPNAME\" \"ANSI31\")").unwrap(); + s.cmdline("(setvar \"HPSCALE\" 1.0)").unwrap(); + s.cmdline("(setvar \"HPANG\" 0.0)").unwrap(); + s.cmdline(&format!("(command \"_.-HATCH\" \"_P\" {props} \"\")")).unwrap(); + assert!(s.running.is_none(), "{props}: {}", s.prompt_text()); + let h = only_hatch(&s).1; + assert!( + h.pattern == pattern && h.solid == solid && (h.scale - scale).abs() < 1e-12 && (h.angle - angle.to_radians()).abs() < 1e-12, + "{props}: {} {} {} {}", + h.pattern, + h.solid, + h.scale, + h.angle.to_degrees() + ); + } + // An object (an entity name from LISP) answers none of these prompts: each is asked again. + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap(); + let h = s.doc().unwrap().model.handles()[0]; + s.cmdline("(command \"_.-HATCH\" \"_P\")").unwrap(); + s.input(crate::Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("Enter a pattern name"), "{}", s.prompt_text()); + s.cmdline("ANSI31").unwrap(); + s.input(crate::Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("Specify a scale for the hatch pattern"), "{}", s.prompt_text()); + s.cancel(); + } + #[test] fn unknown_pattern_error_and_catalog_name_the_pat_file_route() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 4d82d5c5c..0fa342e23 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -11,6 +11,7 @@ use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_p use crate::units::{format_angle, format_distance}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +mod arcs; mod loops; mod regions; mod shapes; @@ -418,6 +419,9 @@ fn run_area(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?; object_area(s, h)? }; + if let Some(what) = too_large(a, per) { + return Err(bad("area", format!("the {what} is too large"))); + } Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) })) } @@ -456,6 +460,17 @@ fn fmt_volume(s: &Session, v: f64) -> String { format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p) } +/// "area" or "perimeter" when that value is past f64's range, `None` when both can be reported. +fn too_large(a: f64, per: f64) -> Option<&'static str> { + if !a.is_finite() { + Some("area") + } else if !per.is_finite() { + Some("perimeter") + } else { + None + } +} + /// Area and perimeter of a closed object (AREA's Object option). pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> { let d = s.doc()?; @@ -694,10 +709,16 @@ pub(super) struct DistM { sources: Vec>, /// Asking for the length of a segment (the Length option). length: bool, + /// The bulge of the segment that starts at each point (the Arc option). + bulges: Vec, + arcs: arcs::Arcs, } -/// Total length of the segments through `pts`. -fn path_len(pts: &[Vec2]) -> f64 { +/// Total length of the segments through `pts`, each with its bulge in `bulges`. +fn path_len(pts: &[Vec2], bulges: &[f64]) -> f64 { + if arcs::has_arcs(bulges) { + return arcs::polyline(pts, bulges, false).len(); + } pts.windows(2) .map(|w| match w { [a, b] => a.dist(*b), @@ -707,15 +728,11 @@ fn path_len(pts: &[Vec2]) -> f64 { } /// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single -/// point): the Length option of AREA and DIST's point sequences. -fn extend_last(pts: &[Vec2], len: f64) -> Option { +/// point, along the tangent after an arc segment): the Length option of AREA and DIST's point +/// sequences. +fn extend_last(pts: &[Vec2], bulges: &[f64], len: f64) -> Option { let last = *pts.last()?; - let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) { - Some(prev) => (last - *prev).normalized(), - None => Vec2::X, - }; - let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; - let p = last + dir * len; + let p = last + arcs::tangent(pts, bulges) * len; p.is_finite().then_some(p) } @@ -724,13 +741,21 @@ fn length_prompt() -> Prompt { } /// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter). -fn length_input(pts: &[Vec2], i: Input) -> Result> { +fn length_input(pts: &[Vec2], bulges: &[f64], i: Input) -> Result> { let t = match i { Input::Text(t) => t, _ => return Ok(None), }; let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - Ok(extend_last(pts, len)) + Ok(extend_last(pts, bulges, len)) +} + +/// Where the paper point `p`, computed near `last` (an arc option's end point), lies in the model +/// space of the viewport `last` was picked through (the last of `sources`). +fn follow_source(sources: &[Option], last: Vec2, p: Vec2) -> Option { + let source = sources.last().copied().flatten()?; + let point = source.point + (p - last) / source.scale; + (source.scale.abs() > 1e-12 && point.is_finite()).then_some(crate::snap::ViewportPoint { point, ..source }) } /// Model units apply only when every point was snapped through one and the same viewport. @@ -743,10 +768,13 @@ impl Interactive for DistM { fn name(&self) -> &'static str { "DIST" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if self.length { return length_prompt(); } + if let Some(p) = self.multi.as_deref().and_then(|pts| self.arcs.prompt(s, pts, &self.bulges, "")) { + return p; + } match (&self.multi, self.first) { (Some(pts), _) if pts.len() < 2 => { Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied()) @@ -763,7 +791,7 @@ impl Interactive for DistM { if let Some(pts) = &mut self.multi { if self.length { let model = model_points(&self.sources); - if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? { + if let Some(p) = length_input(model.as_deref().unwrap_or(pts), &self.bulges, i)? { let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten()); let paper = match (source, pts.last()) { (Some(source), Some(last)) => *last + (p - source.point) * source.scale, @@ -772,31 +800,60 @@ impl Interactive for DistM { if !paper.is_finite() { return Err(bad("dist", "the next point is too large")); } - pts.push(paper); + arcs::push(pts, &mut self.bulges, paper, 0.0); self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source })); let model = model_points(&self.sources); - s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts))))); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); } self.length = false; return Ok(Step::Continue); } + if self.arcs.asking() { + let last = pts.last().copied(); + // A picked end point keeps its own viewport; an end computed from the answers follows the + // last point's. In model units, a typed radius or chord length is in model units too. + let picked_end = match i { + Input::Point(p) if self.arcs.asks_end_point() => Some(p), + _ => None, + }; + let i = match model_points(&self.sources).and(self.sources.last().copied().flatten()) { + Some(source) => self.arcs.scale_typed_size(i, source.scale), + None => i, + }; + if self.arcs.answer(s, i, pts, &mut self.bulges)? { + // The arc can end off the picked point (CEnter projects it onto the circle). + let source = match (picked_end, last, pts.last()) { + (Some(p), _, Some(end)) => follow_source(&[s.picked_viewport], p, *end), + (None, Some(last), Some(end)) => follow_source(&self.sources, last, *end), + _ => None, + }; + self.sources.push(source); + let model = model_points(&self.sources); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); + } + return Ok(Step::Continue); + } match i { Input::Point(p) => { - pts.push(p); + self.arcs.point(pts, &mut self.bulges, p)?; self.sources.push(s.picked_viewport); let model = model_points(&self.sources); - s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts))))); + s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges)))); } - Input::Keyword(k) if k == "Undo" => { - if pts.len() > 1 { - pts.pop(); - self.sources.pop(); - } + Input::Keyword(k) if k == "Undo" && pts.len() > 1 => { + self.arcs.undo(pts, &mut self.bulges); + self.sources.pop(); } Input::Keyword(k) if k == "Length" => self.length = true, - Input::Keyword(k) if k == "Total" => return self.total(s), - Input::Keyword(k) => s.echo(format!("{k}: not available yet")), - Input::Enter => return self.total(s), + Input::Keyword(k) if k == "Total" => return self.total(s, false), + Input::Keyword(k) if k == "CLose" && self.arcs.on => { + self.arcs.close(pts, &mut self.bulges)?; + return self.total(s, true); + } + Input::Keyword(k) if !self.arcs.keyword(&k, pts) => { + s.echo(format!("{k}: not available yet")); + } + Input::Enter => return self.total(s, false), _ => {} } return Ok(Step::Continue); @@ -830,20 +887,20 @@ impl Interactive for DistM { } fn preview(&self, _s: &Session, c: Vec2) -> Vec { match &self.multi { - Some(pts) => { - let mut v: Vec = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); - v.push(cadcraft_geom::PolyVertex::new(c)); - vec![super::helpers::lwpoly(v, false)] - } + Some(pts) => self.arcs.preview(pts, &self.bulges, c, false), None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(), } } } impl DistM { - fn total(&self, s: &mut Session) -> Result { + /// Report the total distance and end; `closed` adds the closing segment (CLose). + fn total(&self, s: &mut Session, closed: bool) -> Result { let model = model_points(&self.sources); - let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len); + let d = model + .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) }); s.echo(format!("Distance = {}", fmt_d(s, d))); Ok(Step::Done) } @@ -936,6 +993,9 @@ pub(super) struct AreaM { pending: Vec, /// The first point of a height given by two points. height_from: Option, + /// The bulge of the segment that starts at each point (the Arc option). + bulges: Vec, + arcs: arcs::Arcs, } impl AreaM { @@ -1039,27 +1099,45 @@ impl AreaM { } /// Report an area (and the running total in add/subtract mode); ends the command in single mode. /// In volume mode the area waits for its height instead. + /// An area, perimeter or total past f64's range is not reported or added to the total. fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step { if self.volume { + if !a.is_finite() { + s.echo("The area is too large."); + return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; + } self.pending.push(a); return Step::Continue; } - s.echo(area_text(s, a, per)); - match self.mode { - AreaMode::Single => return Step::Done, - AreaMode::Add => self.total += a, - AreaMode::Subtract => self.total -= a, + let total = self.total_with(a); + if let Some(what) = too_large(a, per).or_else(|| (!total.is_finite()).then_some("total area")) { + s.echo(format!("The {what} is too large.")); + return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; } + s.echo(area_text(s, a, per)); + if self.mode == AreaMode::Single { + return Step::Done; + } + self.total = total; s.echo(format!("Total area = {}", fmt_d(s, self.total))); Step::Continue } /// Close the boundary picked so far (Enter or Total at the next-point prompt). + /// An arc segment (the Arc option) can close an area of two points. fn close(&mut self, s: &mut Session) -> Step { let pts = std::mem::take(&mut self.pts); - if pts.len() < 3 { + let bulges = std::mem::take(&mut self.bulges); + self.arcs = arcs::Arcs::default(); + let curved = arcs::has_arcs(&bulges); + if pts.len() < if curved { 2 } else { 3 } { return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; } - let (a, per) = area_of(&pts); + let (a, per) = if curved { + let g = arcs::polyline(&pts, &bulges, true); + (g.area().abs(), g.len()) + } else { + area_of(&pts) + }; self.measured(s, a, per) } } @@ -1068,7 +1146,7 @@ impl Interactive for AreaM { fn name(&self) -> &'static str { if self.volume { "MEASUREGEOM" } else { "AREA" } } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if !self.pending.is_empty() { return match self.height_from { Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p), @@ -1081,6 +1159,9 @@ impl Interactive for AreaM { if self.length { return length_prompt(); } + if let Some(p) = self.arcs.prompt(s, &self.pts, &self.bulges, self.mode_prefix()) { + return p; + } let next = format!("{}Specify next point", self.mode_prefix()); let (add, subtract) = self.mode_keywords(); match (self.pts.len(), self.mode) { @@ -1099,24 +1180,34 @@ impl Interactive for AreaM { return Ok(self.object_input(s, i)); } if self.length { - if let Some(p) = length_input(&self.pts, i)? { - self.pts.push(p); + if let Some(p) = length_input(&self.pts, &self.bulges, i)? { + arcs::push(&mut self.pts, &mut self.bulges, p, 0.0); } self.length = false; return Ok(Step::Continue); } + if self.arcs.asking() { + self.arcs.answer(s, i, &mut self.pts, &mut self.bulges)?; + return Ok(Step::Continue); + } match i { - Input::Point(p) => self.pts.push(p), + Input::Point(p) => self.arcs.point(&mut self.pts, &mut self.bulges, p)?, Input::Keyword(k) => match k.as_str() { "Object" => self.object = true, "Add area" | "Add volume" => self.mode = AreaMode::Add, "Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract, - "Undo" => { - self.pts.pop(); - } + "Undo" => self.arcs.undo(&mut self.pts, &mut self.bulges), "Length" => self.length = true, "Total" => return Ok(self.close(s)), - _ => s.echo(format!("{k}: not available yet")), + "CLose" if self.arcs.on => { + self.arcs.close(&self.pts, &mut self.bulges)?; + return Ok(self.close(s)); + } + k => { + if !self.arcs.keyword(k, &self.pts) { + s.echo(format!("{k}: not available yet")); + } + } }, Input::Enter if self.pts.is_empty() => match self.mode { // Enter takes the default, Object; in add/subtract mode it ends the command. @@ -1132,9 +1223,7 @@ impl Interactive for AreaM { if self.pts.is_empty() || self.object || self.length { return Vec::new(); } - let mut v: Vec = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); - v.push(cadcraft_geom::PolyVertex::new(c)); - vec![super::helpers::lwpoly(v, true)] + self.arcs.preview(&self.pts, &self.bulges, c, true) } } diff --git a/crates/engine/src/cmd/inquiry/arcs.rs b/crates/engine/src/cmd/inquiry/arcs.rs new file mode 100644 index 000000000..416e9f941 --- /dev/null +++ b/crates/engine/src/cmd/inquiry/arcs.rs @@ -0,0 +1,195 @@ +//! The Arc option of AREA's and DIST's point sequences (and of MEASUREGEOM's Distance, Area and +//! Volume): arc segments, asked for as PLINE's arc mode asks for them. Each point carries the +//! bulge of the segment that starts at it; the last point's bulge is the closing segment's, +//! which only CLose sets. + +use cadcraft_doc::EntityKind; +use cadcraft_geom::{PolyVertex, Polyline, Vec2, arc_to_bulge, bulge_to_arc}; + +use super::super::helpers::lwpoly; +use super::super::machines::number; +use super::super::pline_opts::{Outcome, PlineAsk}; + +use crate::{Accept, EngineError, Input, Prompt, Result, Session}; + +/// Arc mode's keywords: PLINE's arc mode without Halfwidth and Width. +const KEYWORDS: [&str; 8] = ["Angle", "CEnter", "CLose", "Direction", "Line", "Radius", "Second pt", "Undo"]; + +/// Arc mode of a point sequence. +#[derive(Default)] +pub(super) struct Arcs { + /// The Arc option turned arc mode on; Line turns it off. + pub(super) on: bool, + /// An arc option waiting for its answers (Angle, CEnter, Direction, Radius, Second pt). + ask: Option, +} + +/// The polyline through `pts`, each segment with its bulge in `bulges` (straight when missing). +pub(super) fn polyline(pts: &[Vec2], bulges: &[f64], closed: bool) -> Polyline { + let vertices = pts.iter().enumerate().map(|(i, p)| PolyVertex::with_bulge(*p, bulges.get(i).copied().unwrap_or(0.0))).collect(); + Polyline { vertices, closed } +} + +/// Whether any segment of the sequence is an arc. +pub(super) fn has_arcs(bulges: &[f64]) -> bool { + bulges.iter().any(|b| *b != 0.0) +} + +/// The direction the sequence runs in at its last point (+X before the second point): along the +/// last line segment, or along the tangent at the end of the last arc segment. +pub(super) fn tangent(pts: &[Vec2], bulges: &[f64]) -> Vec2 { + let Some(i) = pts.len().checked_sub(2) else { return Vec2::X }; + let (Some(prev), Some(last)) = (pts.get(i).copied(), pts.last().copied()) else { return Vec2::X }; + let d = match bulge_to_arc(prev, last, bulges.get(i).copied().unwrap_or(0.0)) { + Some((a, ccw)) => { + let r = (last - a.center).perp().normalized(); + if ccw { r } else { -r } + } + None => (last - prev).normalized(), + }; + if d == Vec2::ZERO { Vec2::X } else { d } +} + +/// The bulge of an arc from the last point to `p` that continues the sequence's direction; an +/// error when `p` lies straight behind the last point, where no such arc exists. +fn tangent_bulge(pts: &[Vec2], bulges: &[f64], p: Vec2) -> Result { + let Some(last) = pts.last().copied() else { return Ok(0.0) }; + let dir = tangent(pts, bulges); + let chord = p - last; + // The included angle is twice the angle between the tangent and the chord. + let theta = dir.cross(chord).atan2(dir.dot(chord)); + let b = arc_to_bulge(2.0 * theta); + if theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite() { Ok(b) } else { Err(EngineError::Other("Invalid arc endpoint.".into())) } +} + +/// Add the segment from the last point to `end` with bulge `bulge` (the first point when there is none). +pub(super) fn push(pts: &mut Vec, bulges: &mut Vec, end: Vec2, bulge: f64) { + bulges.resize(pts.len(), 0.0); + if let Some(b) = bulges.last_mut() { + *b = bulge; + } + pts.push(end); + bulges.push(0.0); +} + +impl Arcs { + /// An arc option is waiting for its answers. + pub(super) fn asking(&self) -> bool { + self.ask.is_some() + } + + /// The question being answered takes the arc's end point, so a picked point is the end of the segment. + pub(super) fn asks_end_point(&self) -> bool { + matches!( + self.ask, + Some(PlineAsk::AngleEnd(_) | PlineAsk::CenterEnd(_) | PlineAsk::DirectionEnd(_) | PlineAsk::RadiusEnd(_) | PlineAsk::SecondEnd(_)) + ) + } + + /// A typed radius or chord length multiplied by `scale`, for a sequence measured in another + /// space's units than the one its points are in; any other input as it is. + pub(super) fn scale_typed_size(&self, i: Input, scale: f64) -> Input { + let size = matches!(self.ask, Some(PlineAsk::Radius | PlineAsk::AngleRadius(_) | PlineAsk::CenterLength(_))); + match i { + Input::Text(t) if size => match number(&t).map(|v| v * scale).filter(|v| v.is_finite()) { + Some(v) => Input::Text(v.to_string()), + None => Input::Text(t), + }, + i => i, + } + } + + /// The arc-mode prompt, or the question of the arc option being answered; `None` in line mode + /// and before the first point. + pub(super) fn prompt(&self, s: &Session, pts: &[Vec2], bulges: &[f64], prefix: &str) -> Option { + let st = pts.last().copied()?; + if let Some(a) = self.ask { + return Some(a.prompt(st, tangent(pts, bulges), s)); + } + self.on.then(|| Prompt::new(format!("{prefix}Specify endpoint of arc"), Accept::POINT).kw(&KEYWORDS).base(st)) + } + + /// Feed an answer to the arc option being answered; true when it added a segment. + pub(super) fn answer(&mut self, s: &Session, i: Input, pts: &mut Vec, bulges: &mut Vec) -> Result { + let (Some(a), Some(st)) = (self.ask, pts.last().copied()) else { + self.ask = None; + return Ok(false); + }; + match a.input(s, i, st, tangent(pts, bulges))? { + Outcome::Ask(next) => self.ask = Some(next), + Outcome::Back => self.ask = None, + Outcome::Segment(end, bulge) => { + self.ask = None; + push(pts, bulges, end, bulge); + return Ok(true); + } + } + Ok(false) + } + + /// Arc, Line, and arc mode's Angle, CEnter, Direction, Radius and Second pt; false for any + /// other keyword. + pub(super) fn keyword(&mut self, k: &str, pts: &[Vec2]) -> bool { + match k { + "Arc" if !pts.is_empty() => self.on = true, + "Line" if self.on => self.on = false, + _ => match PlineAsk::from_keyword(k, true) { + Some(a) if self.on && !pts.is_empty() => self.ask = Some(a), + _ => return false, + }, + } + true + } + + /// A point at the next-point prompt: in arc mode, the end of an arc that continues the + /// sequence's direction; otherwise the end of a line segment. + pub(super) fn point(&self, pts: &mut Vec, bulges: &mut Vec, p: Vec2) -> Result<()> { + let bulge = if self.on && !pts.is_empty() { tangent_bulge(pts, bulges, p)? } else { 0.0 }; + push(pts, bulges, p, bulge); + Ok(()) + } + + /// CLose: the closing segment becomes an arc from the last point to the first that continues + /// the sequence's direction. + pub(super) fn close(&self, pts: &[Vec2], bulges: &mut Vec) -> Result<()> { + let Some(first) = pts.first().copied().filter(|_| pts.len() >= 2) else { + return Err(EngineError::Other("CLose needs at least two points.".into())); + }; + let bulge = tangent_bulge(pts, bulges, first)?; + bulges.resize(pts.len(), 0.0); + if let Some(b) = bulges.last_mut() { + *b = bulge; + } + Ok(()) + } + + /// Undo: remove the last point and the segment to it. Removing the first point leaves arc mode. + pub(super) fn undo(&mut self, pts: &mut Vec, bulges: &mut Vec) { + pts.pop(); + bulges.truncate(pts.len()); + if let Some(b) = bulges.last_mut() { + *b = 0.0; + } + if pts.is_empty() { + *self = Arcs::default(); + } + } + + /// The sequence with the segment to the cursor `c` that a click would add. + pub(super) fn preview(&self, pts: &[Vec2], bulges: &[f64], c: Vec2, closed: bool) -> Vec { + let Some(st) = pts.last().copied() else { return Vec::new() }; + let next = match self.ask { + Some(a) => a.preview(st, tangent(pts, bulges), c), + None if self.on => tangent_bulge(pts, bulges, c).ok().map(|b| (c, b)), + None => Some((c, 0.0)), + }; + let mut g = polyline(pts, bulges, closed); + if let Some((end, bulge)) = next { + if let Some(v) = g.vertices.last_mut() { + v.bulge = bulge; + } + g.vertices.push(PolyVertex::new(end)); + } + vec![lwpoly(g.vertices, closed)] + } +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 582a93635..806641ef6 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -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 dbb787917..a538d73b5 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1,4 +1,4 @@ -//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy), +//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas), //! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF. //! //! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays @@ -9,7 +9,7 @@ use cadcraft_geom::{Bounds2, Vec2}; use serde_json::{Value, json}; use super::file::{base64_encode, io}; -use super::*; +use super::{CommandSpec, bad, bool_or, find_command, point_param, point_value, str_param, targets}; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; use viewports::MviewM; @@ -22,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("layout", "Layout", run_layout) .alias(&["lo"]) - .params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)") + .params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)") .interactive(|_| Ok(Box::new(prompts::LayoutM::default()))), CommandSpec::new("layout.new", "New Layout", run_new) .menu(&["Insert", "Layout", "New Layout"]) @@ -32,6 +32,9 @@ pub fn specs() -> Vec { CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"), CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"), CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(), + CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas) + .params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}") + .noundo(), CommandSpec::new("mview", "Viewports", run_mview) .alias(&["mv"]) .params( @@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result { )) } +/// LAYOUT SAveas: a layout written as a drawing template. +mod saveas; + /// `-LAYOUT`-style dispatcher. fn run_layout(s: &mut Session, p: &Value) -> Result { let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase(); @@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result { let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?; (set.run)(s, p) } + "saveas" | "sa" => saveas::run_saveas(s, p), "list" | "?" => run_list(s, p), - _ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")), + _ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")), } } diff --git a/crates/engine/src/cmd/layout/prompts.rs b/crates/engine/src/cmd/layout/prompts.rs index d25b40c86..0d0e20f9b 100644 --- a/crates/engine/src/cmd/layout/prompts.rs +++ b/crates/engine/src/cmd/layout/prompts.rs @@ -25,6 +25,8 @@ enum Stage { RenameFrom, RenameTo(String), Set, + SaveasLayout, + SaveasFile(String), List, } @@ -34,7 +36,7 @@ pub struct LayoutM { } /// The layout the name prompts default to: the current one, else the first tab. -fn current_layout(s: &Session) -> Option { +pub(super) fn current_layout(s: &Session) -> Option { if let Space::Paper(n) = s.layout_space() { return Some(n); } @@ -70,9 +72,10 @@ impl LayoutM { "New" => Stage::New, "Rename" => Stage::RenameFrom, "Set" => Stage::Set, + "SAveas" => Stage::SaveasLayout, "?" => Stage::List, other => { - // Template, SAveas: report and end, leaving the next input to the command line. + // Template: report and end, leaving the next input to the command line. s.echo(format!("LAYOUT {other} is not available yet.")); return Ok(Step::Done); } @@ -131,6 +134,8 @@ impl Interactive for LayoutM { let tab = s.layout_space().tab_name().to_string(); Prompt::new("Enter layout to make current", Accept::TEXT).default(tab) } + Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)), + Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)), Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"), } } @@ -199,6 +204,19 @@ impl Interactive for LayoutM { (set.run)(s, &json!({ "name": n })).map_err(retry)?; Ok(Step::Done) } + Stage::SaveasLayout => { + let n = need(name.or_else(|| current_layout(s)))?; + let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?; + self.stage = Stage::SaveasFile(n); + Ok(Step::Continue) + } + Stage::SaveasFile(layout) => { + let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout)); + let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?; + let path = r.get("path").and_then(Value::as_str).unwrap_or_default(); + s.echo(format!("Layout \"{layout}\" saved to template \"{path}\".")); + Ok(Step::Done) + } Stage::List => { Self::list(s, name.as_deref().unwrap_or("*"))?; Ok(Step::Done) diff --git a/crates/engine/src/cmd/layout/saveas.rs b/crates/engine/src/cmd/layout/saveas.rs new file mode 100644 index 000000000..b6d3f6873 --- /dev/null +++ b/crates/engine/src/cmd/layout/saveas.rs @@ -0,0 +1,133 @@ +//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`). +//! +//! The template holds the layout's page setup and paper space objects, viewports included, and +//! no model space objects and no other layout. Named objects the layout does not use (blocks, +//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups) +//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay. +//! The open drawing is not changed. + +use std::collections::BTreeSet; + +use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point}; +use cadcraft_geom::Vec3; +use serde_json::{Value, json}; + +use super::super::file::{drops_unknown, io, not_saved, not_saved_message}; +use super::super::{bad, str_param}; +use super::layout_name; +use super::prompts::current_layout; +use crate::{Result, Session}; + +/// File types a layout template is written as; a path without an extension gets `.dwt`. +const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"]; + +/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing. +const MAX_PURGE_PASSES: usize = 64; + +/// The file a SAveas typed at the command line suggests: `.dwt` beside the drawing. +pub(super) fn default_path(s: &Session, layout: &str) -> String { + let file = format!("{layout}.dwt"); + match s.state().ok().and_then(|st| st.path.clone()) { + Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(), + None => file, + } +} + +/// `path` with `.dwt` added when it has no extension; an error for any other file type. +fn template_path(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(bad("layout.saveas", "`path` is empty")); + } + match std::path::Path::new(path).extension() { + None => Ok(format!("{path}.dwt")), + Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()), + Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")), + } +} + +/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space, +/// and without the named objects nothing left in it uses. +fn template_of(d: &Drawing, layout: &str) -> Result { + let mut t = d.clone(); + t.layouts.retain(|l| l.name == layout); + t.active_layout = Some(layout.to_string()); + t.model = EntityStore::new(); + t.hidden_objects.clear(); + let gone: Vec = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect(); + t.constraints.retain(|c| !gone.contains(&c.id)); + // Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts + // only objects, so each such layer gets a point in model space while PURGE runs. + let named: BTreeSet = t + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::>()), + _ => None, + }) + .flatten() + .collect(); + for layer in named { + let handle = t.new_handle(); + t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }); + } + // PURGE runs on a session of its own, so the open drawing keeps everything. + let mut scratch = Session::empty(); + scratch.open_drawing(t, layout, None); + for _ in 0..MAX_PURGE_PASSES { + let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?; + let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty()))); + if !purged { + break; + } + } + let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?; + let mut t = std::sync::Arc::unwrap_or_clone(st.doc); + t.model = EntityStore::new(); + Ok(t) +} + +/// `layout.saveas {name?, path}`: write a layout as a drawing template. +pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result { + let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?; + if s.doc()?.refedit.is_some() { + return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving.")); + } + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?, + }; + // A file is the drawing outside the Block Editor. + let d = s.state()?.drawing(); + let name = layout_name("layout.saveas", d, &name)?; + 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))?; + store(&path, &bytes)?; + let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() }); + let lost = not_saved(&t); + if drops_unknown(&path) + && let Some(msg) = not_saved_message(&lost) + { + s.echo(msg); + if let Some(o) = r.as_object_mut() { + o.insert("notSaved".into(), json!(lost)); + } + } + Ok(r) +} + +/// Write the template: natively to `path` through a temporary file; on the web as a download. +fn store(path: &str, bytes: &[u8]) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + { + // 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}"))) + } + #[cfg(target_arch = "wasm32")] + { + super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver()) + } +} diff --git a/crates/engine/src/cmd/modify/break_join.rs b/crates/engine/src/cmd/modify/break_join.rs index 16892c199..74fd482a2 100644 --- a/crates/engine/src/cmd/modify/break_join.rs +++ b/crates/engine/src/cmd/modify/break_join.rs @@ -1,5 +1,8 @@ use super::trim::{param_on, sub_polyline}; -use super::*; +use super::{ + Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step, + Value, Vec2, arc, bad, json, lwpoly, norm_angle, point_param, point_req, targets, +}; /// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation. fn with_z(mut k: EntityKind, z: f64) -> EntityKind { @@ -247,7 +250,7 @@ impl Interactive for BreakM { (None, Input::Point(p)) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); - match crate::select::pick(s.doc()?, &space, p, ap) { + match s.pick_object(&space, p, ap)? { Some(h) => { self.obj = Some((h, p)); s.set_selection(vec![h]); diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs index 3b25ccfee..46ada655d 100644 --- a/crates/engine/src/cmd/modify/explode.rs +++ b/crates/engine/src/cmd/modify/explode.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3}; // ---------------- explode / array / order / break / join ---------------- diff --git a/crates/engine/src/cmd/modify/fillet.rs b/crates/engine/src/cmd/modify/fillet.rs index 01d701817..b0cb32174 100644 --- a/crates/engine/src/cmd/modify/fillet.rs +++ b/crates/engine/src/cmd/modify/fillet.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, arc, bad, + bool_or, ccw_sweep, f64_or, json, line, line_line_infinite, number, point_param, +}; // ---------------- fillet / chamfer ---------------- @@ -115,7 +118,11 @@ pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result { } (None, None) => { let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); - ChamferCut::Dist(d1, f64_or(p, "d2", d1)) + let d2 = f64_or(p, "d2", d1); + if !(d1.is_finite() && d1 >= 0.0 && d2.is_finite() && d2 >= 0.0) { + return Err(bad("chamfer", "`d1` and `d2` must be >= 0")); + } + ChamferCut::Dist(d1, d2) } _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), }; @@ -335,7 +342,7 @@ impl Interactive for FilletM { Input::Point(p) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); - let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { + let Some(h) = s.pick_object(&space, p, ap)? else { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs index 67bdd7ec7..17cd15b09 100644 --- a/crates/engine/src/cmd/modify/offset.rs +++ b/crates/engine/src/cmd/modify/offset.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step, + Value, Vec2, arc, bad, bool_or, ccw_sweep, circle, f64_req, intersect_ext, json, line, lwpoly, number, point_req, str_param, targets, v3, +}; /// Whether OFFSET can work on this kind of object at all. fn offsettable(k: &EntityKind) -> bool { @@ -194,6 +197,9 @@ fn join_point(a: &Segment, b: &Segment) -> Option { pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; let dist = f64_req("offset", p, "distance")?; + if dist == 0.0 { + return Err(bad("offset", "Requires a positive distance.")); + } let side = point_req("offset", p, "side")?; let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; @@ -439,7 +445,7 @@ impl Interactive for OffsetM { (None, Input::Point(p)) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); - match crate::select::pick(s.doc()?, &space, p, ap) { + match s.pick_object(&space, p, ap)? { Some(h) => { self.picked = Some(h); s.set_selection(vec![h]); diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs index 75264990c..10bf08399 100644 --- a/crates/engine/src/cmd/modify/select_then.rs +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -1,7 +1,9 @@ use super::break_join::join; use super::explode::explode; use super::transform::{erase, transform_entities}; -use super::*; +use super::{ + Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number, +}; // ---------------- interactive ---------------- diff --git a/crates/engine/src/cmd/modify/stretch.rs b/crates/engine/src/cmd/modify/stretch.rs index 5aa91bbef..a277f9774 100644 --- a/crates/engine/src/cmd/modify/stretch.rs +++ b/crates/engine/src/cmd/modify/stretch.rs @@ -1,5 +1,8 @@ use super::transform::{delta_of, transform_entities}; -use super::*; +use super::{ + Accept, Entity, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelectKind, SelectMode, Session, Step, Value, Vec2, bad, json, + line, points_param, +}; /// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first /// point. diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 760e37cbc..5a86d9e07 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets}; // ---------------- core operations ---------------- @@ -66,7 +66,10 @@ pub(super) fn run_move(s: &mut Session, p: &Value) -> Result { pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let d = delta_of("copy", p)?; - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); + let n = match p.get("count") { + None => 1, + Some(v) => v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad("copy", "`count` must be an integer from 1 to 10000"))?, + }; let mut out = Vec::new(); for k in 1..=n { out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs index 4caaadfb6..66eab0be8 100644 --- a/crates/engine/src/cmd/modify/trim.rs +++ b/crates/engine/src/cmd/modify/trim.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, EngineError, Entity, EntityKind, Handle, Line, PolyVertex, Polyline, Prim, Result, Segment, Session, TAU, Vec2, angle_in_sweep, arc, + ccw_sweep, intersect_ext, line, lwpoly, norm_angle, +}; // ---------------- trim / extend ---------------- diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 8d814ebe0..0198a962d 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option { } } +mod pedit_multi; mod pedit_vertex; // ===================================================================================== diff --git a/crates/engine/src/cmd/modify2/align.rs b/crates/engine/src/cmd/modify2/align.rs index 90e13887d..edb7ef22c 100644 --- a/crates/engine/src/cmd/modify2/align.rs +++ b/crates/engine/src/cmd/modify2/align.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, Vec2, bool_or, curves, json, + line, point_param, point_req, targets, +}; // ===================================================================================== // ALIGN diff --git a/crates/engine/src/cmd/modify2/blend.rs b/crates/engine/src/cmd/modify2/blend.rs index 72dae0f43..32b477eea 100644 --- a/crates/engine/src/cmd/modify2/blend.rs +++ b/crates/engine/src/cmd/modify2/blend.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Chain, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Spline, Step, Value, Vec2, bad, curves, json, other, point_req, + str_param, +}; // ===================================================================================== // BLEND diff --git a/crates/engine/src/cmd/modify2/fillet.rs b/crates/engine/src/cmd/modify2/fillet.rs index bdc5dded6..20062a87f 100644 --- a/crates/engine/src/cmd/modify2/fillet.rs +++ b/crates/engine/src/cmd/modify2/fillet.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, EntityKind, Handle, LwPolyline, PolyVertex, Result, Session, TanObj, Vec2, arc, ccw_sweep, curve_edits, curves, line, line_circle, + line_line_infinite, other, push_entity, set_kind, +}; // ===================================================================================== // FILLET extensions (arcs, circles, polylines) diff --git a/crates/engine/src/cmd/modify2/lengthen.rs b/crates/engine/src/cmd/modify2/lengthen.rs index d3021cb42..0c86c2c9c 100644 --- a/crates/engine/src/cmd/modify2/lengthen.rs +++ b/crates/engine/src/cmd/modify2/lengthen.rs @@ -1,5 +1,8 @@ use super::trimext::ellipse_of; -use super::*; +use super::{ + Accept, Arc, Chain, EntityKind, Handle, Input, Interactive, LwPolyline, Polyline, Prompt, Result, Session, Step, TAU, Value, Vec2, bad, + ccw_sweep, curve_edits, curves, json, number, other, point_param, set_kind, targets, +}; // ===================================================================================== // LENGTHEN diff --git a/crates/engine/src/cmd/modify2/ncopy.rs b/crates/engine/src/cmd/modify2/ncopy.rs index 237110a5e..475256e84 100644 --- a/crates/engine/src/cmd/modify2/ncopy.rs +++ b/crates/engine/src/cmd/modify2/ncopy.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Common, EntityKind, Handle, Input, Interactive, MAX_BLOCK_DEPTH, MAX_GEN, Mat3, Prompt, Result, Session, Step, Value, Vec2, bad, curves, + json, other, point_param, push_entity, targets, +}; // ===================================================================================== // NCOPY diff --git a/crates/engine/src/cmd/modify2/patharray.rs b/crates/engine/src/cmd/modify2/patharray.rs index e8f03fc6b..3f9445249 100644 --- a/crates/engine/src/cmd/modify2/patharray.rs +++ b/crates/engine/src/cmd/modify2/patharray.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Accept, Chain, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, bad, bool_or, curves, json, + number, other, targets, +}; // ===================================================================================== // ARRAYPATH (non-associative copies along a curve) diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs index 399759cf4..0c1b64879 100644 --- a/crates/engine/src/cmd/modify2/pedit.rs +++ b/crates/engine/src/cmd/modify2/pedit.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Accept, Chain, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Spline, + Step, Value, Vec2, as_lwpoly, bad, bool_or, curves, f64_or, f64_req, json, line, number, other, pedit_vertex, point_req, set_kind, str_param, + targets, +}; /// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex, /// following the tangent directions given per vertex (Edit vertex Tangent). @@ -160,7 +164,7 @@ pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f6 Ok(joined.len()) } -fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { +pub(super) fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { let e = curves::entity(s, h)?; if curves::is_locked(s, h) { return Err(other("The object is on a locked layer.")); @@ -283,6 +287,13 @@ pub(super) enum PPhase { /// The second marker of Break / Straighten. VMark, VTangent, + /// Multiple ([`super::pedit_multi`]): selecting the objects, converting lines and arcs, the + /// options, the width for all, Join's fuzz distance. + MSelect, + MConvert, + MMain, + MWidth, + MFuzz, } #[derive(Default)] @@ -301,6 +312,34 @@ pub(super) struct PeditM { pub(super) tangents: Vec<(Vec2, Vec2)>, /// Pieces split off by Break, with the history length after it (erased again by Undo). pub(super) split_off: Vec<(usize, Vec)>, + /// Multiple: the polylines being edited, their selection, and the drawing before each option + /// (with those polylines and their Decurve record) for Undo. + pub(super) multi: Vec, + pub(super) msel: SelectPhase, + pub(super) mundo: Vec<(cadcraft_doc::Drawing, Vec, std::collections::HashMap>)>, + /// Multiple: each polyline's vertices before its first Fit or Spline, which Decurve restores. + pub(super) morig: std::collections::HashMap>, +} + +/// A Yes/No answer: Enter is Yes, anything but Y/Yes/N/No is refused so the question is asked again. +pub(super) fn yes_no(i: &Input) -> Result { + match i { + Input::Enter => Ok(true), + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Err(other("Enter Yes or No.")), + }, + _ => Err(other("Enter Yes or No.")), + } +} + +/// Width sets one width for the whole polyline, so the vertices Decurve gives back take it too. +pub(super) fn clear_widths(vs: &mut [PolyVertex]) { + for v in vs { + v.start_width = 0.0; + v.end_width = 0.0; + } } impl PeditM { @@ -335,6 +374,10 @@ impl Interactive for PeditM { } fn begin(&mut self, s: &mut Session) -> Result { let sel = s.selection(); + // Several objects selected first: straight into Multiple, as in AutoCAD. + if sel.len() > 1 && s.settings.pickfirst { + return self.start_multi(s); + } if let [h] = sel.as_slice() { let k = curves::entity(s, *h)?.kind; match k { @@ -354,6 +397,7 @@ impl Interactive for PeditM { } fn prompt(&self, s: &Session) -> Prompt { match self.phase { + PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(), PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), PPhase::Convert => { Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") @@ -394,6 +438,7 @@ impl Interactive for PeditM { _ => None, }; match self.phase { + PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_input(s, i), PPhase::Select => match i { Input::Point(p) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; @@ -413,13 +458,13 @@ impl Interactive for PeditM { s.set_selection(vec![h]); Ok(Step::Continue) } - Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")), + Input::Keyword(k) if k.eq_ignore_ascii_case("multiple") || k.eq_ignore_ascii_case("m") => self.start_multi(s), + Input::Keyword(_) => Err(other("Invalid option keyword.")), Input::Enter => Ok(Step::Cancel), _ => Ok(Step::Continue), }, PPhase::Convert => { - let no = kw(&i).is_some_and(|k| k.starts_with('n')); - if no { + if !yes_no(&i)? { return Ok(Step::Done); } let h = self.h.ok_or_else(|| other("No object."))?; @@ -483,6 +528,9 @@ impl Interactive for PeditM { if let Input::Text(t) = &i { let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; self.apply(s, "width", json!({ "width": w }))?; + if let Some(o) = &mut self.original { + clear_widths(o); + } } self.phase = PPhase::Main; Ok(Step::Continue) diff --git a/crates/engine/src/cmd/modify2/pedit_multi.rs b/crates/engine/src/cmd/modify2/pedit_multi.rs new file mode 100644 index 000000000..5a599cb1b --- /dev/null +++ b/crates/engine/src/cmd/modify2/pedit_multi.rs @@ -0,0 +1,235 @@ +//! PEDIT Multiple: edit several polylines (and lines and arcs turned into polylines) at once, as +//! AutoCAD does after `M` at "Select polyline". Join joins the selected objects with each other. + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::PolyVertex; +use serde_json::json; + +use super::super::curves; +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::pedit::{PPhase, PeditM, clear_widths, pedit_apply, pedit_join, yes_no}; +use super::{as_lwpoly, other, set_kind}; +use crate::{Accept, Input, Prompt, Result, Session, Step}; + +/// Most option steps Undo can take back. +const MAX_UNDO: usize = 64; + +/// The options of the Multiple menu. +const OPTIONS: [&str; 10] = ["Close", "Open", "Join", "Width", "Fit", "Spline", "Decurve", "Ltype gen", "Reverse", "Undo"]; + +impl PeditM { + /// Start Multiple: select the objects to edit, or take the ones selected first. + pub(super) fn start_multi(&mut self, s: &mut Session) -> Result { + self.phase = PPhase::MSelect; + self.msel = SelectPhase::begin(s); + if self.msel.done { + let hs = std::mem::take(&mut self.msel.picked); + return self.take_multi(s, hs); + } + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + + /// The objects selected for Multiple: polylines, lines and arcs not on locked layers. + fn take_multi(&mut self, s: &mut Session, hs: Vec) -> Result { + let d = s.doc()?; + let kinds = |h: &Handle| d.entity(*h).map(|e| e.kind.clone()); + let editable: Vec = hs + .iter() + .copied() + .filter(|h| matches!(kinds(h), Some(EntityKind::LwPolyline(_) | EntityKind::Line(_) | EntityKind::Arc(_)))) + .filter(|h| !curves::is_locked(s, *h)) + .collect(); + let convert = editable.iter().any(|h| matches!(kinds(h), Some(EntityKind::Line(_) | EntityKind::Arc(_)))); + self.multi = editable; + if self.multi.is_empty() { + s.echo("No polylines, lines or arcs selected."); + return Ok(Step::Done); + } + self.phase = if convert { PPhase::MConvert } else { PPhase::MMain }; + s.set_selection(self.multi.clone()); + Ok(Step::Continue) + } + + /// The prompt of a Multiple phase. + pub(super) fn multi_prompt(&self) -> Prompt { + match self.phase { + PPhase::MConvert => Prompt::new("Convert Lines and Arcs to polylines", curves::KW).kw(&["Yes", "No"]).default("Y"), + PPhase::MMain => Prompt::new("Enter an option", curves::KW).kw(&OPTIONS), + PPhase::MWidth => Prompt::new("Specify new width for all segments", Accept::NUMBER), + PPhase::MFuzz => Prompt::new("Enter fuzz distance", Accept::NUMBER).default("0.0000"), + _ => self.msel.prompt(), + } + } + + /// Input in a Multiple phase. + pub(super) fn multi_input(&mut self, s: &mut Session, i: Input) -> Result { + let kw = |i: &Input| match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + }; + match self.phase { + PPhase::MSelect => match self.msel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.take_multi(s, hs), + }, + PPhase::MConvert => { + let no = !yes_no(&i)?; + let mut kept = Vec::new(); + for h in std::mem::take(&mut self.multi) { + let kind = curves::entity(s, h)?.kind; + match kind { + EntityKind::LwPolyline(_) => kept.push(h), + _ if no => {} + k => { + if let Some(pl) = as_lwpoly(&k) { + set_kind(s, h, EntityKind::LwPolyline(pl))?; + kept.push(h); + } + } + } + } + self.multi = kept; + if self.multi.is_empty() { + s.echo("No polylines to edit."); + return Ok(Step::Done); + } + s.set_selection(self.multi.clone()); + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + PPhase::MMain => { + let Some(k) = kw(&i) else { + if i == Input::Enter { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + return Ok(Step::Continue); + }; + match k.as_str() { + "close" | "c" => self.apply_all(s, "close", &json!({}))?, + "open" | "o" => self.apply_all(s, "open", &json!({}))?, + "join" | "j" => self.phase = PPhase::MFuzz, + "width" | "w" => self.phase = PPhase::MWidth, + "fit" | "f" => self.curve_all(s, "fit")?, + "spline" | "s" => self.curve_all(s, "spline")?, + "decurve" | "d" => self.decurve_all(s)?, + "ltype gen" | "l" => { + // One setting for all: the opposite of the first one's. + let on = self.multi.first().and_then(|h| curves::entity(s, *h).ok()).is_some_and(|e| match e.kind { + EntityKind::LwPolyline(p) => !p.plinegen, + _ => true, + }); + self.apply_all(s, "ltypegen", &json!({ "on": on }))?; + } + "reverse" | "r" => self.apply_all(s, "reverse", &json!({}))?, + "undo" | "u" => match self.mundo.pop() { + Some((d, multi, morig)) => { + *s.doc_mut()? = d; + self.multi = multi; + self.morig = morig; + s.set_selection(self.multi.clone()); + } + None => s.echo("Nothing to undo."), + }, + _ => return Err(other("Invalid option keyword.")), + } + Ok(Step::Continue) + } + PPhase::MWidth => { + if let Input::Text(t) = &i { + let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.apply_all(s, "width", &json!({ "width": w }))?; + self.morig.values_mut().for_each(|o| clear_widths(o)); + } + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + PPhase::MFuzz => { + let fuzz = match &i { + Input::Text(t) => number(t).filter(|f| *f >= 0.0 && f.is_finite()).ok_or_else(|| other("Requires a non-negative distance."))?, + _ => 0.0, + }; + self.join_all(s, fuzz)?; + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } + + /// Keep the drawing as it is for Undo (and the polylines being edited, with their Decurve + /// record). + fn remember(&mut self, s: &Session) -> Result<()> { + let d: Drawing = s.doc()?.clone(); + if self.mundo.len() >= MAX_UNDO { + self.mundo.remove(0); + } + self.mundo.push((d, self.multi.clone(), self.morig.clone())); + Ok(()) + } + + /// Apply option `opt` to every polyline being edited. + fn apply_all(&mut self, s: &mut Session, opt: &str, p: &serde_json::Value) -> Result<()> { + self.remember(s)?; + self.multi.retain(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())); + for h in self.multi.clone() { + pedit_apply(s, h, opt, p)?; + } + Ok(()) + } + + /// Fit or Spline all of them, first keeping each one's vertices for Decurve. + fn curve_all(&mut self, s: &mut Session, opt: &str) -> Result<()> { + for h in &self.multi { + if !self.morig.contains_key(h) + && let Ok(EntityKind::LwPolyline(p)) = curves::entity(s, *h).map(|e| e.kind) + { + self.morig.insert(*h, p.vertices); + } + } + self.apply_all(s, opt, &json!({})) + } + + /// Decurve all of them: one fitted or splined here gets back its vertices from before (as + /// single-polyline PEDIT does), the others lose their arcs. + fn decurve_all(&mut self, s: &mut Session) -> Result<()> { + self.apply_all(s, "decurve", &json!({}))?; + for h in self.multi.clone() { + if let Some(orig) = self.morig.get(&h) + && let Ok(EntityKind::LwPolyline(mut p)) = curves::entity(s, h).map(|e| e.kind) + { + p.vertices = orig.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect(); + set_kind(s, h, EntityKind::LwPolyline(p))?; + } + } + Ok(()) + } + + /// Join the polylines being edited with each other, ends within `fuzz`: each one still open + /// takes the others that meet it, and those go into it. + fn join_all(&mut self, s: &mut Session, fuzz: f64) -> Result<()> { + self.remember(s)?; + let mut rest: Vec = self.multi.iter().copied().filter(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())).collect(); + let before = rest.len(); + let mut kept = Vec::new(); + while !rest.is_empty() { + let h = rest.remove(0); + let open = matches!(curves::entity(s, h)?.kind, EntityKind::LwPolyline(p) if !p.closed); + if open { + // A polyline that took on segments no longer goes back past the Join on Decurve. + if pedit_join(s, h, &rest, fuzz)? > 0 { + self.morig.remove(&h); + } + rest.retain(|o| s.doc().is_ok_and(|d| d.entity(*o).is_some())); + } + kept.push(h); + } + let added = before - kept.len(); + s.echo(format!("{added} segments added to polyline")); + self.multi = kept; + s.set_selection(self.multi.clone()); + Ok(()) + } +} diff --git a/crates/engine/src/cmd/modify2/selop.rs b/crates/engine/src/cmd/modify2/selop.rs index 90330bbbf..f1b63e3ce 100644 --- a/crates/engine/src/cmd/modify2/selop.rs +++ b/crates/engine/src/cmd/modify2/selop.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EntityKind, Handle, Input, Interactive, LwPolyline, Mat3, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space, Step, Value, + curves, json, lwpoly, other, set_kind, str_param, targets, +}; // ===================================================================================== // REVERSE / CHSPACE / FLATTEN (select, then act) diff --git a/crates/engine/src/cmd/modify2/splinedit.rs b/crates/engine/src/cmd/modify2/splinedit.rs index f5ba767fb..ae01e89c6 100644 --- a/crates/engine/src/cmd/modify2/splinedit.rs +++ b/crates/engine/src/cmd/modify2/splinedit.rs @@ -499,6 +499,94 @@ impl SplineditM { } } +impl SplineditM { + fn input_main(&mut self, s: &mut Session, h: Handle, sp: &Spline, enter: bool, kw: &str) -> Result> { + match (enter, kw) { + (true, _) | (_, "exit" | "x") => { + s.set_selection(Vec::new()); + return Ok(Some(Step::Done)); + } + (_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?, + (_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?, + (_, "reverse" | "r") => self.apply(s, json!({ "option": "reverse" }))?, + (_, "convert to polyline" | "p") => self.sub = Sub::Precision, + (_, "fit data" | "f") if sp.fit.is_empty() => s.echo("The spline has no fit data."), + (_, "fit data" | "f") => self.sub = Sub::Fit, + (_, "edit vertex" | "e") => { + self.mark = 0; + self.sub = Sub::Vertex; + } + (_, "join" | "j") if sp.closed => s.echo("Cannot join to a closed spline."), + (_, "join" | "j") => { + self.join = SelectPhase::default(); + self.sub = Sub::Join; + } + (_, "undo" | "u") => { + if let Some((at, ents)) = self.joined.last() + && *at == self.history.len() + { + let d = s.doc_mut()?; + for (space, e) in ents { + if let Some(st) = d.space_mut(space) { + st.push(e.clone()); + } + } + self.joined.pop(); + } + match self.history.pop() { + Some(k) => set_kind(s, h, k)?, + None => s.echo("All edits have been undone."), + } + } + (_, "") => {} + _ => return Err(other("Invalid option keyword.")), + } + Ok(None) + } + + fn input_fit(&mut self, s: &mut Session, sp: &Spline, enter: bool, kw: &str) -> Result<()> { + match (enter, kw) { + (true, _) | (_, "exit" | "x") => self.sub = Sub::Main, + (_, "add" | "a") => self.sub = Sub::FitAddPick, + (_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?, + (_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?, + (_, "delete" | "d") => self.sub = Sub::FitDelete, + (_, "kink" | "k") => self.sub = Sub::FitKink, + (_, "move" | "m") => { + self.mark = 0; + self.sub = Sub::FitMove; + } + (_, "purge" | "p") => { + self.apply(s, json!({ "option": "purge" }))?; + self.sub = Sub::Main; + } + (_, "tangents" | "t") if sp.closed => s.echo("A closed spline has no end tangents."), + (_, "tangents" | "t") => self.sub = Sub::FitStartTangent, + (_, "tolerance" | "l") => self.sub = Sub::FitTolerance, + (_, "") => {} + _ => return Err(other("Invalid option keyword.")), + } + Ok(()) + } + + fn input_join(&mut self, s: &mut Session, h: Handle, sp: &Spline, input: &Input) -> Result<()> { + match self.join.feed(s, input)? { + SelOutcome::More => {} + SelOutcome::Empty => self.sub = Sub::Main, + SelOutcome::Done(handles) => { + self.sub = Sub::Main; + let before = curves::entity(s, h)?.kind; + let removed = join(s, h, sp, &handles)?; + self.history.push(before); + s.echo(format!("{} object(s) joined into the spline", removed.len())); + self.joined.push((self.history.len(), removed)); + s.set_selection(vec![h]); + } + } + Ok(()) + } +} + impl Interactive for SplineditM { fn name(&self) -> &'static str { "SPLINEDIT" @@ -578,60 +666,12 @@ impl Interactive for SplineditM { let enter = i == Input::Enter; let sp = self.spline(s).ok_or_else(|| other("The spline was erased."))?; match self.sub { - Sub::Main => match (enter, kw) { - (true, _) | (_, "exit" | "x") => { - s.set_selection(Vec::new()); - return Ok(Step::Done); + Sub::Main => { + if let Some(step) = self.input_main(s, h, &sp, enter, kw)? { + return Ok(step); } - (_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?, - (_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?, - (_, "reverse" | "r") => self.apply(s, json!({ "option": "reverse" }))?, - (_, "convert to polyline" | "p") => self.sub = Sub::Precision, - (_, "fit data" | "f") if sp.fit.is_empty() => s.echo("The spline has no fit data."), - (_, "fit data" | "f") => self.sub = Sub::Fit, - (_, "edit vertex" | "e") => { - self.mark = 0; - self.sub = Sub::Vertex; - } - (_, "join" | "j") if sp.closed => s.echo("Cannot join to a closed spline."), - (_, "join" | "j") => { - self.join = SelectPhase::default(); - self.sub = Sub::Join; - } - (_, "undo" | "u") => { - // Undoing a join brings the joined objects back. - if let Some((at, ents)) = self.joined.last() - && *at == self.history.len() - { - let d = s.doc_mut()?; - for (space, e) in ents { - if let Some(st) = d.space_mut(space) { - st.push(e.clone()); - } - } - self.joined.pop(); - } - match self.history.pop() { - Some(k) => set_kind(s, h, k)?, - None => s.echo("All edits have been undone."), - } - } - (_, "") => {} - _ => return Err(other("Invalid option keyword.")), - }, - Sub::Join => match self.join.feed(s, &i)? { - SelOutcome::More => {} - SelOutcome::Empty => self.sub = Sub::Main, - SelOutcome::Done(hs) => { - self.sub = Sub::Main; - let before = curves::entity(s, h)?.kind; - let removed = join(s, h, &sp, &hs)?; - self.history.push(before); - s.echo(format!("{} object(s) joined into the spline", removed.len())); - self.joined.push((self.history.len(), removed)); - s.set_selection(vec![h]); - } - }, + } + Sub::Join => self.input_join(s, h, &sp, &i)?, Sub::Precision => { let precision = match &i { Input::Text(t) => number(t).filter(|n| (0.0..=99.0).contains(n)).ok_or_else(|| other("Requires an integer between 0 and 99."))?, @@ -641,27 +681,7 @@ impl Interactive for SplineditM { s.set_selection(Vec::new()); return Ok(Step::Done); } - Sub::Fit => match (enter, kw) { - (true, _) | (_, "exit" | "x") => self.sub = Sub::Main, - (_, "add" | "a") => self.sub = Sub::FitAddPick, - (_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?, - (_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?, - (_, "delete" | "d") => self.sub = Sub::FitDelete, - (_, "kink" | "k") => self.sub = Sub::FitKink, - (_, "move" | "m") => { - self.mark = 0; - self.sub = Sub::FitMove; - } - (_, "purge" | "p") => { - self.apply(s, json!({ "option": "purge" }))?; - self.sub = Sub::Main; - } - (_, "tangents" | "t") if sp.closed => s.echo("A closed spline has no end tangents."), - (_, "tangents" | "t") => self.sub = Sub::FitStartTangent, - (_, "tolerance" | "l") => self.sub = Sub::FitTolerance, - (_, "") => {} - _ => return Err(other("Invalid option keyword.")), - }, + Sub::Fit => self.input_fit(s, &sp, enter, kw)?, Sub::FitAddPick => match point { Some(p) => { self.mark = nearest(&sp.fit, p).ok_or_else(|| other("The spline has no fit data."))?; @@ -806,6 +826,7 @@ impl Interactive for SplineditM { } Ok(Step::Continue) } + fn preview(&self, s: &Session, c: Vec2) -> Vec { let marked = matches!(self.sub, Sub::FitMove | Sub::VertexMove | Sub::VertexWeight | Sub::FitAddNew); let Some(v) = marked.then(|| self.marks(s).get(self.mark).copied()).flatten() else { return Vec::new() }; diff --git a/crates/engine/src/cmd/modify2/tests.rs b/crates/engine/src/cmd/modify2/tests.rs index 7c8f92625..98b2f40d9 100644 --- a/crates/engine/src/cmd/modify2/tests.rs +++ b/crates/engine/src/cmd/modify2/tests.rs @@ -148,6 +148,11 @@ fn pedit_command_line() { #[test] fn splinedit_json_and_command_line() { + splinedit_json_edits(); + splinedit_command_line_edits(); +} + +fn splinedit_json_edits() { let mut s = Session::new(); let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap()); s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap(); @@ -167,7 +172,9 @@ fn splinedit_json_and_command_line() { s.execute("splinedit", &json!({"handle": sp, "option": "polyline", "precision": 4})).unwrap(); assert!(matches!(kind(&s, &sp), EntityKind::LwPolyline(p) if p.vertices.len() > 4)); assert!(s.execute("splinedit", &json!({"handle": sp, "option": "close"})).is_err()); +} +fn splinedit_command_line_edits() { let mut s = Session::new(); s.execute("spline", &json!({"control": [[0, 0], [2, 3], [4, 0], [6, 3]]})).unwrap(); s.cmdline("splinedit 0,0 c").unwrap(); diff --git a/crates/engine/src/cmd/modify2/textedit.rs b/crates/engine/src/cmd/modify2/textedit.rs index d160d992f..10ca46245 100644 --- a/crates/engine/src/cmd/modify2/textedit.rs +++ b/crates/engine/src/cmd/modify2/textedit.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Accept, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Step, Value, bad, curves, json, other, set_kind, str_param, targets, +}; // ===================================================================================== // TEXTEDIT diff --git a/crates/engine/src/cmd/modify2/trimext.rs b/crates/engine/src/cmd/modify2/trimext.rs index f5258dc43..8a18e4a85 100644 --- a/crates/engine/src/cmd/modify2/trimext.rs +++ b/crates/engine/src/cmd/modify2/trimext.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Ellipse, EntityKind, Line, Result, Segment, TAU, Vec2, ccw_sweep, curve_edits, curves, intersect_ext, norm_angle, other, v3}; // ===================================================================================== // TRIM / EXTEND for ellipses and splines diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 9dbeeddbf..b9a24580b 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value, json}; -use super::*; +use super::{CommandSpec, always, bad}; use crate::{Result, Session, Settings}; /// The profile variables, in the order [`profile`] lists them. diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 370f03518..903ef1bc4 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -61,23 +61,13 @@ impl PlotterStore { } } - /// The `plotters` folder of the CADCraft settings directory (`CADCRAFT_CONFIG_DIR`, else - /// the platform's settings directory). + /// The `plotters` folder of the CADCraft settings directory ([`crate::settings_dir::current`]). #[cfg(not(target_arch = "wasm32"))] pub fn settings_folder() -> PlotterStore { fn dir() -> std::result::Result { - use std::path::PathBuf; - let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); - let root = match std::env::var_os("CADCRAFT_CONFIG_DIR") { - Some(r) if !r.is_empty() => Some(PathBuf::from(r)), - Some(_) => None, - None => match std::env::consts::OS { - "macos" => get("HOME").map(|h| h.join("Library/Application Support/CADCraft")), - "windows" => get("APPDATA").map(|r| r.join("CADCraft")), - _ => get("XDG_CONFIG_HOME").map(|r| r.join("cadcraft")).or_else(|| get("HOME").map(|h| h.join(".config/cadcraft"))), - }, - }; - root.map(|r| r.join("plotters")).ok_or_else(|| "there is no settings folder for plotter configurations".to_string()) + crate::settings_dir::current() + .map(|r| r.join("plotters")) + .ok_or_else(|| "there is no settings folder for plotter configurations".to_string()) } /// A plain file name of ours (no separators, no parent references). fn file(n: &str) -> std::result::Result { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f9fbf2969..daf963f67 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,5 +1,7 @@ -//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME. +//! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units, +//! RENAME. +mod chprop; mod matchprop; mod rename; @@ -18,6 +20,9 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(), CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, thickness?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, ; dimensions: textOverride, dimStyle, textHeight, arrowSize, dimScale, precision (per-dimension overrides)}"), + CommandSpec::new("chprop", "Change Properties", chprop::run_chprop) + .params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)") + .interactive(|_| Ok(Box::new(chprop::ChpropM::new()))), CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop) .menu(&["Modify", "Match Properties"]) .alias(&["ma", "painter"]) @@ -153,7 +158,9 @@ fn run_get(s: &mut Session, p: &Value) -> Result { } fn parse_color(v: &Value) -> Option { - v.as_str().and_then(Color::parse).or_else(|| v.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci)) + v.as_str() + .and_then(Color::parse) + .or_else(|| v.as_i64().filter(|i| (0..=256).contains(i)).and_then(|i| i16::try_from(i).ok()).map(Color::from_aci)) } /// A transparency as JSON, in the form `properties.set` takes: "ByLayer", "ByBlock" or a percentage. @@ -216,212 +223,249 @@ fn run_set(s: &mut Session, p: &Value) -> Result { .transpose()?; let dim_over = dim_overrides(p)?; let anno_scale = d.current_anno_scale(); + let common = CommonEntityProperties { anno_scale: &anno_scale, color, linetype: linetype.as_deref(), lineweight: lw, transparency, thickness }; for h in &hs { d.modify_entity(*h, |e| { - // Annotative: Yes supports the current annotation scale; No drops every scale. - if let Some(a) = p.get("annotative").and_then(Value::as_bool) { - cadcraft_doc::set_annotative(e, a, &anno_scale); - } - if let Some(l) = str_param(p, "layer") { - e.common.layer = l.to_string(); - } - if let Some(c) = color { - e.common.color = c; - } - if let Some(lt) = &linetype { - e.common.linetype.clone_from(lt); - } - if let Some(w) = lw { - e.common.lineweight = w; - } - if let Some(x) = p.get("ltscale").and_then(Value::as_f64).filter(|x| *x > 0.0) { - e.common.ltscale = x; - } - if let Some(t) = transparency { - e.common.transparency = t; - } - if let Some(th) = thickness { - e.common.thickness = th; - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - e.common.visible = v; - } - let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - match &mut e.kind { - EntityKind::Line(l) => { - if let Some(v) = p.get("start") { - set_xy(&mut l.a, v); - } - if let Some(v) = p.get("end") { - set_xy(&mut l.b, v); - } - // Length / angle keep the start point and move the end point. - let (a, b) = (l.a.xy(), l.b.xy()); - let len = num("length").filter(|x| *x > 0.0 && *x < 1e15).unwrap_or_else(|| a.dist(b)); - let ang = num("angle").map(f64::to_radians).unwrap_or_else(|| a.angle_to(b)); - if num("length").is_some() || num("angle").is_some() { - let e = cadcraft_geom::Vec2::polar(a, len, ang); - if e.is_finite() { - l.b.x = e.x; - l.b.y = e.y; - } - } - } - EntityKind::Circle(c) => { - if let Some(v) = p.get("center") { - set_xy(&mut c.center, v); - } - if let Some(r) = num("radius").filter(|r| *r > 0.0) { - c.radius = r; - } - if let Some(dm) = num("diameter").filter(|r| *r > 0.0) { - c.radius = dm / 2.0; - } - if let Some(cf) = num("circumference").filter(|x| *x > 0.0) { - c.radius = cf / cadcraft_geom::TAU; - } - if let Some(a) = num("area").filter(|x| *x > 0.0) { - c.radius = (a / cadcraft_geom::PI).sqrt(); - } - } - EntityKind::Arc(a) => { - if let Some(v) = p.get("center") { - set_xy(&mut a.center, v); - } - if let Some(r) = num("radius").filter(|r| *r > 0.0) { - a.radius = r; - } - if let Some(x) = num("startAngle") { - a.start = cadcraft_geom::norm_angle(x.to_radians()); - } - if let Some(x) = num("endAngle") { - a.end = cadcraft_geom::norm_angle(x.to_radians()); - } - } - EntityKind::Text(t) => { - if let Some(v) = str_param(p, "text") { - t.value = v.to_string(); - } - if let Some(x) = num("height").filter(|x| *x > 0.0) { - t.height = x; - } - if let Some(x) = num("rotation") { - t.rotation = x.to_radians(); - } - if let Some(x) = num("widthFactor").filter(|x| *x > 0.0) { - t.width_factor = x; - } - // The position is the point the text is placed by: moving it moves the text. - if let Some(to) = p.get("position").and_then(point_value) { - let d = to - t.justify_point(); - for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) { - q.x += d.x; - q.y += d.y; - } - } - } - EntityKind::MText(t) => { - if let Some(v) = str_param(p, "text") { - t.contents = v.replace('\n', "\\P"); - } - if let Some(x) = num("height").filter(|x| *x > 0.0) { - t.height = x; - } - if let Some(x) = num("width").filter(|x| *x >= 0.0) { - t.width = x; - } - if let Some(x) = num("rotation") { - t.rotation = x.to_radians(); - } - } - EntityKind::LwPolyline(pl) => { - if let Some(x) = num("width").filter(|x| *x >= 0.0) { - pl.const_width = x; - } - if let Some(c) = p.get("closed").and_then(Value::as_bool) { - pl.closed = c; - } - } - EntityKind::Insert(i) => { - if let Some(x) = num("rotation") { - let turn = x.to_radians() - i.rotation; - i.rotation = x.to_radians(); - // The attached text turns with the reference, around its insertion point (as ROTATE does). - let base = i.insert.xy(); - for a in &mut i.attribs { - let t = &mut a.text; - for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) { - let r = q.xy().rotate_about(base, turn); - q.x = r.x; - q.y = r.y; - } - t.rotation = cadcraft_geom::norm_angle(t.rotation + turn); - if let Some(ml) = &mut a.multiline { - let r = ml.mtext.insert.xy().rotate_about(base, turn); - ml.mtext.insert.x = r.x; - ml.mtext.insert.y = r.y; - ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn); - } - } - } - if let Some(x) = num("scale").filter(|x| *x != 0.0) { - // A new positive scale carries the attached attribute text with it, like SCALE about the insertion point. - if x > 0.0 && i.scale.x > 0.0 { - let m = cadcraft_geom::Mat3::scale_about(i.insert.xy(), x / i.scale.x); - for a in &mut i.attribs { - let mut k = EntityKind::Text(a.text.clone()); - k.transform(&m); - if let EntityKind::Text(t) = k { - a.text = t; - } - a.transform_multiline(&m); - } - } - i.scale = Vec3::new(x, x, x); - } - } - EntityKind::Dimension(dm) => { - if let Some(v) = str_param(p, "textOverride") { - dm.text = v.to_string(); - dm.block = None; - } - if let Some(st) = &dim_style { - dm.style.clone_from(st); - dm.block = None; - } - if !dim_over.is_empty() { - dm.overrides.extend(dim_over.clone()); - dm.block = None; - } - } - EntityKind::Hatch(h) => { - if let Some(v) = str_param(p, "pattern") { - if !h.pattern.eq_ignore_ascii_case(v) { - h.pattern_lines = super::hatch::lines_for(v); - } - h.pattern = v.to_ascii_uppercase(); - // The old pattern's definition lines no longer apply. - h.pattern_lines.clear(); - h.solid = h.pattern == "SOLID"; - if !h.solid { - // A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set. - h.gradient = None; - } - } - if let Some(x) = num("scale").filter(|x| *x > 0.0) { - h.scale = x; - } - if let Some(x) = num("angle") { - h.angle = x.to_radians(); - } - } - _ => {} - } + set_common_properties(e, p, &common); + apply_kind_properties(e, p, dim_style.as_ref(), &dim_over); })?; } Ok(json!({ "changed": hs.len() })) } +fn apply_kind_properties( + entity: &mut cadcraft_doc::Entity, + params: &Value, + dim_style: Option<&String>, + dim_overrides: &serde_json::Map, +) { + let num = |key: &str| params.get(key).and_then(Value::as_f64).filter(|value| value.is_finite()); + match &mut entity.kind { + EntityKind::Line(l) => { + if let Some(v) = params.get("start") { + set_xy(&mut l.a, v); + } + if let Some(v) = params.get("end") { + set_xy(&mut l.b, v); + } + // Length / angle keep the start point and move the end point. + let (a, b) = (l.a.xy(), l.b.xy()); + let len = num("length").filter(|x| *x > 0.0 && *x < 1e15).unwrap_or_else(|| a.dist(b)); + let ang = num("angle").map(f64::to_radians).unwrap_or_else(|| a.angle_to(b)); + if num("length").is_some() || num("angle").is_some() { + let e = cadcraft_geom::Vec2::polar(a, len, ang); + if e.is_finite() { + l.b.x = e.x; + l.b.y = e.y; + } + } + } + EntityKind::Circle(c) => { + if let Some(v) = params.get("center") { + set_xy(&mut c.center, v); + } + if let Some(r) = num("radius").filter(|r| *r > 0.0) { + c.radius = r; + } + if let Some(dm) = num("diameter").filter(|r| *r > 0.0) { + c.radius = dm / 2.0; + } + if let Some(cf) = num("circumference").filter(|x| *x > 0.0) { + c.radius = cf / cadcraft_geom::TAU; + } + if let Some(a) = num("area").filter(|x| *x > 0.0) { + c.radius = (a / cadcraft_geom::PI).sqrt(); + } + } + EntityKind::Arc(a) => { + if let Some(v) = params.get("center") { + set_xy(&mut a.center, v); + } + if let Some(r) = num("radius").filter(|r| *r > 0.0) { + a.radius = r; + } + if let Some(x) = num("startAngle") { + a.start = cadcraft_geom::norm_angle(x.to_radians()); + } + if let Some(x) = num("endAngle") { + a.end = cadcraft_geom::norm_angle(x.to_radians()); + } + } + EntityKind::Text(t) => { + if let Some(v) = str_param(params, "text") { + t.value = v.to_string(); + } + if let Some(x) = num("height").filter(|x| *x > 0.0) { + t.height = x; + } + if let Some(x) = num("rotation") { + t.rotation = x.to_radians(); + } + if let Some(x) = num("widthFactor").filter(|x| *x > 0.0) { + t.width_factor = x; + } + // The position is the point the text is placed by: moving it moves the text. + if let Some(to) = params.get("position").and_then(point_value) { + let d = to - t.justify_point(); + for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) { + q.x += d.x; + q.y += d.y; + } + } + } + EntityKind::MText(t) => { + if let Some(v) = str_param(params, "text") { + t.contents = v.replace('\n', "\\P"); + } + if let Some(x) = num("height").filter(|x| *x > 0.0) { + t.height = x; + } + if let Some(x) = num("width").filter(|x| *x >= 0.0) { + t.width = x; + } + if let Some(x) = num("rotation") { + t.rotation = x.to_radians(); + } + } + EntityKind::LwPolyline(pl) => { + if let Some(x) = num("width").filter(|x| *x >= 0.0) { + pl.const_width = x; + } + if let Some(c) = params.get("closed").and_then(Value::as_bool) { + pl.closed = c; + } + } + EntityKind::Insert(insert) => set_insert_properties(insert, params), + EntityKind::Dimension(dimension) => set_dimension_properties(dimension, params, dim_style, dim_overrides), + EntityKind::Hatch(hatch) => set_hatch_properties(hatch, params), + _ => {} + } +} + +fn set_insert_properties(insert: &mut cadcraft_doc::Insert, params: &Value) { + let number = |key: &str| params.get(key).and_then(Value::as_f64).filter(|value| value.is_finite()); + + if let Some(x) = number("rotation") { + let turn = x.to_radians() - insert.rotation; + insert.rotation = x.to_radians(); + // The attached text turns with the reference, around its insertion point (as ROTATE does). + let base = insert.insert.xy(); + for a in &mut insert.attribs { + let t = &mut a.text; + for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) { + let r = q.xy().rotate_about(base, turn); + q.x = r.x; + q.y = r.y; + } + t.rotation = cadcraft_geom::norm_angle(t.rotation + turn); + if let Some(ml) = &mut a.multiline { + let r = ml.mtext.insert.xy().rotate_about(base, turn); + ml.mtext.insert.x = r.x; + ml.mtext.insert.y = r.y; + ml.mtext.rotation = cadcraft_geom::norm_angle(ml.mtext.rotation + turn); + } + } + } + if let Some(x) = number("scale").filter(|x| *x != 0.0) { + // A new positive scale carries the attached attribute text with it, like SCALE about the insertion point. + if x > 0.0 && insert.scale.x > 0.0 { + let m = cadcraft_geom::Mat3::scale_about(insert.insert.xy(), x / insert.scale.x); + for a in &mut insert.attribs { + let mut k = EntityKind::Text(a.text.clone()); + k.transform(&m); + if let EntityKind::Text(t) = k { + a.text = t; + } + a.transform_multiline(&m); + } + } + insert.scale = Vec3::new(x, x, x); + } +} + +fn set_dimension_properties( + dimension: &mut cadcraft_doc::Dimension, + params: &Value, + style: Option<&String>, + overrides: &serde_json::Map, +) { + if let Some(v) = str_param(params, "textOverride") { + dimension.text = v.to_string(); + dimension.block = None; + } + if let Some(st) = style { + dimension.style.clone_from(st); + dimension.block = None; + } + if !overrides.is_empty() { + dimension.overrides.extend(overrides.clone()); + dimension.block = None; + } +} + +fn set_hatch_properties(hatch: &mut cadcraft_doc::Hatch, params: &Value) { + let number = |key: &str| params.get(key).and_then(Value::as_f64).filter(|value| value.is_finite()); + + if let Some(v) = str_param(params, "pattern") { + if !hatch.pattern.eq_ignore_ascii_case(v) { + hatch.pattern_lines = super::hatch::lines_for(v); + } + hatch.pattern = v.to_ascii_uppercase(); + // The old pattern's definition lines no longer apply. + hatch.pattern_lines.clear(); + hatch.solid = hatch.pattern == "SOLID"; + if !hatch.solid { + // A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set. + hatch.gradient = None; + } + } + if let Some(x) = number("scale").filter(|x| *x > 0.0) { + hatch.scale = x; + } + if let Some(x) = number("angle") { + hatch.angle = x.to_radians(); + } +} + +struct CommonEntityProperties<'a> { + anno_scale: &'a cadcraft_doc::AnnoScale, + color: Option, + linetype: Option<&'a str>, + lineweight: Option, + transparency: Option, + thickness: Option, +} + +fn set_common_properties(entity: &mut cadcraft_doc::Entity, params: &Value, common: &CommonEntityProperties<'_>) { + if let Some(annotative) = params.get("annotative").and_then(Value::as_bool) { + cadcraft_doc::set_annotative(entity, annotative, common.anno_scale); + } + if let Some(layer) = str_param(params, "layer") { + entity.common.layer = layer.to_string(); + } + if let Some(color) = common.color { + entity.common.color = color; + } + if let Some(linetype) = common.linetype { + entity.common.linetype = linetype.to_string(); + } + if let Some(lineweight) = common.lineweight { + entity.common.lineweight = lineweight; + } + if let Some(scale) = params.get("ltscale").and_then(Value::as_f64).filter(|scale| *scale > 0.0) { + entity.common.ltscale = scale; + } + if let Some(transparency) = common.transparency { + entity.common.transparency = transparency; + } + if let Some(thickness) = common.thickness { + entity.common.thickness = thickness; + } + if let Some(visible) = params.get("visible").and_then(Value::as_bool) { + entity.common.visible = visible; + } +} + fn run_color(s: &mut Session, p: &Value) -> Result { let c = p.get("color").and_then(parse_color).ok_or_else(|| bad("color", "`color` is required"))?; set_current_color(s.doc_mut()?, c); @@ -608,9 +652,12 @@ impl Interactive for ThicknessM { } } +/// The unit variables (lower-case name, lowest, highest value) shared by UNITS and SETVAR. +pub(crate) const UNIT_RANGES: [(&str, i64, i64); 5] = [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)]; + fn run_units(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; - for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] { + for (k, lo, hi) in UNIT_RANGES { if let Some(v) = p.get(k).and_then(Value::as_i64) { d.header.set_i64(&k.to_ascii_uppercase(), v.clamp(lo, hi)); } @@ -1181,6 +1228,24 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn properties_set_rejects_out_of_range_colour() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap(); + let h = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("properties.set", &json!({ "handles": [h], "color": 1 })).unwrap(); + let col = |s: &Session| s.doc().unwrap().model.last().unwrap().common.color; + for bad in [300, -5, 257] { + let e = s.execute("properties.set", &json!({ "handles": [h], "color": bad })).unwrap_err(); + assert!(e.to_string().contains("bad colour"), "{e}"); + assert_eq!(col(&s), Color::Index(1)); + } + s.execute("properties.set", &json!({ "handles": [h], "color": 256 })).unwrap(); + assert_eq!(col(&s), Color::ByLayer); + s.execute("properties.set", &json!({ "handles": [h], "color": 0 })).unwrap(); + assert_eq!(col(&s), Color::ByBlock); + } + #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/props/chprop.rs b/crates/engine/src/cmd/props/chprop.rs new file mode 100644 index 000000000..0949abee9 --- /dev/null +++ b/crates/engine/src/cmd/props/chprop.rs @@ -0,0 +1,338 @@ +//! CHPROP: change object properties at the command line, as AutoCAD 2024 asks for them. +//! +//! "Enter property to change [Color/LAyer/LType/ltScale/LWeight/Thickness/TRansparency]" comes +//! back after each answer until Enter; each property asks for its value and applies it at once +//! (through `properties.set`). Measured from AutoLISP's `command` (accoreconsole, every object read +//! back with `entget`): +//! - a colour is 1–255, a name (red … white), BYLAYER or BYBLOCK, or Truecolor and then "r,g,b"; +//! 0, 256 and 300 are refused with "Invalid color number."; +//! - a layer must exist: `Cannot find layer "KHONGCO".`; +//! - a linetype not loaded yet is loaded from the standard ones (PHANTOM2); +//! - a lineweight goes to the nearest standard one, with a note: 0.33 gives "Lineweight rounded to +//! nearest valid value of 0.35 mm"; +//! - `L` alone is "Ambiguous response, please clarify..."; +//! - objects on locked layers are left out at "Select objects", which says how many after what was +//! found ("1 was on a locked layer.", "2 were on a locked layer."); with none left, Enter ends +//! the command. + +use std::collections::HashSet; + +use cadcraft_color::Color; +use cadcraft_doc::{Entity, Handle, Lineweight}; +use serde_json::{Value, json}; + +use super::super::curves::{KW, is_locked}; +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::targets; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; + +/// What CHPROP asks for. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ask { + Property, + Color, + TrueColor, + Layer, + Linetype, + LtScale, + Lineweight, + Thickness, + Transparency, +} + +pub(super) struct ChpropM { + select: SelectPhase, + handles: Vec, + /// `None` while objects are selected. + ask: Option, +} + +impl ChpropM { + pub(super) fn new() -> Self { + ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None } + } + + /// The selection's common value of a property, or "varies". + fn current(&self, s: &Session, f: impl Fn(&Entity) -> String) -> String { + let Ok(d) = s.doc() else { return String::new() }; + let mut vals = self.handles.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)); + let first = vals.next().unwrap_or_default(); + if vals.all(|v| v == first) { first } else { "varies".into() } + } + + /// Apply `props` to the selection through `properties.set`. + fn apply(&self, s: &mut Session, props: Value) -> Result<()> { + let mut p = json!({ "handles": self.handles.iter().map(|h| h.hex()).collect::>() }); + if let (Some(p), Value::Object(props)) = (p.as_object_mut(), props) { + p.extend(props); + } + super::run_set(s, &p).map(|_| ()) + } + + /// Take an answer to `ask`; `false` when it was refused (the prompt comes again). + fn answer(&mut self, s: &mut Session, ask: Ask, t: &str) -> Result { + let t = t.trim(); + let word = t.strip_prefix('_').unwrap_or(t); + match ask { + Ask::Color => match color(word) { + Some(c) => self.apply(s, json!({ "color": color_json(c) }))?, + None => { + s.echo("Invalid color number."); + return Ok(false); + } + }, + Ask::TrueColor => match Color::parse(word) { + Some(c @ Color::True(_)) => self.apply(s, json!({ "color": color_json(c) }))?, + _ => { + s.echo("Requires three integers from 0 to 255, separated by commas."); + return Ok(false); + } + }, + Ask::Layer => match s.doc()?.layer(word).map(|l| l.name.clone()) { + Some(name) => self.apply(s, json!({ "layer": name }))?, + None => { + s.echo(format!("Cannot find layer \"{word}\".")); + return Ok(false); + } + }, + Ask::Linetype => { + let by = ["ByLayer", "ByBlock"].into_iter().find(|b| b.eq_ignore_ascii_case(word)); + let name = match by { + Some(b) => Some(b.to_string()), + None => { + // Not loaded yet: load it from the standard linetypes, as AutoCAD does. + if s.doc()?.linetype(word).is_none() { + let _ = super::run_linetype(s, &json!({ "load": word })); + } + s.doc()?.linetype(word).map(|l| l.name.clone()) + } + }; + match name { + Some(n) => self.apply(s, json!({ "linetype": n }))?, + None => { + s.echo(format!("Cannot find linetype \"{word}\".")); + return Ok(false); + } + } + } + Ask::LtScale | Ask::Thickness => { + let v = number(word).filter(|v| v.is_finite() && (ask == Ask::Thickness || *v > 0.0)); + match v { + Some(v) => self.apply(s, if ask == Ask::LtScale { json!({ "ltscale": v }) } else { json!({ "thickness": v }) })?, + None => { + s.echo(if ask == Ask::LtScale { "Requires a positive number." } else { "Requires a number." }); + return Ok(false); + } + } + } + Ask::Lineweight => match lineweight(word) { + Some((lw, rounded)) => { + if let (true, Lineweight::Mm100(v)) = (rounded, lw) { + s.echo(format!("Lineweight rounded to nearest valid value of {:.2} mm", f64::from(v) / 100.0)); + } + let v = match lw { + Lineweight::Mm100(v) => json!(f64::from(v) / 100.0), + Lineweight::ByLayer => json!("ByLayer"), + Lineweight::ByBlock => json!("ByBlock"), + Lineweight::Default => json!("Default"), + }; + self.apply(s, json!({ "lineweight": v }))?; + } + None => { + s.echo("Invalid lineweight."); + return Ok(false); + } + }, + Ask::Transparency => { + if self.apply(s, json!({ "transparency": word })).is_err() { + s.echo("Requires ByLayer, ByBlock or an integer from 0 to 90."); + return Ok(false); + } + } + Ask::Property => {} + } + Ok(true) + } +} + +/// The objects among `hs` on locked layers. +fn locked(s: &Session, hs: &[Handle]) -> HashSet { + hs.iter().copied().filter(|h| is_locked(s, *h)).collect() +} + +fn say_locked(s: &mut Session, n: usize) { + match n { + 0 => {} + 1 => s.echo("1 was on a locked layer."), + n => s.echo(format!("{n} were on a locked layer.")), + } +} + +/// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the +/// command leaves out too; "locked" says how many were. +pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let locked = locked(s, &hs); + if locked.is_empty() { + return super::run_set(s, p).map(|r| with_locked(r, 0)); + } + let free: Vec = hs.into_iter().filter(|h| !locked.contains(h)).collect(); + let r = if free.is_empty() { + json!({ "changed": 0 }) + } else { + let mut q = p.as_object().cloned().unwrap_or_default(); + q.insert("handles".into(), json!(free.iter().map(|h| h.hex()).collect::>())); + super::run_set(s, &Value::Object(q))? + }; + Ok(with_locked(r, locked.len())) +} + +/// `r` with how many objects were left out on locked layers. +fn with_locked(mut r: Value, n: usize) -> Value { + if let Some(o) = r.as_object_mut() { + o.insert("locked".into(), json!(n)); + } + r +} + +/// A colour typed at CHPROP: 1–255, a name, BYLAYER or BYBLOCK (not 0 or 256, which COLOR takes). +fn color(t: &str) -> Option { + if t.bytes().all(|b| b.is_ascii_digit()) { + return t.parse::().ok().filter(|i| (1..=255).contains(i)).and_then(|i| u8::try_from(i).ok()).map(Color::Index); + } + Color::parse(t).filter(|c| !matches!(c, Color::True(_))) +} + +fn color_json(c: Color) -> Value { + match c { + Color::Index(i) => json!(i), + Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)), + other => json!(other.name()), + } +} + +fn lineweight_name(lw: Lineweight) -> String { + match lw { + Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0), + Lineweight::ByLayer => "ByLayer".into(), + Lineweight::ByBlock => "ByBlock".into(), + Lineweight::Default => "Default".into(), + } +} + +/// A lineweight typed in millimetres (the nearest standard one, and whether that rounded it), or +/// ByLayer, ByBlock or Default. +fn lineweight(t: &str) -> Option<(Lineweight, bool)> { + match t.to_ascii_lowercase().as_str() { + "bylayer" => return Some((Lineweight::ByLayer, false)), + "byblock" => return Some((Lineweight::ByBlock, false)), + "default" => return Some((Lineweight::Default, false)), + _ => {} + } + let mm = number(t).filter(|v| v.is_finite() && *v >= 0.0)?; + let want = mm * 100.0; + let near = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs()))?; + Some((Lineweight::Mm100(near), (f64::from(near) - want).abs() > 1e-9)) +} + +impl Interactive for ChpropM { + fn name(&self) -> &'static str { + "CHPROP" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.select = SelectPhase::begin(s); + if self.select.done { + let locked = locked(s, &self.select.picked); + if !locked.is_empty() { + self.select.picked.retain(|h| !locked.contains(h)); + say_locked(s, locked.len()); + s.set_selection(self.select.picked.clone()); + // Nothing left of the pickfirst selection: ask for objects instead. + self.select.done = !self.select.picked.is_empty(); + } + } + if self.select.done { + self.handles = self.select.picked.clone(); + self.ask = Some(Ask::Property); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + let Some(ask) = self.ask else { return self.select.prompt() }; + match ask { + Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES), + Ask::Color => { + Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase())) + } + Ask::TrueColor => Prompt::new("Red,Green,Blue", Accept::TEXT), + Ask::Layer => Prompt::new("Enter new layer name", Accept::TEXT).default(self.current(s, |e| e.common.layer.clone())), + Ask::Linetype => Prompt::new("Enter new linetype name", Accept::TEXT).default(self.current(s, |e| e.common.linetype.clone())), + Ask::LtScale => { + Prompt::new("Specify new linetype scale", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.ltscale))) + } + Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))), + Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))), + Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some(ask) = self.ask else { + // Objects on locked layers are left out, and said after what was found. + let (i, n) = match i { + Input::Pick(hs) if !self.select.removing => { + let locked = locked(s, &hs); + (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) + } + i => (i, 0), + }; + let outcome = self.select.feed(s, &i)?; + say_locked(s, n); + return Ok(match outcome { + SelOutcome::More => Step::Continue, + SelOutcome::Done(hs) => { + self.handles = hs; + self.ask = Some(Ask::Property); + Step::Continue + } + SelOutcome::Empty => { + s.set_selection(Vec::new()); + Step::Done + } + }); + }; + match (ask, i) { + (Ask::Property, Input::Enter) => { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + (Ask::Property, Input::Keyword(k)) => { + self.ask = Some(match k.as_str() { + "Color" => Ask::Color, + "LAyer" => Ask::Layer, + "LType" => Ask::Linetype, + "ltScale" => Ask::LtScale, + "LWeight" => Ask::Lineweight, + "Thickness" => Ask::Thickness, + _ => Ask::Transparency, + }); + } + (Ask::Property, Input::Text(t)) => { + let t = t.trim().trim_start_matches('_').to_ascii_lowercase(); + let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); + s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." }); + } + (Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor), + // Enter keeps the value. + (_, Input::Enter) => self.ask = Some(Ask::Property), + (ask, Input::Text(t) | Input::Keyword(t)) => { + // A refused answer asks again. + let taken = self.answer(s, ask, &t)?; + self.ask = Some(if taken { Ask::Property } else { ask }); + } + _ => {} + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/trimextend.rs b/crates/engine/src/cmd/trimextend.rs index 4640c58ac..ab03d5c31 100644 --- a/crates/engine/src/cmd/trimextend.rs +++ b/crates/engine/src/cmd/trimextend.rs @@ -474,7 +474,7 @@ impl Interactive for TrimM { Input::Point(p) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); - let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { + let Some(h) = s.pick_object(&space, p, ap)? else { // A pick in empty space opens a crossing window there. self.stage = Stage::Crossing(Some(p)); return Ok(Step::Continue); diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index 5b95f720a..f040133fc 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -6,11 +6,12 @@ //! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current //! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The //! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to -//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. +//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. View > Viewports > Named Viewports... +//! restores or deletes a saved configuration. use std::sync::Arc; -use cadcraft_doc::{Drawing, Space, VportConfig, VportTile}; +use cadcraft_doc::{Drawing, EntityKind, Space, VportConfig, VportTile}; use cadcraft_geom::Vec2; use serde_json::{Value, json}; @@ -37,6 +38,12 @@ pub fn specs() -> Vec { ) .interactive(vports_factory), CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(), + CommandSpec::new("vports.named", "Named Viewports...", run_named) + .menu(&["View", "Viewports", "Named Viewports..."]) + .params( + "{name?: a saved viewport configuration, delete?: bool, p1?, p2?, layout?} (no name: → {current, configurations: [{name, viewports}]}; the Model tab shows the configuration's tiles, a layout gets one viewport per tile over p1-p2 or the printable area; typed or from the menu: the Named Viewports dialog)", + ) + .interactive(named_factory), ] } @@ -388,7 +395,59 @@ fn run_activate(s: &mut Session, p: &Value) -> Result { summary(s) } +/// View > Viewports > Named Viewports...: without a name, the saved configurations and the one +/// the Model tab shows; with one, restore it (its tiles in the Model tab, MVIEW Restore in a +/// layout) or, with `delete`, delete it. +fn run_named(s: &mut Session, p: &Value) -> Result { + let delete = match p.get("delete") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("vports.named", "`delete` must be true or false")), + }; + let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else { + let configs: Vec = + s.doc()?.vports.iter().filter(|c| !c.is_active()).map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect(); + return Ok(json!({ "current": s.viewport_configuration(), "configurations": configs })); + }; + let saved = s + .doc()? + .vports + .iter() + .find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)) + .map(|c| c.name.clone()) + .ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::())))?; + if delete { + s.doc_mut()?.vports.retain(|c| c.is_active() || c.name != saved); + return Ok(json!({ "deleted": saved, "message": format!("Viewport configuration \"{saved}\" deleted.") })); + } + if s.layout_space() != Space::Model || p.get("layout").is_some() { + let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?; + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("delete"); + o.insert("option".into(), json!("restore")); + o.insert("name".into(), json!(saved)); + } + return (mview.run)(s, &p); + } + run_vports(s, &json!({ "option": "restore", "name": saved })) +} + +/// Whether two configurations have their tiles in the same places (in any order). +fn same_places(a: &[VportTile], b: &[VportTile]) -> bool { + let near = |p: Vec2, q: Vec2| (p.x - q.x).abs() < EPS && (p.y - q.y).abs() < EPS; + a.len() == b.len() && a.iter().all(|t| b.iter().any(|u| near(t.min, u.min) && near(t.max, u.max))) +} + impl Session { + /// The saved viewport configuration whose tiles are where the Model tab's tiles are now (the + /// first such one in the drawing), if any. + pub fn viewport_configuration(&self) -> Option { + let st = self.state().ok()?; + let (tiles, _) = live(st, self.viewport_px); + st.doc.vports.iter().filter(|c| !c.is_active()).find(|c| same_places(&c.tiles, &tiles)).map(|c| c.name.clone()) + } + /// The model space tiles to draw (empty: one view, or not in the Model tab). pub fn model_tiles(&self) -> Vec { let Ok(st) = self.state() else { return Vec::new() }; @@ -598,3 +657,33 @@ impl VportsM { } } } + +/// Named Viewports... without the dialog: -VPORTS Restore in the Model tab, MVIEW Restore in a +/// layout. +fn named_factory(s: &Session) -> Result> { + Ok(Box::new(Restoring(vports_factory(s)?))) +} + +/// -VPORTS or MVIEW started at its Restore option. +struct Restoring(Box); + +impl Interactive for Restoring { + fn name(&self) -> &'static str { + self.0.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + match self.0.begin(s)? { + Step::Continue => self.0.input(s, Input::Keyword("Restore".into())), + other => Ok(other), + } + } + fn prompt(&self, s: &Session) -> Prompt { + self.0.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + self.0.input(s, i) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + self.0.preview(s, cursor) + } +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 8934df872..c70989a58 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,5 +1,5 @@ use super::view::{ModelTiles, View, usable_view}; -use super::*; +use super::{Arc, Drawing, Handle, Space, blockedit, undoctl}; #[derive(Clone, Debug)] pub struct Snapshot { diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index a7dee5b0b..8c58e30b5 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -119,74 +119,19 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option { - let g = Arc::new(a.center.xy(), a.radius, a.start, a.end); - let (mut s, mut m, mut e) = (g.start_point(), g.mid_point(), g.end_point()); - match index { - 0 => s = to, - 1 => m = to, - 2 => e = to, - _ => return Some(translated(kind, delta)), - } - let na = Arc::from_3_points(s, m, e)?; - return Some(crate::cmd::helpers::arc(&na)); + return stretch_arc(kind, a, index, to, delta); } EntityKind::Ellipse(e) => { - let c = e.center.xy(); - let mj = e.major.xy(); - let a = mj.len(); - if a <= EPS { + if e.major.xy().len() <= EPS { return None; } - let b = a * e.ratio; - match index { - 0 => return Some(translated(kind, delta)), - 1 | 3 => { - let v = if index == 1 { to - c } else { c - to }; - let l = v.len(); - if l <= EPS { - return None; - } - if l >= b { - e.major = v.to3(0.0); - e.ratio = (b / l).clamp(1e-9, 1.0); - } else { - e.major = (v.normalized().perp() * b).to3(0.0); - e.ratio = (l / b).clamp(1e-9, 1.0); - shift_params(e, -std::f64::consts::FRAC_PI_2); - } - } - _ => { - let u = mj / a; - let l = (to - c).dot(u.perp()).abs(); - if l <= EPS { - return None; - } - if l <= a { - e.ratio = (l / a).clamp(1e-9, 1.0); - } else { - e.major = (u.perp() * l).to3(0.0); - e.ratio = (a / l).clamp(1e-9, 1.0); - shift_params(e, -std::f64::consts::FRAC_PI_2); - } - } + if index == 0 { + return Some(translated(kind, delta)); } + stretch_ellipse(e, index, to)?; } EntityKind::LwPolyline(p) => { - let n = p.vertices.len(); - match poly_grip(p, index)? { - PolyGrip::Vertex(i) => p.vertices.get_mut(i)?.p = to, - PolyGrip::Mid(i) => { - let j = (i + 1) % n.max(1); - let (a, b) = (p.vertices.get(i)?.p, p.vertices.get(j)?.p); - if p.vertices.get(i)?.bulge.abs() > 1e-12 { - // Arc segment: reshape through the dragged midpoint. - p.vertices.get_mut(i)?.bulge = crate::cmd::curves::bulge_through(a, to, b); - } else { - p.vertices.get_mut(i)?.p = a + delta; - p.vertices.get_mut(j)?.p = b + delta; - } - } - } + stretch_polyline(p, index, to, delta)?; } EntityKind::Polyline3d(p) => set_xy(p.points.get_mut(index)?, to), EntityKind::Spline(sp) => { @@ -294,12 +239,92 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option *w.boundary.get_mut(index)? = to, + EntityKind::Wipeout(w) => w.set_vertex(index, to)?, EntityKind::Unknown(_) => return None, } Some(k) } +fn stretch_arc(kind: &EntityKind, arc: &cadcraft_doc::Arc, index: usize, to: Vec2, delta: Vec2) -> Option { + let geometry = Arc::new(arc.center.xy(), arc.radius, arc.start, arc.end); + let (mut start, mut mid, mut end) = (geometry.start_point(), geometry.mid_point(), geometry.end_point()); + match index { + 0 => start = to, + 1 => mid = to, + 2 => end = to, + _ => return Some(translated(kind, delta)), + } + let adjusted = Arc::from_3_points(start, mid, end)?; + Some(crate::cmd::helpers::arc(&adjusted)) +} + +fn stretch_ellipse(ellipse: &mut cadcraft_doc::Ellipse, index: usize, to: Vec2) -> Option<()> { + let center = ellipse.center.xy(); + let major = ellipse.major.xy(); + let major_len = major.len(); + if major_len <= EPS { + return None; + } + let minor_len = major_len * ellipse.ratio; + match index { + 1 | 3 => stretch_ellipse_major(ellipse, index, to, center, minor_len)?, + _ => stretch_ellipse_minor(ellipse, to, center, major, major_len)?, + } + Some(()) +} + +fn stretch_ellipse_major(ellipse: &mut cadcraft_doc::Ellipse, index: usize, to: Vec2, center: Vec2, minor_len: f64) -> Option<()> { + let axis = if index == 1 { to - center } else { center - to }; + let len = axis.len(); + if len <= EPS { + return None; + } + if len >= minor_len { + ellipse.major = axis.to3(0.0); + ellipse.ratio = (minor_len / len).clamp(1e-9, 1.0); + } else { + ellipse.major = (axis.normalized().perp() * minor_len).to3(0.0); + ellipse.ratio = (len / minor_len).clamp(1e-9, 1.0); + shift_params(ellipse, -std::f64::consts::FRAC_PI_2); + } + Some(()) +} + +fn stretch_ellipse_minor(ellipse: &mut cadcraft_doc::Ellipse, to: Vec2, center: Vec2, major: Vec2, major_len: f64) -> Option<()> { + let unit = major / major_len; + let minor_len = (to - center).dot(unit.perp()).abs(); + if minor_len <= EPS { + return None; + } + if minor_len <= major_len { + ellipse.ratio = (minor_len / major_len).clamp(1e-9, 1.0); + } else { + ellipse.major = (unit.perp() * minor_len).to3(0.0); + ellipse.ratio = (major_len / minor_len).clamp(1e-9, 1.0); + shift_params(ellipse, -std::f64::consts::FRAC_PI_2); + } + Some(()) +} + +fn stretch_polyline(polyline: &mut LwPolyline, index: usize, to: Vec2, delta: Vec2) -> Option<()> { + let count = polyline.vertices.len(); + match poly_grip(polyline, index)? { + PolyGrip::Vertex(i) => polyline.vertices.get_mut(i)?.p = to, + PolyGrip::Mid(i) => { + let next = (i + 1) % count.max(1); + let (start, end) = (polyline.vertices.get(i)?.p, polyline.vertices.get(next)?.p); + if polyline.vertices.get(i)?.bulge.abs() > 1e-12 { + // Arc segment: reshape through the dragged midpoint. + polyline.vertices.get_mut(i)?.bulge = crate::cmd::curves::bulge_through(start, to, end); + } else { + polyline.vertices.get_mut(i)?.p = start + delta; + polyline.vertices.get_mut(next)?.p = end + delta; + } + } + } + Some(()) +} + fn shift_params(e: &mut cadcraft_doc::Ellipse, by: f64) { let full = (cadcraft_geom::ccw_sweep(e.start, e.end) - cadcraft_geom::TAU).abs() < 1e-9; if !full { diff --git a/crates/engine/src/grips/tests.rs b/crates/engine/src/grips/tests.rs index 39c43cd01..2fc4502b6 100644 --- a/crates/engine/src/grips/tests.rs +++ b/crates/engine/src/grips/tests.rs @@ -75,6 +75,18 @@ fn arc_and_ellipse_grips() { assert!(matches!(kind(&s, e), EntityKind::Ellipse(x) if (x.major.xy().len() - 8.0).abs() < 1e-9 && (x.ratio - 0.75).abs() < 1e-9)); } +#[test] +fn degenerate_ellipse_cannot_move_from_its_center_grip() { + let ellipse = EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: cadcraft_geom::Vec3::ZERO, + major: cadcraft_geom::Vec3::ZERO, + ratio: 0.5, + start: 0.0, + end: cadcraft_geom::TAU, + }); + assert!(super::stretch_grip(&ellipse, 0, v(1.0, 1.0)).is_none()); +} + #[test] fn polyline_grips() { let mut s = Session::new(); @@ -157,6 +169,21 @@ fn other_entity_grips() { assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0))); } +#[test] +fn wipeout_grips_reject_the_closing_marker_and_hostile_input() { + let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary: vec![v(0.0, 0.0), v(7.0, 1.0), v(3.0, 7.0), v(0.0, 0.0)] }); + for (index, to) in [(3, v(2.0, 2.0)), (usize::MAX, v(2.0, 2.0)), (0, v(f64::NAN, 0.0)), (0, v(0.0, f64::INFINITY))] { + assert!(stretch_grip(&kind, index, to).is_none()); + } + for boundary in [vec![], vec![v(0.0, 0.0)], vec![v(0.0, 0.0), v(0.0, 0.0)]] { + let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary }); + for index in [0, 1, usize::MAX] { + let result = stretch_grip(&kind, index, v(2.0, 2.0)); + assert_eq!(result.is_some(), index < kind.grips().len()); + } + } +} + #[test] fn grip_modes() { let mut s = Session::new(); 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 e984d9feb..73c610329 100644 --- a/crates/engine/src/last_used.rs +++ b/crates/engine/src/last_used.rs @@ -1,4 +1,4 @@ -use super::*; +use super::cmd; /// Options and values the interactive commands remember for the rest of the session, offered as /// the `` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeda8d7d..8f84b35d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod prompt; mod recent; pub mod sample; pub mod select; +pub mod settings_dir; pub mod snap; pub mod spatial; pub mod sysvars; @@ -43,6 +44,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; pub use cadcraft_doc as doc; +pub use cadcraft_fonts as fonts; pub use cadcraft_geom as geom; pub use cadcraft_render as render; pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; @@ -64,6 +66,14 @@ pub use running::{PendingWindow, Running}; pub use settings::Settings; pub use view::{ModelTile, ModelTiles, View}; +/// ` ()`, what `--version` prints after the program name in the app and the CLI. +/// The commit comes from `CADCRAFT_BUILD_SHA` at build time, `dev` without it. +pub fn build_version() -> &'static str { + static VERSION: std::sync::LazyLock = + std::sync::LazyLock::new(|| format!("{} ({})", env!("CARGO_PKG_VERSION"), option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev"))); + &VERSION +} + /// The editor session: open drawings, settings, the running command and the command log. pub struct Session { pub docs: Vec, @@ -132,6 +142,10 @@ pub struct Session { lisp_last: Option>, /// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks). pub hatch_picks: Option, + /// The object named with a point for the input a prompt picking one object is taking + /// ([`Session::input_object`]): a pick at exactly that point picks that object + /// ([`Session::pick_object`]). + pick_hint: Option<(Handle, Vec2)>, } impl Default for Session { @@ -182,6 +196,7 @@ impl Session { lisp: lisp::LispState::default(), lisp_last: None, hatch_picks: None, + pick_hint: None, } } @@ -314,3 +329,7 @@ impl Session { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../tests/support/temp_dir.rs"] +mod temp_dir; diff --git a/crates/engine/src/lisp/builtins/args.rs b/crates/engine/src/lisp/builtins/args.rs index 9ac9c3427..1bb986cb3 100644 --- a/crates/engine/src/lisp/builtins/args.rs +++ b/crates/engine/src/lisp/builtins/args.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, LispError, Value}; pub(super) type R = Result; diff --git a/crates/engine/src/lisp/builtins/files.rs b/crates/engine/src/lisp/builtins/files.rs index 6a1f90957..44452ae89 100644 --- a/crates/engine/src/lisp/builtins/files.rs +++ b/crates/engine/src/lisp/builtins/files.rs @@ -1,6 +1,6 @@ use super::args::{R, arity, string}; use super::output::emit; -use super::*; +use super::{Ctx, Flow, LispError, Value, charge_bytes}; // ---------------- files ---------------- @@ -73,17 +73,17 @@ pub(super) fn findfile(a: &[Value]) -> R { /// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure. #[cfg(not(target_arch = "wasm32"))] -pub fn read_text_file(path: &str) -> Option { +pub fn read_text_file(path: impl AsRef) -> Option { // Read through a byte-limited, regular-file-only reader so a path naming an endless device // cannot exhaust memory before the 64 MiB cap is enforced (CWE-400). const MAX: usize = 64 * 1024 * 1024; - let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?; + let bytes = crate::capped_read::read_capped(path.as_ref(), MAX).ok()?; Some(String::from_utf8_lossy(&bytes).into_owned()) } /// Return `None` on the web, where native filesystem paths are unavailable. #[cfg(target_arch = "wasm32")] -pub fn read_text_file(_path: &str) -> Option { +pub fn read_text_file(_path: impl AsRef) -> Option { None } diff --git a/crates/engine/src/lisp/builtins/functions.rs b/crates/engine/src/lisp/builtins/functions.rs index 2f53d1651..2f94218f3 100644 --- a/crates/engine/src/lisp/builtins/functions.rs +++ b/crates/engine/src/lisp/builtins/functions.rs @@ -1,5 +1,5 @@ use super::args::{R, arity, list_arg, string}; -use super::*; +use super::{Arc, Ctx, Flow, LispError, Sym, Value, read, to_text}; // ---------------- functions of functions ---------------- diff --git a/crates/engine/src/lisp/builtins/geometry.rs b/crates/engine/src/lisp/builtins/geometry.rs index 7de673666..059d64d3c 100644 --- a/crates/engine/src/lisp/builtins/geometry.rs +++ b/crates/engine/src/lisp/builtins/geometry.rs @@ -1,5 +1,5 @@ +use super::Value; use super::args::{R, arity, num, point}; -use super::*; // ---------------- geometry ---------------- diff --git a/crates/engine/src/lisp/builtins/lists.rs b/crates/engine/src/lisp/builtins/lists.rs index ea5a4fb2c..6975ed5e0 100644 --- a/crates/engine/src/lisp/builtins/lists.rs +++ b/crates/engine/src/lisp/builtins/lists.rs @@ -1,5 +1,5 @@ use super::args::{R, arity, int, list_arg, num, string}; -use super::*; +use super::{LispError, Value, eq, equal}; // ---------------- predicates and lists ---------------- diff --git a/crates/engine/src/lisp/builtins/numbers.rs b/crates/engine/src/lisp/builtins/numbers.rs index e1baa86f8..2dfb6911c 100644 --- a/crates/engine/src/lisp/builtins/numbers.rs +++ b/crates/engine/src/lisp/builtins/numbers.rs @@ -1,5 +1,5 @@ use super::args::{R, arity, int, num, real_or_int}; -use super::*; +use super::{LispError, Ordering, Value, eq}; // ---------------- numbers ---------------- diff --git a/crates/engine/src/lisp/builtins/output.rs b/crates/engine/src/lisp/builtins/output.rs index 21db3f87d..d64fd84e3 100644 --- a/crates/engine/src/lisp/builtins/output.rs +++ b/crates/engine/src/lisp/builtins/output.rs @@ -1,5 +1,5 @@ use super::args::{arity, string}; -use super::*; +use super::{Arc, Ctx, Flow, LispError, Sym, Value, charge_bytes, real_text, to_text}; // ---------------- output ---------------- diff --git a/crates/engine/src/lisp/builtins/strings.rs b/crates/engine/src/lisp/builtins/strings.rs index 08456d3ca..46d985bf1 100644 --- a/crates/engine/src/lisp/builtins/strings.rs +++ b/crates/engine/src/lisp/builtins/strings.rs @@ -1,5 +1,5 @@ use super::args::{R, arity, int, list_arg, string}; -use super::*; +use super::{LispError, OsStr, Value, check_string_len, read, to_text}; // ---------------- strings ---------------- diff --git a/crates/engine/src/lisp/builtins/wcmatch.rs b/crates/engine/src/lisp/builtins/wcmatch.rs index 1f171b253..441086b6e 100644 --- a/crates/engine/src/lisp/builtins/wcmatch.rs +++ b/crates/engine/src/lisp/builtins/wcmatch.rs @@ -1,5 +1,5 @@ use super::args::{R, arity, string}; -use super::*; +use super::{LispError, Value}; /// `wcmatch`: `#` a digit, `@` a letter, `.` neither, `*` any run, `?` any character, `~` (first) /// not, `[…]` a set (`[~…]` not in it, ranges `a-z`), `` ` `` the next character as is, `,` diff --git a/crates/engine/src/lisp/drawing.rs b/crates/engine/src/lisp/drawing.rs index b4076fce6..238551094 100644 --- a/crates/engine/src/lisp/drawing.rs +++ b/crates/engine/src/lisp/drawing.rs @@ -64,8 +64,10 @@ fn items(p: &PickSet) -> Vec { /// `(command arg…)`: arguments go to the command line as typed input: a string starts a command /// or answers a prompt ("" is Enter), a number or a point is entered, an entity name or a -/// selection set picks objects, and `pause` waits for the user. A command not finished by the -/// arguments stays active for the next `command` call. No arguments: cancel the active command. +/// selection set picks objects (a name, alone or with a point as `entsel` gives it, also answers +/// a prompt picking one object: [`Session::input_object`]), and `pause` waits for the user. A +/// command not finished by the arguments stays active for the next `command` call. No +/// arguments: cancel the active command. pub fn command(ctx: &mut Ctx<'_>, a: Vec) -> Result { if a.is_empty() { if ctx.s.running.is_some() { @@ -108,14 +110,14 @@ pub fn command(ctx: &mut Ctx<'_>, a: Vec) -> Result { } } } - Value::Ename(h) => feed(ctx.s, Input::Pick(vec![*h])), + Value::Ename(h) => named(ctx.s, *h, None), Value::PickSet(p) => feed(ctx.s, Input::Pick(items(p))), Value::Cons(_) => { if let Some(p) = arg.as_point() { feed(ctx.s, Input::Point(Vec2::new(p[0], p[1]))); } else if let Value::Ename(h) = arg.car() { // An `entsel` result: (ename point). - feed(ctx.s, Input::Pick(vec![h])); + named(ctx.s, h, arg.cdr().car().as_point().map(|p| Vec2::new(p[0], p[1]))); } else { return Err(LispError::bad_arg("command argument", arg)); } @@ -134,6 +136,14 @@ fn feed(s: &mut Session, input: Input) { } } +fn named(s: &mut Session, h: Handle, at: Option) { + if s.running.is_some() + && let Err(e) = s.input_object(h, at) + { + s.echo(e.to_string()); + } +} + // ---------------- system variables ---------------- /// A JSON value as LISP: integers, reals, strings, lists (points). @@ -181,10 +191,22 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option bool { + RADIAN_VARS.iter().any(|v| v.eq_ignore_ascii_case(name.trim())) +} + pub fn getvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { arity(&a, 1, 1)?; let name = string(&a[0])?; - Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default())) + Ok(Flow::Value(match crate::sysvars::get(ctx.s, &name) { + Some(serde_json::Value::Number(n)) if in_radians(&name) => Value::Real(n.as_f64().unwrap_or(0.0).to_radians()), + v => v.map(|v| from_json(&v)).unwrap_or_default(), + })) } /// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors. @@ -195,6 +217,10 @@ pub fn setvar(ctx: &mut Ctx<'_>, a: Vec) -> Result { let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else { return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true)))); }; + let json = match json { + serde_json::Value::Number(n) if in_radians(&name) => serde_json::json!(n.as_f64().unwrap_or(0.0).to_degrees()), + other => other, + }; match crate::sysvars::set(ctx.s, &name, &json) { Ok(()) => Ok(Flow::Value(a[1].clone())), Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))), @@ -1029,7 +1055,7 @@ pub fn rtos(ctx: &mut Ctx<'_>, a: Vec) -> Result { } fn decimal(v: f64, prec: usize, dimzin: i64) -> String { - let mut s = format!("{v:.prec$}"); + let mut s = crate::units::fixed(v, prec); if s.starts_with("-0") && s.trim_start_matches(['-', '0', '.']).is_empty() { s = s.trim_start_matches('-').to_string(); } @@ -1097,5 +1123,5 @@ pub fn load_source(s: &Session, name: &str) -> Option { // As given (a path, or relative to the working folder), then in the support folder. let support = s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::>()).unwrap_or_default(); - candidates.iter().chain(&support).find_map(|c| super::builtins::read_text_file(c)) + candidates.iter().chain(&support).find_map(super::builtins::read_text_file) } diff --git a/crates/engine/src/lisp/startup.rs b/crates/engine/src/lisp/startup.rs index 118f4e7a1..392f88095 100644 --- a/crates/engine/src/lisp/startup.rs +++ b/crates/engine/src/lisp/startup.rs @@ -42,7 +42,7 @@ impl Session { if once && self.lisp.acad_loaded && !per_drawing { continue; } - let Some(text) = super::builtins::read_text_file(&dir.join(name).to_string_lossy()) else { continue }; + let Some(text) = super::builtins::read_text_file(dir.join(name)) else { continue }; if once { self.lisp.acad_loaded = true; } diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index a48c04eba..0cb31f395 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -106,46 +106,54 @@ fn equality_compares_symbols_as_autolisp_does() { #[test] fn lists_and_strings() { let mut s = session(); - assert_eq!(ok(&mut s, "(cons 1 '(2 3))"), "(1 2 3)"); - assert_eq!(ok(&mut s, "(cons 8 \"0\")"), "(8 . \"0\")"); - assert_eq!(ok(&mut s, "(cadr '(1 2 3))"), "2"); - assert_eq!(ok(&mut s, "(cdr (assoc 40 '((10 1.0 2.0 0.0) (40 . 2.5))))"), "2.5"); - assert_eq!(ok(&mut s, "(subst '(40 . 5.0) '(40 . 2.5) '((8 . \"0\") (40 . 2.5)))"), "((8 . \"0\") (40 . 5.0))"); - assert_eq!(ok(&mut s, "(append '(1) '(2 3) nil '(4))"), "(1 2 3 4)"); - assert_eq!(ok(&mut s, "(reverse '(1 2 3))"), "(3 2 1)"); - assert_eq!(ok(&mut s, "(nth 1 '(a b c))"), "B"); - assert_eq!(ok(&mut s, "(member 2 '(1 2 3))"), "(2 3)"); - assert_eq!(ok(&mut s, "(length '(1 2 3))"), "3"); - assert_eq!(ok(&mut s, "(last '(1 2 3))"), "3"); - assert_eq!(ok(&mut s, "(mapcar '+ '(1 2 3) '(10 20 30))"), "(11 22 33)"); - assert_eq!(ok(&mut s, "(mapcar '(lambda (x) (* x x)) '(1 2 3))"), "(1 4 9)"); - assert_eq!(ok(&mut s, "(apply 'max '(3 9 2))"), "9"); - assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)"); - assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) (function (lambda (a b) (> a b))))"), "(3 2 1)"); - assert_eq!(ok(&mut s, "(vl-remove-if 'minusp '(1 -2 3))"), "(1 3)"); - assert_eq!(ok(&mut s, "(strcat \"a\" \"b\" \"c\")"), "\"abc\""); - assert_eq!(ok(&mut s, "(substr \"Hello\" 2 3)"), "\"ell\""); - assert_eq!(ok(&mut s, "(strcase \"abc\")"), "\"ABC\""); - assert_eq!(ok(&mut s, "(strlen \"abc\" \"de\")"), "5"); - assert_eq!(ok(&mut s, "(itoa 42)"), "\"42\""); - assert_eq!(ok(&mut s, "(atoi \"12abc\")"), "12"); - assert_eq!(ok(&mut s, "(atof \"2.5mm\")"), "2.5"); - assert_eq!(ok(&mut s, "(atof \"x\")"), "0.0"); - assert_eq!(ok(&mut s, "(wcmatch \"Name\" \"N*\")"), "T"); - assert_eq!(ok(&mut s, "(wcmatch \"A12\" \"@##\")"), "T"); - assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"x*,a?c\")"), "T"); - assert_eq!(ok(&mut s, "(wcmatch \"abc\" \"~a*\")"), "nil"); - assert_eq!(ok(&mut s, "(wcmatch \"b\" \"[a-c]\")"), "T"); + list_builtins(&mut s); + string_builtins(&mut s); +} + +fn list_builtins(s: &mut Session) { + assert_eq!(ok(s, "(cons 1 '(2 3))"), "(1 2 3)"); + assert_eq!(ok(s, "(cons 8 \"0\")"), "(8 . \"0\")"); + assert_eq!(ok(s, "(cadr '(1 2 3))"), "2"); + assert_eq!(ok(s, "(cdr (assoc 40 '((10 1.0 2.0 0.0) (40 . 2.5))))"), "2.5"); + assert_eq!(ok(s, "(subst '(40 . 5.0) '(40 . 2.5) '((8 . \"0\") (40 . 2.5)))"), "((8 . \"0\") (40 . 5.0))"); + assert_eq!(ok(s, "(append '(1) '(2 3) nil '(4))"), "(1 2 3 4)"); + assert_eq!(ok(s, "(reverse '(1 2 3))"), "(3 2 1)"); + assert_eq!(ok(s, "(nth 1 '(a b c))"), "B"); + assert_eq!(ok(s, "(member 2 '(1 2 3))"), "(2 3)"); + assert_eq!(ok(s, "(length '(1 2 3))"), "3"); + assert_eq!(ok(s, "(last '(1 2 3))"), "3"); + assert_eq!(ok(s, "(mapcar '+ '(1 2 3) '(10 20 30))"), "(11 22 33)"); + assert_eq!(ok(s, "(mapcar '(lambda (x) (* x x)) '(1 2 3))"), "(1 4 9)"); + assert_eq!(ok(s, "(apply 'max '(3 9 2))"), "9"); + assert_eq!(ok(s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)"); + assert_eq!(ok(s, "(vl-sort '(3 1 2) (function (lambda (a b) (> a b))))"), "(3 2 1)"); + assert_eq!(ok(s, "(vl-remove-if 'minusp '(1 -2 3))"), "(1 3)"); +} + +fn string_builtins(s: &mut Session) { + assert_eq!(ok(s, "(strcat \"a\" \"b\" \"c\")"), "\"abc\""); + assert_eq!(ok(s, "(substr \"Hello\" 2 3)"), "\"ell\""); + assert_eq!(ok(s, "(strcase \"abc\")"), "\"ABC\""); + assert_eq!(ok(s, "(strlen \"abc\" \"de\")"), "5"); + assert_eq!(ok(s, "(itoa 42)"), "\"42\""); + assert_eq!(ok(s, "(atoi \"12abc\")"), "12"); + assert_eq!(ok(s, "(atof \"2.5mm\")"), "2.5"); + assert_eq!(ok(s, "(atof \"x\")"), "0.0"); + assert_eq!(ok(s, "(wcmatch \"Name\" \"N*\")"), "T"); + assert_eq!(ok(s, "(wcmatch \"A12\" \"@##\")"), "T"); + assert_eq!(ok(s, "(wcmatch \"abc\" \"x*,a?c\")"), "T"); + assert_eq!(ok(s, "(wcmatch \"abc\" \"~a*\")"), "nil"); + assert_eq!(ok(s, "(wcmatch \"b\" \"[a-c]\")"), "T"); let many = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(80), "*a".repeat(80)); - assert_eq!(ok(&mut s, &many), "T"); + assert_eq!(ok(s, &many), "T"); let too_deep = format!("(wcmatch \"{}\" \"{}\")", "a".repeat(400), "*a".repeat(400)); - assert!(eval(&mut s, &too_deep).is_err()); - assert_eq!(ok(&mut s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\""); - assert_eq!(ok(&mut s, "(vl-string-search \"c\" \"abc\")"), "2"); - assert_eq!(ok(&mut s, "(read \"(1 2)\")"), "(1 2)"); - assert_eq!(ok(&mut s, "(type \"x\")"), "STR"); - assert_eq!(ok(&mut s, "(type 'x)"), "SYM"); - assert_eq!(ok(&mut s, "(equal '(1 2.0) '(1 2.0000001) 1e-3)"), "T"); + assert!(eval(s, &too_deep).is_err()); + assert_eq!(ok(s, "(vl-string-subst \"X\" \"b\" \"abcb\")"), "\"aXcb\""); + assert_eq!(ok(s, "(vl-string-search \"c\" \"abc\")"), "2"); + assert_eq!(ok(s, "(read \"(1 2)\")"), "(1 2)"); + assert_eq!(ok(s, "(type \"x\")"), "STR"); + assert_eq!(ok(s, "(type 'x)"), "SYM"); + assert_eq!(ok(s, "(equal '(1 2.0) '(1 2.0000001) 1e-3)"), "T"); } #[test] @@ -397,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()))), @@ -520,7 +528,8 @@ fn interactive_routine_waits_for_the_user() { #[test] fn getangle_is_relative_to_angbase() { let mut s = session(); - s.cmdline("(setvar \"ANGBASE\" 90)").unwrap(); + // AutoLISP sets angle variables in radians: ANGBASE 90 degrees. + s.cmdline("(setvar \"ANGBASE\" (/ pi 2))").unwrap(); // A picked point at 180° in world terms is 90° from ANGBASE. s.cmdline("(setq a (getangle '(0 0)))").unwrap(); s.input(crate::Input::Point(cadcraft_geom::Vec2::new(-1.0, 0.0))).unwrap(); @@ -535,6 +544,38 @@ fn getangle_is_relative_to_angbase() { assert_eq!(to_text(&s.lisp_var("T2"), true), "3.14159"); } +/// `getvar` and `setvar` take angle variables in radians, as AutoCAD 2024's do (each set to 30 by +/// SETVAR, or to `(/ pi 6)` by `setvar`, reads 0.5235987755982987; DIMJOGANG's 45 degrees reads +/// 0.785398), while the SETVAR command keeps degrees. +#[test] +fn getvar_and_setvar_take_angle_variables_in_radians() { + let mut s = session(); + let degrees = |s: &Session, name: &str| crate::sysvars::get(s, name).and_then(|v| v.as_f64()).unwrap(); + // The defaults: polar tracking every 90 degrees, a 45-degree jog. + assert_eq!(ok(&mut s, "(list (equal (getvar \"POLARANG\") (/ pi 2) 1e-12) (equal (getvar \"DIMJOGANG\") (/ pi 4) 1e-12))"), "(T T)"); + // Set from LISP in radians, shown by SETVAR in degrees. + for (name, radians, deg) in + [("SNAPANG", "(/ pi 6)", 30.0), ("CHAMFERD", "0.95993108859688", 55.0), ("ANGBASE", "(/ pi 2)", 90.0), ("POLARANG", "(/ pi 4)", 45.0)] + { + ok(&mut s, &format!("(setvar \"{name}\" {radians})")); + assert!((degrees(&s, name) - deg).abs() < 1e-9, "{name}: {}", degrees(&s, name)); + assert_eq!(ok(&mut s, &format!("(equal (getvar \"{name}\") {radians} 1e-12)")), "T", "{name}"); + } + // Set by the SETVAR command in degrees, read by LISP in radians. + s.cmdline("SETVAR HPANG 45").unwrap(); + assert!((degrees(&s, "HPANG") - 45.0).abs() < 1e-9); + assert_eq!(ok(&mut s, "(equal (getvar \"HPANG\") 0.7853981633974482 1e-12)"), "T"); + // DIMJOGANG set to a whole number of degrees still takes a fraction of one from LISP. + s.cmdline("SETVAR DIMJOGANG 45").unwrap(); + ok(&mut s, "(setvar \"DIMJOGANG\" 0.5)"); + assert!((degrees(&s, "DIMJOGANG") - 0.5_f64.to_degrees()).abs() < 1e-9, "{}", degrees(&s, "DIMJOGANG")); + // It takes the value the dimension style takes: `true` is a DIMSCALE of 1. + crate::sysvars::set(&mut s, "DIMSCALE", &serde_json::json!(true)).unwrap(); + assert_eq!(crate::sysvars::get(&s, "DIMSCALE").and_then(|v| v.as_f64()), Some(1.0)); + // Other variables are as they were. + assert_eq!(ok(&mut s, "(getvar \"ANGDIR\")"), "0"); +} + #[test] fn appload_and_load() { let mut s = session(); @@ -555,6 +596,111 @@ fn appload_and_load() { assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type"))); } +/// `command` gives an entity name, alone or as `entsel` returns it (name and point), to a prompt +/// picking one object: the point picks that object, and a bare name stands at its DXF group 10 +/// point. Each call and its numbers are AutoCAD 2024's (accoreconsole, read back with `entget`); +/// AutoCAD ran TRIM and EXTEND in Standard mode there. +#[test] +fn command_names_objects_at_prompts_picking_one() { + use cadcraft_doc::EntityKind; + let near = |a: [f64; 2], b: [f64; 2]| (0..2).all(|i| (a[i] - b[i]).abs() <= 1e-9 * a[i].abs().max(1.0)); + // The drawing's points, lines and polyline widths after `forms`, each evaluated on its own. + let run = |forms: &[&str]| { + let mut s = session(); + ok(&mut s, "(setvar \"TRIMEXTENDMODE\" 0)"); + for f in forms { + ok(&mut s, f); + } + assert!(s.running.is_none(), "{forms:?}: a command is still running"); + let (mut points, mut lines, mut widths) = (Vec::new(), Vec::new(), Vec::new()); + for e in s.doc().unwrap().model.iter() { + match &e.kind { + EntityKind::Point(p) => points.push([p.p.x, p.p.y]), + EntityKind::Line(l) => lines.push(([l.a.x, l.a.y], [l.b.x, l.b.y])), + EntityKind::LwPolyline(pl) => widths.push(pl.const_width), + _ => {} + } + } + (points, lines, widths) + }; + let has_point = |points: &[[f64; 2]], p: [f64; 2]| points.iter().any(|q| near(*q, p)); + let has_line = |lines: &[([f64; 2], [f64; 2])], a: [f64; 2], b: [f64; 2]| lines.iter().any(|(p, q)| near(*p, a) && near(*q, b)); + let line = |name: &str, a: [f64; 2], b: [f64; 2]| { + format!("(setq {name} (progn (entmake '((0 . \"LINE\") (10 {} {} 0.0) (11 {} {} 0.0))) (entlast)))", a[0], a[1], b[0], b[1]) + }; + + // A circle named alone stands at its centre, where nothing is drawn. + let (points, ..) = run(&[ + "(setq a (progn (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 15.0))) (entlast)))", + "(command \"_.MEASURE\" a 10.275598705220492)", + ]); + assert!(points.len() == 9 && has_point(&points, [14.89599800946832, -1.763304653744166]), "{points:?}"); + // An arc named alone stands at its centre too, as near to either end: MEASURE runs from its + // end (250°), so its points run from 233.4° down to 34.4°, not from 46.6° up. + let (points, ..) = run(&[ + "(setq a (progn (entmake '((0 . \"ARC\") (10 -35000.0 81000.0 0.0) (40 . 1000.0) (50 . 0.5235987755982988) (51 . 4.363323129985824))) (entlast)))", + "(command \"_.MEASURE\" a 289.4960530820059)", + ]); + assert!(points.len() == 13 && has_point(&points, [-35596.04133310627, 80197.0462471419]), "{points:?}"); + assert!(has_point(&points, [-34174.59349504207, 81564.53883973838]), "{points:?}"); + // PEDIT takes a polyline by name. + let (_, _, widths) = run(&[ + "(setq a (progn (entmake '((0 . \"LWPOLYLINE\") (100 . \"AcDbEntity\") (100 . \"AcDbPolyline\") (90 . 3) (70 . 0) (10 0.0 0.0) (10 30.0 0.0) (42 . 0.4) (10 40.0 20.0))) (entlast)))", + "(command \"_.PEDIT\" a \"_W\" 1.25 \"\")", + ]); + assert_eq!(widths, [1.25]); + // `entsel` lists: the point picks the end LENGTHEN moves, BREAK's first point, the piece + // TRIM removes and the end EXTEND moves. + let (_, lines, _) = run(&[ + &line("a", [0.0, 0.0], [-11.745250375031729, -27.60523670660726]), + "(command \"_.LENGTHEN\" \"_DE\" 5.0 (list a '(-10.962233683362948 -25.764887592833446)) \"\")", + ]); + assert!(has_line(&lines, [0.0, 0.0], [-13.70279210420368, -32.2061094910418]), "{lines:?}"); + let (_, lines, _) = run(&[ + &line("a", [0.0, 0.0], [-97.11873949964188, 23.831710765295487]), + "(command \"_.BREAK\" (list a '(-19.77309329359773 4.852067095763353)) '(-77.20177993977879 18.94434070674469))", + ]); + assert!(lines.len() == 2 && has_line(&lines, [0.0, 0.0], [-19.77309329359773, 4.852067095763352]), "{lines:?}"); + assert!(has_line(&lines, [-77.20177993977879, 18.94434070674469], [-97.11873949964188, 23.83171076529548]), "{lines:?}"); + let (_, lines, _) = run(&[ + &line("a", [-38.39479850296135, -32.02872847799295], [38.39479850296135, 32.02872847799295]), + &line("b", [-9.422796057479543, -30.193534825244917], [-15.589739201426854, 9.328216303207391]), + &line("c", [35.428336962443794, 7.710053620262788], [-2.7893410422311042, 19.517216189270982]), + "(command \"_.TRIM\" a b c \"\" (list a '(1.9066151653265737 1.5904878218740615)) \"\")", + ]); + assert!(lines.len() == 4 && has_line(&lines, [-38.39479850296135, -32.02872847799294], [-12.50626762945319, -10.43265926101876]), "{lines:?}"); + assert!(has_line(&lines, [16.31949796010634, 13.61363490476688], [38.39479850296135, 32.02872847799294]), "{lines:?}"); + let (_, lines, _) = run(&[ + &line("a", [0.0, 0.0], [18.819750237318146, -6.7688256739971955]), + &line("b", [17.056820153998288, -35.36317014637398], [58.2221807952743, 8.2878674503852]), + "(command \"_.EXTEND\" a b \"\" (list a '(14.11481267798861 -5.076619255497897)) \"\")", + ]); + assert!(has_line(&lines, [0.0, 0.0], [37.63950047463629, -13.53765134799439]), "{lines:?}"); +} + +#[test] +fn a_named_object_holds_for_its_own_input_in_its_own_space() { + use cadcraft_doc::Space; + let mut s = session(); + ok(&mut s, "(setq a (progn (entmake '((0 . \"LINE\") (10 0.0 0.0 0.0) (11 10.0 0.0 0.0))) (entlast)))"); + // BREAK takes A at (5,3), off the line; a later pick there finds nothing. + ok(&mut s, "(command \"_.BREAK\" (list a '(5.0 3.0)))"); + assert!(s.prompt_text().contains("Specify second break point"), "{}", s.prompt_text()); + let model = s.space(); + assert_eq!(s.pick_object(&model, cadcraft_geom::Vec2::new(5.0, 3.0), 0.5).unwrap(), None); + s.cancel(); + // A paper-space copy of A named in model space picks nothing, not A under its start. + let p = { + let d = s.doc_mut().unwrap(); + let e = d.model.iter().next().unwrap(); + let (common, kind) = (e.common.clone(), e.kind.clone()); + d.add(&Space::Paper("Layout1".into()), common, kind).unwrap() + }; + ok(&mut s, &format!("(command \"_.BREAK\" (handent \"{}\"))", p.hex())); + assert!(s.prompt_text().contains("Select object"), "{}", s.prompt_text()); + s.cancel(); +} + #[test] fn appload_reads_a_lsp_path_and_reports_its_commands() { let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id())); @@ -868,3 +1014,28 @@ fn angtof_reads_the_string_in_the_given_units() { assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil"); assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil"); } + +/// #1920: the value of an expression typed at a prompt is the answer, not another expression. +/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack +/// overflowed and the process aborted (no `catch_unwind` catches that). +#[test] +fn a_lisp_value_at_a_prompt_is_not_evaluated_again() { + let mut s = session(); + s.cmdline("circle (setq q \"!q\")").unwrap(); + assert_eq!(ok(&mut s, "q"), "\"!q\""); + // "!q" is no centre point, so CIRCLE still asks for one; the session carries on. + assert!(s.running.is_some()); + s.cancel(); + // A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3. + s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap(); + assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius"); + s.cancel(); + // Values that answer the prompt still do. + s.cmdline("(setq r \"4\")").unwrap(); + s.cmdline("circle 0,0 !r").unwrap(); + let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Circle(c) => Some(c.radius), + _ => None, + }); + assert_eq!(r, Some(4.0)); +} diff --git a/crates/engine/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs index 30da0b897..97ecede6f 100644 --- a/crates/engine/src/mtext_edit.rs +++ b/crates/engine/src/mtext_edit.rs @@ -136,11 +136,9 @@ impl Formatted { i += 2; match code { None => {} - Some('P') => { - if f.paras.len() < MAX_PARAS { - f.push_char('\n', &cur); - f.paras.push(props.clone()); - } + Some('P') if f.paras.len() < MAX_PARAS => { + f.push_char('\n', &cur); + f.paras.push(props.clone()); } Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur), Some('L') => cur.underline = true, diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index c02551b80..1563be702 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -180,10 +180,13 @@ impl Prompt { (k, k.to_ascii_lowercase(), shortcut) }); // A whole keyword or its capital-letter shortcut wins over a prefix of another keyword - // (`M` is Multiple, not a prefix of mEthod). + // (`M` is Multiple, not a prefix of mEthod). A prefix reaches past the keyword's last + // capital, as in AutoCAD: `L` is none of LAyer, LType, ltScale or LWeight (AutoCAD 2024's + // CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale. + let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1); keys.clone() .find(|(_, kl, sc)| tl == *kl || tl == *sc) - .or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl))) + .or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl))) .map(|(k, _, _)| k.clone()) } } @@ -341,5 +344,32 @@ mod tests { let q = Prompt::new("", Accept::POINT).kw(&["3P", "2P", "Ttr"]); assert_eq!(q.match_keyword("3p").as_deref(), Some("3P")); assert_eq!(q.match_keyword("t").as_deref(), Some("Ttr")); + // A prefix reaches past the last capital (AutoCAD 2024's CHPROP: `L` is ambiguous). + let c = + Prompt::new("Enter property to change", Accept::TEXT).kw(&["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]); + assert_eq!(c.match_keyword("l"), None); + assert_eq!(c.match_keyword("la").as_deref(), Some("LAyer")); + assert_eq!(c.match_keyword("lts").as_deref(), Some("ltScale")); + assert_eq!(c.match_keyword("s").as_deref(), Some("ltScale")); + assert_eq!(c.match_keyword("tr").as_deref(), Some("TRansparency")); + // Even when no other keyword starts with it: AutoCAD 2024 answers QDIM's `DA` with "Point or + // option keyword required." and PEDIT's vertex `E` with "Invalid option keyword.". + let q = Prompt::new("", Accept::POINT).kw(&[ + "Continuous", + "Staggered", + "Baseline", + "Ordinate", + "Radius", + "Diameter", + "datumPoint", + "Edit", + "seTtings", + ]); + assert_eq!(q.match_keyword("da"), None); + assert_eq!(q.match_keyword("datump").as_deref(), Some("datumPoint")); + assert_eq!(q.match_keyword("p").as_deref(), Some("datumPoint")); + let v = Prompt::new("", Accept::TEXT).kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"]); + assert_eq!(v.match_keyword("e"), None); + assert_eq!(v.match_keyword("ex").as_deref(), Some("eXit")); } } diff --git a/crates/engine/src/running.rs b/crates/engine/src/running.rs index afcd0b38f..4ff8ce429 100644 --- a/crates/engine/src/running.rs +++ b/crates/engine/src/running.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Drawing, Handle, Interactive, Serialize, Vec2}; /// An interactive command in progress. pub struct Running { diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index d3db01c9b..6c70bee37 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -181,7 +181,9 @@ pub(crate) fn candidates<'a>( } } -/// The topmost entity within `aperture` of `p`. +/// The topmost entity within `aperture` of `p`: the one drawn last (on top in the draw order), not +/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is +/// picked, and DRAWORDER changes which). pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option { let ix = spatial::index(d, space); pick_with(d, space, &ix, p, aperture) @@ -189,7 +191,7 @@ pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option>, p: Vec2, aperture: f64) -> Option { let probe = Bounds2::new(p, p).expand(aperture); - let mut best: Option<(f64, Handle)> = None; + // Candidates come topmost first: the first within the aperture is the one. for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? { if !selectable(d, e) { continue; @@ -198,30 +200,24 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option Vec { let ix = spatial::index(d, space); let probe = Bounds2::new(p, p).expand(aperture); let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() }; - let mut hits: Vec<(f64, Handle)> = cands + cands .filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe))) - .map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle)) - .filter(|(dist, _)| *dist <= aperture) - .collect(); - hits.sort_by(|a, b| a.0.total_cmp(&b.0)); - hits.into_iter().map(|(_, h)| h).collect() + .filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture) + .map(|(e, _)| e.handle) + .collect() } /// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on @@ -486,6 +482,26 @@ mod tests { assert_eq!(fence_hits(EntityKind::XLine(line.clone()), [(0.0, 1.0), (0.0, 5.0)]), 0); assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0); } + + /// Two objects under the pickbox: AutoCAD 2024 picks the one drawn last, not the nearer one, + /// and DRAWORDER changes which (`(ssget pt)` in accoreconsole). + #[test] + fn a_pick_takes_the_object_drawn_last() { + let mut d = Drawing::new_metric(); + let circle = d + .add(&Space::Model, Common::default(), EntityKind::Circle(cadcraft_doc::Circle { center: Vec3::new(50.0, 50.0, 0.0), radius: 10.0 })) + .unwrap(); + let line = EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(40.5, 45.0, 0.0), b: Vec3::new(40.5, 55.0, 0.0) }); + let line = d.add(&Space::Model, Common::default(), line).unwrap(); + // 0.05 from the circle and 0.45 from the line, both inside an aperture of 1. + let p = Vec2::new(40.05, 50.0); + assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(line)); + assert_eq!(pick_all(&d, &Space::Model, p, 1.0), vec![line, circle]); + d.model.send_to_back(line); + assert_eq!(pick(&d, &Space::Model, p, 1.0), Some(circle)); + // Only one within reach: that one, whatever the order. + assert_eq!(pick(&d, &Space::Model, Vec2::new(41.3, 50.0), 1.0), Some(line)); + } } #[cfg(test)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index ef9bf8d73..d1ae67ee1 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -2,7 +2,10 @@ use super::doc_state::{DocState, Snapshot}; use super::error::{EngineError, Result}; use super::running::{PendingWindow, Running}; use super::view::View; -use super::*; +use super::{ + Arc, Bounds2, Drag, Drawing, Entity, Handle, Input, Prompt, Session, Space, Step, Value, Vec2, assoc, cmd, find_command, guard, lisp, pointmod, + prompt, select, snap, sysvars, units, +}; mod cmdline; mod execute; diff --git a/crates/engine/src/session/cmdline.rs b/crates/engine/src/session/cmdline.rs index 5a57e329e..2a1b45836 100644 --- a/crates/engine/src/session/cmdline.rs +++ b/crates/engine/src/session/cmdline.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Input, Result, Session, Value, Vec2, cmd, lisp, prompt, snap, units}; impl Session { /// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a @@ -69,6 +69,13 @@ impl Session { /// One token typed while a command runs. pub(crate) fn typed(&mut self, t: &str) -> Result<()> { + self.typed_token(t, true) + } + + /// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value + /// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would + /// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920). + fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> { let Some(prompt) = self.current_prompt() else { return Ok(()) }; let tt = t.trim(); if tt.is_empty() { @@ -84,7 +91,7 @@ impl Session { } // A LISP expression typed at a command's prompt: its value is the answer. // Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2"). - if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() { + if lisp && lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() { let src = tt.strip_prefix('!').unwrap_or(tt).to_string(); let v = match self.lisp_eval(&src, false, true, Vec::new()) { Ok(Some(Ok(v))) => v, @@ -96,9 +103,9 @@ impl Session { }; return match v { lisp::value::Value::Nil => Ok(()), - lisp::value::Value::Int(i) => self.typed(&i.to_string()), - lisp::value::Value::Real(r) => self.typed(&format!("{r}")), - lisp::value::Value::Str(s) => self.typed(&s), + lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false), + lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false), + lisp::value::Value::Str(s) => self.typed_token(&s, false), other => match other.as_point() { Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))), None => { diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 165ec0863..a4c57eec5 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -1,10 +1,30 @@ -use super::*; +use super::{Arc, EngineError, Result, Session, Snapshot, Value, cmd, find_command}; 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) { diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index f0270c798..95d439f8c 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocState, Drawing, EngineError, Result, Session, Space, Value, Vec2, View, cmd}; impl Session { /// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index. diff --git a/crates/engine/src/session/interactive.rs b/crates/engine/src/session/interactive.rs index 335004349..d90c91683 100644 --- a/crates/engine/src/session/interactive.rs +++ b/crates/engine/src/session/interactive.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, Bounds2, Drag, Drawing, EngineError, Entity, Handle, Input, PendingWindow, Prompt, Result, Running, Session, Snapshot, Step, Value, Vec2, + assoc, cmd, find_command, guard, pointmod, select, snap, sysvars, +}; impl Session { // ---------------- interactive commands ---------------- @@ -6,6 +9,7 @@ impl Session { /// Start a command by name or alias as if typed at the command line. pub fn start(&mut self, name: &str) -> Result<()> { self.last_polar = None; + self.pick_hint = None; let name = name.trim(); let (transparent, name) = match name.strip_prefix('\'') { Some(n) => (true, n), @@ -337,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; @@ -387,6 +405,8 @@ impl Session { /// started in, so associativity is not applied across a document switch; `ok` is false for a /// failed or non-undoable programmatic command, which skips the constraint pass. pub(super) fn after_command(&mut self, before: Option<&Arc>, 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 2c7a61108..a9d146571 100644 --- a/crates/engine/src/session/picking.rs +++ b/crates/engine/src/session/picking.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bounds2, Drawing, Entity, Handle, Input, PendingWindow, Result, Session, Space, Vec2, cmd, select}; impl Session { // ---------------- picking without a command ---------------- @@ -125,4 +125,47 @@ impl Session { st.previous_selection = sel.to_vec(); } } + + // ---------------- one object by name ---------------- + + /// Give the running command an object by name, as AutoLISP's `command` does with an entity + /// name or an `entsel` list (name and point). A prompt picking one object with a point + /// (BREAK's "Select object", MEASURE's "Select object to measure"…) takes the point, and a + /// pick there finds that object ([`Session::pick_object`]) wherever it lies. A bare name + /// stands at the object's DXF group 10 point (a line's start, a circle's centre, a + /// polyline's first vertex), the point AutoCAD picks it at. The name holds for that input + /// only: a later pick at the same point finds what lies there. Other prompts take the object + /// as a pick. + pub fn input_object(&mut self, h: Handle, at: Option) -> Result<()> { + let one = self.current_prompt().is_some_and(|p| p.accept.point && !p.accept.select && p.picks_objects()); + let p = if one { at.or_else(|| self.doc().ok().and_then(|d| group_10(d, d.entity(h)?))) } else { None }; + let Some(p) = p.filter(|p| p.is_finite()) else { return self.input(Input::Pick(vec![h])) }; + self.pick_hint = Some((h, p)); + let r = self.input(Input::Point(p)); + self.pick_hint = None; + r + } + + /// The object a prompt picking one object finds at `p` in `space`: the one named for that + /// point ([`Session::input_object`]), which is none when it lies in another space, else the + /// object under `p` within `aperture`. + pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result> { + let d = self.doc()?; + if let Some((h, q)) = self.pick_hint + && q == p + { + return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h)); + } + Ok(select::pick(d, space, p, aperture)) + } +} + +/// An object's DXF group 10 point: the first point of its entity list, or its first grip where +/// entity lists are not built in. +fn group_10(d: &Drawing, e: &Entity) -> Option { + let Some(codec) = cmd::file::entity_codec() else { return e.kind.grips().first().copied() }; + let (_, tags) = (codec.groups)(d, e); + let i = tags.iter().position(|t| t.code == 10)?; + let y = tags.get(i + 1).filter(|t| t.code == 20)?; + Some(Vec2::new(tags[i].value.as_f64(), y.value.as_f64())) } diff --git a/crates/engine/src/session/zoom.rs b/crates/engine/src/session/zoom.rs index 68be05a38..3936f3d14 100644 --- a/crates/engine/src/session/zoom.rs +++ b/crates/engine/src/session/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bounds2, Result, Session, Space, Vec2, View}; impl Session { // ---------------- views ---------------- diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index de936b0c7..32507162f 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize, Vec2, snap}; /// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's /// snap/grid spacing in the corresponding fields for UI and JSON consumers. diff --git a/crates/engine/src/settings_dir.rs b/crates/engine/src/settings_dir.rs new file mode 100644 index 000000000..689a5ade2 --- /dev/null +++ b/crates/engine/src/settings_dir.rs @@ -0,0 +1,82 @@ +//! The per-user CADCraft settings directory (logs, autosave, plotter configurations). The host +//! chooses it ([`set`], possibly to none); without a choice it is the platform's ([`platform`]). + +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Mutex; + +/// `None`: no choice yet; `Some(None)`: the host chose to have no settings directory. +static CHOSEN: Mutex>> = Mutex::new(None); + +/// The platform's settings directory, with `var` looking up the OS variables: `$XDG_CONFIG_HOME/cadcraft` +/// or `~/.config/cadcraft` on Linux and the BSDs, `~/Library/Application Support/CADCraft` on +/// macOS, `%APPDATA%\CADCraft` on Windows. An empty variable counts as unset, so a blank value +/// never means the working directory. +pub fn platform(var: impl Fn(&str) -> Option) -> Option { + let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from); + if cfg!(target_os = "macos") { + set("HOME").map(|h| h.join("Library/Application Support/CADCraft")) + } else if cfg!(windows) { + set("APPDATA").map(|a| a.join("CADCraft")) + } else { + // The XDG spec ignores a relative XDG_CONFIG_HOME. + set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft")) + } +} + +/// Use `dir` as the settings directory; `None` means there is none (plotter configurations are +/// neither read nor written). +pub fn set(dir: Option) { + if let Ok(mut chosen) = CHOSEN.lock() { + *chosen = Some(dir); + } +} + +/// The settings directory: the host's choice, else the platform's. +pub fn current() -> Option { + match CHOSEN.lock().ok().and_then(|c| c.clone()) { + Some(choice) => choice, + None => platform(|k| std::env::var_os(k)), + } +} + +#[cfg(test)] +mod tests { + use super::platform; + use std::ffi::OsString; + use std::path::PathBuf; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn empty_values_are_unset() { + assert_eq!(platform(env(&[("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")])), None, "not the working directory"); + assert_eq!(platform(env(&[])), None); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_and_bsd_follow_xdg_config_home() { + assert_eq!(platform(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft"))); + assert_eq!(platform(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + // The XDG spec says a relative XDG_CONFIG_HOME is ignored. + assert_eq!(platform(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_uses_application_support() { + assert_eq!(platform(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft"))); + } + + #[cfg(windows)] + #[test] + fn windows_uses_appdata() { + assert_eq!( + platform(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])), + Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft")) + ); + } +} diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 956d919c7..8def404d2 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -588,101 +588,110 @@ impl Sink<'_> { } None => p, }; - let cands = &mut *self.cands; - match &p { - Prim::Seg(s) => { - if osmode & mode::END != 0 { - cands.push((mode::END, s.start())); - cands.push((mode::END, s.end())); - } - if osmode & mode::MID != 0 { - cands.push((mode::MID, s.mid())); - } - if let Segment::Arc { arc, .. } = s { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, arc.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - let a = k as f64 * PI / 2.0; - if arc.contains_angle(a) { - cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); - } - } - } - } - } - Prim::Circle(c) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, c.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); - } - } - } - Prim::Ellipse(el) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, el.center)); - } - if osmode & mode::QUA != 0 { - // Only the quadrant points the arc passes through. - for k in 0..4 { - let t = k as f64 * PI / 2.0; - if cadcraft_geom::angle_in_sweep(t, el.start, el.end) { - cands.push((mode::QUA, el.at_param(t))); - } - } - } - if osmode & mode::END != 0 && !el.is_full() { - cands.push((mode::END, el.at_param(el.start))); - cands.push((mode::END, el.at_param(el.end))); - } - if osmode & mode::MID != 0 - && let Some(m) = curves::ellipse_mid(el) - { - cands.push((mode::MID, m)); - } - } - Prim::Spline(s) => { - if osmode & mode::END != 0 && !s.closed { - let (lo, hi) = s.domain(); - cands.push((mode::END, s.eval(lo))); - cands.push((mode::END, s.eval(hi))); - } - if osmode & mode::MID != 0 - && let Some(m) = curves::spline_mid(s) - { - cands.push((mode::MID, m)); - } - } - Prim::Point(pt) => { - if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) { - cands.push((mode::NOD, *pt)); - } - } - Prim::Infinite { base, .. } => { - if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) { - cands.push((mode::END, *base)); - } - } - Prim::Fill(_) => {} - } - self.near.push(p); + self.primitive(kind, p); } if osmode & mode::GCEN != 0 { - // The centroid of the area a closed polyline or spline encloses. - let pts = match kind { - EntityKind::LwPolyline(pl) if pl.closed => { - cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0) + self.closed_centroid(kind, &at); + } + } + + fn primitive(&mut self, kind: &EntityKind, p: Prim) { + let osmode = self.osmode; + let cands = &mut *self.cands; + match &p { + Prim::Seg(s) => { + if osmode & mode::END != 0 { + cands.push((mode::END, s.start())); + cands.push((mode::END, s.end())); + } + if osmode & mode::MID != 0 { + cands.push((mode::MID, s.mid())); + } + if let Segment::Arc { arc, .. } = s { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, arc.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + let a = k as f64 * PI / 2.0; + if arc.contains_angle(a) { + cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); + } + } + } } - EntityKind::Spline(s) if s.closed => s.tessellate(1e-3), - _ => Vec::new(), - }; - if let Some(c) = centroid(&pts) { - self.cands.push((mode::GCEN, at(c))); } + Prim::Circle(c) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, c.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); + } + } + } + Prim::Ellipse(el) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, el.center)); + } + if osmode & mode::QUA != 0 { + // Only the quadrant points the arc passes through. + for k in 0..4 { + let t = k as f64 * PI / 2.0; + if cadcraft_geom::angle_in_sweep(t, el.start, el.end) { + cands.push((mode::QUA, el.at_param(t))); + } + } + } + if osmode & mode::END != 0 && !el.is_full() { + cands.push((mode::END, el.at_param(el.start))); + cands.push((mode::END, el.at_param(el.end))); + } + if osmode & mode::MID != 0 + && let Some(m) = curves::ellipse_mid(el) + { + cands.push((mode::MID, m)); + } + } + Prim::Spline(s) => { + if osmode & mode::END != 0 && !s.closed { + let (lo, hi) = s.domain(); + cands.push((mode::END, s.eval(lo))); + cands.push((mode::END, s.eval(hi))); + } + if osmode & mode::MID != 0 + && let Some(m) = curves::spline_mid(s) + { + cands.push((mode::MID, m)); + } + } + Prim::Point(pt) => { + if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) { + cands.push((mode::NOD, *pt)); + } + } + Prim::Infinite { base, .. } => { + if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) { + cands.push((mode::END, *base)); + } + } + Prim::Fill(_) => {} + } + self.near.push(p); + } + + fn closed_centroid(&mut self, kind: &EntityKind, at: &impl Fn(Vec2) -> Vec2) { + // The centroid of the area a closed polyline or spline encloses. + let pts = match kind { + EntityKind::LwPolyline(pl) if pl.closed => { + cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0) + } + EntityKind::Spline(s) if s.closed => s.tessellate(1e-3), + _ => Vec::new(), + }; + if let Some(c) = centroid(&pts) { + self.cands.push((mode::GCEN, at(c))); } } } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index e22529aac..b532f66dd 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -70,6 +70,7 @@ const SESSION_VARS: &[&str] = &[ "FONTFALLBACK", "SAVETIME", "SAVEFILEPATH", + "ISAVEBAK", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -154,6 +155,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)), @@ -366,6 +368,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { as_f64(v).filter(|m| *m >= 0.0 && *m <= f64::from(crate::autosave::MAX_SAVETIME)).ok_or_else(bad)?.round() as u32, ), "SAVEFILEPATH" => crate::autosave::set_savefilepath(v.as_str().ok_or_else(bad)?), + "ISAVEBAK" => crate::autosave::set_isavebak(as_bool(v).ok_or_else(bad)?), // A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line). "ANGBASE" => { let a = match v { @@ -375,6 +378,15 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?; s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a)); } + // Whole numbers inside the range UNITS clamps to. + "LUNITS" | "LUPREC" | "AUNITS" | "AUPREC" => { + let (_, lo, hi) = crate::cmd::UNIT_RANGES.iter().find(|(k, ..)| k.eq_ignore_ascii_case(&n)).copied().ok_or_else(bad)?; + let i = as_i(v) + .or_else(|| as_f64(v).filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64)) + .filter(|i| (lo..=hi).contains(i)) + .ok_or_else(bad)?; + s.doc_mut()?.header.set_i64(&n, i); + } "CONSTRAINTINFER" => s.doc_mut()?.parametric.settings.infer = as_bool(v).ok_or_else(bad)?, _ => { if let Some(r) = table::set(s, &n, v) { @@ -387,6 +399,13 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { if !st.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v.clone())])).is_empty() { return Err(bad()); } + // A real style field (DIMJOGANG, DIMSCALE...) is a real variable, even set to a + // whole number first, so a later fraction still fits. It takes the value the style + // took. + if let Some(f) = serde_json::to_value(&st).ok().and_then(|j| j.get(*field).filter(|f| f.is_f64()).and_then(|f| f.as_f64())) { + d.header.set_f64(&n, f); + return Ok(()); + } } let val = match v { Value::Number(x) if x.is_i64() => cadcraft_doc::HVal::Int(x.as_i64().unwrap_or(0)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8c590dd97..1f3d0e8e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -344,6 +344,15 @@ fn fillet_two_lines() { assert!(g.iter().any(|p| p.near(Vec2::new(2.0, 0.0), 1e-9))); } +#[test] +fn offset_zero_distance_is_rejected() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let r = s.execute("offset", &json!({"handle": "100", "distance": 0, "side": [1, 1]})); + assert!(r.is_err(), "offset distance 0 made a coincident copy: {r:?}"); + assert_eq!(s.doc().unwrap().model.len(), 1); +} + #[test] fn offset_circle_and_polyline() { let mut s = Session::new(); @@ -633,6 +642,17 @@ fn setvar_keeps_header_types() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0); } +#[test] +fn setvar_rejects_out_of_range_units() { + let mut s = Session::new(); + for (name, v) in [("LUPREC", 100), ("LUNITS", 9), ("LUNITS", 0), ("AUPREC", -5), ("AUNITS", 5)] { + assert!(s.execute("setvar", &json!({"name": name, "value": v})).is_err(), "{name} {v}"); + } + s.execute("setvar", &json!({"name": "LUPREC", "value": 8})).unwrap(); + assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 8); + assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9); +} + #[test] fn dynamic_input_pointer_settings() { let mut s = Session::new(); @@ -680,25 +700,6 @@ fn cancel_keeps_partial_line_as_one_undo() { assert_eq!(s.doc().unwrap().model.len(), 0); } -#[test] -fn hostile_params_never_panic() { - let mut s = Session::new(); - for c in command_specs() { - for p in [ - json!(null), - json!([]), - json!("x"), - json!({"points": [[1e308, -1e308], ["a"]]}), - json!({"handles": ["zz", 99999]}), - json!({"radius": -1, "center": [0, 0]}), - json!({"gradient": {"name": 5, "color1": [1], "angle": "x", "centered": "y"}}), - json!({"points": [[0, 0]], "handles": ["1"], "gradient": "x", "name": "RAINBOW"}), - ] { - let _ = s.execute(c.id, &p); - } - } -} - #[test] fn every_interactive_command_starts_and_cancels() { for c in command_specs().iter().filter(|c| c.interactive.is_some()) { @@ -1433,3 +1434,34 @@ fn hostile_variable_like_input_never_panics() { s.cmdline("pickadd").unwrap(); assert!(setvar_prompt_open(&s)); } + +#[test] +fn chamfer_rejects_negative_distances() { + let mut s = Session::new(); + let h1 = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h2 = s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap(); + let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()); + let r = s.execute("chamfer", &json!({"h1": h1["handles"][0], "h2": h2["handles"][0], "p1": [5, 0], "p2": [0, 5], "d1": -3, "d2": 2})); + assert!(r.is_err(), "negative chamfer distance accepted: {r:?}"); + let after = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()); + assert_eq!(before, after); +} + +#[test] +fn array_counts_reject_invalid_values() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); + for bad in [json!(-3), json!(0), json!(2.5)] { + for key in ["count", "rows", "levels"] { + let mut p = json!({"center": [0, 0], "associative": false}); + p[key] = bad.clone(); + assert!(s.execute("arraypolar", &p).is_err(), "arraypolar {key}={bad}"); + } + for key in ["rows", "cols", "levels"] { + let mut p = json!({"associative": false}); + p[key] = bad.clone(); + assert!(s.execute("arrayrect", &p).is_err(), "arrayrect {key}={bad}"); + } + } + s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap(); +} diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index 0a18a0039..3c6f3456e 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -68,23 +68,62 @@ pub fn format_distance(v: f64, lunits: i64, luprec: i64) -> String { }; format!("{}{}{}", if neg { "-" } else { "" }, whole, fs) } - _ => format!("{:.*}", prec, v), + _ => fixed(v, prec), } } -/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`). -fn scientific(v: f64, prec: usize) -> String { - let s = format!("{:.*E}", prec, v); - match s.split_once('E') { - Some((mantissa, exp)) => { - let (sign, digits) = match exp.strip_prefix('-') { - Some(d) => ('-', d), - None => ('+', exp), - }; - format!("{mantissa}E{sign}{digits:0>2}") - } - None => s, +/// `v` with `prec` decimals, rounded as AutoCAD rounds a number it shows (RTOS, LUNITS): at 15 +/// significant digits, with a half rounded away from zero. AutoCAD 2024 gives 1.01 for 1.005 to two +/// places (its binary value is just below 1.005), 3 for 2.5 and -0.13 for -0.125, where Rust's +/// formatting rounds the binary value and a half to even (1.00, 2 and -0.12). +pub fn fixed(v: f64, prec: usize) -> String { + let plain = || format!("{v:.prec$}"); + if !v.is_finite() { + return plain(); } + let sci = format!("{:.14e}", v.abs()); + let Some((mantissa, exp)) = sci.split_once('e') else { return plain() }; + let Ok(exp) = exp.parse::() else { return plain() }; + let sig: Vec = mantissa.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(); + // Digit `k` of `sig` is worth 10^(exp - k); keep those down to 10^-prec, in units of 10^-prec. + let digit = |k: i64| usize::try_from(k).ok().and_then(|k| sig.get(k)).copied().unwrap_or(0); + let keep = exp + 1 + prec as i64; + let mut units: Vec = (0..keep).map(digit).collect(); + if keep >= 0 && digit(keep) >= 5 { + let mut carry = true; + for d in units.iter_mut().rev() { + if *d == 9 { + *d = 0; + } else { + *d += 1; + carry = false; + break; + } + } + if carry { + units.insert(0, 1); + } + } + let n: String = units.iter().map(|d| char::from(b'0' + d)).collect(); + let n = format!("{:0>width$}", n.trim_start_matches('0'), width = prec + 1); + let (int, frac) = n.split_at(n.len() - prec); + let body = if prec == 0 { int.to_string() } else { format!("{int}.{frac}") }; + if v < 0.0 && n.bytes().any(|b| b != b'0') { format!("-{body}") } else { body } +} + +/// Scientific notation with a signed exponent of at least two digits (`1.7500E+01`, `1.00E-05`), +/// the mantissa rounded as [`fixed`] rounds. +fn scientific(v: f64, prec: usize) -> String { + let s = format!("{:.14e}", v); + let Some((m, e)) = s.split_once('e') else { return s }; + let (Ok(m), Ok(mut e)) = (m.parse::(), e.parse::()) else { return s }; + let mut mantissa = fixed(m, prec); + // 9.99… rounded up to 10: one more power of ten. + if mantissa.trim_start_matches('-').starts_with("10") { + e += 1; + mantissa = fixed(m / 10.0, prec); + } + format!("{mantissa}E{}{:0>2}", if e < 0 { '-' } else { '+' }, e.abs()) } fn gcd(a: i64, b: i64) -> i64 { @@ -435,11 +474,38 @@ mod tests { assert_eq!(format_distance(11.6, 3, 0), "1'-0\""); } + /// RTOS in AutoCAD 2024 (accoreconsole, DIMZIN 0): a half rounds away from zero, at 15 + /// significant digits. + #[test] + fn numbers_round_as_autocad_rounds_them() { + for (v, prec, want) in [ + (1500.5, 0, "1501"), + (2.5, 0, "3"), + (-2.5, 0, "-3"), + (0.125, 2, "0.13"), + (0.5, 0, "1"), + (1.005, 2, "1.01"), + (3.5, 0, "4"), + (0.0625, 3, "0.063"), + (-0.125, 2, "-0.13"), + (1234.5678, 3, "1234.568"), + ] { + assert_eq!(format_distance(v, 2, prec), want, "{v} to {prec}"); + } + assert_eq!(format_distance(2.5, 1, 0), "3E+00"); + assert_eq!(format_distance(0.125, 1, 1), "1.3E-01"); + assert_eq!(format_distance(9.96, 1, 1), "1.0E+01"); + assert_eq!(fixed(-0.004, 2), "0.00"); + assert_eq!(fixed(1e-20, 3), "0.000"); + assert_eq!(fixed(123456789012.0, 1), "123456789012.0"); + } + #[test] fn scientific_exponent_is_signed_and_padded() { assert_eq!(format_distance(17.5, 1, 4), "1.7500E+01"); assert_eq!(format_distance(0.00001, 1, 2), "1.00E-05"); - assert_eq!(format_distance(1234.5, 1, 3), "1.234E+03"); + // 1.2345 is halfway: away from zero, as AutoCAD rounds 2.5 to 3E+00. + assert_eq!(format_distance(1234.5, 1, 3), "1.235E+03"); assert_eq!(format_distance(12345678.0, 1, 3), "1.235E+07"); assert_eq!(format_distance(-17.5, 1, 4), "-1.7500E+01"); assert_eq!(format_distance(0.0, 1, 2), "0.00E+00"); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 9fd1e3f79..9f08b4fc5 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, Serialize, Vec2}; /// A 2D view of a space: world centre and visible height in drawing units. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/tests/aliases.rs b/crates/engine/tests/aliases.rs index 7587a9ffb..d1b39bb81 100644 --- a/crates/engine/tests/aliases.rs +++ b/crates/engine/tests/aliases.rs @@ -8,6 +8,11 @@ use cadcraft_engine::Session; use cadcraft_engine::cmd::resolve_alias; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); const PGP: &str = "\ @@ -55,10 +60,8 @@ fn an_alias_file_adds_and_overrides_aliases() { #[test] fn reinit_reads_the_alias_file_again() { let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); - let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("mine.pgp").to_string_lossy().into_owned(); - std::fs::write(&file, "ZZR, *RECTANG\n").unwrap(); + let dir = TempDir::new("aliases"); + let file = dir.file("mine.pgp", "ZZR, *RECTANG\n"); let mut s = Session::new(); s.execute("aliasedit", &json!({ "file": file })).unwrap(); assert_eq!(resolve_alias("zzr"), "rectang"); diff --git a/crates/engine/tests/block_editor.rs b/crates/engine/tests/block_editor.rs index 711bcd796..4eb8a2a2f 100644 --- a/crates/engine/tests/block_editor.rs +++ b/crates/engine/tests/block_editor.rs @@ -140,7 +140,8 @@ fn what_the_block_editor_refuses() { assert!(s.execute("bclose", &json!({})).is_err(), "not open"); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); - assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open"); + s.execute("bedit", &json!({ "name": "DOOR" })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Door"), "the block already open stays open"); let r = s.execute("layout.set", &json!({ "name": "Layout1" })); assert!(r.is_err_and(|e| e.to_string().contains("Block Editor"))); assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model); @@ -370,3 +371,129 @@ fn the_current_drawing_opens_in_the_block_editor() { s.undo().unwrap(); assert_eq!(s.doc().unwrap().model.len(), 1); } + +/// BEDIT in the Block Editor switches to another block, as AutoCAD's does: changes not saved yet +/// are saved or discarded first (`save`), and a block it can't open leaves the editor as it was. +#[test] +fn bedit_in_the_block_editor_switches_blocks() { + let mut s = Session::new(); + door(&mut s); + let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]); + s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap(); + let model = s.doc().unwrap().model.len(); + + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + line(&mut s, [0.0, 0.0], [0.0, 3.0]); + // Changes not saved: JSON must say what to do with them. + let e = s.execute("bedit", &json!({ "name": "Frame" })).unwrap_err().to_string(); + assert!(e.contains("save"), "{e}"); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved(), "nothing was lost"); + // A block that isn't there, or `save` that isn't a bool: refused, nothing changes. + assert!(s.execute("bedit", &json!({ "name": "Nope", "save": true })).is_err()); + assert!(s.execute("bedit", &json!({ "name": "Frame", "save": "yes" })).is_err()); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved()); + + // Saved, then switched. + let r = s.execute("bedit", &json!({ "name": "frame", "save": true })).unwrap(); + assert_eq!((r["block"].as_str(), r["saved"].as_bool()), (Some("Frame"), Some(true))); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert_eq!(lines_of(&s, "Door").len(), 2, "Door keeps the new line"); + + // Discarded, then switched; with nothing to save `save` isn't needed. + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + s.execute("bedit", &json!({ "name": "Door", "save": false })).unwrap(); + assert_eq!(lines_of(&s, "Frame").len(), 1, "Frame's change was discarded"); + s.execute("bedit", &json!({ "name": "Frame" })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + // A new block, and the drawing itself. + s.execute("bedit", &json!({ "name": "Gate", "create": true })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Gate")); + s.execute("bedit", &json!({ "name": "" })).unwrap(); + s.execute("bclose", &json!({ "save": false })).unwrap(); + assert!(editor(&s).is_none()); + assert_eq!(s.doc().unwrap().model.len(), model, "the drawing as it was"); +} + +/// A block BEDIT can't switch to is refused before it asks about the changes not saved, so the +/// question never traps the user; `check` asks the same of JSON callers. +#[test] +fn bedit_refuses_a_bad_target_before_asking_to_save() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + assert!(s.state().unwrap().block_unsaved()); + s.script( + "BEDIT +Nope +", + ) + .unwrap(); + let message = s.current_prompt().map(|p| p.message).unwrap_or_default(); + assert!(!message.contains("Save changes"), "no question for a block it can't open: {message}"); + s.input(cadcraft_engine::Input::Cancel).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved(), "nothing lost"); + // The check, without switching or saving. + assert!(s.execute("bedit", &json!({ "name": "Nope", "check": true })).is_err()); + assert!(s.execute("bedit", &json!({ "name": "Nope", "create": true, "check": true })).is_ok(), "a new name is fine"); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved()); +} + +/// Typed, BEDIT in the Block Editor asks whether to save the changes before it switches. +#[test] +fn bedit_typed_in_the_block_editor_asks_to_save_first() { + let mut s = Session::new(); + door(&mut s); + let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]); + s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap(); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + + // Nothing changed: it switches at once. + s.script( + "BEDIT +Frame +", + ) + .unwrap(); + assert!(s.running.is_none()); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + s.script( + "BEDIT +Door +", + ) + .unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Save changes to block \"Frame\"?"); + // Escape keeps the editor on Frame with its change. + s.input(cadcraft_engine::Input::Cancel).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert!(s.state().unwrap().block_unsaved()); + + s.script( + "BEDIT +Door +Y +", + ) + .unwrap(); + assert!(s.running.is_none()); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert_eq!(lines_of(&s, "Frame").len(), 2, "saved before switching"); + + line(&mut s, [3.0, 3.0], [4.0, 4.0]); + s.script( + "BEDIT +Frame +N +", + ) + .unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert_eq!(lines_of(&s, "Door").len(), 1, "discarded"); +} diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs index 77bfd9d85..41f7a92a7 100644 --- a/crates/engine/tests/insert_file.rs +++ b/crates/engine/tests/insert_file.rs @@ -8,6 +8,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line}; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + /// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from /// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10. fn read(bytes: &[u8], _name: &str) -> Result { @@ -44,15 +49,6 @@ fn hooks() { set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); } -/// Write a test drawing file with $INSBASE `base`; returns its path. -fn file(name: &str, base: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join(name); - std::fs::write(&p, base).unwrap(); - p.to_string_lossy().into_owned() -} - /// Lines in model space as (ax, ay, bx, by), rounded. fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> { let r = |v: f64| (v * 1e6).round() / 1e6; @@ -105,7 +101,9 @@ fn star_name_inserts_the_blocks_objects_with_one_scale_factor() { #[test] fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { hooks(); - let bolt = file("bolt.dxf", "10,10"); + // Test drawing files hold their $INSBASE. + let dir = TempDir::new("insert-file"); + let bolt = dir.file("bolt.dxf", "10,10"); let mut s = Session::new(); // This drawing's own layer PARTS keeps its colour. s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap(); @@ -128,7 +126,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { assert_eq!(inserts(&s).len(), 2); // NAME=path defines the block under that name without asking; *path inserts the file's objects. - let pin = file("pin.dxf", "10,10"); + let pin = dir.file("pin.dxf", "10,10"); s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap(); assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text()); assert!(s.doc().unwrap().block("M8").is_some()); @@ -138,7 +136,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0)); // JSON: `file` with an optional block name, never a dialog. - let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap(); + let r = s.execute("insert", &json!({ "file": dir.file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap(); assert!(r["handle"].is_string(), "{r}"); assert!(inserts(&s).iter().any(|i| i.0 == "washer")); diff --git a/crates/engine/tests/it/attribute_value_baseline.rs b/crates/engine/tests/it/attribute_value_baseline.rs new file mode 100644 index 000000000..8f7523e0b --- /dev/null +++ b/crates/engine/tests/it/attribute_value_baseline.rs @@ -0,0 +1,203 @@ +//! Small value changes must not export an imported justified attribute's old baseline. +use cadcraft_doc::{Attrib, Drawing, EntityKind, HAlign, Handle, Text, VAlign}; +use cadcraft_engine::{Input, Session}; +use cadcraft_geom::Vec3; +use serde_json::json; + +const BEFORE: &str = "POWER ADAPTER"; +const AFTER: &str = "POWER ADAPTER."; + +fn fixture() -> (Session, Handle, Handle) { + let mut s = Session::new(); + for (tag, value, y) in [("T", BEFORE, 15), ("U", "ADPT-01", 0)] { + s.execute("attdef", &json!({"tag":tag,"default":value,"at":[0,y],"height":3})).unwrap(); + } + s.execute("line", &json!({"points":[[0,-5],[40,-5]]})).unwrap(); + let handles: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name":"Equipment","handles":handles,"base":[0,0],"keep":"delete"})).unwrap(); + let a = s.execute("insert", &json!({"name":"Equipment","at":[100,200],"scale":2,"rotation":30})).unwrap(); + let b = s.execute("insert", &json!({"name":"Equipment","at":[300,200],"attribs":{"T":"Other"}})).unwrap(); + let a = Handle::parse_hex(a["handle"].as_str().unwrap()).unwrap(); + let b = Handle::parse_hex(b["handle"].as_str().unwrap()).unwrap(); + // Set existing document fields directly: this regression does not require Text Options. + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + let t = &mut i.attribs[0].text; + t.insert.z = 7.0; + t.align_pt = Some(t.insert); + t.halign = HAlign::Center; + t.valign = VAlign::Top; + }) + .unwrap(); + let imported = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let mut s = Session::empty(); + s.open_drawing(imported, "Imported", None); + (s, a, b) +} + +fn attributes(d: &Drawing, h: Handle) -> Vec { + let EntityKind::Insert(i) = &d.entity(h).unwrap().kind else { panic!("insert") }; + i.attribs.clone() +} + +fn baseline(d: &Drawing, t: &Text) -> cadcraft_geom::Vec2 { + let style = d.text_style(&t.style).unwrap(); + let (h, v) = cadcraft_render::text_align(t.halign, t.valign); + cadcraft_fonts::baseline_start( + &cadcraft_fonts::TextFont::resolve(&style.font), + &t.value, + &cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: t.align_pt.map(|p| p.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: style.backwards, + upside_down: style.upside_down, + }, + ) +} + +#[test] +fn all_attribute_value_paths_refresh_small_justified_export_changes() { + for path in ["json", "interactive", "global-json", "global-interactive"] { + let (mut s, a, b) = fixture(); + let before = attributes(s.doc().unwrap(), a); + let other = attributes(s.doc().unwrap(), b); + let definition = s.doc().unwrap().block("Equipment").unwrap().clone(); + match path { + "json" => { + s.execute("attedit", &json!({"handle":a.hex(),"values":{"t":AFTER}})).unwrap(); + } + "interactive" => { + s.start("attedit").unwrap(); + s.input(Input::Pick(vec![a])).unwrap(); + s.input(Input::Text(AFTER.into())).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.running.is_none()); + } + "global-json" => { + s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER."})).unwrap(); + } + "global-interactive" => { + s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER.\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + } + _ => panic!("path"), + } + let edited = attributes(s.doc().unwrap(), a); + assert_eq!(edited[0].text.value, AFTER, "{path}"); + assert_eq!(edited[0].text.align_pt, before[0].text.align_pt); + assert_eq!(edited[0].text.insert.z, 7.0); + assert_eq!(edited[1], before[1]); + assert_eq!(attributes(s.doc().unwrap(), b), other); + assert_eq!(s.doc().unwrap().block("Equipment").unwrap(), &definition); + let expected = baseline(s.doc().unwrap(), &edited[0].text); + assert!(expected.dist(before[0].text.insert.xy()) > 0.01, "fixture must change width"); + for name in ["edited.dxf", "edited.dwg"] { + let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap(); + let restored = attributes(&reopened, a); + assert!( + restored[0].text.insert.xy().dist(expected) < 1e-8, + "{path} {name}: stale {:?}, expected {expected:?}", + restored[0].text.insert.xy() + ); + assert_eq!(restored[0].text.align_pt, before[0].text.align_pt); + assert_eq!(restored[0].text.value, AFTER); + assert_eq!(restored[0].text.insert.z, 7.0); + } + s.undo().unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before, "{path} undo"); + s.redo().unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), edited, "{path} redo"); + } +} + +#[test] +fn equal_attribute_values_preserve_an_imported_font_baseline() { + let (mut s, a, _) = fixture(); + // A small imported-font offset must survive a no-op edit (writer tolerance is deliberate). + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + i.attribs[0].text.insert.x += 0.1; + }) + .unwrap(); + let before = attributes(s.doc().unwrap(), a); + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":BEFORE}})).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before); + s.start("attedit").unwrap(); + s.input(Input::Pick(vec![a])).unwrap(); + s.input(Input::Enter).unwrap(); + s.input(Input::Enter).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before); + s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER"})).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before, "global JSON no-op"); + s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(attributes(s.doc().unwrap(), a), before, "global interactive no-op"); +} + +/// Recomputing a justified baseline must not replace its independent insertion elevation. +#[test] +fn value_edits_preserve_distinct_insertion_and_alignment_elevations_on_export() { + let (mut s, a, _) = fixture(); + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + i.attribs[0].text.insert.z = 9.0; + }) + .unwrap(); + let anchor = attributes(s.doc().unwrap(), a)[0].text.align_pt; + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap(); + for name in ["elevations.dxf", "elevations.dwg"] { + let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap(); + let restored = attributes(&reopened, a); + assert_eq!(restored[0].text.insert.z, 9.0, "{name}: insertion elevation"); + assert_eq!(restored[0].text.align_pt.map(|p| p.xy()), anchor.map(|p| p.xy()), "{name}: alignment XY"); + if name.ends_with(".dxf") { + assert_eq!(restored[0].text.align_pt, anchor, "DXF retains independent alignment elevation"); + } else { + // The DWG bridge uses one text elevation for both points. + assert_eq!(restored[0].text.align_pt.unwrap().z, 9.0); + } + } +} + +#[test] +fn value_edits_preserve_left_baseline_and_two_point_placements() { + for horizontal in [HAlign::Left, HAlign::Aligned, HAlign::Fit] { + let (mut s, a, _) = fixture(); + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + let t = &mut i.attribs[0].text; + t.halign = horizontal; + t.valign = VAlign::Baseline; + t.insert = Vec3::new(10.0, 20.0, 7.0); + t.align_pt = (horizontal != HAlign::Left).then_some(Vec3::new(40.0, 30.0, 9.0)); + }) + .unwrap(); + let before = attributes(s.doc().unwrap(), a); + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap(); + let mut expected = before; + expected[0].text.value = AFTER.into(); + assert_eq!(attributes(s.doc().unwrap(), a), expected); + if horizontal != HAlign::Left { + let reopened = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let restored = attributes(&reopened, a); + assert_eq!(restored[0].text.insert, expected[0].text.insert); + assert_eq!(restored[0].text.align_pt, expected[0].text.align_pt); + assert_eq!(restored[0].text.halign, horizontal); + assert_eq!(restored[0].text.value, AFTER); + } + } +} diff --git a/crates/engine/tests/it/block_description.rs b/crates/engine/tests/it/block_description.rs new file mode 100644 index 000000000..8a0e5a81f --- /dev/null +++ b/crates/engine/tests/it/block_description.rs @@ -0,0 +1,211 @@ +//! Block Description (blocks.description): a block's description changes in the drawing, and in +//! the Block Editor BSAVE saves the edited block's new description. + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +/// A drawing with block "Door" (one line, described "Hinged door") and one reference to it, which +/// is returned. +fn door(s: &mut Session) -> Value { + door_with(s, false) +} + +fn door_with(s: &mut Session, annotative: bool) -> Value { + let l = s.execute("line", &json!({ "points": [[10, 10], [12, 10]] })).unwrap()["handles"][0].clone(); + let p = json!({ "name": "Door", "base": [10, 10], "handles": [l], "description": "Hinged door", "annotative": annotative }); + s.execute("block", &p).unwrap()["insert"].clone() +} + +fn description(s: &Session, block: &str) -> Option { + s.doc().unwrap().block(block).map(|b| b.description.clone()) +} + +/// The description of `block` in the drawing outside the Block Editor. +fn saved_description(s: &Session, block: &str) -> Option { + let st = s.state().unwrap(); + let d = st.block_edit.as_ref().map_or(&st.doc, |be| &be.host.doc); + d.block(block).map(|b| b.description.clone()) +} + +#[test] +fn the_description_of_a_block_changes_and_undo_puts_it_back() { + let mut s = Session::new(); + door(&mut s); + let r = s.execute("blocks.description", &json!({ "name": "door", "description": "Sliding door" })).unwrap(); + assert_eq!(r, json!({ "block": "Door", "description": "Sliding door" })); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + // An empty description clears it; 255 characters are the most a description takes. + s.execute("blocks.description", &json!({ "name": "Door", "description": "" })).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("")); + let longest = "é".repeat(255); + s.execute("blocks.description", &json!({ "name": "Door", "description": longest })).unwrap(); + assert_eq!(description(&s, "Door"), Some(longest)); +} + +#[test] +fn what_block_description_refuses() { + let mut s = Session::new(); + door(&mut s); + for p in [ + json!({ "name": "Door", "description": "x".repeat(256) }), + json!({ "name": "Door", "description": "two\nlines" }), + json!({ "name": "Door", "description": "carriage\rreturn" }), + json!({ "name": "Window", "description": "x" }), + json!({ "name": "Door" }), + json!({ "name": "Door", "description": 7 }), + json!({ "description": "nothing selected" }), + ] { + assert!(s.execute("blocks.description", &p).is_err(), "{p}"); + } + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); +} + +#[test] +fn block_refuses_a_description_a_block_cannot_take() { + let mut s = Session::new(); + for cmd in ["block", "-block"] { + for description in ["x".repeat(256), "two\nlines".into()] { + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + let p = json!({ "name": "B", "base": [0, 0], "handles": [l], "description": description }); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {description:?}"); + assert!(s.doc().unwrap().block("B").is_none()); + } + } + for (cmd, name) in [("block", "B"), ("-block", "C")] { + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + s.execute(cmd, &json!({ "name": name, "base": [0, 0], "handles": [l], "description": "x".repeat(255) })).unwrap(); + assert_eq!(description(&s, name), Some("x".repeat(255)), "{cmd}"); + } +} + +#[test] +fn the_selected_reference_names_the_block_at_the_prompts() { + let mut s = Session::new(); + let r = door(&mut s); + s.execute("select", &json!({ "handles": [r] })).unwrap(); + s.cmdline("blocks.description").unwrap(); + assert!(s.prompt_text().contains("Enter description for block \"Door\" "), "{}", s.prompt_text()); + // Enter keeps the description. + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + s.execute("select", &json!({ "handles": [] })).unwrap(); + s.script("blocks.description\nDoor\nSliding door\n").unwrap(); + assert!(s.running.is_none()); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); +} + +#[test] +fn the_block_editor_saves_the_new_description_with_the_block() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + assert!(s.state().unwrap().block_unsaved()); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + s.execute("bsave", &json!({})).unwrap(); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Sliding door")); + + // Closing without saving leaves the drawing's description as it was. + s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap(); + s.execute("bclose", &json!({ "save": false })).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Sliding door")); +} + +#[test] +fn bsaveas_gives_the_new_description_to_the_new_block_only() { + let mut s = Session::new(); + door_with(&mut s, true); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap(); + assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door")); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + + // The editor now edits the new block; saving again leaves the old block's description alone + // and keeps the new block's other properties. + s.execute("blocks.description", &json!({ "description": "Sliding door, 900" })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door, 900")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + assert!(s.doc().unwrap().block("Slider").unwrap().annotative); +} + +#[test] +fn bsaveas_over_another_block_keeps_the_saved_description() { + let mut s = Session::new(); + door(&mut s); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone(); + s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l], "description": "Casement" })).unwrap(); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap(); + assert_eq!(saved_description(&s, "Window").as_deref(), Some("Sliding door")); + // The prompt shows the saved description, and a later save keeps it. + s.cmdline("blocks.description").unwrap(); + assert!(s.prompt_text().contains(""), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 1]] })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Window").as_deref(), Some("Sliding door")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + + // An undo in the editor after saving over Window leaves Window's new description. + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Pocket door" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 2]] })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Window", "replace": true })).unwrap(); + s.execute("undo", &json!({})).unwrap(); + s.execute("bsave", &json!({})).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Window").as_deref(), Some("Pocket door")); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); +} + +#[test] +fn undo_after_bsaveas_does_not_give_the_old_block_the_new_description() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Sliding door" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 3]] })).unwrap(); + s.execute("bsaveas", &json!({ "name": "Slider" })).unwrap(); + // Undo goes back past the line to the editor's drawing as it was before BSAVEAS. + s.execute("undo", &json!({})).unwrap(); + s.execute("bsave", &json!({})).unwrap(); + assert_eq!(saved_description(&s, "Door").as_deref(), Some("Hinged door")); + assert_eq!(saved_description(&s, "Slider").as_deref(), Some("Sliding door")); + + // In the editor, only the edited block's description changes. + assert!(s.execute("blocks.description", &json!({ "name": "Door", "description": "x" })).is_err()); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Door").as_deref(), Some("Hinged door")); + assert_eq!(description(&s, "Slider").as_deref(), Some("Sliding door")); +} + +#[test] +fn a_new_block_in_the_block_editor_takes_its_description() { + let mut s = Session::new(); + s.execute("bedit", &json!({ "name": "Table", "create": true })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap(); + s.execute("blocks.description", &json!({ "description": "Desk" })).unwrap(); + assert_eq!(saved_description(&s, "Table"), None); + + // Saved under another name first, the started block is not created empty. + s.execute("bsaveas", &json!({ "name": "Desk" })).unwrap(); + assert_eq!(saved_description(&s, "Desk").as_deref(), Some("Desk")); + assert_eq!(saved_description(&s, "Table"), None); + s.execute("bclose", &json!({})).unwrap(); + assert!(s.doc().unwrap().block("Table").is_none()); + + s.execute("bedit", &json!({ "name": "Chair", "create": true })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap(); + s.execute("blocks.description", &json!({ "name": "chair", "description": "Office chair" })).unwrap(); + s.execute("bclose", &json!({})).unwrap(); + assert_eq!(description(&s, "Chair").as_deref(), Some("Office chair")); + assert_eq!(s.doc().unwrap().block("Chair").unwrap().entities.len(), 1); +} diff --git a/crates/engine/tests/it/chprop_command.rs b/crates/engine/tests/it/chprop_command.rs new file mode 100644 index 000000000..8e22c90af --- /dev/null +++ b/crates/engine/tests/it/chprop_command.rs @@ -0,0 +1,149 @@ +//! CHPROP as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back +//! with `entget`). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::color::{Color, Rgb}; +use cadcraft_engine::doc::{Handle, Lineweight, Transparency}; +use serde_json::json; + +/// A session with line A (0,0)-(50,0) on layer 0 and line B (0,20)-(50,30) on layer L1, as `a` and +/// `b` in LISP, after `code`; the properties of A then B. +fn run(code: &str) -> (Session, [cadcraft_engine::doc::Common; 2]) { + let mut s = Session::new(); + s.doc_mut().unwrap().ensure_layer("L1"); + s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap(); + s.cmdline("(setq a (entlast))").unwrap(); + s.execute("line", &json!({ "points": [[0, 20], [50, 30]] })).unwrap(); + s.cmdline("(setq b (entlast))").unwrap(); + s.cmdline("(command \"_.CHPROP\" b \"\" \"_LA\" \"L1\" \"\")").unwrap(); + s.cmdline(&format!("(progn {code})")).unwrap(); + if s.running.is_some() { + s.cancel(); + } + let common = { + let d = s.doc().unwrap(); + let mut it = d.model.iter().map(|e| e.common.clone()); + [it.next().unwrap(), it.next().unwrap()] + }; + (s, common) +} + +#[test] +fn chprop_sets_each_property_as_autocad() { + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 1 \"\")"); + assert_eq!(a.color, Color::Index(1)); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"blue\" \"\")"); + assert_eq!(a.color, Color::Index(5)); + let (_, [_, b]) = run("(command \"_.CHPROP\" b \"\" \"_C\" \"_BYBLOCK\" \"\")"); + assert_eq!(b.color, Color::ByBlock); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"\")"); + assert_eq!(a.color, Color::ByLayer); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"255,128,0\" \"\")"); + assert_eq!(a.color, Color::True(Rgb(255, 128, 0))); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"L1\" \"\")"); + assert_eq!(a.layer, "L1"); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"DASHED\" \"\")"); + assert_eq!(a.linetype, "DASHED"); + // Not loaded yet: loaded and given, as AutoCAD loads it from acadiso.lin. + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LT\" \"PHANTOM2\" \"\")"); + assert_eq!(a.linetype, "PHANTOM2"); + assert!(s.doc().unwrap().linetype("PHANTOM2").is_some()); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_S\" 2.5 \"\")"); + assert_eq!(a.ltscale, 2.5); + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LW\" 0.33 \"\")"); + assert_eq!(a.lineweight, Lineweight::Mm100(35)); + assert!(s.log.iter().any(|l| l == "Lineweight rounded to nearest valid value of 0.35 mm"), "{:?}", s.log); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_T\" 5.0 \"\")"); + assert_eq!(a.thickness, 5.0); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 50 \"\")"); + assert_eq!(a.transparency, Transparency::Percent(50)); + let (_, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" \"_BYBLOCK\" \"\")"); + assert_eq!(a.transparency, Transparency::ByBlock); + // Several objects and several properties in one go. + let (_, [a, b]) = run("(command \"_.CHPROP\" a b \"\" \"_C\" 3 \"_LA\" \"L1\" \"_LW\" 0.5 \"\")"); + for c in [a, b] { + assert_eq!((c.color, c.layer.as_str(), c.lineweight), (Color::Index(3), "L1", Lineweight::Mm100(50))); + } + let (_, [a, _]) = run( + "(command \"_.CHPROP\" a \"\" \"_C\" 3 \"\") (command \"_.CHPROP\" a \"\" \"_C\" \"_BYLAYER\" \"_LT\" \"_BYBLOCK\" \"_LW\" \"_BYLAYER\" \"\")", + ); + assert_eq!((a.color, a.linetype.as_str(), a.lineweight), (Color::ByLayer, "ByBlock", Lineweight::ByLayer)); +} + +#[test] +fn chprop_refuses_what_autocad_refuses() { + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_LA\" \"KHONGCO\" \"\")"); + assert_eq!(a.layer, "0"); + assert!(s.log.iter().any(|l| l == "Cannot find layer \"KHONGCO\"."), "{:?}", s.log); + for n in [0, 256, 300] { + let (s, [a, _]) = run(&format!("(command \"_.CHPROP\" a \"\" \"_C\" {n} \"\")")); + assert_eq!(a.color, Color::ByLayer, "{n}"); + assert!(s.log.iter().any(|l| l == "Invalid color number."), "{n}: {:?}", s.log); + } + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_C\" \"_T\" \"300,0,0\" \"\")"); + assert_eq!(a.color, Color::ByLayer); + assert!(s.log.iter().any(|l| l == "Requires three integers from 0 to 255, separated by commas."), "{:?}", s.log); + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_TR\" 95 \"\")"); + assert_eq!(a.transparency, Transparency::ByLayer); + assert!(s.log.iter().any(|l| l == "Requires ByLayer, ByBlock or an integer from 0 to 90."), "{:?}", s.log); + // `L` is LAyer, LType, ltScale or LWeight: none of them. + let (s, [a, _]) = run("(command \"_.CHPROP\" a \"\" \"_L\")"); + assert_eq!(a.layer, "0"); + assert!(s.log.iter().any(|l| l == "Ambiguous response, please clarify..."), "{:?}", s.log); +} + +/// Line a on layer 0 and lines b and c on the locked layer L1 (`a`, `b` and `c` in LISP); their +/// handles. +fn locked_drawing() -> (Session, Vec) { + let mut s = Session::new(); + s.doc_mut().unwrap().ensure_layer("L1"); + for (v, y) in [("a", 0), ("b", 20), ("c", 40)] { + s.execute("line", &json!({ "points": [[0, y], [50, y]] })).unwrap(); + s.cmdline(&format!("(setq {v} (entlast))")).unwrap(); + } + s.cmdline("(command \"_.CHPROP\" b c \"\" \"_LA\" \"L1\" \"\")").unwrap(); + s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap(); + s.log.clear(); + let hs = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + (s, hs) +} + +fn colors(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.common.color).collect() +} + +/// AutoCAD 2024 leaves objects on locked layers out at "Select objects" and says how many after +/// what was found; with none left, Enter ends the command. +#[test] +fn chprop_leaves_objects_on_locked_layers_alone() { + // The colours of a, b and c after `code`, run with `pickfirst` selected. + let after = |code: &str, pickfirst: &[usize]| { + let (mut s, hs) = locked_drawing(); + s.set_selection(pickfirst.iter().map(|i| hs[*i]).collect()); + s.cmdline(code).unwrap(); + (colors(&s), s.log.clone(), s.running.is_some()) + }; + let (c, log, _) = after("(command \"_.CHPROP\" (ssadd b (ssadd a)) \"\" \"_C\" 3 \"\")", &[]); + assert_eq!(c, [Color::Index(3), Color::ByLayer, Color::ByLayer]); + assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}"); + let (c, log, _) = after("(command \"_.CHPROP\" (ssadd c (ssadd b (ssadd a))) \"\" \"_C\" 4 \"\")", &[]); + assert_eq!(c, [Color::Index(4), Color::ByLayer, Color::ByLayer]); + assert!(log.iter().any(|l| l == "2 were on a locked layer."), "{log:?}"); + // Nothing left: Enter ends the command. + let (_, log, running) = after("(command \"_.CHPROP\" b \"\")", &[]); + assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}"); + assert!(!running); + // The pickfirst selection loses them too; with none of it left, CHPROP asks for objects. + let (c, log, _) = after("(command \"_.CHPROP\" \"_C\" 5 \"\")", &[0, 1]); + assert_eq!(c, [Color::Index(5), Color::ByLayer, Color::ByLayer]); + assert!(log.iter().any(|l| l == "1 was on a locked layer."), "{log:?}"); + let (c, _, _) = after("(command \"_.CHPROP\" a \"\" \"_C\" 6 \"\")", &[1]); + assert_eq!(c, [Color::Index(6), Color::ByLayer, Color::ByLayer]); + // The JSON form leaves them out as well, and says how many. + let (mut s, hs) = locked_drawing(); + let r = s.execute("chprop", &json!({ "handles": [hs[0].hex(), hs[1].hex()], "color": 3 })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}"); + let r = s.execute("chprop", &json!({ "handles": [hs[2].hex()], "color": 4 })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(colors(&s), [Color::Index(3), Color::ByLayer, Color::ByLayer]); +} diff --git a/crates/engine/tests/it/copy_count.rs b/crates/engine/tests/it/copy_count.rs new file mode 100644 index 000000000..64deab8f0 --- /dev/null +++ b/crates/engine/tests/it/copy_count.rs @@ -0,0 +1,24 @@ +//! JSON COPY refuses a `count` that is not an integer from 1 to 10000 instead of clamping it. + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +fn entities(s: &Session) -> usize { + s.doc().map(|d| d.model.iter().count()).unwrap_or(0) +} + +#[test] +fn invalid_copy_count_is_refused() { + let mut s = Session::new(); + let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h = r["handles"][0].clone(); + let before = entities(&s); + let bad: [Value; 7] = [json!(0), json!(-3), json!(2.5), json!("abc"), json!(null), json!(10001), json!(50000)]; + for c in bad { + let r = s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": c})); + assert!(r.is_err(), "count {c} should be refused"); + assert_eq!(entities(&s), before, "count {c} changed the drawing"); + } + s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": 3})).unwrap(); + assert_eq!(entities(&s), before + 3); +} diff --git a/crates/engine/tests/it/dim_commands.rs b/crates/engine/tests/it/dim_commands.rs index 8e67b1039..3c5b5c90a 100644 --- a/crates/engine/tests/it/dim_commands.rs +++ b/crates/engine/tests/it/dim_commands.rs @@ -99,6 +99,7 @@ fn jogged_radius() { let (dm, g) = dim_of(&s, &h); let j = dm.marks.jogged.clone().unwrap(); assert_eq!(g.value, "R50.0000", "the true radius"); + assert!((dm.measurement - 50.0).abs() < 1e-9, "the stored measurement is the radius, got {} (#2421)", dm.measurement); assert!(j.center.xy().dist(Vec2::new(20.0, -10.0)) < 1e-9); // The line runs from the arrowhead on the circle to the override, with a 45° jog (DIMJOGANG). let tip = Vec2::new(1.0, 1.0).normalized() * 50.0; @@ -257,3 +258,29 @@ fn marks_follow_move_and_rotate_and_hostile_values_do_not_panic() { .unwrap(); let _ = dim_of(&s, &h); } + +/// A dimension made in the session reports its measurement at once, not 0.0 until it is saved +/// and reopened (#2421). +#[test] +fn a_new_dimension_reports_its_measurement() { + let mut s = Session::new(); + let measurement = |s: &Session, h: &str| dim_of(s, h).0.measurement; + let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap()); + assert!((measurement(&s, &horizontal) - 100.0).abs() < 1e-9, "{}", measurement(&s, &horizontal)); + let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-10, 20] })).unwrap()); + assert!((measurement(&s, &vertical) - 37.5).abs() < 1e-9, "{}", measurement(&s, &vertical)); +} + +/// A new dimension's text position is known at once, as it is after save and reopen (#2421). +#[test] +fn a_new_dimension_reports_its_text_position() { + let mut s = Session::new(); + let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap()); + let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-20, 18] })).unwrap()); + for h in [&horizontal, &vertical] { + let (dm, g) = dim_of(&s, h); + assert!(dm.text_mid.xy().dist(g.text_pos) < 1e-9, "stored {:?}, drawn at {:?}", dm.text_mid, g.text_pos); + assert!(dm.text_mid.xy().dist(Vec2::ZERO) > 1.0, "still at the origin: {:?}", dm.text_mid); + } + assert!((dim_of(&s, &horizontal).0.text_mid.x - 50.0).abs() < 1e-9); +} diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 5a2c4cb6e..b6f78c005 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -34,6 +34,45 @@ fn area_object_add_and_subtract_keep_a_running_total() { assert!(s.running.is_none(), "{}", s.prompt_text()); } +#[test] +fn area_too_large_to_hold_is_refused() { + // A 2e154 square has an area of 4e308, past f64's range; its perimeter fits. + let big = json!([[0, 0], [2e154, 0], [2e154, 2e154], [0, 2e154]]); + let mut s = Session::new(); + let err = s.execute("area", &json!({ "points": big })).unwrap_err(); + assert!(err.to_string().contains("the area is too large"), "{err}"); + // Two sides of 1e308: the area fits, the perimeter does not. + let long = json!([[0, 0], [1e308, 0], [1e308, 1e-300]]); + let err = s.execute("area", &json!({ "points": long })).unwrap_err(); + assert!(err.to_string().contains("the perimeter is too large"), "{err}"); + + // Typed, nothing is reported. + let (_, log) = run(&["area", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]); + assert!(has(&log, "The area is too large."), "{log:?}"); + assert!(!has(&log, "Area = "), "{log:?}"); + + // Areas of 8.1e307 that each fit overflow the running total at the third; the total keeps two. + let sq = ["0,0", "9e153,0", "9e153,9e153", "0,9e153", ""]; + for mode in ["a", "s"] { + let mut lines = vec!["area", mode]; + for _ in 0..3 { + lines.extend(sq); + } + let (s, log) = run(&lines); + assert!(has(&log, "The total area is too large."), "{mode}: {log:?}"); + assert_eq!(log.iter().filter(|l| l.contains("Total area = ")).count(), 2, "{mode}: {log:?}"); + assert!(s.running.is_some(), "{mode}: the command goes on"); + } + + // MEASUREGEOM's Volume option does not keep such an area waiting for a height: the next area is measured. + let mut lines = vec!["measuregeom", "v", "a", "0,0", "2e154,0", "2e154,2e154", "0,2e154", ""]; + lines.extend(["0,0", "1,0", "1,1", "0,1", "", "2"]); + let (s, log) = run(&lines); + assert!(has(&log, "The area is too large."), "{log:?}"); + assert!(has(&log, "Total volume = 2"), "{log:?}"); + assert!(s.running.is_some(), "the command goes on"); +} + #[test] fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() { // Open, 160 long; the closing side back to 0,0 would add 116.619. @@ -42,10 +81,7 @@ fn area_object_on_an_open_polyline_closes_the_area_but_not_the_perimeter() { } #[test] -fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() { - let (s, log) = run(&["area", "0,0", "a"]); - assert!(has(&log, "not available yet"), "{log:?}"); - assert!(s.running.is_some()); +fn area_length_extends_the_last_segment() { // Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10. let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]); assert!(has(&log, "Area = 50.0000"), "{log:?}"); @@ -58,6 +94,91 @@ fn dist_multiple_points_sums_the_segments() { assert!(s.running.is_none(), "{}", s.prompt_text()); } +#[test] +fn area_arc_option_measures_arc_segments() { + // A 10 x 10 square whose right side is a half circle of radius 5 bulging out: + // area 100 + 12.5 pi, perimeter 30 + 5 pi. + let expected = "Area = 139.2699, Perimeter = 45.7080"; + + // Arc mode: a point ends an arc that continues the last segment; Line goes back to lines. + let (s, _) = run(&["area", "0,0", "10,0", "a"]); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + assert!(s.prompt_text().contains("CEnter"), "{}", s.prompt_text()); + let (s, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]); + assert!(has(&log, expected), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // The same half circle by its center, by its included angle and by a second point. + for arc in [&["ce", "10,5", "10,10"][..], &["a", "180", "10,10"], &["s", "15,5", "10,10"]] { + let mut lines = vec!["area", "0,0", "10,0", "a"]; + lines.extend_from_slice(arc); + lines.extend(["l", "0,10", ""]); + let (_, log) = run(&lines); + assert!(has(&log, expected), "{arc:?}: {log:?}"); + } + + // After the arc, Length continues along its end tangent (-X): 10 to 0,10. + let (_, log) = run(&["area", "0,0", "10,0", "a", "10,10", "l", "l", "10", ""]); + assert!(has(&log, expected), "{log:?}"); + + // Undo removes the arc segment; arc mode stays on. + let (s, _) = run(&["area", "0,0", "10,0", "a", "10,10", "u"]); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // CLose ends with an arc back to the first point: center 0,10, radius 10, 270 degrees. + let (s, log) = run(&["area", "0,0", "10,0", "10,10", "a", "cl"]); + assert!(has(&log, "Area = 335.6194, Perimeter = 67.1239"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // No arc continues the last segment back to the first point when it lies straight behind. + let (s, log) = run(&["area", "0,0", "10,0", "a", "cl"]); + assert!(has(&log, "Invalid arc endpoint."), "{log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // Add mode keeps its prefix in arc mode and adds the curved area to the total. + let (s, _) = run(&["area", "a", "0,0", "10,0", "a"]); + assert!(s.prompt_text().contains("(ADD mode) Specify endpoint of arc"), "{}", s.prompt_text()); + let (_, log) = run(&["area", "a", "0,0", "10,0", "a", "10,10", "l", "0,10", ""]); + assert!(has(&log, "Total area = 139.2699"), "{log:?}"); + + // MEASUREGEOM's Volume option measures the same area times the height. + let (_, log) = run(&["measuregeom", "v", "0,0", "10,0", "a", "10,10", "l", "0,10", "", "2"]); + assert!(has(&log, "Volume = 278.5398"), "{log:?}"); +} + +#[test] +fn dist_multiple_points_arc_option_adds_arc_lengths() { + // 10 along X, then a half circle of radius 5 up to 10,10: 10 + 5 pi. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10"]); + assert!(has(&log, "Distance = 25.7080"), "{log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{}", s.prompt_text()); + + // Line, then Length continues along the tangent at the arc's end (-X): 5 more. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "l", "l", "5", ""]); + assert!(has(&log, "Distance = 30.7080"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Undo removes the arc segment. + let (_, log) = run(&["dist", "0,0", "m", "10,0", "a", "10,10", "u", ""]); + assert_eq!(log.iter().filter(|l| l.contains("Distance = 10.0000")).count(), 2, "{log:?}"); + + // CLose adds an arc back to the first point and ends: 20 + 15 pi. + let (s, log) = run(&["dist", "0,0", "m", "10,0", "10,10", "a", "cl"]); + assert!(has(&log, "Distance = 67.1239"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // CLose needs a second point. + let (s, log) = run(&["dist", "0,0", "m", "a", "cl"]); + assert!(has(&log, "CLose needs at least two points."), "{log:?}"); + assert!(s.running.is_some()); + + // No arc continues the last segment to a point straight behind it. + for end in ["5,0", "cl"] { + let (s, log) = run(&["dist", "0,0", "m", "10,0", "a", end]); + assert!(has(&log, "Invalid arc endpoint."), "{end}: {log:?}"); + assert!(s.prompt_text().contains("Specify endpoint of arc"), "{end}: {}", s.prompt_text()); + } +} + #[test] fn area_of_an_open_elliptical_arc_is_refused_and_a_full_ellipse_is_measured() { for end in [90, 45] { diff --git a/crates/engine/tests/it/insert_prompts.rs b/crates/engine/tests/it/insert_prompts.rs index 89ea729d3..00c5b8e41 100644 --- a/crates/engine/tests/it/insert_prompts.rs +++ b/crates/engine/tests/it/insert_prompts.rs @@ -1,4 +1,4 @@ -//! Command-line INSERT: X/Y scale prompts and the Basepoint/Scale/Rotate options (#245). +//! Command-line INSERT: X/Y/Z scale prompts and the Basepoint/Scale/X/Y/Z/Rotate options (#245). use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; @@ -53,3 +53,79 @@ fn script_insert_reads_x_and_y_scale_then_rotation_and_honours_its_options() { s.execute("insert", &json!({ "name": "B1", "at": [0, 80], "scale": [2, 3], "basePoint": [10, 0] })).unwrap(); assert_eq!(inserts(&s).last(), Some(&(-20.0, 80.0, 2.0, 3.0, 0.0))); } + +/// (x, y, x scale, y scale, z scale, rotation in degrees) of the last block reference. +fn last_insert(s: &Session) -> (f64, f64, f64, f64, f64, f64) { + let r = |v: f64| (v * 1e9).round() / 1e9; + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some((r(i.insert.x), r(i.insert.y), r(i.scale.x), r(i.scale.y), r(i.scale.z), r(i.rotation.to_degrees()))), + _ => None, + }) + .last() + .unwrap() +} + +/// The options of AutoCAD 2024's -INSERT, each run as AutoLISP's `command` gives it (accoreconsole, +/// the block reference read back with `entget`): X/Y/Z presets set that axis alone and skip the +/// scale prompts, XYZ asks Z too, a Z factor not asked is the X factor's size, and a Basepoint is +/// picked on the block dropped at the cursor (the origin in a script). +#[test] +fn insert_options_match_autocad() { + // A line (0,0)-(10,0) and a circle at (5,5) r 3, around `base`, as block `name`. + let block = |s: &mut Session, name: &str, base: [f64; 2], unit: f64| { + let at = |x: f64, y: f64| [base[0] + x * unit, base[1] + y * unit]; + s.execute("line", &json!({ "points": [at(0.0, 0.0), at(10.0, 0.0)] })).unwrap(); + s.execute("circle", &json!({ "center": at(5.0, 5.0), "radius": 3.0 * unit })).unwrap(); + let hs: Vec = s.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect(); + s.execute("block", &json!({ "name": name, "base": base, "keep": "delete", "handles": hs })).unwrap(); + }; + let cases: [(&str, [f64; 2], f64, &str, (f64, f64, f64, f64, f64, f64)); 17] = [ + // X -1, Y as X: Z +1. + ("B3", [612345.678, 2398765.432], 1.0, "\"B3\" '(612365.678 2398775.432) -1 \"\" 0", (612365.678, 2398775.432, -1.0, -1.0, 1.0, 0.0)), + ("B4", [0.0, 0.0], 1.0, "\"B4\" \"_S\" 2.5 '(20.0 10.0) 45", (20.0, 10.0, 2.5, 2.5, 2.5, 45.0)), + ("B5", [0.0, 0.0], 1.0, "\"B5\" \"_R\" 60 '(20.0 10.0) 2 \"\"", (20.0, 10.0, 2.0, 2.0, 2.0, 60.0)), + // X alone: the next number is the rotation. + ("B6", [0.5, 0.25], 0.001, "\"B6\" \"_X\" 2 '(0.52 0.26) 3", (0.52, 0.26, 2.0, 1.0, 1.0, 3.0)), + ( + "B7", + [612345.678, 2398765.432], + 1.0, + "\"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0", + (15.0, 5.0, 1.0, 1.0, 1.0, 0.0), + ), + ("B8", [0.0, 0.0], 1.0, "\"B8\" '(20.0 10.0) \"_XYZ\" 2 3 4 0", (20.0, 10.0, 2.0, 3.0, 4.0, 0.0)), + ("B9", [0.0, 0.0], 1.0, "\"B9\" '(20.0 10.0) \"_C\" '(26.0 14.0) 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)), + // Z is stored as a size unless typed at XYZ's Z prompt: Scale -2, XYZ -2 with Enter at Y + // and Z, and a Z preset of -3 all give +; XYZ's -4 stays. + ("C1", [0.0, 0.0], 1.0, "\"C1\" \"_S\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)), + ("C2", [0.0, 0.0], 1.0, "\"C2\" '(20.0 10.0) \"_XYZ\" -2 \"\" \"\" 0", (20.0, 10.0, -2.0, -2.0, 2.0, 0.0)), + ("C5", [0.0, 0.0], 1.0, "\"C5\" \"_Z\" -3 '(20.0 10.0) 0", (20.0, 10.0, 1.0, 1.0, 3.0, 0.0)), + ("C6", [0.0, 0.0], 1.0, "\"C6\" '(20.0 10.0) \"_XYZ\" 2 3 -4 0", (20.0, 10.0, 2.0, 3.0, -4.0, 0.0)), + ("C7", [0.0, 0.0], 1.0, "\"C7\" \"_X\" -2 '(20.0 10.0) 0", (20.0, 10.0, -2.0, 1.0, 1.0, 0.0)), + // Scale and the axis presets in turn: the later one wins for its axes. + ("C8", [0.0, 0.0], 1.0, "\"C8\" \"_S\" -3 \"_X\" 2 '(20.0 10.0) 0", (20.0, 10.0, 2.0, -3.0, 3.0, 0.0)), + ("C9", [0.0, 0.0], 1.0, "\"C9\" \"_X\" 2 \"_S\" -3 '(20.0 10.0) 0", (20.0, 10.0, -3.0, -3.0, 3.0, 0.0)), + ("CA", [0.0, 0.0], 1.0, "\"CA\" \"_S\" 2 \"_Z\" -5 '(20.0 10.0) 0", (20.0, 10.0, 2.0, 2.0, 5.0, 0.0)), + // XYZ then a corner (by Corner or straight away) still asks Z, which defaults to X's size. + ("C3", [0.0, 0.0], 1.0, "\"C3\" '(20.0 10.0) \"_XYZ\" \"_C\" '(26.0 14.0) \"\" 0", (20.0, 10.0, 6.0, 4.0, 6.0, 0.0)), + ("C4", [0.0, 0.0], 1.0, "\"C4\" '(20.0 10.0) \"_XYZ\" '(14.0 14.0) \"\" 0", (20.0, 10.0, -6.0, 4.0, 6.0, 0.0)), + ]; + for (name, base, unit, args, want) in cases { + let mut s = Session::new(); + block(&mut s, name, base, unit); + s.cmdline(&format!("(command \"_.-INSERT\" {args})")).unwrap(); + assert!(s.running.is_none(), "{name}: {}", s.prompt_text()); + assert_eq!(last_insert(&s), want, "{name}"); + } + // Basepoint is picked on the block dropped at the cursor: with the cursor at (100, 50) + // instead of the origin, B7's call inserts 100 and 50 further on. + let mut s = Session::new(); + block(&mut s, "B7", [612345.678, 2398765.432], 1.0); + s.cursor = cadcraft_engine::geom::Vec2::new(100.0, 50.0); + s.cmdline("(command \"_.-INSERT\" \"B7\" \"_B\" '(612350.678 2398770.432) '(612365.678 2398775.432) 1 \"\" 0)").unwrap(); + assert_eq!(last_insert(&s), (115.0, 55.0, 1.0, 1.0, 1.0, 0.0)); +} 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 775230541..afa784e0b 100644 --- a/crates/engine/tests/it/layout_prompts.rs +++ b/crates/engine/tests/it/layout_prompts.rs @@ -39,7 +39,7 @@ fn typed_layout_options_prompt_for_names() { s.script("LAYOUT\n\nModel\n").unwrap(); assert_eq!(s.layout_space(), Space::Model); - // ? lists; Template and SAveas report and end without taking the next input. + // ? lists; Template reports and ends without taking the next input. let n = s.log.len(); s.cmdline("LAYOUT ? Sheet*").unwrap(); assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8babc2c49..5c0a37f45 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -10,6 +10,8 @@ mod attedit_prompts; mod attrib_modes; mod attrib_props; mod attribute_tools; +mod attribute_value_baseline; +mod block_description; mod block_keep; mod block_redefine; mod blockreplace; @@ -17,9 +19,11 @@ mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod chprop_command; mod compare_export; mod constraint_geometry; mod constraint_infer; +mod copy_count; mod count; mod dash_block; mod data_extraction; @@ -75,6 +79,7 @@ mod modify_curves; mod mtext_columns; mod mtext_options; mod multiline_attributes; +mod named_viewports; mod named_views; mod non_finite_edits; mod offset_closed_spline; @@ -83,6 +88,7 @@ mod offset_move; mod options_profile; mod overkill; mod parallel_display_list; +mod pedit_multiple; mod pedit_splinedit; mod pick_text; mod pickdrag; @@ -121,6 +127,7 @@ mod snap_nearest; mod snap_overrides; mod snap_tracking; mod snaps_more; +mod solid_command; mod space_is_enter; mod spline_degree; mod spline_options; @@ -140,6 +147,7 @@ mod undo_layout_tab; mod viewport_clip; mod vplayer_prompts; mod vports; +mod wipeout_grips; mod xdata_follows_entity; mod zoom_keywords; mod zoom_viewport; diff --git a/crates/engine/tests/it/measure_distance_snapping.rs b/crates/engine/tests/it/measure_distance_snapping.rs index adb365774..9842b65e0 100644 --- a/crates/engine/tests/it/measure_distance_snapping.rs +++ b/crates/engine/tests/it/measure_distance_snapping.rs @@ -161,6 +161,99 @@ fn multiple_points_length_and_undo_keep_the_measurement_units() { assert_eq!(s.log.last().unwrap(), "Distance = 20.0000"); } +#[test] +fn multiple_points_arc_options_keep_the_measurement_units() { + let (d, _) = drawing(); + let mut s = session(d); + s.start("dist").unwrap(); + s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + s.input(Input::Keyword("Multiple points".into())).unwrap(); + s.input_snapped(pick(&s, Vec2::new(100.0, 50.0), mode::INT)).unwrap(); + // A half circle of radius 2.5 on paper, ended by its included angle, is radius 5 in the + // model: 10 + 5 pi. + s.input(Input::Keyword("Arc".into())).unwrap(); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input(Input::Keyword("Angle".into())).unwrap(); + s.input(Input::Text("180".into())).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + let base = s.current_prompt().unwrap().base.unwrap(); + assert!(base.near(Vec2::new(100.0, 55.0), 1e-9), "the path stays in paper coordinates: {base:?}"); + s.input(Input::Enter).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + + // A typed radius is in model units: 5 is 2.5 on paper, the same half circle. + let start = |s: &mut Session| { + s.start("dist").unwrap(); + s.input_snapped(pick(s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + s.input(Input::Keyword("Multiple points".into())).unwrap(); + s.input_snapped(pick(s, Vec2::new(100.0, 50.0), mode::INT)).unwrap(); + s.input(Input::Keyword("Arc".into())).unwrap(); + }; + start(&mut s); + for i in [Input::Keyword("Radius".into()), Input::Text("5".into()), Input::Keyword("Angle".into()), Input::Text("180".into()), Input::Enter] { + s.input(i).unwrap(); + } + assert_eq!(s.log.last().unwrap(), "Distance = 25.7080"); + + // A snapped end point off the circle gives the direction of the end: the model end is the + // arc's, not the snapped point (0,0). Radius 5 from angle -90 to the angle of (-2,-1). + start(&mut s); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input_snapped(pick(&s, Vec2::new(95.0, 50.0), mode::END)).unwrap(); + let sweep = (-1.0f64).atan2(-2.0) + std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU; + assert_eq!(s.log.last().unwrap(), &format!("Distance = {:.4}", 10.0 + 5.0 * sweep)); + s.input(Input::Enter).unwrap(); + + // A typed end point is a paper point, as in line mode: the path is measured in paper units. + start(&mut s); + s.input(Input::Keyword("CEnter".into())).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 52.5))).unwrap(); + s.input(Input::Point(Vec2::new(100.0, 55.0))).unwrap(); + assert_eq!(s.log.last().unwrap(), "Distance = 12.8540"); +} + +#[test] +fn an_arc_end_picked_through_another_viewport_uses_paper_units() { + let (mut d, paper) = drawing(); + let mut vp = match &d.entity(viewport(&d, &paper)).unwrap().kind { + EntityKind::Viewport(vp) => vp.clone(), + _ => panic!("viewport fixture"), + }; + vp.center.x = 120.0; + vp.view_height = 20.0; + vp.id = 3; + d.add(&paper, Common::default(), EntityKind::Viewport(vp)).unwrap(); + let mut s = session(d); + let mut measure = |snap: bool| { + s.start("dist").unwrap(); + let first = pick(&s, Vec2::new(95.0, 50.0), mode::END); + let second = pick(&s, Vec2::new(100.0, 50.0), mode::INT); + let end = pick(&s, Vec2::new(110.0, 50.0), mode::END); + assert_ne!(first.viewport.unwrap().viewport, end.viewport.unwrap().viewport); + for (n, hit) in [first, second].into_iter().enumerate() { + if n == 1 { + s.input(Input::Keyword("Multiple points".into())).unwrap(); + } + if snap { s.input_snapped(hit).unwrap() } else { s.input(Input::Point(hit.point)).unwrap() } + } + s.input(Input::Keyword("Arc".into())).unwrap(); + s.input(Input::Keyword("Second pt".into())).unwrap(); + s.input(Input::Point(Vec2::new(105.0, 52.0))).unwrap(); + if snap { + s.input_snapped(end).unwrap() + } else { + s.input(Input::Point(end.point)).unwrap() + } + s.input(Input::Enter).unwrap(); + s.log.last().unwrap().clone() + }; + let typed = measure(false); + assert_eq!(typed, "Distance = 16.0347", "5 along X, then the arc through 105,52 to 110,50"); + assert_eq!(measure(true), typed); +} + #[test] fn explicit_endpoint_and_intersection_overrides_work_from_the_command_line() { let (d, _) = drawing(); diff --git a/crates/engine/tests/it/named_viewports.rs b/crates/engine/tests/it/named_viewports.rs new file mode 100644 index 000000000..03ebe9ea4 --- /dev/null +++ b/crates/engine/tests/it/named_viewports.rs @@ -0,0 +1,101 @@ +//! View > Viewports > Named Viewports...: the saved model space viewport configurations, the one +//! the Model tab shows, and restoring or deleting one (its tiles in the Model tab, layout +//! viewports in a layout). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Space}; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) and "Quad" (four tiles) saved, showing one view. +fn session() -> Session { + let mut s = Session::new(); + s.viewport_px = (1200.0, 800.0); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap(); + s.execute("-vports", &json!({ "option": "4", "applyTo": "display" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Quad" })).unwrap(); + s.execute("-vports", &json!({ "option": "single" })).unwrap(); + s +} + +fn rects(s: &Session) -> Vec<[f64; 4]> { + let mut r: Vec<[f64; 4]> = s.model_tiles().iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect(); + r.sort_by(|a, b| a.partial_cmp(b).unwrap()); + r +} + +fn layout_viewports(s: &Session) -> usize { + let d = s.doc().unwrap(); + d.space(&Space::Paper("Layout1".into())).unwrap().iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count() +} + +#[test] +fn named_viewports_lists_restores_and_deletes_in_the_model_tab() { + let mut s = session(); + let list = s.execute("vports.named", &json!({})).unwrap(); + assert_eq!(list["configurations"], json!([{ "name": "Two", "viewports": 2 }, { "name": "Quad", "viewports": 4 }])); + assert_eq!(list["current"], json!(null), "one view matches no saved configuration"); + assert_eq!(s.viewport_configuration(), None); + + s.execute("vports.named", &json!({ "name": "two" })).unwrap(); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + assert_eq!(s.viewport_configuration().as_deref(), Some("Two")); + assert_eq!(s.execute("vports.named", &json!({})).unwrap()["current"], json!("Two")); + s.undo().unwrap(); + assert!(rects(&s).is_empty(), "restoring is one undo step"); + + let r = s.execute("vports.named", &json!({ "name": "QUAD", "delete": true })).unwrap(); + assert_eq!(r["deleted"], json!("Quad")); + let names: Vec = s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect(); + assert_eq!(names, vec!["Two".to_string()]); + s.undo().unwrap(); + assert!(s.doc().unwrap().vports.iter().any(|c| c.name == "Quad"), "deleting is one undo step"); +} + +#[test] +fn named_viewports_refuses_unknown_names_and_a_delete_that_is_not_a_boolean() { + let mut s = session(); + for bad in [ + json!({ "name": "nope" }), + json!({ "name": "*Active" }), + json!({ "name": "Two", "delete": "yes" }), + json!({ "name": "nope", "delete": true }), + ] { + assert!(s.execute("vports.named", &bad).is_err(), "{bad}"); + } + // Only the exact name is deleted, never a wildcard match. + assert!(s.execute("vports.named", &json!({ "name": "*", "delete": true })).is_err()); + assert_eq!(s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).count(), 2); +} + +#[test] +fn named_viewports_places_a_configuration_in_a_layout() { + let mut s = session(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + let r = s.execute("vports.named", &json!({ "name": "Quad", "p1": [10, 10], "p2": [210, 110] })).unwrap(); + assert_eq!(r["configuration"], json!("Quad")); + assert_eq!(layout_viewports(&s), before + 4); + assert!(s.model_tiles().is_empty(), "the Model tab's tiles do not change"); +} + +#[test] +fn typed_named_viewports_asks_for_the_configuration() { + let mut s = session(); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter name of viewport configuration to restore"), "{}", s.prompt_text()); + s.cmdline("Two").unwrap(); + assert!(s.running.is_none()); + assert_eq!(rects(&s).len(), 2); + + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text()); + s.cmdline("Quad").unwrap(); + assert!(s.prompt_text().contains("Specify first corner"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(layout_viewports(&s), before + 4, "Enter fits the configuration to the printable area"); +} diff --git a/crates/engine/tests/it/pedit_multiple.rs b/crates/engine/tests/it/pedit_multiple.rs new file mode 100644 index 000000000..70a1a8bec --- /dev/null +++ b/crates/engine/tests/it/pedit_multiple.rs @@ -0,0 +1,197 @@ +//! #2182: PEDIT Multiple at the command line, as AutoCAD: select several objects, turn lines and +//! arcs into polylines, then each option applies to all of them; Join joins them with each other. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +fn polylines(s: &Session) -> Vec<(usize, bool, f64)> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some((p.vertices.len(), p.closed, p.const_width)) } else { None }) + .collect() +} + +fn count(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +fn lines(s: &mut Session, pts: &[[f64; 4]]) { + for p in pts { + s.execute("line", &json!({ "points": [[p[0], p[1]], [p[2], p[3]]] })).unwrap(); + } +} + +#[test] +fn join_turns_four_lines_into_one_closed_polyline() { + let mut s = Session::new(); + lines(&mut s, &[[0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [10.0, 5.0, 0.0, 5.0], [0.0, 5.0, 0.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", "", "Y", "J", "", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(polylines(&s), vec![(4, true, 0.0)]); + assert_eq!(count(&s), 1); + assert!(s.log.iter().any(|l| l.contains("3 segments added to polyline")), "{:?}", s.log); +} + +#[test] +fn join_bridges_gaps_within_the_fuzz_distance_only() { + // Two chains with a 0.5 gap between them. + let gap = |s: &mut Session| lines(s, &[[0.0, 0.0, 10.0, 0.0], [10.5, 0.0, 20.0, 0.0]]); + let mut s = Session::new(); + gap(&mut s); + for t in ["PE", "M", "ALL", "", "Y", "J", "0", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s).len(), 2, "no fuzz: still two"); + let mut s = Session::new(); + gap(&mut s); + for t in ["PE", "M", "ALL", "", "Y", "J", "1", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s).len(), 1, "a fuzz of 1 joins them"); +} + +#[test] +fn options_apply_to_all_and_undo_takes_one_back() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 20,0 30,0 30,10").unwrap(); + s.cmdline("").unwrap(); + for t in ["PEDIT", "M", "ALL", "", "W", "0.5", "C"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, true, 0.5), (3, true, 0.5)], "width and close on both"); + s.cmdline("U").unwrap(); + assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "Undo opens them again"); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn lines_left_out_when_not_converted() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + lines(&mut s, &[[20.0, 0.0, 30.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", "", "N", "C", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, true, 0.0)], "only the polyline is edited"); + assert!(s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Line(_))), "the line stays a line"); +} + +#[test] +fn objects_selected_first_go_straight_to_multiple() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 20,0 30,0 30,10").unwrap(); + s.cmdline("").unwrap(); + let all: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + s.set_selection(all); + for t in ["PEDIT", "W", "0.5", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "no second selection asked for"); + assert!(s.running.is_none()); +} + +#[test] +fn decurve_after_fit_gives_back_the_vertices() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 0,20 10,20 10,30").unwrap(); + s.cmdline("").unwrap(); + for t in ["PEDIT", "M", "ALL", "", "S"] { + s.cmdline(t).unwrap(); + } + assert!(polylines(&s).iter().all(|p| p.0 > 4), "Spline adds vertices: {:?}", polylines(&s)); + s.cmdline("D").unwrap(); + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "Decurve gives back each one's vertices"); + s.cmdline("").unwrap(); +} + +#[test] +fn decurve_still_gives_back_the_vertices_after_a_join_that_joins_nothing_or_is_undone() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 0,20 10,20 10,30").unwrap(); + s.cmdline("").unwrap(); + // Join with nothing in reach joins nothing: Decurve still gives back the vertices. + for t in ["PEDIT", "M", "ALL", "", "S", "J", "0", "D"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after a Join that joined nothing"); + // Spline, a Join that joins them, Undo: Decurve gives back the vertices again. + for t in ["S", "J", "100", "U", "D"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after an undone Join"); + s.cmdline("").unwrap(); +} + +fn widths(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p) } else { None }) + .flat_map(|p| p.vertices.iter().flat_map(|v| [v.start_width, v.end_width])) + .collect() +} + +#[test] +fn convert_asks_again_for_an_answer_that_is_not_yes_or_no() { + let mut s = Session::new(); + lines(&mut s, &[[0.0, 0.0, 10.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", ""] { + s.cmdline(t).unwrap(); + } + s.cmdline("foo").unwrap(); + assert!(s.prompt_text().contains("Convert"), "{}", s.prompt_text()); + assert_eq!(polylines(&s), vec![], "nothing converted on a wrong answer"); + s.cmdline("Y").unwrap(); + assert_eq!(polylines(&s).len(), 1); + s.cmdline("").unwrap(); + + // Single-polyline PEDIT asks again too. + lines(&mut s, &[[0.0, 5.0, 10.0, 5.0]]); + let l = s.doc().unwrap().model.last().unwrap().handle; + s.set_selection(vec![l]); + s.cmdline("PEDIT").unwrap(); + s.cmdline("foo").unwrap(); + assert!(s.prompt_text().contains("turn it into one"), "{}", s.prompt_text()); + s.cmdline("N").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn decurve_after_width_keeps_the_new_width() { + let mut s = Session::new(); + for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().any(|w| *w > 0.0), "{:?}", widths(&s)); + for t in ["PEDIT", "M", "ALL", "", "S", "W", "0", "D", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().all(|w| *w == 0.0), "Multiple: {:?}", widths(&s)); + + let mut s = Session::new(); + for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] { + s.cmdline(t).unwrap(); + } + let h = s.doc().unwrap().model.last().unwrap().handle; + s.set_selection(vec![h]); + for t in ["PEDIT", "S", "W", "0", "D", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().all(|w| *w == 0.0), "single: {:?}", widths(&s)); +} diff --git a/crates/engine/tests/it/solid_command.rs b/crates/engine/tests/it/solid_command.rs new file mode 100644 index 000000000..4e92f38e9 --- /dev/null +++ b/crates/engine/tests/it/solid_command.rs @@ -0,0 +1,115 @@ +//! SOLID (2D solids) as AutoCAD 2024 draws them from AutoLISP's `command` (accoreconsole, every +//! solid read back with `entget`). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +/// The corners of every solid, in drawing order. +fn solids(s: &Session) -> Vec<[Vec2; 4]> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Solid(so) => Some(so.corners.map(|c| Vec2::new(c.x, c.y))), + _ => None, + }) + .collect() +} + +#[test] +fn solid_chains_its_corners_as_autocad() { + let p = |x: f64, y: f64| (x, y); + // (points, Enters after them, each solid as indexes into the points) + let cases: [(Vec<(f64, f64)>, usize, Vec<[usize; 4]>); 7] = [ + // Enter at the fourth point: a triangle repeating its third corner. + ( + vec![p(19.451648928934738, -21.25585202371201), p(34.61693931374715, -3.839951210926415), p(14.55170247613551, -36.00688291519149)], + 1, + vec![[0, 1, 2, 2]], + ), + // Two solids, the second on the first one's last two corners. + ( + vec![ + p(612336.9748522255, 2398742.6897319513), + p(612351.3507889743, 2398794.3456611084), + p(612371.7043989347, 2398771.9517326546), + p(612379.1755178814, 2398787.9912080113), + p(612308.5250187548, 2398780.014694856), + p(612356.1118879627, 2398726.474738389), + ], + 1, + vec![[0, 1, 2, 3], [2, 3, 4, 5]], + ), + // A solid, then a triangle. + ( + vec![ + p(15.962585354071173, 3.3526103822549587), + p(-0.8095657933516947, 13.123790321338404), + p(-19.412229808271626, -27.60123059493223), + p(-33.66123919971652, 12.18177290955613), + p(14.002713873827737, -8.925735622823005), + ], + 1, + vec![[0, 1, 2, 3], [2, 3, 4, 4]], + ), + // Three solids, the last a triangle. + ( + vec![ + p(29.3005193409103, -24.874809106460187), + p(-21.460879071052776, 35.356609837704795), + p(-31.347336242570112, -21.756568658673665), + p(-39.08169761088666, 23.588062542950155), + p(23.0763904721503, 10.219805555956789), + p(-22.701751810515756, 22.76615204356692), + p(4.338066830902321, -37.50391578060281), + ], + 1, + vec![[0, 1, 2, 3], [2, 3, 4, 5], [4, 5, 6, 6]], + ), + // Enter twice: the triangle, then Enter at the third point ends the command. + ( + vec![p(612325.6793323631, 2398757.396299568), p(612370.1009101832, 2398796.2224137546), p(612378.8199480829, 2398804.1981022176)], + 2, + vec![[0, 1, 2, 2]], + ), + // Corners stay in the order given (a "Z" order makes a rectangle). + (vec![p(0.0, 0.0), p(30.0, 0.0), p(0.0, 20.0), p(30.0, 20.0)], 1, vec![[0, 1, 2, 3]]), + // Enter after two points: nothing. + (vec![p(-34769.59558954922, 81960.0236700466), p(-35927.22173307072, 79264.64372697374)], 1, vec![]), + ]; + let same = |a: Vec2, b: (f64, f64)| (a.x - b.0).abs() <= 1e-9 * b.0.abs().max(1.0) && (a.y - b.1).abs() <= 1e-9 * b.1.abs().max(1.0); + for (pts, enters, want) in cases { + let mut s = Session::new(); + let args: Vec = pts.iter().map(|(x, y)| format!("'({x:?} {y:?})")).chain(std::iter::repeat_n("\"\"".to_string(), enters)).collect(); + s.cmdline(&format!("(command \"_.SOLID\" {})", args.join(" "))).unwrap(); + // One Enter leaves the command at "Specify third point", as AutoCAD's does (its log then + // shows the measuring routine ending it). + if s.running.is_some() { + assert!(s.prompt_text().contains("Specify third point"), "{pts:?}: {}", s.prompt_text()); + s.cancel(); + } + let got = solids(&s); + assert_eq!(got.len(), want.len(), "{pts:?}: {got:?}"); + for (g, w) in got.iter().zip(&want) { + assert!(g.iter().zip(w).all(|(c, &i)| same(*c, pts[i])), "{pts:?}: {got:?}"); + } + } +} + +#[test] +fn solid_json_chains_the_same_way() { + let mut s = Session::new(); + s.execute("solid", &json!({ "points": [[0, 0], [10, 0], [0, 5], [10, 5], [0, 10]] })).unwrap(); + let got = solids(&s); + let v = |x: f64, y: f64| Vec2::new(x, y); + assert_eq!(got, vec![[v(0.0, 0.0), v(10.0, 0.0), v(0.0, 5.0), v(10.0, 5.0)], [v(0.0, 5.0), v(10.0, 5.0), v(0.0, 10.0), v(0.0, 10.0)]]); + assert!(s.execute("solid", &json!({ "points": [[0, 0], [1, 0]] })).is_err()); + // The alias. + s.cmdline("so 20,0 30,0 20,5").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(solids(&s).len(), 3); +} diff --git a/crates/engine/tests/it/wipeout_grips.rs b/crates/engine/tests/it/wipeout_grips.rs new file mode 100644 index 000000000..e34620089 --- /dev/null +++ b/crates/engine/tests/it/wipeout_grips.rs @@ -0,0 +1,227 @@ +//! WIPEOUT's optional repeated closing point is not a second editable corner (#2256). +//! Authored contours keep their grips and edit results through DXF/DWG saves, both via the +//! command API and the interactive grip machine, without changing the file's explicit closure. + +use cadcraft_doc::{Drawing, EntityKind, Handle, LwPolyline, Space}; +use cadcraft_engine::{ + Input, Session, + grips::{GripMode, stretch_grip}, +}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{SaveOptions, Version}; +use serde_json::json; + +fn fixture(explicit: bool, reversed: bool) -> (Drawing, Handle, Vec) { + let mut points = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)]; + if reversed { + points.reverse(); + } + let mut input = points.clone(); + if explicit { + input.push(points[0]); + } + let mut session = Session::new(); + let input: Vec<_> = input.iter().map(|p| [p.x, p.y]).collect(); + let reply = session.execute("wipeout", &json!({"points": input})).unwrap(); + let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap(); + (session.doc().unwrap().clone(), handle, points) +} + +fn points_equal(actual: &[Vec2], expected: &[Vec2]) { + assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}"); + for (a, b) in actual.iter().zip(expected) { + assert!(a.near(*b, 1e-12), "{actual:?} != {expected:?}"); + } +} + +fn assert_boundary(drawing: &Drawing, handle: Handle, points: &[Vec2], explicit: bool) { + let EntityKind::Wipeout(w) = &drawing.entity(handle).unwrap().kind else { panic!("WIPEOUT lost") }; + let mut expected = points.to_vec(); + if explicit { + expected.push(points[0]); + } + points_equal(&w.boundary, &expected); +} + +fn move_grip(session: &mut Session, handle: Handle, index: usize, to: Vec2, interactive: bool) { + session.set_selection(vec![handle]); + if interactive { + session.start_grip_edit(&[(handle, index)]).unwrap(); + session.input(Input::Point(to)).unwrap(); + assert!(!session.prompting()); + } else { + session.grip_edit(handle, index, to, GripMode::Stretch).unwrap(); + } +} + +fn edit(drawing: Drawing, handle: Handle, points: &[Vec2], explicit: bool, index: usize, interactive: bool) -> Drawing { + let mut session = Session::new(); + session.open_drawing(drawing, "wipeout", None); + points_equal(&session.grips_of(handle).unwrap(), points); + let to = points[index] + Vec2::new(2.0, 1.0); + let mut expected = points.to_vec(); + expected[index] = to; + move_grip(&mut session, handle, index, to, interactive); + assert_boundary(session.doc().unwrap(), handle, &expected, explicit); + points_equal(&session.grips_of(handle).unwrap(), &expected); + session.execute("undo", &json!({})).unwrap(); + assert_boundary(session.doc().unwrap(), handle, points, explicit); + session.execute("redo", &json!({})).unwrap(); + assert_boundary(session.doc().unwrap(), handle, &expected, explicit); + session.doc().unwrap().clone() +} + +fn check(format: &str, interactive: bool) { + for explicit in [false, true] { + for reversed in [false, true] { + let (drawing, handle, points) = fixture(explicit, reversed); + for index in 0..points.len() { + edit(drawing.clone(), handle, &points, explicit, index, interactive); + } + for version in Version::ALL { + for binary in [false, true].into_iter().filter(|binary| !*binary || format == "wipeout.dxf") { + let options = SaveOptions { version: Some(version), binary }; + let mut current = drawing.clone(); + for _pass in 0..2 { + let bytes = cadcraft_io::write_with(¤t, format, &options).unwrap(); + current = cadcraft_io::read(&bytes, format).unwrap(); + assert!(current.read_notes.is_empty() && current.skipped_records.is_empty()); + assert_boundary(¤t, handle, &points, true); + for index in 0..points.len() { + let edited = edit(current.clone(), handle, &points, true, index, interactive); + let mut expected = points.clone(); + expected[index] += Vec2::new(2.0, 1.0); + let bytes = cadcraft_io::write_with(&edited, format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + assert_boundary(&back, handle, &expected, true); + } + } + } + } + } + } +} + +#[test] +fn dxf_api_grips_survive_roundtrip() { + check("wipeout.dxf", false); +} + +#[test] +fn dxf_interactive_grips_survive_roundtrip() { + check("wipeout.dxf", true); +} + +#[test] +fn dwg_api_grips_survive_roundtrip() { + check("wipeout.dwg", false); +} + +#[test] +fn dwg_interactive_grips_survive_roundtrip() { + check("wipeout.dwg", true); +} + +#[test] +fn polyline_closedness_is_not_inferred_from_coincident_endpoints() { + for closed in [false, true] { + for repeated in [false, true] { + let mut points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)]; + if repeated { + points.push(points[0]); + } + let polyline = LwPolyline { + vertices: points.iter().copied().map(PolyVertex::new).collect(), + closed, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + let kind = EntityKind::LwPolyline(polyline); + let segments = if repeated || closed { 3 } else { 2 }; + assert_eq!(kind.prims().len(), segments); + assert_eq!(kind.grips().len(), points.len() + segments); + let to = Vec2::new(2.0, 3.0); + let EntityKind::LwPolyline(edited) = stretch_grip(&kind, 0, to).unwrap() else { panic!("polyline") }; + assert_eq!(edited.closed, closed); + let mut expected = points.clone(); + expected[0] = to; + assert_eq!(edited.vertices.iter().map(|v| v.p).collect::>(), expected); + + // WIPEOUT conversion requires the closed flag, even with coincident endpoints. + let mut session = Session::new(); + let handle = session.doc_mut().unwrap().add(&Space::Model, Default::default(), kind).unwrap(); + let result = session.execute("wipeout", &json!({"handle": handle.hex()})); + assert_eq!(result.is_ok(), closed); + if let Ok(result) = result { + let wipeout = Handle::parse_hex(result["handle"].as_str().unwrap()).unwrap(); + assert_boundary(session.doc().unwrap(), wipeout, &points[..3], repeated); + points_equal(&session.grips_of(wipeout).unwrap(), &points[..3]); + } else { + assert_eq!(session.doc().unwrap().model.len(), 1); + } + } + } +} + +#[test] +fn coincident_endpoints_remain_separately_editable_after_save() { + let original = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)]; + for explicit in [false, true] { + for interactive in [false, true] { + for index in [0, 3] { + let other = 3 - index; + let mut input: Vec<_> = original.iter().map(|p| [p.x, p.y]).collect(); + if explicit { + input.push(input[0]); + } + let mut session = Session::new(); + let reply = session.execute("wipeout", &json!({"points": input})).unwrap(); + let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap(); + let mut coincident = original; + coincident[index] = original[other]; + move_grip(&mut session, handle, index, coincident[index], interactive); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + assert_boundary(session.doc().unwrap(), handle, &coincident, true); + session.undo().unwrap(); + assert_boundary(session.doc().unwrap(), handle, &original, explicit); + session.execute("redo", &json!({})).unwrap(); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + let collapsed = session.doc().unwrap().clone(); + for version in Version::ALL { + for (format, binary) in [("wipeout.dxf", false), ("wipeout.dxf", true), ("wipeout.dwg", false)] { + let options = SaveOptions { version: Some(version), binary }; + let bytes = cadcraft_io::write_with(&collapsed, format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + session.open_drawing(back, "coincident corners", None); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + assert_boundary(session.doc().unwrap(), handle, &coincident, true); + let mut separated = coincident; + separated[other] = Vec2::new(-2.0, 4.0); + move_grip(&mut session, handle, other, separated[other], interactive); + points_equal(&session.grips_of(handle).unwrap(), &separated); + assert_boundary(session.doc().unwrap(), handle, &separated, true); + let bytes = cadcraft_io::write_with(session.doc().unwrap(), format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + assert_boundary(&back, handle, &separated, true); + } + } + } + } + } +} + +#[test] +fn multiple_hot_grips_keep_the_closing_point_attached() { + let (drawing, handle, mut points) = fixture(true, false); + let mut session = Session::new(); + session.open_drawing(drawing, "wipeout", None); + session.set_selection(vec![handle]); + session.start_grip_edit(&[(handle, 0), (handle, 1)]).unwrap(); + let delta = Vec2::new(2.0, 1.0); + session.input(Input::Point(points[0] + delta)).unwrap(); + points[0] += delta; + points[1] += delta; + assert_boundary(session.doc().unwrap(), handle, &points, true); + points_equal(&session.grips_of(handle).unwrap(), &points); +} diff --git a/crates/engine/tests/lin_pat_files.rs b/crates/engine/tests/lin_pat_files.rs index 1c57d9f25..91f99062e 100644 --- a/crates/engine/tests/lin_pat_files.rs +++ b/crates/engine/tests/lin_pat_files.rs @@ -6,16 +6,12 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::{EntityKind, Hatch}; use serde_json::json; -const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; +#[path = "support/temp_dir.rs"] +mod temp_dir; -/// A user file in the test's temp folder; returns its path. -fn file(name: &str, text: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join(name); - std::fs::write(&p, text).unwrap(); - p.to_string_lossy().into_owned() -} +use temp_dir::TempDir; + +const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; fn linetype_pattern(s: &Session, name: &str) -> Option> { s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect()) @@ -46,7 +42,8 @@ fn linetype_json_loads_named_linetypes_from_file_text() { #[test] fn linetype_json_reads_a_lin_file_by_path() { - let path = file("mine.lin", LIN); + let dir = TempDir::new("lin-pat"); + let path = dir.file("mine.lin", LIN); let mut s = Session::new(); s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap(); assert!(linetype_pattern(&s, "MYTEXT").is_some()); @@ -58,7 +55,8 @@ fn linetype_json_reads_a_lin_file_by_path() { #[test] fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { - let path = file("cmdline.lin", LIN); + let dir = TempDir::new("lin-pat"); + let path = dir.file("cmdline.lin", LIN); let mut s = Session::new(); s.cmdline("-linetype").unwrap(); for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] { @@ -72,7 +70,7 @@ fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log); // Already loaded: asked whether to reload, in the file's order. - let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2")); + let changed = dir.file("changed.lin", LIN.replace(".5,-.25", "2,-2")); s.cmdline("-linetype").unwrap(); for line in ["l", "*", changed.as_str()] { s.cmdline(line).unwrap(); @@ -132,12 +130,16 @@ fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() { #[test] fn hatch_pattern_prompt_takes_a_pat_file_name() { - let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); + let dir = TempDir::new("lin-pat"); + let path = dir.file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); let mut s = Session::new(); s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); s.cmdline("hatch").unwrap(); s.cmdline("p").unwrap(); s.cmdline(&path).unwrap(); + // Its scale and angle follow; Enter keeps them. + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); // The pattern named like the file is the one used. s.cmdline("5,5").unwrap(); s.cmdline("").unwrap(); @@ -149,9 +151,21 @@ fn hatch_pattern_prompt_takes_a_pat_file_name() { s.cmdline("hatch").unwrap(); s.cmdline("p").unwrap(); s.cmdline("myother").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); s.cmdline("5,5").unwrap(); s.cmdline("").unwrap(); assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1); + + // A file with no pattern in it, or none at all, asks for the pattern again. + let empty = dir.file("EMPTY.pat", ";; no patterns\n"); + for file in [empty, dir.path_str("MISSING.pat")] { + s.cmdline("hatch").unwrap(); + s.cmdline("p").unwrap(); + s.cmdline(&file).unwrap(); + assert!(s.prompt_text().contains("Enter a pattern name"), "{file}: {}", s.prompt_text()); + s.cancel(); + } } #[test] 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/read_file_unicode.rs b/crates/engine/tests/read_file_unicode.rs new file mode 100644 index 000000000..4c3c520e8 --- /dev/null +++ b/crates/engine/tests/read_file_unicode.rs @@ -0,0 +1,25 @@ +//! A drawing path that isn't valid Unicode is refused: the session keeps paths as text, so it +//! could never be saved back to. + +#![cfg(unix)] + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; + +use cadcraft_doc::Drawing; +use cadcraft_engine::cmd::file::{IoHooks, read_file, set_io}; + +#[test] +fn read_file_refuses_a_name_that_is_not_unicode() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(Vec::new()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-nonunicode-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("good.dxf"); + let bad = dir.join(OsString::from_vec(b"bad-\xff.dxf".to_vec())); + std::fs::write(&good, b"0\nEOF\n").unwrap(); + std::fs::write(&bad, b"0\nEOF\n").unwrap(); + let e = read_file(&bad).err().expect("a non-Unicode name is refused"); + assert!(e.contains("not valid Unicode") && e.contains("bad-"), "{e}"); + assert!(read_file(&good).is_ok()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/save_backup.rs b/crates/engine/tests/save_backup.rs new file mode 100644 index 000000000..afa5a14cb --- /dev/null +++ b/crates/engine/tests/save_backup.rs @@ -0,0 +1,296 @@ +//! 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.bak", "Plan.dxf"]); + assert_eq!((circles(&path), circles(&backup_path(&path))), (2, 1), "the .bak is the previous save"); + + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_damaged_file_never_replaces_a_good_backup_and_isavebak_0_keeps_none() { + let _saving = saving(); + hooks(); + let dir = temp_dir("damaged"); + let path = dir.join("Plan.dxf"); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(); + add_circle(&mut s, 0.0); + s.execute("saveas", &json!({ "path": p })).unwrap(); + add_circle(&mut s, 5.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1); + + // Another program truncates the drawing; the next save keeps the good backup. + std::fs::write(&path, b"").unwrap(); + add_circle(&mut s, 10.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (3, 1)); + + // Cut off with a drawing's start kept (half a DXF, a DWG's version tag alone): the backup + // stays too. + let whole = std::fs::read(&path).unwrap(); + std::fs::write(&path, &whole[..whole.len() / 2]).unwrap(); + add_circle(&mut s, 11.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "half a DXF"); + std::fs::write(&path, b"AC1032").unwrap(); + add_circle(&mut s, 12.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!(circles(&backup_path(&path)), 1, "a DWG's tag alone"); + + // ISAVEBAK 0: the backup is left as it was. + s.execute("setvar", &json!({ "name": "ISAVEBAK", "value": 0 })).unwrap(); + add_circle(&mut s, 15.0); + s.execute("qsave", &json!({})).unwrap(); + assert_eq!((circles(&path), circles(&backup_path(&path))), (6, 1)); + assert_eq!(files(&dir), ["Plan.bak", "Plan.dxf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn drawings_are_recognised_by_their_start() { + assert!(looks_like_drawing(b"AC1032\0\0\0\0\0")); + assert!(looks_like_drawing(b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")); + assert!(looks_like_drawing(b"999\ndxfrw\n0\nSECTION\n")); + assert!(looks_like_drawing(b"AutoCAD Binary DXF\r\n\x1a\0")); + for junk in [&b""[..], b"\0\0\0\0", b"0\nEOF\n", b"", 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); +} diff --git a/crates/engine/tests/support/temp_dir.rs b/crates/engine/tests/support/temp_dir.rs new file mode 100644 index 000000000..3b4662e0d --- /dev/null +++ b/crates/engine/tests/support/temp_dir.rs @@ -0,0 +1,46 @@ +//! A test's own folder in the system temp directory, removed when the test ends, even when an +//! assertion fails. Shared by the integration tests (`#[path]`) and the crate's unit tests. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `cadcraft---`: the process id keeps parallel test binaries apart and the counter +/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the test's temp folder"); + Self(dir) + } + + /// Write `contents` to `name` in the folder; returns the file's path as text. + pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String { + let p = self.0.join(name); + std::fs::write(&p, contents).expect("write a test file"); + p.to_string_lossy().into_owned() + } + + /// The path of `name` in the folder as text, without creating it. + pub fn path_str(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for TempDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/engine/tests/templates.rs b/crates/engine/tests/templates.rs index e85eb2242..cb8636061 100644 --- a/crates/engine/tests/templates.rs +++ b/crates/engine/tests/templates.rs @@ -1,5 +1,5 @@ //! Drawing templates (#552): SAVEAS to a .dwt, NEW/QNEW from a template file, and the default -//! template (QNEWTEMPLATE). The io hooks here keep drawings in memory: a "file" holds the number +//! template (QNEWTEMPLATE), and a layout saved as a template (LAYOUT SAveas). The io hooks here keep drawings in memory: a "file" holds the number //! of the drawing written, so the test sees exactly what the engine hands the file formats. use std::sync::Mutex; @@ -9,6 +9,11 @@ use cadcraft_engine::cmd::file::{IoHooks, set_io}; use cadcraft_engine::doc::Drawing; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + static FILES: Mutex> = Mutex::new(Vec::new()); fn write(d: &Drawing, name: &str) -> Result, String> { @@ -27,17 +32,11 @@ fn hooks() { set_io(IoHooks { read, write, plot: None }); } -fn path(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(name).to_string_lossy().into_owned() -} - /// A drawing with an office layer and a title block circle, saved as a template. -fn office_template(s: &mut Session, name: &str) -> String { +fn office_template(s: &mut Session, dir: &TempDir, name: &str) -> String { s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap(); s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap(); - let p = path(name); + let p = dir.path_str(name); s.execute("saveas", &json!({ "path": p })).unwrap(); p } @@ -46,7 +45,8 @@ fn office_template(s: &mut Session, name: &str) -> String { fn saveas_dwt_then_new_from_the_template() { hooks(); let mut s = Session::new(); - let dwt = office_template(&mut s, "office.dwt"); + let dir = TempDir::new("templates"); + let dwt = office_template(&mut s, &dir, "office.dwt"); // A template is saved like a drawing: the format sees the .dwt name, the drawing is saved. assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt"))); let st = s.state().unwrap(); @@ -68,7 +68,8 @@ fn saveas_dwt_then_new_from_the_template() { fn qnewtemplate_is_the_default_for_new_and_qnew() { hooks(); let mut s = Session::new(); - let dwt = office_template(&mut s, "default.dwt"); + let dir = TempDir::new("templates"); + let dwt = office_template(&mut s, &dir, "default.dwt"); let docs = s.docs.len(); s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap(); @@ -99,9 +100,103 @@ fn a_template_that_cannot_be_read_makes_no_drawing() { let docs = s.docs.len(); let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string(); assert!(err.contains("office.dwt"), "{err}"); - let junk = path("junk.dwt"); - std::fs::write(&junk, b"not a drawing at all").unwrap(); + let dir = TempDir::new("templates"); + let junk = dir.file("junk.dwt", b"not a drawing at all"); assert!(s.execute("new", &json!({ "template": junk })).is_err()); assert!(s.execute("new", &json!({ "template": 42 })).is_err()); assert_eq!(s.docs.len(), docs); } + +/// The drawing the io hooks were last handed for a file whose name ends with `name`. +fn written(name: &str) -> Drawing { + FILES.lock().unwrap().iter().rev().find(|(n, _)| n.ends_with(name)).map(|f| f.1.clone()).unwrap() +} + +fn layout_names(d: &Drawing) -> Vec { + d.layouts.iter().map(|l| l.name.clone()).collect() +} + +/// LAYOUT SAveas: the template holds the layout alone, with the named objects it uses, and the +/// drawing it came from keeps everything. +#[test] +fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { + hooks(); + let mut s = Session::new(); + let dir = TempDir::new("templates"); + for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("layer.current", &json!({ "name": "PARTS-LAYER" })).unwrap(); + let c = s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap()["handle"].clone(); + s.execute("block", &json!({ "name": "PARTS", "base": [0, 0], "handles": [c], "keep": "retain" })).unwrap(); + // WALLS has model space objects only, and NOTES none; the sheet's viewport freezes one and recolors the other. + s.execute("layer.current", &json!({ "name": "WALLS" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [0, 10]] })).unwrap(); + let vp = s.execute("layout.new", &json!({ "name": "Sheet" })).unwrap()["viewport"].clone(); + s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["WALLS"], "colors": { "NOTES": 1 } })).unwrap(); + s.execute("layout.set", &json!({ "name": "Sheet" })).unwrap(); + s.execute("layer.current", &json!({ "name": "TITLE" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count(); + + let p = dir.path_str("sheet.dwt"); + let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap(); + assert_eq!(r["name"], json!("Sheet"), "the current layout by default"); + assert_eq!(r["path"], json!(p)); + let t = written("sheet.dwt"); + assert_eq!(layout_names(&t), ["Sheet"]); + assert_eq!(t.active_layout.as_deref(), Some("Sheet")); + assert_eq!(t.model.iter().count(), 0, "no model space objects"); + assert_eq!(t.layout("Sheet").unwrap().entities.iter().count(), sheet_objects, "the layout's viewport and line"); + assert!(t.layer("TITLE").is_some() && t.layer("0").is_some()); + assert!(t.layer("UNUSED").is_none() && t.layer("PARTS-LAYER").is_none(), "unused layers are left out"); + assert!(t.block("PARTS").is_none(), "unused blocks are left out"); + // Layers the viewport freezes or recolors stay, and so do those settings. + assert!(t.layer("WALLS").is_some() && t.layer("NOTES").is_some()); + let sheet_vp = t.layout("Sheet").unwrap().entities.iter().find_map(|e| match &e.kind { + cadcraft_engine::doc::EntityKind::Viewport(v) if v.id != 1 => Some(v.clone()), + _ => None, + }); + let sheet_vp = sheet_vp.unwrap(); + assert_eq!(sheet_vp.frozen_layers, ["WALLS"]); + assert_eq!(sheet_vp.layer_colors.len(), 1); + + // The drawing is not changed. + let d = s.doc().unwrap(); + assert_eq!(layout_names(d), ["Layout1", "Layout2", "Sheet"]); + assert_eq!(d.model.iter().count(), 2); + assert!(d.layer("UNUSED").is_some() && d.block("PARTS").is_some()); + + // A drawing started from the template has the layout. + s.execute("new", &json!({ "template": p })).unwrap(); + assert_eq!(layout_names(s.doc().unwrap()), ["Sheet"]); +} + +#[test] +fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() { + hooks(); + let mut s = Session::new(); + let dir = TempDir::new("templates"); + s.cmdline("LAYOUT").unwrap(); + s.cmdline("sa").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template :"); + s.cmdline("Layout2").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name :"); + // No extension: the template is a .dwt. + let p = dir.path_str("typed"); + s.cmdline(&p).unwrap(); + assert!(s.current_prompt().is_none()); + let saved = format!("{p}.dwt"); + assert!(s.log.iter().any(|l| *l == format!("Layout \"Layout2\" saved to template \"{saved}\".")), "{:?}", s.log); + assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]); + + // The JSON form of LAYOUT, other file types, a missing path and an unknown layout. + let dxf = dir.path_str("sheet1.dxf"); + let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap(); + assert_eq!(r["name"], json!("Layout1")); + assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]); + assert!(s.execute("layout.saveas", &json!({ "path": dir.path_str("sheet.png") })).is_err()); + assert!(s.execute("layout.saveas", &json!({})).is_err()); + assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": dir.path_str("nope.dwt") })).is_err()); +} diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index 405f71c66..c67c627b2 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -2,7 +2,7 @@ //! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/ //! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use cadcraft_engine::Session; @@ -12,6 +12,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, MAX_BLOCK_DEPTH, XrefStatus}; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + fn hooks() { set_io(IoHooks { read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), @@ -20,16 +25,13 @@ fn hooks() { }); } -/// A fresh folder for one test. -fn folder(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir +/// A fresh folder for one test, removed when the test ends. +fn folder(tag: &str) -> TempDir { + TempDir::new(&format!("xref-{tag}")) } fn save(d: &Drawing, path: &Path) { - std::fs::write(path, cadcraft_io::write(d, &path.to_string_lossy()).unwrap()).unwrap(); + std::fs::write(path, cadcraft_io::write(d, path).unwrap()).unwrap(); } fn line(d: &mut Drawing, layer: &str, a: (f64, f64), b: (f64, f64)) { diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index 0c6cb074c..23736d017 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -537,6 +537,69 @@ 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) }) } +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(); + let mut line = Line::default(); + let mut pending_space = None; + let mut last_fmt = 0usize; + let mut paragraph = 0usize; + let finish = |lines: &mut Vec, line: &mut Line, format: &Fmt, paragraph: usize| { + if line.items.is_empty() { + line.max_h = format.height; + } + line.para = paragraph; + lines.push(std::mem::take(line)); + }; + + for atom in atoms { + match atom { + Atom::Space(format) => { + if !line.items.is_empty() || pending_space.is_some() { + pending_space = Some(*format); + } + last_fmt = *format; + } + Atom::Para(format) => { + pending_space = None; + finish(&mut lines, &mut line, fmt(*format), paragraph); + paragraph += 1; + last_fmt = *format; + } + Atom::Word(segments) => { + 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 { + 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; + 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; + } + } + } + } + finish(&mut lines, &mut line, fmt(last_fmt), paragraph); + lines +} + /// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the /// column width and the attachment point. pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { @@ -550,62 +613,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout { return para::layout(contents, p, &base); } let fmt = |i: usize| fmts.get(i).unwrap_or(&base); - let mut lines: Vec = Vec::new(); - let mut line = Line::default(); - let mut pending_space: Option = None; - let mut last_fmt = 0usize; - let mut para = 0usize; - let finish = |lines: &mut Vec, line: &mut Line, f: &Fmt, para: usize| { - if line.items.is_empty() { - line.max_h = f.height; - } - line.para = para; - lines.push(std::mem::take(line)); - }; - for a in &atoms { - match a { - Atom::Space(f) => { - if !line.items.is_empty() || pending_space.is_some() { - pending_space = Some(*f); - } - last_fmt = *f; - } - Atom::Para(f) => { - pending_space = None; - finish(&mut lines, &mut line, fmt(*f), para); - para += 1; - last_fmt = *f; - } - Atom::Word(segs) => { - let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(); - let sw = pending_space.map(|f| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf)).unwrap_or(0.0); - if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > width + 1e-9 { - finish(&mut lines, &mut line, fmt(last_fmt), para); - } else if !line.items.is_empty() || pending_space.is_some() { - if let Some(f) = pending_space { - let at = line.chars; - line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at }); - line.text.push(' '); - line.chars += 1; - } - line.width += sw; - } - pending_space = None; - for (sg, f) in segs { - let fw = seg_width(sg, fmt(*f)); - line.max_h = line.max_h.max(fmt(*f).height); - let at = line.chars; - let plain = seg_plain(sg); - line.chars += plain.chars().count(); - line.text.push_str(&plain); - line.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at }); - line.width += fw; - last_fmt = *f; - } - } - } - } - finish(&mut lines, &mut line, fmt(last_fmt), para); + let mut lines = plain_lines(&atoms, &fmts, &base, width); // Right-to-left text: each line in display order, its paragraph's direction from the // paragraph's first strong character. let mut bases: Vec> = Vec::new(); diff --git a/crates/fonts/src/mtext/para.rs b/crates/fonts/src/mtext/para.rs index d31f6a79d..064e62d02 100644 --- a/crates/fonts/src/mtext/para.rs +++ b/crates/fonts/src/mtext/para.rs @@ -402,6 +402,154 @@ fn split_tabs(segs: &[(Seg, usize)]) -> Vec { out } +struct LineBuilder<'a> { + paragraphs: &'a Paragraphs, + formats: &'a [Fmt], + base: &'a Fmt, + width: f64, + height: f64, + lines: Vec, + line: PLine, + paragraph: usize, + pending_space: Option, + last_format: usize, +} + +impl<'a> LineBuilder<'a> { + fn new(paragraphs: &'a Paragraphs, formats: &'a [Fmt], base: &'a Fmt, width: f64, height: f64) -> Self { + let first = paragraphs.props(0); + Self { + paragraphs, + formats, + base, + width, + height, + lines: Vec::new(), + line: PLine { width: (first.left + first.first).max(0.0), ..Default::default() }, + paragraph: 0, + pending_space: None, + last_format: 0, + } + } + + fn format(&self, index: usize) -> &Fmt { + self.formats.get(index).unwrap_or(self.base) + } + + fn build(mut self, atoms: &[Atom]) -> Vec { + for atom in atoms { + self.push_atom(atom); + } + self.finish(true, 0.0); + self.lines + } + + fn push_atom(&mut self, atom: &Atom) { + match atom { + Atom::Space(format) => { + if !self.line.items.is_empty() || self.pending_space.is_some() { + self.pending_space = Some(*format); + } + self.last_format = *format; + } + Atom::Para(format) => { + self.pending_space = None; + let next_start = self.first_start(self.paragraph + 1); + self.last_format = *format; + self.finish(true, next_start); + self.paragraph += 1; + } + Atom::Word(segments) => self.push_word(segments), + } + } + + fn first_start(&self, paragraph: usize) -> f64 { + let props = self.paragraphs.props(paragraph); + (props.left + props.first).max(0.0) + } + + fn finish(&mut self, paragraph_end: bool, next_start: f64) { + if self.line.items.is_empty() { + self.line.max_h = self.format(self.last_format).height; + } + self.line.para = self.paragraph; + self.line.para_end = paragraph_end; + let next = PLine { width: next_start, ..Default::default() }; + self.lines.push(std::mem::replace(&mut self.line, next)); + } + + fn push_word(&mut self, segments: &[(Seg, usize)]) { + let parts = split_tabs(segments); + for (index, part) in parts.iter().enumerate() { + match part { + Part::Tab => self.push_tab(&parts, index), + Part::Text(text) => self.push_text(index, text), + } + } + } + + fn push_tab(&mut self, parts: &[Part], index: usize) { + self.pending_space = None; + let (available, left) = { + let props = self.paragraphs.props(self.paragraph); + (self.available_width(props), props.left.max(0.0)) + }; + let (stop, kind) = next_tab(self.line.width, self.paragraphs.props(self.paragraph), self.height); + let next_width: f64 = match parts.get(index + 1) { + Some(Part::Text(segments)) => segments.iter().map(|(segment, format)| seg_width(segment, self.format(*format))).sum(), + _ => 0.0, + }; + let x = match kind { + TabKind::Left => stop, + TabKind::Center => (stop - next_width / 2.0).max(self.line.width), + TabKind::Right | TabKind::Decimal => (stop - next_width).max(self.line.width), + }; + if x > available + 1e-9 && !self.line.items.is_empty() { + self.finish(false, left); + } else { + self.line.width = x; + self.line.text.push(' '); + } + } + + 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(); + 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)) + .unwrap_or(0.0) + } else { + 0.0 + }; + let (available, left) = { + let props = self.paragraphs.props(self.paragraph); + (self.available_width(props), props.left.max(0.0)) + }; + if self.width > 0.0 && !self.line.items.is_empty() && self.line.width + space_width + word_width > available + 1e-9 { + self.finish(false, left); + } else if index == 0 && (!self.line.items.is_empty() || self.pending_space.is_some()) { + self.line.width += space_width; + if self.pending_space.is_some() { + self.line.text.push(' '); + self.line.gaps += 1; + } + } + self.pending_space = None; + for (segment, format) in segments { + let segment_width = seg_width(segment, self.format(*format)); + self.line.max_h = self.line.max_h.max(self.format(*format).height); + self.line.text.push_str(&seg_plain(segment)); + self.line.items.push(Item { seg: segment.clone(), fmt: *format, x: self.line.width, gap: self.line.gaps }); + self.line.width += segment_width; + self.last_format = *format; + } + } + + fn available_width(&self, props: &ParaProps) -> f64 { + if self.width > 0.0 { (self.width - props.right).max(0.0) } else { f64::INFINITY } + } +} + /// The next tab stop right of `x`: the paragraph's stops, the left indent of a hanging indent, /// then every [`DEFAULT_TAB`] text heights. fn next_tab(x: f64, pp: &ParaProps, h: f64) -> (f64, TabKind) { @@ -427,94 +575,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout let paras = Paragraphs::scan(contents); let (atoms, fmts) = parse(contents, base.clone()); let fmt = |i: usize| fmts.get(i).unwrap_or(base); - let space_w = |f: usize| crate::space_width(&fmt(f).font, fmt(f).height, fmt(f).wf); - - let mut lines: Vec = Vec::new(); - let mut k = 0usize; - let first_start = |k: usize| { - let pp = paras.props(k); - (pp.left + pp.first).max(0.0) - }; - let mut line = PLine { width: first_start(0), ..Default::default() }; - let mut pending_space: Option = None; - let mut last_fmt = 0usize; - let finish = |lines: &mut Vec, line: &mut PLine, f: &Fmt, k: usize, para_end: bool, next_start: f64| { - if line.items.is_empty() { - line.max_h = f.height; - } - line.para = k; - line.para_end = para_end; - let next = PLine { width: next_start, ..Default::default() }; - lines.push(std::mem::replace(line, next)); - }; - for a in &atoms { - let pp = paras.props(k); - let avail = if width > 0.0 { (width - pp.right).max(0.0) } else { f64::INFINITY }; - let left = pp.left.max(0.0); - match a { - Atom::Space(f) => { - if !line.items.is_empty() || pending_space.is_some() { - pending_space = Some(*f); - } - last_fmt = *f; - } - Atom::Para(f) => { - pending_space = None; - finish(&mut lines, &mut line, fmt(*f), k, true, first_start(k + 1)); - k += 1; - last_fmt = *f; - } - Atom::Word(segs) => { - let parts = split_tabs(segs); - for (pi, part) in parts.iter().enumerate() { - match part { - Part::Tab => { - pending_space = None; - let (stop, kind) = next_tab(line.width, pp, h); - let next_w: f64 = match parts.get(pi + 1) { - Some(Part::Text(s)) => s.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(), - _ => 0.0, - }; - let x = match kind { - TabKind::Left => stop, - TabKind::Center => (stop - next_w / 2.0).max(line.width), - TabKind::Right | TabKind::Decimal => (stop - next_w).max(line.width), - }; - if x > avail + 1e-9 && !line.items.is_empty() { - finish(&mut lines, &mut line, fmt(last_fmt), k, false, left); - } else { - line.width = x; - line.text.push(' '); - } - } - Part::Text(segs) => { - let ww: f64 = segs.iter().map(|(sg, f)| seg_width(sg, fmt(*f))).sum(); - let sw = if pi == 0 { pending_space.map(space_w).unwrap_or(0.0) } else { 0.0 }; - if width > 0.0 && !line.items.is_empty() && line.width + sw + ww > avail + 1e-9 { - finish(&mut lines, &mut line, fmt(last_fmt), k, false, left); - } else if pi == 0 && (!line.items.is_empty() || pending_space.is_some()) { - line.width += sw; - if pending_space.is_some() { - line.text.push(' '); - line.gaps += 1; - } - } - pending_space = None; - for (sg, f) in segs { - let fw = seg_width(sg, fmt(*f)); - line.max_h = line.max_h.max(fmt(*f).height); - line.text.push_str(&seg_plain(sg)); - line.items.push(Item { seg: sg.clone(), fmt: *f, x: line.width, gap: line.gaps }); - line.width += fw; - last_fmt = *f; - } - } - } - } - } - } - } - finish(&mut lines, &mut line, fmt(last_fmt), k, true, 0.0); + let lines = LineBuilder::new(¶s, &fmts, base, width, h).build(&atoms); // Vertical placement: baselines below the top of each line's column. let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 }; @@ -634,6 +695,7 @@ mod tests { for (s, w, a) in [ ("the quick brown fox jumps over the lazy dog", 8.0, 1u8), ("one\\Ptwo\\P\\Pfour", 0.0, 5), + ("\\H3x;\\Psmall", 0.0, 1), ("alpha {\\H2x;beta} gamma delta", 6.0, 9), ("A{\\C1;B}C \\S1/2; x", 0.0, 2), (" lead spaces and\\P more", 5.0, 3), diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 52998900c..6b4c5ebdd 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -11,8 +11,7 @@ //! - [`fallback_fonts`] (`FONTFALLBACK`): fonts tried first for characters the text's font //! lacks (CJK in an SHX big font, for example), before the automatic system font search. //! -//! On native builds both start from the `CADCRAFT_FONTALT` / `CADCRAFT_FONTFALLBACK` -//! environment variables. +//! Both start empty; the host sets its starting values ([`set_font_alt`], [`set_fallback_fonts`]). use std::collections::HashMap; use std::path::PathBuf; @@ -524,16 +523,8 @@ struct Prefs { } fn prefs() -> MutexGuard<'static, Prefs> { - static P: OnceLock> = OnceLock::new(); - P.get_or_init(|| { - #[cfg(not(target_arch = "wasm32"))] - let env = |k: &str| std::env::var(k).unwrap_or_default(); - #[cfg(target_arch = "wasm32")] - let env = |_: &str| String::new(); - Mutex::new(Prefs { alt: clean_name(&env("CADCRAFT_FONTALT")), fallback: split_list(&env("CADCRAFT_FONTFALLBACK")) }) - }) - .lock() - .unwrap_or_else(PoisonError::into_inner) + static P: Mutex = Mutex::new(Prefs { alt: String::new(), fallback: Vec::new() }); + P.lock().unwrap_or_else(PoisonError::into_inner) } /// A font name setting: trimmed, capped, "." = none. diff --git a/crates/geom/src/nurbs.rs b/crates/geom/src/nurbs.rs index 5268203b1..104ba8d3c 100644 --- a/crates/geom/src/nurbs.rs +++ b/crates/geom/src/nurbs.rs @@ -487,7 +487,7 @@ impl Spline { /// 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()) { + if !(self.is_valid() && a.is_finite() && b.is_finite()) { return 0.0; } let (lo, hi) = self.domain(); diff --git a/crates/io/examples/export_bench.rs b/crates/io/examples/export_bench.rs new file mode 100644 index 000000000..d5ae0d436 --- /dev/null +++ b/crates/io/examples/export_bench.rs @@ -0,0 +1,79 @@ +//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid +//! (a floor plan of many apartments). +//! +//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]` +//! +//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side +//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall +//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR, +//! the last run's output is written there as `bench.FORMAT` to compare builds. + +use std::time::{Duration, Instant}; + +use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::{Mat3, Vec2}; + +fn drawing(grid: usize) -> Drawing { + let src = include_bytes!("../../../examples/apartment.dxf"); + let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else { + eprintln!("apartment.dxf did not read"); + std::process::exit(1); + }; + let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default(); + let ext = d.extents(&Space::Model); + let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1); + for i in 0..grid { + for j in 0..grid { + if (i, j) == (0, 0) { + continue; + } + let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64)); + for e in &ents { + let mut e = e.clone(); + e.handle = d.new_handle(); + e.kind.transform(&m); + if let Some(s) = d.space_mut(&Space::Model) { + s.push(e); + } + } + } + } + d +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let format = args.first().map(String::as_str).unwrap_or("png").to_string(); + let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10); + let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1); + let out_dir = args.get(3); + let d = drawing(grid); + eprintln!("{} entities", d.entity_count()); + let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] }; + for f in formats { + let name = format!("bench.{f}"); + let mut times: Vec = Vec::new(); + let mut last = Vec::new(); + for _ in 0..runs { + let t = Instant::now(); + match cadcraft_io::write(&d, &name) { + Ok(bytes) => { + times.push(t.elapsed()); + last = bytes; + } + Err(e) => { + eprintln!("{f}: {e}"); + std::process::exit(1); + } + } + } + times.sort(); + let ms = |t: Duration| t.as_secs_f64() * 1000.0; + println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len()); + if let Some(dir) = out_dir + && let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last) + { + eprintln!("{name}: {e}"); + } + } +} diff --git a/crates/io/src/dxf_raw.rs b/crates/io/src/dxf_raw.rs index a1103056f..9b755f80f 100644 --- a/crates/io/src/dxf_raw.rs +++ b/crates/io/src/dxf_raw.rs @@ -135,10 +135,8 @@ fn head(tags: &[Tag]) -> Head { h.has_reactors |= group.as_deref() == Some("ACAD_REACTORS"); } (_, Some("ACAD_REACTORS")) => h.reactors.push(norm(&t.str())), - (_, Some("ACAD_XDICTIONARY")) => { - if h.xdict.is_none() { - h.xdict = Some(norm(&t.str())); - } + (_, Some("ACAD_XDICTIONARY")) if h.xdict.is_none() => { + h.xdict = Some(norm(&t.str())); } (_, Some(_)) => {} (5 | 105, None) if h.handle.is_none() => h.handle = Some(norm(&t.str())), @@ -328,22 +326,10 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { let get = |name: &str| secs.iter().find(|s| s.name == name).map(|s| split(&s.tags)).unwrap_or_default(); let (classes, tables, blocks, entities, objects) = (get("CLASSES"), get("TABLES"), get("BLOCKS"), get("ENTITIES"), get("OBJECTS")); // The drawing's entities by handle: whether each is of a type CADCraft doesn't model. - let mut ents: HashMap = HashMap::new(); - for e in all_entities(d) { - ents.entry(e.handle.hex()).or_insert(matches!(e.kind, EntityKind::Unknown(_))); - } + let ents = entity_types(d); let ids = identities(&tables, &blocks, &objects); // Extension dictionaries of entities and records: owner → dictionary. - let mut xdict_of: HashMap = HashMap::new(); - for (kind, _, t) in tables.iter().chain(&blocks).chain(&entities) { - if kind == "ENDBLK" { - continue; - } - let h = head(t); - if let (Some(o), Some(x)) = (h.handle, h.xdict) { - xdict_of.entry(o).or_insert(x); - } - } + let xdict_of = extension_dictionaries(&tables, &blocks, &entities); // Objects by handle: the first of each, and none that has an entity's handle. let mut by_handle: HashMap = HashMap::new(); for (i, (_, _, t)) in objects.iter().enumerate() { @@ -361,48 +347,7 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { let (Some(h), Some(o)) = (hd.handle, hd.owner) else { return false }; !by_handle.contains_key(&o) && xdict_of.get(&o) == Some(&h) && (ents.contains_key(&o) || ids.contains_key(&o)) }; - let modelled_entry = |t: &[Tag], h: &str| entries(t).iter().any(|(k, eh)| eh == h && MODELLED_XDICT_KEYS.contains(&up(k).as_str())); - // Which objects to keep: those under a named object dictionary entry CADCraft doesn't model, - // under the extension dictionary of an entity or record (minus modelled entries; one left - // empty goes), or owned by an entity of a type CADCraft doesn't model. Memoized along each - // ownership chain; cycles and over-long chains are not kept. - let mut memo: HashMap = HashMap::new(); - for i in 0..objects.len() { - let mut path: Vec = Vec::new(); - let mut cur = i; - let verdict = loop { - if let Some(&v) = memo.get(&cur) { - break v; - } - if path.contains(&cur) || path.len() >= MAX_DEPTH { - break false; - } - path.push(cur); - let Some((_, _, t)) = objects.get(cur) else { break false }; - let hd = head(t); - let (Some(h), Some(o)) = (hd.handle, hd.owner) else { break false }; - if by_handle.get(&h) != Some(&cur) { - break false; - } - if Some(&o) == root.as_ref() { - break root_key(&h).is_some_and(|k| !modelled_key(&k)); - } - if let Some(&j) = by_handle.get(&o) { - if is_xdict(j) && objects.get(j).is_some_and(|p| modelled_entry(p.2, &h)) { - break false; - } - cur = j; - continue; - } - if is_xdict(cur) { - break entries(t).iter().any(|(k, _)| !MODELLED_XDICT_KEYS.contains(&up(k).as_str())); - } - break ents.get(&o) == Some(&true); - }; - for p in path { - memo.insert(p, verdict); - } - } + let memo = retained_objects(&objects, &ents, &ids, &by_handle, root.as_deref(), &root_entries, &xdict_of); let mut f = Foreign::default(); let mut kept: HashSet = HashSet::new(); for (i, (kind, _, t)) in objects.iter().enumerate() { @@ -511,6 +456,92 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) { } } +fn entity_types(drawing: &Drawing) -> HashMap { + let mut types = HashMap::new(); + for entity in all_entities(drawing) { + types.entry(entity.handle.hex()).or_insert(matches!(entity.kind, EntityKind::Unknown(_))); + } + types +} + +fn extension_dictionaries( + tables: &[(String, usize, &[Tag])], + blocks: &[(String, usize, &[Tag])], + entities: &[(String, usize, &[Tag])], +) -> HashMap { + let mut dictionaries = HashMap::new(); + for (kind, _, tags) in tables.iter().chain(blocks).chain(entities) { + if kind == "ENDBLK" { + continue; + } + let header = head(tags); + if let (Some(owner), Some(dictionary)) = (header.handle, header.xdict) { + dictionaries.entry(owner).or_insert(dictionary); + } + } + dictionaries +} + +/// Determine which objects belong to unmodelled dictionary entries or owners. +fn retained_objects( + objects: &[(String, usize, &[Tag])], + entities: &HashMap, + records: &HashMap, + by_handle: &HashMap, + root: Option<&str>, + root_entries: &[(String, String)], + xdict_of: &HashMap, +) -> HashMap { + let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key); + let is_xdict = |index: usize| { + let Some((_, _, tags)) = objects.get(index) else { return false }; + let header = head(tags); + let (Some(handle), Some(owner)) = (header.handle, header.owner) else { return false }; + !by_handle.contains_key(&owner) && xdict_of.get(&owner) == Some(&handle) && (entities.contains_key(&owner) || records.contains_key(&owner)) + }; + 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())) + }; + let mut retained = HashMap::new(); + for index in 0..objects.len() { + let mut path = Vec::new(); + let mut current = index; + let keep = loop { + if let Some(&known) = retained.get(¤t) { + break known; + } + if path.contains(¤t) || path.len() >= MAX_DEPTH { + break false; + } + path.push(current); + let Some((_, _, tags)) = objects.get(current) else { break false }; + let header = head(tags); + let (Some(handle), Some(owner)) = (header.handle, header.owner) else { break false }; + if by_handle.get(&handle) != Some(¤t) { + break false; + } + if root == Some(owner.as_str()) { + break root_key(&handle).is_some_and(|key| !modelled_key(key)); + } + if let Some(&parent) = by_handle.get(&owner) { + if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) { + break false; + } + current = parent; + continue; + } + if is_xdict(current) { + break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str())); + } + break entities.get(&owner) == Some(&true); + }; + for visited in path { + retained.insert(visited, keep); + } + } + retained +} + /// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's /// objects and entities, whose class is named by number in group 91. fn is_proxy(kind: &str) -> bool { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 88d1801c3..1489f4def 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -3,7 +3,12 @@ use std::collections::HashMap; use cadcraft_color::{Color, Rgb}; -use cadcraft_doc::*; +use cadcraft_doc::{ + Arc, AssocSnap, Attrib, AttribModes, AttribProps, Block, Circle, Common, DashElement, DimAssoc, DimKind, DimStyle, Dimension, Drawing, Ellipse, + Entity, EntityKind, Face3d, Gradient, Group, HAlign, HVal, Handle, Hatch, HatchLoop, Helix, HelixConstrain, Insert, Layer, Layout, Leader, Line, + Linetype, Lineweight, LwPolyline, MAX_FRAME_CHARS, MLeaderStyle, MText, MultilineAttrib, NamedView, PageSetup, Point, Polyline3d, RawTag, + RayLine, Solid, Table, TableCell, Text, TextStyle, Tolerance, Transparency, Ucs, Unknown, VAlign, Viewport, VportConfig, VportTile, Wipeout, +}; use cadcraft_dxf::{Tag, records, sections}; use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3}; diff --git a/crates/io/src/dxf_read/common.rs b/crates/io/src/dxf_read/common.rs index 18e8edd8e..e46f3c09d 100644 --- a/crates/io/src/dxf_read/common.rs +++ b/crates/io/src/dxf_read/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color, Common, Lineweight, Rgb, Tag, Transparency, Vec2, Vec3}; /// Value lookup helpers over an entity's tags. pub(super) struct T<'a>(pub(super) &'a [Tag]); @@ -43,28 +43,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/dimassoc.rs b/crates/io/src/dxf_read/dimassoc.rs index a6219c1d4..fb4fc5aa1 100644 --- a/crates/io/src/dxf_read/dimassoc.rs +++ b/crates/io/src/dxf_read/dimassoc.rs @@ -1,5 +1,5 @@ use super::common::T; -use super::*; +use super::{Arc, AssocSnap, Circle, DimAssoc, DimKind, Dimension, Drawing, EntityKind, Handle, Tag, Vec2, Vec3}; /// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides /// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index 245bda331..d732f4450 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::*; +use super::{Block, BlockDef, Drawing, Entity, EntityKind, Handle, HashMap, Layout, LayoutDef, Rx, Tag, records}; pub(super) fn parse_entities( recs: &[(String, Vec)], @@ -35,13 +35,11 @@ pub(super) fn parse_entities( extra = more; kindent = Some((c, k)); } - "INSERT" => { - if t.i(66) == Some(1) { - let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); - let attribs = insert_attribs(recs, &mut i, &ins_layer); - if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { - ins.attribs = attribs; - } + "INSERT" if t.i(66) == Some(1) => { + let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); + let attribs = insert_attribs(recs, &mut i, &ins_layer); + if let Some((_, EntityKind::Insert(ins))) = kindent.as_mut() { + ins.attribs = attribs; } } // A VERTEX or ATTRIB with no POLYLINE or INSERT before it; a stray SEQEND holds nothing. @@ -311,7 +309,11 @@ pub(super) fn apply_sortents( for (owner, map) in tables { let Some(name) = brs.get(owner) else { continue }; let up = name.to_ascii_uppercase(); - let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); + // A sort handle of 0 draws last (ezdxf `reorder`, which notes AutoCAD defines it, undocumented). + let key = |e: &Entity| { + let k = map.get(&e.handle).copied().unwrap_or(e.handle.0); + (k == 0, k) + }; if up == "*MODEL_SPACE" { d.model.sort_by_key(key); } else if up.starts_with("*PAPER_SPACE") { diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index 9f0816d2a..fc55fe335 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -1,7 +1,11 @@ use super::common::{T, common, ocs}; use super::hatch::hatch; use super::text::{attdef, attrib_modes, attrib_props, mtext_from, multiline_attrib, split_embedded, text_from}; -use super::*; +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, +}; /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. pub(super) fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { @@ -535,7 +539,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, diff --git a/crates/io/src/dxf_read/hatch.rs b/crates/io/src/dxf_read/hatch.rs index 18fe81531..13013a85d 100644 --- a/crates/io/src/dxf_read/hatch.rs +++ b/crates/io/src/dxf_read/hatch.rs @@ -1,5 +1,5 @@ use super::common::T; -use super::*; +use super::{Color, EntityKind, Gradient, Handle, Hatch, HatchLoop, PolyVertex, Rgb, Spline, Tag, Vec2}; pub(super) fn hatch(tags: &[Tag], t: &T) -> Option { let (origin, background) = hatch_xdata(tags); @@ -19,181 +19,11 @@ pub(super) fn hatch(tags: &[Tag], t: &T) -> Option { let flags = get(i)?.i64(); i += 1; let outer = flags & 1 != 0; - let mut verts: Vec = Vec::new(); - if flags & 2 != 0 { - // Polyline boundary: 72 has-bulge, 73 closed, 93 count, then 10/20/42. - let has_bulge = get(i).filter(|x| x.code == 72).map(Tag::i64).unwrap_or(0) != 0; - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - let x = get(i).filter(|t| t.code == 10)?.f64(); - let y = get(i + 1).filter(|t| t.code == 20)?.f64(); - i += 2; - let mut b = 0.0; - if has_bulge && get(i).is_some_and(|t| t.code == 42) { - b = get(i)?.f64(); - i += 1; - } - verts.push(PolyVertex::with_bulge(Vec2::new(x, y), b)); - } - } else { - // Edge boundary: 93 edge count, each 72 edge type. - while get(i).is_some_and(|x| x.code != 93) { - i += 1; - } - let n = get(i)?.i64().clamp(0, 1_000_000) as usize; - i += 1; - for _ in 0..n { - while get(i).is_some_and(|x| x.code != 72) { - i += 1; - } - let ty = get(i)?.i64(); - i += 1; - let f = |i: usize| get(i).map(Tag::f64).unwrap_or(0.0); - match ty { - 1 => { - let a = Vec2::new(f(i), f(i + 1)); - let b = Vec2::new(f(i + 2), f(i + 3)); - i += 4; - if verts.last().is_none_or(|l| !l.p.near(a, 1e-9)) { - verts.push(PolyVertex::new(a)); - } - verts.push(PolyVertex::new(b)); - } - 2 => { - let c = Vec2::new(f(i), f(i + 1)); - let r = f(i + 2); - let sa = f(i + 3).to_radians(); - let ea = f(i + 4).to_radians(); - let ccw = get(i + 5).map(Tag::i64).unwrap_or(1) != 0; - i += 6; - let (s, e) = if ccw { (sa, ea) } else { (-sa, -ea) }; - let p0 = Vec2::polar(c, r, s); - let p1 = Vec2::polar(c, r, e); - let sweep = if ccw { cadcraft_geom::ccw_sweep(s, e) } else { -cadcraft_geom::ccw_sweep(e, s) }; - if verts.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { - verts.push(PolyVertex::new(p0)); - } - // A full circle (0 to 360) starts and ends on the same point, which one - // bulge can't span: it goes in as two half circles. - let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; - let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); - if let Some(l) = verts.last_mut() { - l.bulge = bulge; - } - if full { - verts.push(PolyVertex::with_bulge(Vec2::polar(c, r, s + sweep / 2.0), bulge)); - } - verts.push(PolyVertex::new(p1)); - } - 3 => { - // Elliptic arc: approximate with points. - let c = Vec2::new(f(i), f(i + 1)); - let m = Vec2::new(f(i + 2), f(i + 3)); - let ratio = f(i + 4); - let sa = f(i + 5).to_radians(); - let ea = f(i + 6).to_radians(); - let ccw = get(i + 7).filter(|t| t.code == 73).map(Tag::i64).unwrap_or(1) != 0; - i += 8; - // A clockwise edge stores its parameters mirrored, as an arc edge does: it - // runs from -start clockwise to -end, the ccw sweep from -end to -start - // walked backwards. - let (s, e) = if ccw { (sa, ea) } else { (-ea, -sa) }; - let e = cadcraft_geom::Ellipse { center: c, major: m, ratio, start: s, end: e }; - let mut pts = Vec::new(); - e.tessellate(m.len() * 1e-3, &mut pts); - if !ccw { - pts.reverse(); - } - verts.extend(pts.into_iter().map(PolyVertex::new)); - } - 4 => { - // Spline edge: 94 degree, 73 rational, 74 periodic, 95 knot count, 96 - // control point count, then the 40 knots, the 10/20 control points (each - // with a 42 weight when rational), and 97 fit point count with 11/21 fit - // points and 12/22, 13/23 end tangents (R2010+). - let (mut degree, mut nk, mut nc) = (3, 0, 0); - while let Some(x) = get(i).filter(|x| matches!(x.code, 94 | 73 | 74 | 95 | 96)) { - let n = x.i64().clamp(0, 100_000) as usize; - match x.code { - 94 => degree = n.clamp(1, 10), // same cap as the SPLINE reader - 95 => nk = n, - 96 => nc = n, - _ => {} - } - i += 1; - } - let mut knots = Vec::new(); - while knots.len() < nk && get(i).is_some_and(|x| x.code == 40) { - knots.push(f(i)); - i += 1; - } - let (mut control, mut weights) = (Vec::new(), Vec::new()); - while control.len() < nc && get(i).is_some_and(|x| x.code == 10) { - control.push(Vec2::new(f(i), f(i + 1))); - i += 2; - if get(i).is_some_and(|t| t.code == 42) { - weights.push(f(i)); - i += 1; - } - } - let mut fit = Vec::new(); - if get(i).is_some_and(|x| x.code == 97) { - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; - i += 1; - while fit.len() < n && get(i).is_some_and(|x| x.code == 11) { - fit.push(Vec2::new(f(i), f(i + 1))); - i += 2; - } - } - if weights.len() != control.len() { - weights.clear(); - } - let sp = Spline { degree, knots, control, weights, ..Spline::default() }; - let sp = if sp.is_valid() { - sp - } else if sp.control.is_empty() && fit.len() > 1 { - Spline::from_fit_points(&fit) - } else { - Spline::from_control(sp.control, degree) - }; - let pts = sp.tessellate(1e-3); - let skip = usize::from(verts.last().zip(pts.first()).is_some_and(|(l, p)| l.p.near(*p, 1e-9))); - verts.extend(pts.into_iter().skip(skip).map(PolyVertex::new)); - } - _ => {} - } - } - } + let mut verts = if flags & 2 != 0 { polyline_boundary(tags, &mut i)? } else { edge_boundary(tags, &mut i)? }; if verts.len() > 1 && verts.first().map(|f| f.p).zip(verts.last().map(|l| l.p)).is_some_and(|(a, b)| a.near(b, 1e-9)) { verts.pop(); } - // 97: number of source boundary objects, then their handles (330). A spline edge's fit - // data count is a 97 too, followed by its fit points instead. - let mut sources = Vec::new(); - for _ in 0..2 { - while get(i).is_some_and(|x| !matches!(x.code, 97 | 92 | 75)) { - i += 1; - } - if get(i).is_none_or(|x| x.code != 97) { - break; - } - let n = get(i).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; - i += 1; - if get(i).is_some_and(|x| !matches!(x.code, 330 | 92 | 75)) { - continue; - } - while sources.len() < n - && let Some(x) = get(i).filter(|x| x.code == 330) - { - sources.extend(Handle::parse_hex(&x.str()).filter(|h| h.0 != 0)); - i += 1; - } - break; - } + let sources = boundary_sources(tags, &mut i); loops.push(HatchLoop { vertices: verts, outer, sources }); } let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); @@ -219,6 +49,191 @@ pub(super) fn hatch(tags: &[Tag], t: &T) -> Option { })) } +fn seek_code(tags: &[Tag], index: &mut usize, code: i32) { + while tags.get(*index).is_some_and(|tag| tag.code != code) { + *index += 1; + } +} + +fn polyline_boundary(tags: &[Tag], index: &mut usize) -> Option> { + let get = |i: usize| tags.get(i); + let has_bulge = get(*index).filter(|tag| tag.code == 72).map(Tag::i64).unwrap_or(0) != 0; + seek_code(tags, index, 93); + let count = get(*index)?.i64().clamp(0, 1_000_000) as usize; + *index += 1; + let mut vertices = Vec::new(); + for _ in 0..count { + let x = get(*index).filter(|tag| tag.code == 10)?.f64(); + let y = get(*index + 1).filter(|tag| tag.code == 20)?.f64(); + *index += 2; + let mut bulge = 0.0; + if has_bulge && get(*index).is_some_and(|tag| tag.code == 42) { + bulge = get(*index)?.f64(); + *index += 1; + } + vertices.push(PolyVertex::with_bulge(Vec2::new(x, y), bulge)); + } + Some(vertices) +} + +fn edge_boundary(tags: &[Tag], index: &mut usize) -> Option> { + let get = |i: usize| tags.get(i); + seek_code(tags, index, 93); + let count = get(*index)?.i64().clamp(0, 1_000_000) as usize; + *index += 1; + let mut vertices = Vec::new(); + for _ in 0..count { + seek_code(tags, index, 72); + let kind = get(*index)?.i64(); + *index += 1; + match kind { + 1 => line_edge(tags, index, &mut vertices), + 2 => circular_edge(tags, index, &mut vertices), + 3 => elliptic_edge(tags, index, &mut vertices), + 4 => spline_edge(tags, index, &mut vertices), + _ => {} + } + } + Some(vertices) +} + +fn edge_value(tags: &[Tag], index: usize) -> f64 { + tags.get(index).map(Tag::f64).unwrap_or(0.0) +} + +fn line_edge(tags: &[Tag], index: &mut usize, vertices: &mut Vec) { + let start = Vec2::new(edge_value(tags, *index), edge_value(tags, *index + 1)); + let end = Vec2::new(edge_value(tags, *index + 2), edge_value(tags, *index + 3)); + *index += 4; + if vertices.last().is_none_or(|last| !last.p.near(start, 1e-9)) { + vertices.push(PolyVertex::new(start)); + } + vertices.push(PolyVertex::new(end)); +} + +fn circular_edge(tags: &[Tag], index: &mut usize, vertices: &mut Vec) { + let center = Vec2::new(edge_value(tags, *index), edge_value(tags, *index + 1)); + let radius = edge_value(tags, *index + 2); + let start_angle = edge_value(tags, *index + 3).to_radians(); + let end_angle = edge_value(tags, *index + 4).to_radians(); + let ccw = tags.get(*index + 5).map(Tag::i64).unwrap_or(1) != 0; + *index += 6; + let (start, end) = if ccw { (start_angle, end_angle) } else { (-start_angle, -end_angle) }; + let start_point = Vec2::polar(center, radius, start); + let end_point = Vec2::polar(center, radius, end); + let sweep = if ccw { cadcraft_geom::ccw_sweep(start, end) } else { -cadcraft_geom::ccw_sweep(end, start) }; + if vertices.last().is_none_or(|last| !last.p.near(start_point, 1e-9)) { + vertices.push(PolyVertex::new(start_point)); + } + // A full circle needs two bulges because one bulge cannot span coincident endpoints. + let full = (sweep.abs() - std::f64::consts::TAU).abs() < 1e-9; + let bulge = cadcraft_geom::arc_to_bulge(if full { sweep / 2.0 } else { sweep }); + if let Some(last) = vertices.last_mut() { + last.bulge = bulge; + } + if full { + vertices.push(PolyVertex::with_bulge(Vec2::polar(center, radius, start + sweep / 2.0), bulge)); + } + vertices.push(PolyVertex::new(end_point)); +} + +fn elliptic_edge(tags: &[Tag], index: &mut usize, vertices: &mut Vec) { + let center = Vec2::new(edge_value(tags, *index), edge_value(tags, *index + 1)); + let major = Vec2::new(edge_value(tags, *index + 2), edge_value(tags, *index + 3)); + let ratio = edge_value(tags, *index + 4); + let start_angle = edge_value(tags, *index + 5).to_radians(); + let end_angle = edge_value(tags, *index + 6).to_radians(); + let ccw = tags.get(*index + 7).filter(|tag| tag.code == 73).map(Tag::i64).unwrap_or(1) != 0; + *index += 8; + let (start, end) = if ccw { (start_angle, end_angle) } else { (-end_angle, -start_angle) }; + let ellipse = cadcraft_geom::Ellipse { center, major, ratio, start, end }; + let mut points = Vec::new(); + ellipse.tessellate(major.len() * 1e-3, &mut points); + if !ccw { + points.reverse(); + } + vertices.extend(points.into_iter().map(PolyVertex::new)); +} + +fn spline_edge(tags: &[Tag], index: &mut usize, vertices: &mut Vec) { + let get = |i: usize| tags.get(i); + let mut degree = 3; + let (mut knot_count, mut control_count) = (0, 0); + while let Some(tag) = get(*index).filter(|tag| matches!(tag.code, 94 | 73 | 74 | 95 | 96)) { + let count = tag.i64().clamp(0, 100_000) as usize; + match tag.code { + 94 => degree = count.clamp(1, 10), + 95 => knot_count = count, + 96 => control_count = count, + _ => {} + } + *index += 1; + } + let mut knots = Vec::new(); + while knots.len() < knot_count && get(*index).is_some_and(|tag| tag.code == 40) { + knots.push(edge_value(tags, *index)); + *index += 1; + } + let (mut control, mut weights) = (Vec::new(), Vec::new()); + while control.len() < control_count && get(*index).is_some_and(|tag| tag.code == 10) { + control.push(Vec2::new(edge_value(tags, *index), edge_value(tags, *index + 1))); + *index += 2; + if get(*index).is_some_and(|tag| tag.code == 42) { + weights.push(edge_value(tags, *index)); + *index += 1; + } + } + let mut fit = Vec::new(); + if get(*index).is_some_and(|tag| tag.code == 97) { + let count = get(*index).map(Tag::i64).unwrap_or(0).clamp(0, 100_000) as usize; + *index += 1; + while fit.len() < count && get(*index).is_some_and(|tag| tag.code == 11) { + fit.push(Vec2::new(edge_value(tags, *index), edge_value(tags, *index + 1))); + *index += 2; + } + } + if weights.len() != control.len() { + weights.clear(); + } + let spline = Spline { degree, knots, control, weights, ..Spline::default() }; + let spline = if spline.is_valid() { + spline + } else if spline.control.is_empty() && fit.len() > 1 { + Spline::from_fit_points(&fit) + } else { + Spline::from_control(spline.control, degree) + }; + let points = spline.tessellate(1e-3); + let skip = usize::from(vertices.last().zip(points.first()).is_some_and(|(last, point)| last.p.near(*point, 1e-9))); + vertices.extend(points.into_iter().skip(skip).map(PolyVertex::new)); +} + +fn boundary_sources(tags: &[Tag], index: &mut usize) -> Vec { + let get = |i: usize| tags.get(i); + let mut sources = Vec::new(); + for _ in 0..2 { + while get(*index).is_some_and(|tag| !matches!(tag.code, 97 | 92 | 75)) { + *index += 1; + } + if get(*index).is_none_or(|tag| tag.code != 97) { + break; + } + let count = get(*index).map(Tag::i64).unwrap_or(0).clamp(0, MAX_HATCH_SOURCES as i64) as usize; + *index += 1; + if get(*index).is_some_and(|tag| !matches!(tag.code, 330 | 92 | 75)) { + continue; + } + while sources.len() < count + && let Some(tag) = get(*index).filter(|tag| tag.code == 330) + { + sources.extend(Handle::parse_hex(&tag.str()).filter(|handle| handle.0 != 0)); + *index += 1; + } + break; + } + sources +} + /// Upper bound on the source boundary objects of one HATCH loop. const MAX_HATCH_SOURCES: usize = 4096; diff --git a/crates/io/src/dxf_read/mtext_dxf.rs b/crates/io/src/dxf_read/mtext_dxf.rs index f955603e6..9ab133e44 100644 --- a/crates/io/src/dxf_read/mtext_dxf.rs +++ b/crates/io/src/dxf_read/mtext_dxf.rs @@ -74,16 +74,21 @@ 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); + // An application-defined group in the record may hold any code, 50 included. + let tags: Vec<&Tag> = super::common::outside_app(own(tags)).map(|(_, t)| t).collect(); let start = tags.iter().position(|t| t.code == 75)?; - let kind = match tags.get(start).map(Tag::i64) { + 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); @@ -115,6 +120,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 97b30c786..4d692ccee 100644 --- a/crates/io/src/dxf_read/objects.rs +++ b/crates/io/src/dxf_read/objects.rs @@ -1,7 +1,7 @@ use super::common::T; use super::dimassoc::dimassoc; use super::document::sortents_table; -use super::*; +use super::{Drawing, EntityKind, Group, Handle, HashMap, MLeaderStyle, PageSetup, Rx, Tag, Vec2}; /// Non-graphical objects applied once every entity is placed. #[derive(Default)] diff --git a/crates/io/src/dxf_read/tables.rs b/crates/io/src/dxf_read/tables.rs index 9419a081e..8a57f7f9e 100644 --- a/crates/io/src/dxf_read/tables.rs +++ b/crates/io/src/dxf_read/tables.rs @@ -1,6 +1,9 @@ use super::common::T; use super::objects::MAX_OBJECTS; -use super::*; +use super::{ + Color, DashElement, DimStyle, Drawing, HVal, HashMap, Layer, Linetype, Lineweight, NamedView, Rgb, Rx, Tag, TextStyle, Ucs, Vec2, Vec3, + VportConfig, VportTile, records, +}; pub(super) fn header(tags: &[Tag], d: &mut Drawing) { let mut i = 0; @@ -99,30 +102,7 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { }) .collect(); // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. - for (kind, tg) in &recs { - let t = T(tg); - let (Some(h), Some(n)) = (t.s(5), t.s(2)) else { continue }; - match kind.as_str() { - "STYLE" => { - rx.styles.insert(h.trim().to_ascii_uppercase(), n); - } - "BLOCK_RECORD" => { - let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); - rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); - if let Some(a) = crate::dxf_ext::read_array(tg) { - rx.block_arrays.insert(n.to_ascii_uppercase(), a); - } - rx.brs.insert(h.trim().to_ascii_uppercase(), n); - } - "LTYPE" => { - rx.ltypes.insert(h.trim().to_ascii_uppercase(), n); - } - "LAYER" => { - rx.layers.insert(h.trim().to_ascii_uppercase(), n); - } - _ => {} - } - } + index_table_handles(&recs, rx); let mut active_vport = false; // Index of layers already appended, by lower-cased name, so each LAYER record updates or // appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer @@ -300,6 +280,33 @@ pub(super) fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); } +fn index_table_handles(recs: &[(String, Vec)], rx: &mut Rx) { + for (kind, tags) in recs { + let table = T(tags); + let (Some(handle), Some(name)) = (table.s(5), table.s(2)) else { continue }; + match kind.as_str() { + "STYLE" => { + rx.styles.insert(handle.trim().to_ascii_uppercase(), name); + } + "BLOCK_RECORD" => { + let units = table.i(70).and_then(|units| u8::try_from(units).ok()).unwrap_or(0); + rx.block_props.insert(name.to_ascii_uppercase(), (units, table.i(280).unwrap_or(1) != 0)); + if let Some(array) = crate::dxf_ext::read_array(tags) { + rx.block_arrays.insert(name.to_ascii_uppercase(), array); + } + rx.brs.insert(handle.trim().to_ascii_uppercase(), name); + } + "LTYPE" => { + rx.ltypes.insert(handle.trim().to_ascii_uppercase(), name); + } + "LAYER" => { + rx.layers.insert(handle.trim().to_ascii_uppercase(), name); + } + _ => {} + } + } +} + /// Most tiles a viewport configuration keeps. const MAX_VPORT_TILES: usize = 64; diff --git a/crates/io/src/dxf_read/text.rs b/crates/io/src/dxf_read/text.rs index 45ee2f003..10d770c0e 100644 --- a/crates/io/src/dxf_read/text.rs +++ b/crates/io/src/dxf_read/text.rs @@ -1,5 +1,5 @@ use super::common::{T, common, ocs_n}; -use super::*; +use super::{Attrib, AttribModes, AttribProps, Common, HAlign, MText, MultilineAttrib, Tag, Text, VAlign, Vec2, Vec3, mtext_dxf}; /// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when /// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 4c41398de..f2c125512 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1,12 +1,16 @@ //! 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; use cadcraft_color::Color; -use cadcraft_doc::*; +use cadcraft_doc::{ + AssocSnap, Attrib, Block, Common, DimAssoc, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, EntityStore, Gradient, Group, HAlign, + HVal, Handle, Hatch, Insert, Layout, Leader, Line, Lineweight, LwPolyline, MLeader, MLeaderStyle, MText, PageSetup, Point, Solid, Space, Table, + TableStyle, Text, Transparency, VAlign, VportConfig, VportTile, +}; use cadcraft_dxf::{Tag, Version}; use cadcraft_geom::{Bounds2, Vec2, Vec3}; diff --git a/crates/io/src/dxf_write/assoc.rs b/crates/io/src/dxf_write/assoc.rs index 9d9a607aa..45a14df22 100644 --- a/crates/io/src/dxf_write/assoc.rs +++ b/crates/io/src/dxf_write/assoc.rs @@ -1,5 +1,5 @@ use super::ctx::OsnapRef; -use super::*; +use super::{AssocSnap, DimAssoc, DimKind, Dimension, Drawing, EntityKind, LwPolyline}; /// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment: /// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]); diff --git a/crates/io/src/dxf_write/blocks.rs b/crates/io/src/dxf_write/blocks.rs index 6514d49bc..2de1400cf 100644 --- a/crates/io/src/dxf_write/blocks.rs +++ b/crates/io/src/dxf_write/blocks.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Block, Color, Common, Dimension, Drawing, Entity, EntityKind, EntityStore, Handle, HashMap, Line, MText, Solid, Table, Vec2, Vec3, dxf_ext, +}; /// What happens to the anonymous `*D` blocks read from a file (#495): a dimension that still /// names its block regenerates its geometry under that name, keeping the block's entity diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index 91e00d888..b09d90410 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -1,5 +1,8 @@ use super::ctx::{AssocObj, Ctx}; -use super::*; +use super::{ + Attrib, Color, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, HAlign, HashMap, Insert, Lineweight, Text, Transparency, + VAlign, Vec3, Version, W, dxf_ext, +}; /// `AcadAnnotative` xdata marking an annotative style. pub(super) fn annotative_xdata(w: &mut W) { @@ -234,11 +237,23 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { upside_down: ts.upside_down, }; let q = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::resolve(&ts.font), &t.value, &p); - if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } + // Justification recalculates the baseline in XY; group 10 keeps its own elevation. + 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); if t.rotation != 0.0 { @@ -273,7 +288,7 @@ 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); } diff --git a/crates/io/src/dxf_write/ctx.rs b/crates/io/src/dxf_write/ctx.rs index 11c679a95..ce9c38b7a 100644 --- a/crates/io/src/dxf_write/ctx.rs +++ b/crates/io/src/dxf_write/ctx.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DimVal, Handle, HashMap, K, Vec3, Version, dxf_ext}; /// The `DIMASSOC` object of an associative dimension (stored in the dimension's extension /// dictionary under `ACAD_DIMASSOC`). diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index 6c58cdfac..fd7c265d8 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -2,7 +2,9 @@ use super::blocks::table_size; use super::common::{attrib_common, common, common_x, dim_xdata, layer_and_props, multiline_tags, oblique_52, text_tags}; use super::ctx::Ctx; use super::layout::paper_view; -use super::*; +use super::{ + Bounds2, Color, DimKind, Drawing, Entity, EntityKind, Gradient, Handle, HashMap, Hatch, Leader, MLeader, Point, Vec2, Vec3, Version, W, dxf_ext, +}; /// The persistent reactors of boundary objects: each source object of an associative hatch in /// the same space or block → the hatches it bounds (AutoCAD links the two both ways; without the @@ -31,6 +33,19 @@ pub(super) fn hatch_reactors(d: &Drawing) -> HashMap> { } 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); + write_insert_entity(w, d, e, owner, paper, cx); + write_dimension_entity(w, d, e, owner, paper, cx); + write_table_entity(w, d, e, owner, paper, cx); + write_annotations_entity(w, d, e, owner, paper, cx); + write_hatch_entity(w, d, e, owner, paper, cx); + write_viewport_entity(w, d, e, owner, paper, cx); + write_wipeout_entity(w, d, e, owner, paper, cx); + 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) { match &e.kind { EntityKind::Line(l) => { w.s(0, "LINE"); @@ -38,6 +53,7 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo thickness(w, e); w.p(10, l.a); w.p(11, l.b); + extrusion(w, e); } EntityKind::Point(p) => { w.s(0, "POINT"); @@ -199,6 +215,12 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo w.p(10, r.base); w.p(11, r.dir); } + _ => {} + } +} + +fn write_text_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + match &e.kind { EntityKind::Text(t) => { w.s(0, "TEXT"); common(w, e, owner, paper, "AcDbText"); @@ -255,196 +277,211 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo w.f(44, t.line_spacing); w.t.extend(crate::dxf_read::mtext_dxf::column_tags(t)); } - EntityKind::Insert(i) => { - w.s(0, "INSERT"); - common(w, e, owner, paper, "AcDbBlockReference"); - if !i.attribs.is_empty() { - w.i(66, 1); - } - w.s(2, &i.block); - w.p(10, i.insert); - w.f(41, i.scale.x); - w.f(42, i.scale.y); - w.f(43, i.scale.z); - w.f(50, i.rotation.to_degrees()); - if i.cols > 1 || i.rows > 1 { - w.i(70, i64::from(i.cols)); - w.i(71, i64::from(i.rows)); - w.f(44, i.col_spacing); - w.f(45, i.row_spacing); - } - if !i.attribs.is_empty() { - for a in &i.attribs { - let ah = w.h(); - w.s(0, "ATTRIB"); - w.s(5, ah); - w.s(330, e.handle.hex()); - w.s(100, "AcDbEntity"); - let (c, base) = attrib_common(d, e, i, a); - layer_and_props(w, &c, Some(&base)); - // Hidden with its definition (a dynamic block's visibility state hides it). - if !base.visible { - w.i(60, 1); - } - w.s(100, "AcDbText"); - text_tags(w, d, &a.text, Some((a, cx.target)), None); - if cx.target >= Version::R2007 { - multiline_tags(w, a, false); - } - } - let sh = w.h(); - w.s(0, "SEQEND"); - w.s(5, sh); + _ => {} + } +} + +fn write_insert_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + if let EntityKind::Insert(i) = &e.kind { + w.s(0, "INSERT"); + common(w, e, owner, paper, "AcDbBlockReference"); + if !i.attribs.is_empty() { + w.i(66, 1); + } + w.s(2, &i.block); + w.p(10, i.insert); + w.f(41, i.scale.x); + w.f(42, i.scale.y); + w.f(43, i.scale.z); + w.f(50, i.rotation.to_degrees()); + if i.cols > 1 || i.rows > 1 { + w.i(70, i64::from(i.cols)); + w.i(71, i64::from(i.rows)); + w.f(44, i.col_spacing); + w.f(45, i.row_spacing); + } + if !i.attribs.is_empty() { + for a in &i.attribs { + let ah = w.h(); + w.s(0, "ATTRIB"); + w.s(5, ah); w.s(330, e.handle.hex()); w.s(100, "AcDbEntity"); - w.s(8, &e.common.layer); + let (c, base) = attrib_common(d, e, i, a); + layer_and_props(w, &c, Some(&base)); + // Hidden with its definition (a dynamic block's visibility state hides it). + if !base.visible { + w.i(60, 1); + } + w.s(100, "AcDbText"); + text_tags(w, d, &a.text, Some((a, cx.target)), None); + if cx.target >= Version::R2007 { + multiline_tags(w, a, false); + } + } + 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); + } + } +} + +fn write_dimension_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + if let EntityKind::Dimension(dm) = &e.kind { + w.s(0, "DIMENSION"); + common_x(w, e, owner, paper, "AcDbDimension", cx.assoc.get(&e.handle)); + let bname = cx.dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default(); + w.s(2, bname); + w.p(10, dm.defpt); + 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) }); + let ty: i64 = match dm.kind { + DimKind::Linear { .. } => 0, + DimKind::Aligned => 1, + DimKind::Angular => 2, + DimKind::Diameter => 3, + DimKind::Radius => 4, + DimKind::Angular3P => 5, + DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, + // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). + DimKind::ArcLength => 1, + }; + w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); + // 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(3, &dm.style); + if dm.text_rotation != 0.0 { + w.f(53, dm.text_rotation.to_degrees()); + } + match dm.kind { + DimKind::Linear { rotation } => { + w.s(100, "AcDbAlignedDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.f(50, rotation.to_degrees()); + oblique_52(w, dm, rotation); + w.s(100, "AcDbRotatedDimension"); + } + DimKind::Aligned | DimKind::ArcLength => { + w.s(100, "AcDbAlignedDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + if matches!(dm.kind, DimKind::Aligned) { + oblique_52(w, dm, (dm.p14.xy() - dm.p13.xy()).angle()); + } + } + DimKind::Radius => { + w.s(100, "AcDbRadialDimension"); + w.p(15, dm.p15); + w.f(40, 0.0); + } + DimKind::Diameter => { + w.s(100, "AcDbDiametricDimension"); + w.p(15, dm.p15); + w.f(40, 0.0); + } + DimKind::Angular => { + w.s(100, "AcDb2LineAngularDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.p(15, dm.p15); + w.p(16, dm.p16); + } + DimKind::Angular3P => { + w.s(100, "AcDb3PointAngularDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); + w.p(15, dm.p15); + } + DimKind::Ordinate { .. } => { + w.s(100, "AcDbOrdinateDimension"); + w.p(13, dm.p13); + w.p(14, dm.p14); } } - EntityKind::Dimension(dm) => { - w.s(0, "DIMENSION"); - common_x(w, e, owner, paper, "AcDbDimension", cx.assoc.get(&e.handle)); - let bname = cx.dim_blocks.get(&e.handle).cloned().or_else(|| dm.block.clone()).unwrap_or_default(); - w.s(2, bname); - w.p(10, dm.defpt); - 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) }); - let ty: i64 = match dm.kind { - DimKind::Linear { .. } => 0, - DimKind::Aligned => 1, - DimKind::Angular => 2, - DimKind::Diameter => 3, - DimKind::Radius => 4, - DimKind::Angular3P => 5, - DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, - // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). - DimKind::ArcLength => 1, - }; - w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); - // 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(3, &dm.style); - if dm.text_rotation != 0.0 { - w.f(53, dm.text_rotation.to_degrees()); - } - match dm.kind { - DimKind::Linear { rotation } => { - w.s(100, "AcDbAlignedDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.f(50, rotation.to_degrees()); - oblique_52(w, dm, rotation); - w.s(100, "AcDbRotatedDimension"); - } - DimKind::Aligned | DimKind::ArcLength => { - w.s(100, "AcDbAlignedDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - if matches!(dm.kind, DimKind::Aligned) { - oblique_52(w, dm, (dm.p14.xy() - dm.p13.xy()).angle()); - } - } - DimKind::Radius => { - w.s(100, "AcDbRadialDimension"); - w.p(15, dm.p15); - w.f(40, 0.0); - } - DimKind::Diameter => { - w.s(100, "AcDbDiametricDimension"); - w.p(15, dm.p15); - w.f(40, 0.0); - } - DimKind::Angular => { - w.s(100, "AcDb2LineAngularDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.p(15, dm.p15); - w.p(16, dm.p16); - } - DimKind::Angular3P => { - w.s(100, "AcDb3PointAngularDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - w.p(15, dm.p15); - } - DimKind::Ordinate { .. } => { - w.s(100, "AcDbOrdinateDimension"); - w.p(13, dm.p13); - w.p(14, dm.p14); - } - } - dim_xdata(w, dm, cx); + dim_xdata(w, dm, cx); + } +} + +fn write_table_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + if let EntityKind::Table(t) = &e.kind { + // Tables without a generated block (none expected) are not written. + let Some((bname, brh)) = cx.tables.get(&e.handle) else { return }; + w.s(0, "ACAD_TABLE"); + common(w, e, owner, paper, "AcDbBlockReference"); + w.s(2, bname); + w.p(10, t.insert); + w.s(100, "AcDbTable"); + w.i(280, 0); + w.s(342, cx.table_style(&t.style)); + w.s(343, brh); + w.p(11, Vec3::new(1.0, 0.0, 0.0)); + w.i(90, 0); + let (rows, cols) = table_size(t); + w.i(91, rows as i64); + w.i(92, cols as i64); + for c in [93, 94, 95, 96] { + w.i(c, 0); } - EntityKind::Table(t) => { - // Tables without a generated block (none expected) are not written. - let Some((bname, brh)) = cx.tables.get(&e.handle) else { return }; - w.s(0, "ACAD_TABLE"); - common(w, e, owner, paper, "AcDbBlockReference"); - w.s(2, bname); - w.p(10, t.insert); - w.s(100, "AcDbTable"); - w.i(280, 0); - w.s(342, cx.table_style(&t.style)); - w.s(343, brh); - w.p(11, Vec3::new(1.0, 0.0, 0.0)); - w.i(90, 0); - let (rows, cols) = table_size(t); - w.i(91, rows as i64); - w.i(92, cols as i64); - for c in [93, 94, 95, 96] { - w.i(c, 0); - } - for h in t.row_heights.iter().take(rows) { - w.f(141, *h); - } - for c in t.col_widths.iter().take(cols) { - w.f(142, *c); - } - let cov = cadcraft_render::table_covered(t); - let span = |v: u32| i64::from(v.min(i16::MAX as u32)); - for r in 0..rows { - for c in 0..cols { - let cell = t.cells.get(r).and_then(|x| x.get(c)); - let covered = cov.get(r).and_then(|x| x.get(c)).copied().unwrap_or(false); - let merge = cell.and_then(|x| x.merged).filter(|&(a, b)| a >= 1 && b >= 1 && (a, b) != (1, 1)); - w.i(171, 1); - w.i(172, 0); - w.i(173, i64::from(covered || merge.is_some())); - w.i(174, 0); - let (rs, cs) = if covered { (0, 0) } else { merge.map(|(a, b)| (span(a), span(b))).unwrap_or((1, 1)) }; - w.i(175, cs); - w.i(176, rs); - w.i(91, 0); - w.i(178, 0); - w.f(145, 0.0); - if let Some(h) = cell.and_then(|x| x.text_height).filter(|h| h.is_finite() && *h > 0.0) { - w.f(140, h); - } - // Long text: 250-character code-2 chunks, the rest in code 1. - let chars: Vec = cell.map(|x| x.text.chars().collect()).unwrap_or_default(); - let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); - let n = chunks.len(); - for (i, ch) in chunks.into_iter().enumerate() { - w.s(if i + 1 == n { 1 } else { 2 }, ch); - } - if n == 0 { - w.s(1, ""); - } + for h in t.row_heights.iter().take(rows) { + w.f(141, *h); + } + for c in t.col_widths.iter().take(cols) { + w.f(142, *c); + } + let cov = cadcraft_render::table_covered(t); + let span = |v: u32| i64::from(v.min(i16::MAX as u32)); + for r in 0..rows { + for c in 0..cols { + let cell = t.cells.get(r).and_then(|x| x.get(c)); + let covered = cov.get(r).and_then(|x| x.get(c)).copied().unwrap_or(false); + let merge = cell.and_then(|x| x.merged).filter(|&(a, b)| a >= 1 && b >= 1 && (a, b) != (1, 1)); + w.i(171, 1); + w.i(172, 0); + w.i(173, i64::from(covered || merge.is_some())); + w.i(174, 0); + let (rs, cs) = if covered { (0, 0) } else { merge.map(|(a, b)| (span(a), span(b))).unwrap_or((1, 1)) }; + w.i(175, cs); + w.i(176, rs); + w.i(91, 0); + w.i(178, 0); + w.f(145, 0.0); + if let Some(h) = cell.and_then(|x| x.text_height).filter(|h| h.is_finite() && *h > 0.0) { + w.f(140, h); + } + // Long text: 250-character code-2 chunks, the rest in code 1. + let chars: Vec = cell.map(|x| x.text.chars().collect()).unwrap_or_default(); + let chunks: Vec = chars.chunks(250).map(|c| c.iter().collect()).collect(); + let n = chunks.len(); + for (i, ch) in chunks.into_iter().enumerate() { + w.s(if i + 1 == n { 1 } else { 2 }, ch); + } + if n == 0 { + w.s(1, ""); } - } - w.s(1001, dxf_ext::APP); - w.s(1000, "TABLE"); - w.i(1070, i64::from(t.title)); - w.i(1070, i64::from(t.header)); - w.f(1040, t.text_height); - w.s(1000, &t.style); - for c in crate::dxf_table::table_xdata(t) { - w.s(1000, c); } } + w.s(1001, dxf_ext::APP); + w.s(1000, "TABLE"); + w.i(1070, i64::from(t.title)); + w.i(1070, i64::from(t.header)); + w.f(1040, t.text_height); + w.s(1000, &t.style); + for c in crate::dxf_table::table_xdata(t) { + w.s(1000, c); + } + } +} + +fn write_annotations_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + match &e.kind { EntityKind::MLeader(m) if !cx.native_mleader() => mleader_fallback(w, d, e, m, owner, paper, cx), EntityKind::MLeader(m) => { w.s(0, "MULTILEADER"); @@ -493,142 +530,152 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo w.p(13, f.corners[3]); w.i(70, i64::from(f.hidden_edges)); } - EntityKind::Hatch(h) => { - w.s(0, "HATCH"); - common(w, e, owner, paper, "AcDbHatch"); - w.p(10, Vec3::new(0.0, 0.0, h.elevation)); - w.p(210, Vec3::Z); - w.s(2, &h.pattern); - w.i(70, i64::from(h.solid)); - w.i(71, i64::from(h.associative)); - w.i(91, h.loops.len() as i64); - for l in &h.loops { - w.i(92, 2 | if l.outer { 1 } else { 0 }); - let has_bulge = l.vertices.iter().any(|v| v.bulge != 0.0); - w.i(72, i64::from(has_bulge)); - w.i(73, 1); - w.i(93, l.vertices.len() as i64); - for v in &l.vertices { - w.p2(10, v.p); - if has_bulge { - w.f(42, v.bulge); - } - } - // Source boundary objects of an associative hatch (the ones still beside it, in - // its space or block, which carry the hatch as a reactor). - let own = e.handle.hex(); - let sources: Vec = - l.sources.iter().filter(|s| h.associative && w.reactors.get(*s).is_some_and(|r| r.contains(&own))).map(|s| s.hex()).collect(); - w.i(97, sources.len() as i64); - for s in &sources { - w.s(330, s); + _ => {} + } +} + +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"); + common(w, e, owner, paper, "AcDbHatch"); + w.p(10, Vec3::new(0.0, 0.0, h.elevation)); + w.p(210, Vec3::Z); + w.s(2, &h.pattern); + w.i(70, i64::from(h.solid)); + w.i(71, i64::from(h.associative)); + w.i(91, h.loops.len() as i64); + for l in &h.loops { + w.i(92, 2 | if l.outer { 1 } else { 0 }); + let has_bulge = l.vertices.iter().any(|v| v.bulge != 0.0); + w.i(72, i64::from(has_bulge)); + w.i(73, 1); + w.i(93, l.vertices.len() as i64); + for v in &l.vertices { + w.p2(10, v.p); + if has_bulge { + w.f(42, v.bulge); } } - w.i(75, i64::from(h.style)); - w.i(76, 1); - 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() - } else { - h.pattern_lines.clone() - }; - w.i(78, lines.len() as i64); - for pl in lines { - let ang = pl.angle.to_radians() + h.angle; - w.f(53, ang.to_degrees()); - let o = h.origin + Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; - w.f(43, o.x); - w.f(44, o.y); - let dl = Vec2::new(pl.delta.0, pl.delta.1).rotate(ang) * h.scale; - w.f(45, dl.x); - w.f(46, dl.y); - w.i(79, pl.dashes.len() as i64); - for dsh in pl.dashes { - w.f(49, dsh * h.scale); - } - } + // Source boundary objects of an associative hatch (the ones still beside it, in + // its space or block, which carry the hatch as a reactor). + let own = e.handle.hex(); + let sources: Vec = + l.sources.iter().filter(|s| h.associative && w.reactors.get(*s).is_some_and(|r| r.contains(&own))).map(|s| s.hex()).collect(); + w.i(97, sources.len() as i64); + for s in &sources { + w.s(330, s); } - w.i(98, 0); - if let Some(g) = &h.gradient { - gradient(w, g); - } - hatch_xdata(w, h); } - EntityKind::Viewport(v) => { - paper_view(w, d, e, owner, cx); - w.s(0, "VIEWPORT"); - common(w, e, owner, paper, "AcDbViewport"); - w.p(10, v.center); - w.f(40, v.width); - w.f(41, v.height); - // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off. - w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 }); - w.i(69, i64::from(v.id)); - w.p2(12, v.view_center); - w.f(45, v.view_height); - // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the - // 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 }; - 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() { - w.s(1001, dxf_ext::APP); - if !v.frozen_layers.is_empty() { - w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); - } - if !v.layer_colors.is_empty() { - w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); + w.i(75, i64::from(h.style)); + w.i(76, 1); + 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() + } else { + h.pattern_lines.clone() + }; + w.i(78, lines.len() as i64); + for pl in lines { + let ang = pl.angle.to_radians() + h.angle; + w.f(53, ang.to_degrees()); + let o = h.origin + Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; + w.f(43, o.x); + w.f(44, o.y); + let dl = Vec2::new(pl.delta.0, pl.delta.1).rotate(ang) * h.scale; + w.f(45, dl.x); + w.f(46, dl.y); + w.i(79, pl.dashes.len() as i64); + for dsh in pl.dashes { + w.f(49, dsh * h.scale); } } } - EntityKind::Wipeout(wo) => { - // A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is - // the image's top-left corner with y pointing down: the reader maps each vertex back through - // insert + u * (x + 0.5) + v * (0.5 - y). - // Polygonal clip boundaries are closed, so the first vertex is repeated when needed. - let b = Bounds2::from_points(wo.boundary.iter().copied()); - let o = if b.is_empty() { Vec2::ZERO } else { b.min }; - let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 }); - w.s(0, "WIPEOUT"); - common(w, e, owner, paper, "AcDbWipeout"); - w.i(90, 0); - w.p(10, Vec3::new(o.x, o.y, 0.0)); - w.p(11, Vec3::new(sx, 0.0, 0.0)); - w.p(12, Vec3::new(0.0, sy, 0.0)); - w.p2(13, Vec2::new(1.0, 1.0)); - // Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade. - w.i(70, 7); - w.i(280, 1); - w.i(281, 50); - w.i(282, 50); - w.i(283, 0); - w.i(71, 2); - let mut pts = wo.boundary.clone(); - if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) - && first != last - { - pts.push(first); - } - w.i(91, pts.len() as i64); - for q in pts { - w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); + w.i(98, 0); + if let Some(g) = &h.gradient { + gradient(w, g); + } + hatch_xdata(w, h); + } +} + +fn write_viewport_entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx) { + if let EntityKind::Viewport(v) = &e.kind { + paper_view(w, d, e, owner, cx); + w.s(0, "VIEWPORT"); + common(w, e, owner, paper, "AcDbViewport"); + w.p(10, v.center); + w.f(40, v.width); + w.f(41, v.height); + // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off. + w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 }); + w.i(69, i64::from(v.id)); + w.p2(12, v.view_center); + w.f(45, v.view_height); + // Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the + // 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 }; + 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() { + w.s(1001, dxf_ext::APP); + if !v.frozen_layers.is_empty() { + w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + } + if !v.layer_colors.is_empty() { + w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); + } + } + } +} + +fn write_wipeout_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) { + if let EntityKind::Wipeout(wo) = &e.kind { + // A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is + // the image's top-left corner with y pointing down: the reader maps each vertex back through + // insert + u * (x + 0.5) + v * (0.5 - y). + // Polygonal clip boundaries are closed, so the first vertex is repeated when needed. + let b = Bounds2::from_points(wo.boundary.iter().copied()); + let o = if b.is_empty() { Vec2::ZERO } else { b.min }; + let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 }); + w.s(0, "WIPEOUT"); + common(w, e, owner, paper, "AcDbWipeout"); + w.i(90, 0); + w.p(10, Vec3::new(o.x, o.y, 0.0)); + w.p(11, Vec3::new(sx, 0.0, 0.0)); + w.p(12, Vec3::new(0.0, sy, 0.0)); + w.p2(13, Vec2::new(1.0, 1.0)); + // Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade. + w.i(70, 7); + w.i(280, 1); + w.i(281, 50); + w.i(282, 50); + w.i(283, 0); + w.i(71, 2); + let pts = wo.closed_boundary(); + w.i(91, pts.len() as i64); + for q in pts.iter() { + w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); + } + } +} + +fn write_other_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: bool, _cx: &Ctx) { + match &e.kind { EntityKind::Tolerance(t) => { w.s(0, "TOLERANCE"); common(w, e, owner, paper, "AcDbFcf"); @@ -639,6 +686,7 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo } // Entity types CADCraft doesn't model aren't written; the save command reports them. EntityKind::Unknown(_) => {} + _ => {} } } @@ -705,6 +753,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 a864509bc..354c293c8 100644 --- a/crates/io/src/dxf_write/header.rs +++ b/crates/io/src/dxf_write/header.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DimVal, Drawing, HVal, K, Version, W, dxf_ext}; /// Header variables we write, with their group codes (DXF Reference, HEADER section). /// `P2`/`P3` = 2D/3D point. @@ -41,6 +41,7 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("PLINEWID", 40), ("CELWEIGHT", 370), ("LWDISPLAY", 290), + ("PSTYLEMODE", 290), ("INSUNITS", 70), ("MEASUREMENT", 70), ("PSLTSCALE", 70), diff --git a/crates/io/src/dxf_write/layout.rs b/crates/io/src/dxf_write/layout.rs index ccffa2929..4ad19ffbb 100644 --- a/crates/io/src/dxf_write/layout.rs +++ b/crates/io/src/dxf_write/layout.rs @@ -1,5 +1,5 @@ use super::ctx::Ctx; -use super::*; +use super::{Bounds2, Drawing, Entity, EntityKind, Handle, HashMap, Layout, Vec2, Vec3, VportConfig, VportTile, W, dxf_ext}; /// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed /// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space diff --git a/crates/io/src/dxf_write/objects.rs b/crates/io/src/dxf_write/objects.rs index 2227ae096..83fe3c7ca 100644 --- a/crates/io/src/dxf_write/objects.rs +++ b/crates/io/src/dxf_write/objects.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Drawing, Group, MLeaderStyle, TableStyle, W, dxf_ext}; /// A TABLESTYLE object (DXF Reference, OBJECTS: TABLESTYLE): margins, title/header /// suppression and per row type (data, column header, title) text and border settings. diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index 7198eeee8..9a21ef7a9 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -6,7 +6,879 @@ use super::entity::{entity, hatch_reactors}; use super::header::{code_page, header_vars}; use super::layout::{paper_views, record_head, sheet_first, table_head, vport_table}; use super::objects::{cadcraft_fonts_name, group_obj, mleader_style_obj, scale_list, table_style_obj}; -use super::*; +use super::{ + Block, Color, DimStyle, DimVal, Drawing, Entity, EntityKind, EntityStore, Group, HVal, Handle, HashMap, Layout, MLeaderStyle, PageSetup, Space, + TableStyle, Tag, Vec2, Vec3, Version, W, dxf_ext, +}; + +struct TableWritePlan<'a> { + d: &'a Drawing, + version: Version, + cx: &'a mut Ctx, + ext: cadcraft_geom::Bounds2, +} + +fn write_tables(w: &mut W, plan: TableWritePlan<'_>) -> HashMap { + let d = plan.d; + let version = plan.version; + let cx = plan.cx; + let ext = plan.ext; + // ---------------- TABLES ---------------- + w.s(0, "SECTION"); + w.s(2, "TABLES"); + vport_table(w, d, ext); + // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles + // in the STYLE table below: (tag position, style name, is a shape) are filled in there. + let mut ltype_handles: HashMap = HashMap::new(); + let mut ltype_style_refs: Vec<(usize, Option, bool)> = Vec::new(); + let th = table_head(w, "LTYPE", d.linetypes.len()); + for lt in &d.linetypes { + let h = record_head(w, "LTYPE", &th, "AcDbLinetypeTableRecord"); + ltype_handles.entry(lt.name.to_ascii_uppercase()).or_insert(h.clone()); + cx.ltypes.entry(lt.name.to_ascii_uppercase()).or_insert(h); + w.s(2, <.name); + w.i(70, if crate::dxf_xref::dependent(<.name) { 48 } else { 0 }); + w.s(3, <.description); + w.i(72, 65); + w.i(73, lt.pattern.len() as i64); + w.f(40, lt.pattern_length()); + for el in <.pattern { + w.f(49, el.length); + // 74: 2 = text, 4 = shape, + 1 when the rotation is absolute (DXF Reference, LTYPE). + let (kind, shape) = match (&el.text, el.shape) { + (Some(_), _) => (2, 0), + (None, Some(n)) => (4, n), + (None, None) => (0, 0), + }; + if kind == 0 { + w.i(74, 0); + continue; + } + w.i(74, kind | i64::from(el.absolute)); + w.i(75, i64::from(shape)); + ltype_style_refs.push((w.t.len(), el.style.clone(), kind == 4)); + w.s(340, "0"); + w.f(46, el.scale); + w.f(50, el.rotation); + w.f(44, el.offset.x); + w.f(45, el.offset.y); + if let Some(t) = &el.text { + w.s(9, t.clone()); + } + } + } + w.s(0, "ENDTAB"); + write_layer_table(w, d); + // STYLE. Linetype shapes from a shape file that isn't a text style get an unnamed shape + // style record (70 bit 1) naming the file. + let mut shape_files: Vec = Vec::new(); + for (_, style, shape) in <ype_style_refs { + let Some(f) = style.as_deref().map(str::trim).filter(|f| *shape && !f.is_empty() && d.text_style(f).is_none()) else { continue }; + if !shape_files.iter().any(|x| x.eq_ignore_ascii_case(f)) { + shape_files.push(f.to_string()); + } + } + let th = table_head(w, "STYLE", d.text_styles.len() + shape_files.len()); + for s in &d.text_styles { + let h = record_head(w, "STYLE", &th, "AcDbTextStyleTableRecord"); + cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h); + w.s(2, &s.name); + // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. + w.i(70, if s.vertical { 4 } else { 0 }); + w.f(40, s.height); + w.f(41, s.width_factor); + 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() }; + w.s(3, file); + w.s(4, &s.big_font); + if s.annotative { + annotative_xdata(w); + } + if file != s.font { + w.s(1001, dxf_ext::APP); + w.s(1000, "FONT"); + w.s(1000, dxf_ext::xdata_str(&s.font)); + } + } + let mut shape_handles: HashMap = HashMap::new(); + for f in &shape_files { + let h = record_head(w, "STYLE", &th, "AcDbTextStyleTableRecord"); + shape_handles.insert(f.to_ascii_uppercase(), h); + w.s(2, ""); + w.i(70, 1); + w.f(40, 0.0); + w.f(41, 1.0); + w.f(50, 0.0); + w.i(71, 0); + w.f(42, 0.2); + w.s(3, f.clone()); + w.s(4, ""); + } + w.s(0, "ENDTAB"); + // Linetype text and shapes without a known style use Standard (else the first style). + let fallback_style = cx.style("Standard").or_else(|| d.text_styles.first().and_then(|s| cx.style(&s.name))).unwrap_or_else(|| "0".into()); + for (pos, style, shape) in ltype_style_refs { + let name = style.as_deref().map(str::trim).unwrap_or_default(); + let h = shape.then(|| shape_handles.get(&name.to_ascii_uppercase()).cloned()).flatten().or_else(|| cx.style(name)); + if let Some(t) = w.t.get_mut(pos) { + *t = Tag::s(340, h.unwrap_or_else(|| fallback_style.clone())); + } + } + // VIEW: named views (DXF Reference, VIEW). A view's layer state is CADCraft xdata. + let th = table_head(w, "VIEW", d.views.len()); + for v in &d.views { + record_head(w, "VIEW", &th, "AcDbViewTableRecord"); + w.s(2, &v.name); + w.i(70, i64::from(v.layout.is_some())); + w.f(40, v.height); + w.p2(10, v.center); + w.f(41, v.width); + w.p(11, Vec3::Z); + w.p(12, Vec3::ZERO); + w.f(42, 50.0); + w.f(43, 0.0); + w.f(44, 0.0); + w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 }); + w.i(71, 0); + w.i(281, 0); + w.i(72, 0); + let xdata = [("LAYERSTATE", v.layer_state.as_deref()), ("LAYOUT", v.layout.as_deref().filter(|l| !l.is_empty()))]; + if xdata.iter().any(|(_, x)| x.is_some()) { + w.s(1001, dxf_ext::APP); + for (key, value) in xdata { + if let Some(value) = value { + w.s(1000, key); + w.s(1000, dxf_ext::xdata_str(value)); + } + } + } + } + w.s(0, "ENDTAB"); + // UCS: named user coordinate systems (DXF Reference, UCS). + let th = table_head(w, "UCS", d.ucss.len()); + for u in &d.ucss { + record_head(w, "UCS", &th, "AcDbUCSTableRecord"); + w.s(2, &u.name); + w.i(70, 0); + w.p(10, u.origin); + w.p(11, u.x_axis); + w.p(12, u.y_axis); + w.i(79, 0); + w.f(146, 0.0); + } + w.s(0, "ENDTAB"); + let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP]; + let th = table_head(w, "APPID", apps.len()); + for app in apps { + record_head(w, "APPID", &th, "AcDbRegAppTableRecord"); + w.s(2, app); + w.i(70, 0); + } + w.s(0, "ENDTAB"); + write_dimstyles(w, d, cx, version); + ltype_handles +} + +fn write_layer_table(w: &mut W, d: &Drawing) { + let th = table_head(w, "LAYER", d.layers.len()); + for l in &d.layers { + let lh = record_head(w, "LAYER", &th, "AcDbLayerTableRecord"); + w.layers.insert(l.name.to_ascii_uppercase(), lh); + w.s(2, &l.name); + // 16 | 32: an xref's layer (`xref|name`), externally dependent and resolved. + let xref = if crate::dxf_xref::dependent(&l.name) { 48 } else { 0 }; + w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 } | xref); + let aci = match l.color { + Color::True(rgb) => i64::from(cadcraft_color::nearest_aci(rgb)), + c => i64::from(c.to_aci()).clamp(1, 255), + }; + w.i(62, if l.on { aci } else { -aci }); + if let Color::True(rgb) = l.color { + w.i(420, i64::from(rgb.to_u32())); + } + w.s(6, &l.linetype); + if !l.plot { + w.i(290, 0); + } + w.i(370, i64::from(l.lineweight.to_dxf())); + let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned(); + if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) { + w.s(390, h); + } + if l.transparency > 0 { + w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP); + w.i(1071, dxf_ext::transparency_to_dxf(l.transparency)); + } + if !l.description.is_empty() { + // The first string is the layer standard's name, unused here. + w.s(1001, dxf_ext::LAYER_DESCRIPTION_APP); + w.s(1000, ""); + w.s(1000, dxf_ext::xdata_str(&l.description)); + } + } + w.s(0, "ENDTAB"); +} + +fn write_dimstyles(w: &mut W, d: &Drawing, cx: &mut Ctx, version: Version) { + // DIMSTYLE + let th = { + let h = w.h(); + w.s(0, "TABLE"); + w.s(2, "DIMSTYLE"); + w.s(5, h.clone()); + w.s(330, "0"); + w.s(100, "AcDbSymbolTable"); + w.i(70, d.dim_styles.len() as i64); + w.s(100, "AcDbDimStyleTable"); + w.i(71, 0); + h + }; + for s in &d.dim_styles { + record_head(w, "DIMSTYLE", &th, "AcDbDimStyleTableRecord"); + w.s(2, &s.name); + w.i(70, 0); + let v = serde_json::to_value(s).unwrap_or_default(); + let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default(); + let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; + for (field, code, kind) in dxf_ext::DIM_CODES { + let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; + // DIMPOST is written even when empty: a reader that defaults a missing one to "<>" + // (the DWG bridge's) would otherwise give radius and diameter dimensions no "R" or + // "Ø" in AutoCAD (#1053). + if *code == dxf_ext::DIMPOST + && let DimVal::Str(t) = &enc + && t.is_empty() + { + w.s(*code, ""); + continue; + } + if !dxf_ext::NEWER_CODES.contains(code) { + w.dimval(*code, enc, false); + continue; + } + // Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR + // are written only when set, as other writers do, so a style without a text + // background has a single 70 (its flags). + if version >= dxf_ext::newer_since(*code) { + if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) { + continue; + } + w.dimval(*code, enc, false); + } else if v.get(*field) != defaults.get(*field) { + newer.i(1070, i64::from(*code)); + newer.dimval(*code, enc, true); + } + } + if !s.arrow_block1.trim().is_empty() || !s.arrow_block2.trim().is_empty() { + w.i(dxf_ext::DIMSAH, 1); + } + if s.annotative { + annotative_xdata(w); + } + if !newer.t.is_empty() { + w.s(1001, dxf_ext::APP); + w.s(1000, dxf_ext::DIMVARS_LIST); + w.s(1002, "{"); + w.xdata(newer.t); + w.s(1002, "}"); + } + } + w.s(0, "ENDTAB"); +} + +fn write_header(w: &mut W, d: &Drawing, version: Version) -> usize { + w.s(0, "SECTION"); + w.s(2, "HEADER"); + w.s(9, "$ACADVER"); + w.s(1, version.code()); + w.s(9, "$DWGCODEPAGE"); + w.s(3, code_page(d, version)); + let ext = d.extents(&Space::Model); + if !ext.is_empty() { + w.s(9, "$EXTMIN"); + w.p(10, ext.min.to3(0.0)); + w.s(9, "$EXTMAX"); + w.p(10, ext.max.to3(0.0)); + } + header_vars(w, d); + let seed_pos = w.t.len(); + w.s(9, "$HANDSEED"); + w.s(5, "0"); + w.s(0, "ENDSEC"); + + seed_pos +} + +fn write_classes(w: &mut W, cx: &Ctx, every: &[&Entity]) { + w.s(0, "SECTION"); + w.s(2, "CLASSES"); + let mut classes = vec![ + ("TABLESTYLE", "AcDbTableStyle", 4095, false), + ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false), + ("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false), + ("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false), + ]; + if !cx.tables.is_empty() { + classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); + } + if every.iter().any(|e| matches!(e.kind, EntityKind::Helix(_))) { + classes.push(("HELIX", "AcDbHelix", 4095, true)); + } + if !cx.assoc.is_empty() { + classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false)); + } + if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) { + classes.push(("MULTILEADER", "AcDbMLeader", 1025, true)); + } + if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { + classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); + } + if !cx.images.is_empty() { + classes.push(("IMAGE", "AcDbRasterImage", 127, true)); + classes.push(("IMAGEDEF", "AcDbRasterImageDef", 0, false)); + classes.push(("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", 1, false)); + classes.push(("RASTERVARIABLES", "AcDbRasterVariables", 0, false)); + } + classes.push(("SCALE", "AcDbScale", 0, false)); + for (dxf_name, cpp, proxy, is_entity) in classes { + w.s(0, "CLASS"); + w.s(1, dxf_name); + w.s(2, cpp); + w.s(3, "ObjectDBX Classes"); + w.i(90, proxy); + w.i(280, 0); + w.i(281, i64::from(is_entity)); + } + w.s(0, "ENDSEC"); +} + +fn write_entities(w: &mut W, d: &Drawing, ms_br: &str, ps_brs: &[(String, String, &Layout)], cx: &Ctx) { + w.s(0, "SECTION"); + w.s(2, "ENTITIES"); + for e in d.model.iter() { + entity(w, d, e, ms_br, false, cx); + } + if let Some((_, h, l)) = ps_brs.first() { + for e in sheet_first(l) { + entity(w, d, e, h, true, cx); + } + } + w.s(0, "ENDSEC"); +} + +struct LayoutWriteContext<'a> { + drawing: &'a Drawing, + dictionary: &'a str, +} + +struct LayoutWriteData<'a> { + handle: &'a str, + name: &'a str, + tab: i64, + block_record: &'a str, + flags: i64, + page: &'a PageSetup, + view: Option<(Vec2, f64)>, +} + +fn layout_obj(w: &mut W, context: LayoutWriteContext<'_>, data: LayoutWriteData<'_>) { + let d = context.drawing; + let layout_dict = context.dictionary; + let h = data.handle; + let name = data.name; + let tab = data.tab; + let brh = data.block_record; + let flags = data.flags; + let page = data.page; + let view = data.view; + // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units + // per 143 drawing units). + let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; + let standard = page.scale_to_fit || scale == 1.0; + let plot_flags = 512 + | 32 + | if standard { 16 } else { 0 } + | if page.lineweights { 128 } else { 0 } + | if page.center { 4 } else { 0 } + | if page.show_plot_styles { 2 } else { 0 }; + let plot_type = match page.plot_area.as_str() { + "display" => 0, + "extents" => 1, + "limits" => 2, + "window" => 4, + _ => 5, + }; + w.s(0, "LAYOUT"); + w.s(5, h); + w.s(330, layout_dict.to_string()); + w.s(100, "AcDbPlotSettings"); + w.s(1, ""); + let device = page.device.trim(); + w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); + w.s(4, page.paper.replace(' ', "_")); + w.s(6, ""); + for (c, v) in [ + (40, page.margins_mm[0]), + (41, page.margins_mm[1]), + (42, page.margins_mm[2]), + (43, page.margins_mm[3]), + (44, page.width_mm), + (45, page.height_mm), + (46, 0.0), + (47, 0.0), + (48, page.plot_window[0]), + (49, page.plot_window[1]), + (140, page.plot_window[2]), + (141, page.plot_window[3]), + (142, scale), + (143, 1.0), + ] { + w.f(c, v); + } + w.i(70, plot_flags); + // Plot paper units: 1 millimetres, 0 inches (#1058). + w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0)); + w.i(73, i64::from(page.landscape)); + w.i(74, plot_type); + 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); + 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)); + w.p(12, Vec3::ZERO); + w.p(14, Vec3::ZERO); + w.p(15, Vec3::ZERO); + w.f(146, 0.0); + w.p(13, Vec3::ZERO); + w.p(16, Vec3::new(1.0, 0.0, 0.0)); + w.p(17, Vec3::new(0.0, 1.0, 0.0)); + w.i(76, 0); + w.s(330, brh); + // The saved paper-space view (centre, height) as CADCraft xdata. + if let Some((c, height)) = view.filter(|(c, v)| c.x.is_finite() && c.y.is_finite() && v.is_finite()) { + w.s(1001, dxf_ext::APP); + w.s(1000, "PSVIEW"); + w.s(1002, "{"); + w.p(1010, c.to3(0.0)); + w.f(1040, height); + w.s(1002, "}"); + } +} + +struct ObjectWritePlan<'a> { + d: &'a Drawing, + cx: &'a Ctx, + root_dict: &'a String, + group_dict: &'a String, + layout_dict: &'a String, + table_style_dict: &'a String, + pstyle_dict: &'a String, + scale_dict: &'a String, + model_layout: &'a String, + ms_br: &'a String, + ps_brs: &'a [(String, String, &'a Layout)], + layout_handles: &'a [String], + xrecords: &'a [(&'static str, String, Vec)], + scales: &'a [(&'a cadcraft_doc::AnnoScale, String)], + pstyles: &'a [(String, String)], + table_styles: &'a [TableStyle], + mleader_styles: &'a [MLeaderStyle], + ltype_handles: &'a HashMap, +} + +fn write_layout_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { + let d = plan.d; + let root_dict = plan.root_dict; + let layout_dict = plan.layout_dict; + let model_layout = plan.model_layout; + let ms_br = plan.ms_br; + let ps_brs = plan.ps_brs; + let layout_handles = plan.layout_handles; + w.s(0, "DICTIONARY"); + w.s(5, layout_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + w.s(3, "Model"); + w.s(350, model_layout.clone()); + for (i, (_, _, l)) in ps_brs.iter().enumerate() { + w.s(3, &l.name); + w.s(350, layout_handles.get(i).cloned().unwrap_or_default()); + } + // Model space has no page setup of its own: write what PLOT does for it, the drawing extents + // fitted to the paper (plot type 1, scaled to fit). The default page's "layout" area is + // plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means + // nothing for model space: another program's Plot dialog then shows no plot area or scale. + let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() }; + 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 }, + ); + for (i, (_, brh, l)) in ps_brs.iter().enumerate() { + let h = layout_handles.get(i).cloned().unwrap_or_default(); + layout_obj( + w, + LayoutWriteContext { drawing: d, dictionary: layout_dict }, + LayoutWriteData { + handle: &h, + name: &l.name, + tab: i64::from(l.tab_order.max(1)), + block_record: brh, + flags: 1, + page: &l.page, + view: l.view, + }, + ); + } +} + +fn write_root_group_objects( + w: &mut W, + plan: &ObjectWritePlan<'_>, + groups: &[(&Group, String)], + group_owner: &str, + written: &std::collections::HashSet, + mleader_dict: &str, +) { + let cx = plan.cx; + let root_dict = plan.root_dict; + let group_dict = plan.group_dict; + let layout_dict = plan.layout_dict; + let table_style_dict = plan.table_style_dict; + let pstyle_dict = plan.pstyle_dict; + let scale_dict = plan.scale_dict; + let xrecords = plan.xrecords; + w.s(0, "SECTION"); + w.s(2, "OBJECTS"); + w.s(0, "DICTIONARY"); + w.s(5, root_dict.clone()); + w.s(330, "0"); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + w.s(3, "ACAD_GROUP"); + w.s(350, group_dict.clone()); + if !cx.images.is_empty() { + w.s(3, "ACAD_IMAGE_DICT"); + w.s(350, cx.images.dict.clone()); + w.s(3, "ACAD_IMAGE_VARS"); + w.s(350, cx.images.vars.clone()); + } + w.s(3, "ACAD_LAYOUT"); + w.s(350, layout_dict.clone()); + w.s(3, "ACAD_MLEADERSTYLE"); + w.s(350, mleader_dict.to_string()); + w.s(3, "ACAD_MLINESTYLE"); + w.s(350, cx.mlines.dict.clone()); + w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY); + w.s(350, pstyle_dict.clone()); + w.s(3, "ACAD_TABLESTYLE"); + w.s(350, table_style_dict.clone()); + for (key, h, _) in xrecords { + w.s(3, *key); + w.s(350, h); + } + w.s(3, "ACAD_SCALELIST"); + w.s(350, scale_dict.clone()); + w.s(0, "DICTIONARY"); + w.s(5, group_dict); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (g, h) in groups { + w.s(3, &g.name); + w.s(350, h); + } + for (g, h) in groups { + group_obj(w, g, h, group_owner, written); + } +} + +fn write_style_objects(w: &mut W, plan: &ObjectWritePlan<'_>, mleader_dict: &str, mleader_handles: &[String]) { + let d = plan.d; + let cx = plan.cx; + let root_dict = plan.root_dict; + let table_style_dict = plan.table_style_dict; + let mleader_styles = plan.mleader_styles; + let table_styles = plan.table_styles; + let ltype_handles = plan.ltype_handles; + // Multileader styles. + w.s(0, "DICTIONARY"); + w.s(5, mleader_dict.to_string()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (s, h) in mleader_styles.iter().zip(mleader_handles) { + w.s(3, &s.name); + w.s(350, h); + } + let byblock_ltype = ltype_handles.get("BYBLOCK").cloned(); + for (s, h) in mleader_styles.iter().zip(mleader_handles) { + mleader_style_obj(w, s, h, mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); + } + // Multiline styles. + w.t.extend(crate::dxf_mline::objects(&cx.mlines, root_dict)); + // Table styles. + w.s(0, "DICTIONARY"); + w.s(5, table_style_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (name, h) in &cx.table_styles { + w.s(3, name); + w.s(350, h); + } + for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) { + table_style_obj(w, d, s, h, table_style_dict); + } +} + +fn write_named_objects(w: &mut W, plan: &ObjectWritePlan<'_>) { + let root_dict = plan.root_dict; + let pstyle_dict = plan.pstyle_dict; + let xrecords = plan.xrecords; + let pstyles = plan.pstyles; + let scales = plan.scales; + let scale_dict = plan.scale_dict; + // Plot style names: a dictionary with a default (Normal) of placeholder objects. + w.s(0, "ACDBDICTIONARYWDFLT"); + w.s(5, pstyle_dict.clone()); + w.group("ACAD_REACTORS", 330, &[root_dict]); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (n, h) in pstyles { + w.s(3, n); + w.s(350, h); + } + w.s(100, "AcDbDictionaryWithDefault"); + w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default()); + for (_, h) in pstyles { + w.s(0, "ACDBPLACEHOLDER"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[pstyle_dict]); + w.s(330, pstyle_dict.clone()); + } + // Parametric constraints and parameters, saved layer states, plot style tables (CADCraft + // data). + for (_, h, chunks) in xrecords { + w.s(0, "XRECORD"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[root_dict]); + w.s(330, root_dict.clone()); + w.s(100, "AcDbXrecord"); + w.i(280, 1); + for c in chunks { + w.s(1, c.clone()); + } + } + scale_list(w, scales, scale_dict, root_dict); +} + +fn write_associative_objects(w: &mut W, cx: &Ctx) { + // Dimension associativity: extension dictionary + DIMASSOC per dimension. + let mut assoc: Vec<(&Handle, &AssocObj)> = cx.assoc.iter().collect(); + assoc.sort_by_key(|(h, _)| **h); + for (dim, a) in assoc { + let dh = dim.hex(); + w.s(0, "DICTIONARY"); + w.s(5, a.xdict.clone()); + w.group("ACAD_REACTORS", 330, &[&dh]); + w.s(330, dh.clone()); + w.s(100, "AcDbDictionary"); + w.i(280, 1); + w.i(281, 1); + w.s(3, "ACAD_DIMASSOC"); + w.s(360, a.handle.clone()); + w.s(0, "DIMASSOC"); + w.s(5, a.handle.clone()); + w.group("ACAD_REACTORS", 330, &[&a.xdict]); + w.s(330, a.xdict.clone()); + w.s(100, "AcDbDimAssoc"); + w.s(330, dh); + w.i(90, a.flags); + w.i(70, 0); + w.i(71, 0); + for r in &a.refs { + w.s(1, "AcDbOsnapPointRef"); + w.i(72, r.osnap); + w.s(331, r.main.hex()); + w.i(73, if r.segment > 0 { 2 } else { 0 }); + w.i(91, r.segment); + w.s(301, ""); + w.f(40, r.param); + w.p(10, r.point); + if let Some(o) = r.other { + w.s(332, o.hex()); + w.i(74, 0); + w.i(92, 0); + w.s(302, ""); + } + w.i(75, 0); + } + } +} + +fn write_objects(w: &mut W, plan: ObjectWritePlan<'_>) { + // Groups (members that still exist) and multileader styles. + let group_owner = plan.group_dict.clone(); + let written: std::collections::HashSet = w.t.iter().filter(|t| t.code == 5).map(Tag::str).collect(); + let groups: Vec<(&Group, String)> = plan.d.groups.iter().filter(|g| !g.name.is_empty()).map(|g| (g, w.h())).collect(); + let mleader_dict = w.h(); + let mleader_handles: Vec = plan.cx.mleader_styles.iter().map(|(_, h)| h.clone()).collect(); + write_root_group_objects(w, &plan, &groups, &group_owner, &written, &mleader_dict); + write_style_objects(w, &plan, &mleader_dict, &mleader_handles); + write_named_objects(w, &plan); + write_associative_objects(w, plan.cx); + write_layout_objects(w, &plan); + // Raster image definitions. + w.t.extend(crate::dxf_image::objects(&plan.cx.images, plan.root_dict)); + w.s(0, "ENDSEC"); +} + +fn dimension_block_definitions( + d: &Drawing, + all_spaces: &[&EntityStore], + dim_plan: &super::blocks::DimBlockPlan, + w: &mut W, + cx: &mut Ctx, +) -> Vec<(String, String, Vec)> { + let mut dim_defs = Vec::new(); + let mut n = 1; + let mut reused_handles = std::collections::HashSet::new(); + for st in all_spaces { + for e in st.iter() { + if let EntityKind::Dimension(dm) = &e.kind { + let reused = dim_plan.reuse.get(&e.handle); + let ents: Vec; + let name = match reused { + // A dimension still linked to the block it was read with hasn't changed since + // (edits drop the link, and the screen draws that block): the block is written + // as read, so other programs show it as they drew it. + Some(b) if !b.entities.is_empty() => { + ents = b.entities.iter().map(|x| x.as_ref().clone()).collect(); + reused_handles.extend(ents.iter().map(|x| x.handle).filter(|h| h.0 != 0)); + b.name.clone() + } + Some(b) => { + let mut fresh = dim_block_entities(d, dm, &e.common.layer); + reuse_handles(&mut fresh, b, &mut reused_handles); + ents = fresh; + b.name.clone() + } + None => { + ents = dim_block_entities(d, dm, &e.common.layer); + let mut name = format!("*D{n}"); + while d.block(&name).is_some() { + n += 1; + name = format!("*D{n}"); + } + n += 1; + name + } + }; + let brh = w.h(); + cx.dim_blocks.insert(e.handle, name.clone()); + dim_defs.push((name, brh, ents)); + } + } + } + dim_defs +} + +fn arrow_block_definitions( + d: &Drawing, + every: &[&Entity], + user_blocks: &[(String, String, &Block)], + w: &mut W, + cx: &mut Ctx, +) -> Vec<(String, String, Vec)> { + let mut arrow_names: Vec = Vec::new(); + for s in &d.dim_styles { + arrow_names.extend([s.arrow_block.clone(), s.arrow_block1.clone(), s.arrow_block2.clone(), s.leader_arrow_block.clone()]); + } + for e in every { + if let EntityKind::Dimension(dm) = &e.kind { + for (k, v) in &dm.overrides { + let Some(f) = DimStyle::field_name(k).filter(|f| f.starts_with("arrowBlock") || *f == "leaderArrowBlock") else { continue }; + if let Some(s) = dxf_ext::canonical(f, v).as_ref().and_then(|v| v.as_str()) { + arrow_names.push(s.to_string()); + } + } + } + } + let mut arrow_defs: Vec<(String, String, Vec)> = Vec::new(); + for name in arrow_names { + let key = name.trim().to_ascii_uppercase(); + if key.is_empty() || cx.arrows.contains_key(&key) { + continue; + } + if let Some((_, h, _)) = user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())) { + cx.arrows.insert(key, h.clone()); + continue; + } + let Some((bname, kind)) = dxf_ext::arrow_block_name(&name) else { continue }; + let existing = arrow_defs.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone()); + let existing = existing.or_else(|| user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone())); + let h = match existing { + Some(h) => h, + None => { + let h = w.h(); + arrow_defs.push((bname, h.clone(), arrow_block_entities(kind))); + h + } + }; + cx.arrows.insert(key, h); + } + + arrow_defs +} + +fn table_block_definitions(d: &Drawing, every: &[&Entity], w: &mut W, cx: &mut Ctx) -> Vec<(String, String, Vec)> { + let mut table_defs = Vec::new(); + let mut tn = 1; + for e in every { + if let EntityKind::Table(t) = &e.kind { + if cx.tables.contains_key(&e.handle) { + continue; + } + let mut name = format!("*T{tn}"); + while d.block(&name).is_some() { + tn += 1; + name = format!("*T{tn}"); + } + tn += 1; + let brh = w.h(); + cx.tables.insert(e.handle, (name.clone(), brh.clone())); + table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer))); + } + } + + table_defs +} + +fn plan_dimension_associations(d: &Drawing, all_spaces: &[&EntityStore], w: &mut W, cx: &mut Ctx) { + for st in all_spaces { + for e in st.iter() { + 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 }); + } + } + } + } +} /// 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. @@ -45,125 +917,20 @@ 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() }; cx.block_records = user_blocks.iter().map(|(n, h, _)| (n.to_ascii_uppercase(), h.clone())).collect(); - // Dimension blocks. - let mut dim_defs: Vec<(String, String, Vec)> = Vec::new(); // filled below - let mut n = 1; - let mut reused_handles = std::collections::HashSet::new(); let all_spaces: Vec<&EntityStore> = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).collect(); - for st in &all_spaces { - for e in st.iter() { - if let EntityKind::Dimension(dm) = &e.kind { - let reused = dim_plan.reuse.get(&e.handle); - let ents: Vec; - let name = match reused { - // A dimension still linked to the block it was read with hasn't changed since - // (edits drop the link, and the screen draws that block): the block is written - // as read, so other programs show it as they drew it. - Some(b) if !b.entities.is_empty() => { - ents = b.entities.iter().map(|x| x.as_ref().clone()).collect(); - reused_handles.extend(ents.iter().map(|x| x.handle).filter(|h| h.0 != 0)); - b.name.clone() - } - Some(b) => { - let mut fresh = dim_block_entities(d, dm, &e.common.layer); - reuse_handles(&mut fresh, b, &mut reused_handles); - ents = fresh; - b.name.clone() - } - None => { - ents = dim_block_entities(d, dm, &e.common.layer); - let mut name = format!("*D{n}"); - while d.block(&name).is_some() { - n += 1; - name = format!("*D{n}"); - } - n += 1; - name - } - }; - let brh = w.h(); - cx.dim_blocks.insert(e.handle, name.clone()); - dim_defs.push((name, brh, ents)); - } - } - } + let mut dim_defs = dimension_block_definitions(d, &all_spaces, &dim_plan, &mut w, &mut cx); // Every entity, including block contents (for arrows, tables and overrides). let every: Vec<&Entity> = all_spaces .iter() .flat_map(|s| s.iter().map(|e| e.as_ref())) .chain(d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.as_ref()))) .collect(); - // Arrowhead blocks: user blocks by name, otherwise generated from our own geometry. - let mut arrow_names: Vec = Vec::new(); - for s in &d.dim_styles { - arrow_names.extend([s.arrow_block.clone(), s.arrow_block1.clone(), s.arrow_block2.clone(), s.leader_arrow_block.clone()]); - } - for e in &every { - if let EntityKind::Dimension(dm) = &e.kind { - for (k, v) in &dm.overrides { - let Some(f) = DimStyle::field_name(k).filter(|f| f.starts_with("arrowBlock") || *f == "leaderArrowBlock") else { continue }; - if let Some(s) = dxf_ext::canonical(f, v).as_ref().and_then(|v| v.as_str()) { - arrow_names.push(s.to_string()); - } - } - } - } - let mut arrow_defs: Vec<(String, String, Vec)> = Vec::new(); - for name in arrow_names { - let key = name.trim().to_ascii_uppercase(); - if key.is_empty() || cx.arrows.contains_key(&key) { - continue; - } - if let Some((_, h, _)) = user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())) { - cx.arrows.insert(key, h.clone()); - continue; - } - let Some((bname, kind)) = dxf_ext::arrow_block_name(&name) else { continue }; - let existing = arrow_defs.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone()); - let existing = existing.or_else(|| user_blocks.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(&bname)).map(|(_, h, _)| h.clone())); - let h = match existing { - Some(h) => h, - None => { - let h = w.h(); - arrow_defs.push((bname, h.clone(), arrow_block_entities(kind))); - h - } - }; - cx.arrows.insert(key, h); - } - // Table blocks (`*T`). - let mut table_defs: Vec<(String, String, Vec)> = Vec::new(); - let mut tn = 1; - for e in &every { - if let EntityKind::Table(t) = &e.kind { - if cx.tables.contains_key(&e.handle) { - continue; - } - let mut name = format!("*T{tn}"); - while d.block(&name).is_some() { - tn += 1; - name = format!("*T{tn}"); - } - tn += 1; - let brh = w.h(); - cx.tables.insert(e.handle, (name.clone(), brh.clone())); - table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer))); - } - } + let mut arrow_defs = arrow_block_definitions(d, &every, &user_blocks, &mut w, &mut cx); + let mut table_defs = table_block_definitions(d, &every, &mut w, &mut cx); // Image definitions (IMAGEDEF objects) of raster images anywhere in the drawing. cx.images = crate::dxf_image::plan(every.iter().copied(), || w.h()); cx.mlines = crate::dxf_mline::plan(d, every.iter().copied(), || w.h()); - // DIMASSOC objects of associative dimensions in model and paper space. - for st in &all_spaces { - for e in st.iter() { - 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 }); - } - } - } - } + plan_dimension_associations(d, &all_spaces, &mut w, &mut cx); let default_table_style = [TableStyle::default()]; let table_styles: &[TableStyle] = if d.table_styles.is_empty() { &default_table_style } else { &d.table_styles }; cx.table_styles = table_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); @@ -213,325 +980,12 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec = ps_brs.iter().map(|_| w.h()).collect(); - // ---------------- HEADER ---------------- - w.s(0, "SECTION"); - w.s(2, "HEADER"); - w.s(9, "$ACADVER"); - w.s(1, version.code()); - w.s(9, "$DWGCODEPAGE"); - w.s(3, code_page(d, version)); - let ext = d.extents(&Space::Model); - if !ext.is_empty() { - w.s(9, "$EXTMIN"); - w.p(10, ext.min.to3(0.0)); - w.s(9, "$EXTMAX"); - w.p(10, ext.max.to3(0.0)); - } - header_vars(&mut w, d); - let seed_pos = w.t.len(); - w.s(9, "$HANDSEED"); - w.s(5, "0"); - w.s(0, "ENDSEC"); + let seed_pos = write_header(&mut w, d, version); - // ---------------- CLASSES ---------------- - w.s(0, "SECTION"); - w.s(2, "CLASSES"); - let mut classes = vec![ - ("TABLESTYLE", "AcDbTableStyle", 4095, false), - ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false), - ("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false), - ("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false), - ]; - if !cx.tables.is_empty() { - classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); - } - if every.iter().any(|e| matches!(e.kind, EntityKind::Helix(_))) { - classes.push(("HELIX", "AcDbHelix", 4095, true)); - } - if !cx.assoc.is_empty() { - classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false)); - } - if cx.native_mleader() && every.iter().any(|e| matches!(e.kind, EntityKind::MLeader(_))) { - classes.push(("MULTILEADER", "AcDbMLeader", 1025, true)); - } - if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { - classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); - } - if !cx.images.is_empty() { - classes.push(("IMAGE", "AcDbRasterImage", 127, true)); - classes.push(("IMAGEDEF", "AcDbRasterImageDef", 0, false)); - classes.push(("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", 1, false)); - classes.push(("RASTERVARIABLES", "AcDbRasterVariables", 0, false)); - } - classes.push(("SCALE", "AcDbScale", 0, false)); - for (dxf_name, cpp, proxy, is_entity) in classes { - w.s(0, "CLASS"); - w.s(1, dxf_name); - w.s(2, cpp); - w.s(3, "ObjectDBX Classes"); - w.i(90, proxy); - w.i(280, 0); - w.i(281, i64::from(is_entity)); - } - w.s(0, "ENDSEC"); + write_classes(&mut w, &cx, &every); + + let ltype_handles = write_tables(&mut w, TableWritePlan { d, version, cx: &mut cx, ext: d.extents(&Space::Model) }); - // ---------------- TABLES ---------------- - w.s(0, "SECTION"); - w.s(2, "TABLES"); - vport_table(&mut w, d, ext); - // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles - // in the STYLE table below: (tag position, style name, is a shape) are filled in there. - let mut ltype_handles: HashMap = HashMap::new(); - let mut ltype_style_refs: Vec<(usize, Option, bool)> = Vec::new(); - let th = table_head(&mut w, "LTYPE", d.linetypes.len()); - for lt in &d.linetypes { - let h = record_head(&mut w, "LTYPE", &th, "AcDbLinetypeTableRecord"); - ltype_handles.entry(lt.name.to_ascii_uppercase()).or_insert(h.clone()); - cx.ltypes.entry(lt.name.to_ascii_uppercase()).or_insert(h); - w.s(2, <.name); - w.i(70, if crate::dxf_xref::dependent(<.name) { 48 } else { 0 }); - w.s(3, <.description); - w.i(72, 65); - w.i(73, lt.pattern.len() as i64); - w.f(40, lt.pattern_length()); - for el in <.pattern { - w.f(49, el.length); - // 74: 2 = text, 4 = shape, + 1 when the rotation is absolute (DXF Reference, LTYPE). - let (kind, shape) = match (&el.text, el.shape) { - (Some(_), _) => (2, 0), - (None, Some(n)) => (4, n), - (None, None) => (0, 0), - }; - if kind == 0 { - w.i(74, 0); - continue; - } - w.i(74, kind | i64::from(el.absolute)); - w.i(75, i64::from(shape)); - ltype_style_refs.push((w.t.len(), el.style.clone(), kind == 4)); - w.s(340, "0"); - w.f(46, el.scale); - w.f(50, el.rotation); - w.f(44, el.offset.x); - w.f(45, el.offset.y); - if let Some(t) = &el.text { - w.s(9, t.clone()); - } - } - } - w.s(0, "ENDTAB"); - // LAYER - let th = table_head(&mut w, "LAYER", d.layers.len()); - for l in &d.layers { - let lh = record_head(&mut w, "LAYER", &th, "AcDbLayerTableRecord"); - w.layers.insert(l.name.to_ascii_uppercase(), lh); - w.s(2, &l.name); - // 16 | 32: an xref's layer (`xref|name`), externally dependent and resolved. - let xref = if crate::dxf_xref::dependent(&l.name) { 48 } else { 0 }; - w.i(70, i64::from(l.frozen) | if l.vp_freeze_new { 2 } else { 0 } | if l.locked { 4 } else { 0 } | xref); - let aci = match l.color { - Color::True(rgb) => i64::from(cadcraft_color::nearest_aci(rgb)), - c => i64::from(c.to_aci()).clamp(1, 255), - }; - w.i(62, if l.on { aci } else { -aci }); - if let Color::True(rgb) = l.color { - w.i(420, i64::from(rgb.to_u32())); - } - w.s(6, &l.linetype); - if !l.plot { - w.i(290, 0); - } - w.i(370, i64::from(l.lineweight.to_dxf())); - let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned(); - if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) { - w.s(390, h); - } - if l.transparency > 0 { - w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP); - w.i(1071, dxf_ext::transparency_to_dxf(l.transparency)); - } - if !l.description.is_empty() { - // The first string is the layer standard's name, unused here. - w.s(1001, dxf_ext::LAYER_DESCRIPTION_APP); - w.s(1000, ""); - w.s(1000, dxf_ext::xdata_str(&l.description)); - } - } - w.s(0, "ENDTAB"); - // STYLE. Linetype shapes from a shape file that isn't a text style get an unnamed shape - // style record (70 bit 1) naming the file. - let mut shape_files: Vec = Vec::new(); - for (_, style, shape) in <ype_style_refs { - let Some(f) = style.as_deref().map(str::trim).filter(|f| *shape && !f.is_empty() && d.text_style(f).is_none()) else { continue }; - if !shape_files.iter().any(|x| x.eq_ignore_ascii_case(f)) { - shape_files.push(f.to_string()); - } - } - let th = table_head(&mut w, "STYLE", d.text_styles.len() + shape_files.len()); - for s in &d.text_styles { - let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); - cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h); - w.s(2, &s.name); - // 70 bit 4: vertical; 71 bits 2/4: backwards / upside down. - w.i(70, if s.vertical { 4 } else { 0 }); - w.f(40, s.height); - w.f(41, s.width_factor); - 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() }; - w.s(3, file); - w.s(4, &s.big_font); - if s.annotative { - annotative_xdata(&mut w); - } - if file != s.font { - w.s(1001, dxf_ext::APP); - w.s(1000, "FONT"); - w.s(1000, dxf_ext::xdata_str(&s.font)); - } - } - let mut shape_handles: HashMap = HashMap::new(); - for f in &shape_files { - let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); - shape_handles.insert(f.to_ascii_uppercase(), h); - w.s(2, ""); - w.i(70, 1); - w.f(40, 0.0); - w.f(41, 1.0); - w.f(50, 0.0); - w.i(71, 0); - w.f(42, 0.2); - w.s(3, f.clone()); - w.s(4, ""); - } - w.s(0, "ENDTAB"); - // Linetype text and shapes without a known style use Standard (else the first style). - let fallback_style = cx.style("Standard").or_else(|| d.text_styles.first().and_then(|s| cx.style(&s.name))).unwrap_or_else(|| "0".into()); - for (pos, style, shape) in ltype_style_refs { - let name = style.as_deref().map(str::trim).unwrap_or_default(); - let h = shape.then(|| shape_handles.get(&name.to_ascii_uppercase()).cloned()).flatten().or_else(|| cx.style(name)); - if let Some(t) = w.t.get_mut(pos) { - *t = Tag::s(340, h.unwrap_or_else(|| fallback_style.clone())); - } - } - // VIEW: named views (DXF Reference, VIEW). A view's layer state is CADCraft xdata. - let th = table_head(&mut w, "VIEW", d.views.len()); - for v in &d.views { - record_head(&mut w, "VIEW", &th, "AcDbViewTableRecord"); - w.s(2, &v.name); - w.i(70, i64::from(v.layout.is_some())); - w.f(40, v.height); - w.p2(10, v.center); - w.f(41, v.width); - w.p(11, Vec3::Z); - w.p(12, Vec3::ZERO); - w.f(42, 50.0); - w.f(43, 0.0); - w.f(44, 0.0); - w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 }); - w.i(71, 0); - w.i(281, 0); - w.i(72, 0); - let xdata = [("LAYERSTATE", v.layer_state.as_deref()), ("LAYOUT", v.layout.as_deref().filter(|l| !l.is_empty()))]; - if xdata.iter().any(|(_, x)| x.is_some()) { - w.s(1001, dxf_ext::APP); - for (key, value) in xdata { - if let Some(value) = value { - w.s(1000, key); - w.s(1000, dxf_ext::xdata_str(value)); - } - } - } - } - w.s(0, "ENDTAB"); - // UCS: named user coordinate systems (DXF Reference, UCS). - let th = table_head(&mut w, "UCS", d.ucss.len()); - for u in &d.ucss { - record_head(&mut w, "UCS", &th, "AcDbUCSTableRecord"); - w.s(2, &u.name); - w.i(70, 0); - w.p(10, u.origin); - w.p(11, u.x_axis); - w.p(12, u.y_axis); - w.i(79, 0); - w.f(146, 0.0); - } - w.s(0, "ENDTAB"); - let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP]; - let th = table_head(&mut w, "APPID", apps.len()); - for app in apps { - record_head(&mut w, "APPID", &th, "AcDbRegAppTableRecord"); - w.s(2, app); - w.i(70, 0); - } - w.s(0, "ENDTAB"); - // DIMSTYLE - let th = { - let h = w.h(); - w.s(0, "TABLE"); - w.s(2, "DIMSTYLE"); - w.s(5, h.clone()); - w.s(330, "0"); - w.s(100, "AcDbSymbolTable"); - w.i(70, d.dim_styles.len() as i64); - w.s(100, "AcDbDimStyleTable"); - w.i(71, 0); - h - }; - for s in &d.dim_styles { - record_head(&mut w, "DIMSTYLE", &th, "AcDbDimStyleTableRecord"); - w.s(2, &s.name); - w.i(70, 0); - let v = serde_json::to_value(s).unwrap_or_default(); - let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default(); - let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new(), reactors: HashMap::new() }; - for (field, code, kind) in dxf_ext::DIM_CODES { - let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue }; - // DIMPOST is written even when empty: a reader that defaults a missing one to "<>" - // (the DWG bridge's) would otherwise give radius and diameter dimensions no "R" or - // "Ø" in AutoCAD (#1053). - if *code == dxf_ext::DIMPOST - && let DimVal::Str(t) = &enc - && t.is_empty() - { - w.s(*code, ""); - continue; - } - if !dxf_ext::NEWER_CODES.contains(code) { - w.dimval(*code, enc, false); - continue; - } - // Table groups where the file version has them, else xdata. DIMTFILL and DIMTFILLCLR - // are written only when set, as other writers do, so a style without a text - // background has a single 70 (its flags). - if version >= dxf_ext::newer_since(*code) { - if matches!(*code, 69 | 70) && v.get(*field) == defaults.get(*field) { - continue; - } - w.dimval(*code, enc, false); - } else if v.get(*field) != defaults.get(*field) { - newer.i(1070, i64::from(*code)); - newer.dimval(*code, enc, true); - } - } - if !s.arrow_block1.trim().is_empty() || !s.arrow_block2.trim().is_empty() { - w.i(dxf_ext::DIMSAH, 1); - } - if s.annotative { - annotative_xdata(&mut w); - } - if !newer.t.is_empty() { - w.s(1001, dxf_ext::APP); - w.s(1000, dxf_ext::DIMVARS_LIST); - w.s(1002, "{"); - w.xdata(newer.t); - w.s(1002, "}"); - } - } - w.s(0, "ENDTAB"); // BLOCK_RECORD let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len() + arrow_defs.len() + table_defs.len()); let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>, blk: Option<&Block>| { @@ -559,7 +1013,7 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec Vec = w.t.iter().filter(|t| t.code == 5).map(Tag::str).collect(); - let groups: Vec<(&Group, String)> = d.groups.iter().filter(|g| !g.name.is_empty()).map(|g| (g, w.h())).collect(); - let mleader_dict = w.h(); - let mleader_handles: Vec = cx.mleader_styles.iter().map(|(_, h)| h.clone()).collect(); - w.s(0, "SECTION"); - w.s(2, "OBJECTS"); - w.s(0, "DICTIONARY"); - w.s(5, root_dict.clone()); - w.s(330, "0"); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - w.s(3, "ACAD_GROUP"); - w.s(350, group_dict.clone()); - if !cx.images.is_empty() { - w.s(3, "ACAD_IMAGE_DICT"); - w.s(350, cx.images.dict.clone()); - w.s(3, "ACAD_IMAGE_VARS"); - w.s(350, cx.images.vars.clone()); - } - w.s(3, "ACAD_LAYOUT"); - w.s(350, layout_dict.clone()); - w.s(3, "ACAD_MLEADERSTYLE"); - w.s(350, mleader_dict.clone()); - w.s(3, "ACAD_MLINESTYLE"); - w.s(350, cx.mlines.dict.clone()); - w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY); - w.s(350, pstyle_dict.clone()); - w.s(3, "ACAD_TABLESTYLE"); - w.s(350, table_style_dict.clone()); - for (key, h, _) in &xrecords { - w.s(3, *key); - w.s(350, h); - } - w.s(3, "ACAD_SCALELIST"); - w.s(350, scale_dict.clone()); - w.s(0, "DICTIONARY"); - w.s(5, group_dict); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (g, h) in &groups { - w.s(3, &g.name); - w.s(350, h); - } - for (g, h) in &groups { - group_obj(&mut w, g, h, &group_owner, &written); - } - // Multileader styles. - w.s(0, "DICTIONARY"); - w.s(5, mleader_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (s, h) in mleader_styles.iter().zip(&mleader_handles) { - w.s(3, &s.name); - w.s(350, h); - } - let byblock_ltype = ltype_handles.get("BYBLOCK").cloned(); - for (s, h) in mleader_styles.iter().zip(&mleader_handles) { - mleader_style_obj(&mut w, s, h, &mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); - } - // Multiline styles. - w.t.extend(crate::dxf_mline::objects(&cx.mlines, &root_dict)); - // Table styles. - w.s(0, "DICTIONARY"); - w.s(5, table_style_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (name, h) in &cx.table_styles { - w.s(3, name); - w.s(350, h); - } - for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) { - table_style_obj(&mut w, d, s, h, &table_style_dict); - } - // Plot style names: a dictionary with a default (Normal) of placeholder objects. - w.s(0, "ACDBDICTIONARYWDFLT"); - w.s(5, pstyle_dict.clone()); - w.group("ACAD_REACTORS", 330, &[&root_dict]); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - for (n, h) in &pstyles { - w.s(3, n); - w.s(350, h); - } - w.s(100, "AcDbDictionaryWithDefault"); - w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default()); - for (_, h) in &pstyles { - w.s(0, "ACDBPLACEHOLDER"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[&pstyle_dict]); - w.s(330, pstyle_dict.clone()); - } - // Parametric constraints and parameters, saved layer states, plot style tables (CADCraft - // data). - for (_, h, chunks) in &xrecords { - w.s(0, "XRECORD"); - w.s(5, h); - w.group("ACAD_REACTORS", 330, &[&root_dict]); - w.s(330, root_dict.clone()); - w.s(100, "AcDbXrecord"); - w.i(280, 1); - for c in chunks { - w.s(1, c.clone()); - } - } - scale_list(&mut w, &scales, &scale_dict, &root_dict); - // Dimension associativity: extension dictionary + DIMASSOC per dimension. - let mut assoc: Vec<(&Handle, &AssocObj)> = cx.assoc.iter().collect(); - assoc.sort_by_key(|(h, _)| **h); - for (dim, a) in assoc { - let dh = dim.hex(); - w.s(0, "DICTIONARY"); - w.s(5, a.xdict.clone()); - w.group("ACAD_REACTORS", 330, &[&dh]); - w.s(330, dh.clone()); - w.s(100, "AcDbDictionary"); - w.i(280, 1); - w.i(281, 1); - w.s(3, "ACAD_DIMASSOC"); - w.s(360, a.handle.clone()); - w.s(0, "DIMASSOC"); - w.s(5, a.handle.clone()); - w.group("ACAD_REACTORS", 330, &[&a.xdict]); - w.s(330, a.xdict.clone()); - w.s(100, "AcDbDimAssoc"); - w.s(330, dh); - w.i(90, a.flags); - w.i(70, 0); - w.i(71, 0); - for r in &a.refs { - w.s(1, "AcDbOsnapPointRef"); - w.i(72, r.osnap); - w.s(331, r.main.hex()); - w.i(73, if r.segment > 0 { 2 } else { 0 }); - w.i(91, r.segment); - w.s(301, ""); - w.f(40, r.param); - w.p(10, r.point); - if let Some(o) = r.other { - w.s(332, o.hex()); - w.i(74, 0); - w.i(92, 0); - w.s(302, ""); - } - w.i(75, 0); - } - } - w.s(0, "DICTIONARY"); - w.s(5, layout_dict.clone()); - w.s(330, root_dict.clone()); - w.s(100, "AcDbDictionary"); - w.i(281, 1); - w.s(3, "Model"); - w.s(350, model_layout.clone()); - for (i, (_, _, l)) in ps_brs.iter().enumerate() { - w.s(3, &l.name); - w.s(350, layout_handles.get(i).cloned().unwrap_or_default()); - } - let layout_obj = |w: &mut W, h: &str, (name, tab): (&str, i64), brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { - // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units - // per 143 drawing units). - let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; - let standard = page.scale_to_fit || scale == 1.0; - let plot_flags = 512 - | 32 - | if standard { 16 } else { 0 } - | if page.lineweights { 128 } else { 0 } - | if page.center { 4 } else { 0 } - | if page.show_plot_styles { 2 } else { 0 }; - let plot_type = match page.plot_area.as_str() { - "display" => 0, - "extents" => 1, - "limits" => 2, - "window" => 4, - _ => 5, - }; - w.s(0, "LAYOUT"); - w.s(5, h); - w.s(330, layout_dict.clone()); - w.s(100, "AcDbPlotSettings"); - w.s(1, ""); - let device = page.device.trim(); - w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); - w.s(4, page.paper.replace(' ', "_")); - w.s(6, ""); - for (c, v) in [ - (40, page.margins_mm[0]), - (41, page.margins_mm[1]), - (42, page.margins_mm[2]), - (43, page.margins_mm[3]), - (44, page.width_mm), - (45, page.height_mm), - (46, 0.0), - (47, 0.0), - (48, page.plot_window[0]), - (49, page.plot_window[1]), - (140, page.plot_window[2]), - (141, page.plot_window[3]), - (142, scale), - (143, 1.0), - ] { - w.f(c, v); - } - w.i(70, plot_flags); - // Plot paper units: 1 millimetres, 0 inches (#1058). - w.i(72, i64::from(cadcraft_render::paper::page_unit_mm(d, page) == 1.0)); - w.i(73, i64::from(page.landscape)); - w.i(74, plot_type); - 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); - 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)); - w.p(12, Vec3::ZERO); - w.p(14, Vec3::ZERO); - w.p(15, Vec3::ZERO); - w.f(146, 0.0); - w.p(13, Vec3::ZERO); - w.p(16, Vec3::new(1.0, 0.0, 0.0)); - w.p(17, Vec3::new(0.0, 1.0, 0.0)); - w.i(76, 0); - w.s(330, brh); - // The saved paper-space view (centre, height) as CADCraft xdata. - if let Some((c, height)) = view.filter(|(c, v)| c.x.is_finite() && c.y.is_finite() && v.is_finite()) { - w.s(1001, dxf_ext::APP); - w.s(1000, "PSVIEW"); - w.s(1002, "{"); - w.p(1010, c.to3(0.0)); - w.f(1040, height); - w.s(1002, "}"); - } - }; - // Model space has no page setup of its own: write what PLOT does for it, the drawing extents - // fitted to the paper (plot type 1, scaled to fit). The default page's "layout" area is - // plot type 5, "Layout information" (DXF Reference, PLOTSETTINGS group 74), which means - // nothing for model space: another program's Plot dialog then shows no plot area or scale. - let model_page = PageSetup { plot_area: "extents".into(), scale_to_fit: true, ..PageSetup::default() }; - layout_obj(&mut w, &model_layout, ("Model", 0), &ms_br, 1, &model_page, None); - for (i, (_, brh, l)) in ps_brs.iter().enumerate() { - let h = layout_handles.get(i).cloned().unwrap_or_default(); - layout_obj(&mut w, &h, (&l.name, i64::from(l.tab_order.max(1))), brh, 1, &l.page, l.view); - } - // Raster image definitions. - w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); - w.s(0, "ENDSEC"); w.s(0, "EOF"); let seed = format!("{:X}", w.next); if let Some(t) = w.t.get_mut(seed_pos + 1) { @@ -907,14 +1117,27 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec= 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/lib.rs b/crates/io/src/lib.rs index 605298db9..ccfba201e 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -22,7 +22,9 @@ mod dxf_mline; use cadcraft_doc::{Drawing, Space}; use cadcraft_geom::Bounds2; +use std::path::{Path, PathBuf}; +pub use cadcraft_doc::Format; pub use cadcraft_dxf::Version; pub use dxf_read::entity_from_groups; pub use dxf_write::entity_groups; @@ -53,40 +55,42 @@ pub enum IoError { pub type Result = std::result::Result; -fn ext(name: &str) -> String { - std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +fn ext(name: impl AsRef) -> String { + name.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } /// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name /// (SVG and gzip-compressed SVGZ). -pub fn read(bytes: &[u8], name: &str) -> Result { +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 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); } - match ext(name).as_str() { - "svg" | "svgz" => svg_read::read(bytes), - "dxf" | "" => dxf_read::read(bytes), - e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())), - e => Err(IoError::Unsupported(e.to_string())), + match (Format::from_path(name), ext(name).as_str()) { + (Some(Format::Svg | Format::Svgz), _) => svg_read::read(bytes), + (Some(Format::Dxf), _) => dxf_read::read(bytes), + (_, e) if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())), + (_, e) => Err(IoError::Unsupported(e.to_string())), } } /// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show /// model space fitted to its extents. DXF and DWG are written in the drawing's version /// ([`save_version`]), DXF as ASCII. -pub fn write(d: &Drawing, name: &str) -> Result> { +pub fn write(d: &Drawing, name: impl AsRef) -> Result> { write_framed(d, name, None) } /// Like [`write`], with the DXF/DWG version and binary DXF chosen by `opts`. Binary applies to /// DXF only; asking for it with another format is an error. -pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result> { +pub fn write_with(d: &Drawing, name: impl AsRef, opts: &SaveOptions) -> Result> { + let name = name.as_ref(); let version = opts.version.unwrap_or_else(|| save_version(d)); - match ext(name).as_str() { - "dxf" | "" => { + match Format::from_path(name) { + Some(Format::Dxf) => { let tags = dxf_write::tags(d, version); // Before 2007 the text is in the code page the header declares. let code_page = dxf_write::code_page(d, version); @@ -100,11 +104,11 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result // The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without // one, at least 2004: R2000 DWG has no true colours, transparency, table styles or // gradients, so a drawing from an R2000 DXF doesn't lose them by default. - "dwg" if !opts.binary => { + 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) } - e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))), + _ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))), _ => write_framed(d, name, None), } } @@ -113,28 +117,32 @@ pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result /// drawing units, centred and scaled to fit the image without distortion) instead of the /// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with /// [`check_window`]. -pub fn write_framed(d: &Drawing, name: &str, window: Option) -> Result> { +pub fn write_framed(d: &Drawing, name: impl AsRef, window: Option) -> Result> { + let name = name.as_ref(); // A drawing template (.dwt) is DWG content. - if ext(name) == "dwt" { - return write_framed(d, &format!("{name}.dwg"), None); + let format = Format::from_path(name); + if format == Some(Format::Dwt) { + let mut dwg = name.as_os_str().to_owned(); + dwg.push(".dwg"); + return write_framed(d, PathBuf::from(dwg), None); } if let Some(w) = &window && is_image(name) { check_window(w)?; } - match ext(name).as_str() { - "dxf" | "" | "dwg" => write_with(d, name, &SaveOptions::default()), - "svg" => Ok(svg::export_window(d, &Space::Model, window).into_bytes()), - "png" => png_framed(d, &Space::Model, 2400, 1600, window), - "pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, window, ..PdfOptions::default() }), - e => Err(IoError::Unsupported(e.to_string())), + match format { + Some(Format::Dxf | Format::Dwg) => write_with(d, name, &SaveOptions::default()), + Some(Format::Svg) => Ok(svg::export_window(d, &Space::Model, window).into_bytes()), + Some(Format::Png) => png_framed(d, &Space::Model, 2400, 1600, window), + Some(Format::Pdf) => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, window, ..PdfOptions::default() }), + _ => Err(IoError::Unsupported(ext(name))), } } /// Whether `name` is written as an image (PNG, SVG, PDF), i.e. whether framing applies to it. -pub fn is_image(name: &str) -> bool { - matches!(ext(name).as_str(), "png" | "svg" | "pdf") +pub fn is_image(name: impl AsRef) -> bool { + Format::from_path(name).is_some_and(Format::is_image) } /// Largest coordinate magnitude accepted in an export window. diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index d5fe5d2f7..977a7c184 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2813,3 +2813,17 @@ fn proxies_keep_their_class_when_classes_are_rewritten() { dxf_records(&pairs, "ACAD_PROXY_OBJECT").iter().map(|r| classes[own_91(r).parse::().unwrap() - 500].clone()).collect(); assert_eq!(names, ["VISUALSTYLE", "WIDGETX"]); } + +/// A name that isn't valid Unicode still picks its format by extension. +#[cfg(unix)] +#[test] +fn non_unicode_names_pick_the_format_by_extension() { + use std::os::unix::ffi::OsStringExt as _; + let name = |ext: &str| std::path::PathBuf::from(std::ffi::OsString::from_vec([b"caf\xe9.".as_slice(), ext.as_bytes()].concat())); + assert!(is_image(name("png")) && !is_image(name("dxf"))); + let d = sample(); + let bytes = write(&d, name("dxf")).unwrap(); + assert!(read(&bytes, name("dxf")).is_ok()); + assert!(write(&d, name("png")).unwrap().starts_with(b"\x89PNG")); + assert!(write(&d, name("dwt")).is_ok(), "a template is written as DWG"); +} diff --git a/crates/io/tests/it/annotative_orientation.rs b/crates/io/tests/it/annotative_orientation.rs new file mode 100644 index 000000000..c11688de6 --- /dev/null +++ b/crates/io/tests/it/annotative_orientation.rs @@ -0,0 +1,45 @@ +//! Annotative TEXT read from a file with "Match orientation to layout" on (`AcadAnnoPO` xdata = 1) +//! is drawn and measured upright, ignoring its stored rotation, as AutoCAD shows it (#2498). + +use cadcraft_doc::{EntityKind, Space, entity_bounds}; + +/// The xdata AutoCAD writes on such text: the `AcadAnnotative` data, then `AcadAnnoPO`. +const ANNO_PO: &str = "1001\nAcadAnnotative\n1000\nAnnotativeData\n1002\n{\n1070\n1\n1070\n1\n1002\n}\n1001\nAcadAnnoPO\n1070\n1\n"; + +/// One TEXT "TITLE" at 10,5, height 2.5, rotated `deg` degrees, followed by `xdata`. +fn dxf(deg: f64, xdata: &str) -> String { + format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n5\n2A\n8\n0\n10\n10\n20\n5\n40\n2.5\n1\nTITLE\n50\n{deg}\n{xdata}0\nENDSEC\n0\nEOF\n") +} + +/// (rendered box, extents box) of the drawing's only object, as (min x, min y, max x, max y). +fn boxes(text: &str) -> ([f64; 4], [f64; 4]) { + let d = cadcraft_io::read(text.as_bytes(), "t.dxf").unwrap(); + let r = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds; + let e = d.model.iter().find(|e| matches!(e.kind, EntityKind::Text(_))).map(|e| entity_bounds(&d, e, 0)).unwrap(); + ([r.min.x, r.min.y, r.max.x, r.max.y], [e.min.x, e.min.y, e.max.x, e.max.y]) +} + +fn same(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +#[test] +fn match_orientation_to_layout_draws_the_text_upright() { + let d = cadcraft_io::read(dxf(180.0, ANNO_PO).as_bytes(), "t.dxf").unwrap(); + assert!(d.model.iter().any(|e| e.common.matches_layout_orientation()), "the AcadAnnoPO flag is read"); + let (upright_r, upright_e) = boxes(&dxf(0.0, "")); + let (r, e) = boxes(&dxf(180.0, ANNO_PO)); + assert!(same(r, upright_r), "drawn at {r:?}, upright at {upright_r:?}"); + assert!(same(e, upright_e), "extents {e:?}, upright {upright_e:?}"); +} + +#[test] +fn without_the_flag_the_stored_rotation_applies() { + let (upright_r, upright_e) = boxes(&dxf(0.0, "")); + let off = ANNO_PO.replace("AcadAnnoPO\n1070\n1\n", "AcadAnnoPO\n1070\n0\n"); + for xdata in ["", off.as_str()] { + let (r, e) = boxes(&dxf(180.0, xdata)); + assert!(!same(r, upright_r), "drawn at {r:?}"); + assert!(!same(e, upright_e), "extents {e:?}"); + } +} 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/dwg_header_vars.rs b/crates/io/tests/it/dwg_header_vars.rs index d1850278f..644fe375c 100644 --- a/crates/io/tests/it/dwg_header_vars.rs +++ b/crates/io/tests/it/dwg_header_vars.rs @@ -59,3 +59,46 @@ fn header_settings_round_trip_through_dwg() { let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); 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] +fn the_current_layer_round_trips_through_dwg() { + for name in ["Survey_B", "Survey_\u{c4}"] { + let mut d = Drawing::new_metric(); + d.layers.push(cadcraft_doc::Layer::new(name)); + d.header.set_str("CLAYER", name); + let dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(dxf.header.str("CLAYER", "?"), name, "DXF keeps it"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(r.header.str("CLAYER", "?"), name, "DWG"); + } +} + +/// `$CLAYER` naming the layer in other letter case still finds it: the DWG comes back on the +/// layer, under the layer's own spelling, not on "0" (#2252). +#[test] +fn a_current_layer_in_other_case_resolves_to_the_layer() { + let mut d = Drawing::new_metric(); + d.layers.push(cadcraft_doc::Layer::new("Survey_\u{c4}")); + d.header.set_str("CLAYER", "Survey_\u{e4}"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(r.header.str("CLAYER", "?"), "Survey_\u{c4}"); +} diff --git a/crates/io/tests/it/dxf_versions.rs b/crates/io/tests/it/dxf_versions.rs index eafd989e9..390c94703 100644 --- a/crates/io/tests/it/dxf_versions.rs +++ b/crates/io/tests/it/dxf_versions.rs @@ -101,6 +101,31 @@ fn dxf_at_every_version_reads_back_the_same() { } } +/// 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] diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index d86d0b4b8..38d325604 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(); diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 51e7f1965..5d6c343d0 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -2,8 +2,10 @@ mod acad_interop; #[cfg(not(target_arch = "wasm32"))] mod acis_data_store; 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; diff --git a/crates/io/tests/it/mtext_columns_roundtrip.rs b/crates/io/tests/it/mtext_columns_roundtrip.rs index 7db9ed685..4273afa12 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::{entity_from_groups, read_dxf, write_dxf}; 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,45 @@ 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)); +} diff --git a/crates/io/tests/it/roundtrip_audit.rs b/crates/io/tests/it/roundtrip_audit.rs index 10f910ae8..0b15f3181 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] diff --git a/crates/io/tests/it/sortents.rs b/crates/io/tests/it/sortents.rs index 0be17e910..35b3e0dab 100644 --- a/crates/io/tests/it/sortents.rs +++ b/crates/io/tests/it/sortents.rs @@ -46,3 +46,96 @@ fn broken_sortentstables_are_ignored() { assert_eq!(order(&d), ["A1", "A2", "A3"], "{table:?}"); } } + +/// A block whose SORTENTSTABLE leaves out the circle (it sorts by its own handle) draws the +/// wipeout, the circle, then the two lines (#2410). +fn block_dxf() -> String { + block_dxf_with("331\n25B\n5\n25D\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n") +} + +fn block_dxf_with(entries: &str) -> String { + let rec = |h: &str, n: &str| format!("0\nBLOCK_RECORD\n5\n{h}\n2\n{n}\n"); + let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n"); + s.push_str(&rec("1F", "*Model_Space")); + s.push_str(&rec("2A", "MASKED_B")); + s.push_str("0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n5\n2B\n330\n2A\n8\n0\n2\nMASKED_B\n70\n0\n10\n0\n20\n0\n30\n0\n"); + let ent = |kind: &str, h: &str, body: &str| format!("0\n{kind}\n5\n{h}\n330\n2A\n8\n0\n{body}"); + s.push_str(&ent("LINE", "25B", "10\n0\n20\n0\n30\n0\n11\n10\n21\n0\n31\n0\n")); + s.push_str(&ent("CIRCLE", "25C", "10\n5\n20\n5\n30\n0\n40\n2\n")); + s.push_str(&ent("LINE", "25D", "10\n5\n20\n-5\n30\n0\n11\n5\n21\n5\n31\n0\n")); + s.push_str(&ent( + "WIPEOUT", + "25E", + "10\n0\n20\n0\n30\n0\n11\n1\n21\n0\n31\n0\n12\n0\n22\n1\n32\n0\n14\n-0.5\n24\n-0.5\n14\n9.5\n24\n-0.5\n14\n9.5\n24\n9.5\n14\n-0.5\n24\n9.5\n", + )); + s.push_str("0\nENDBLK\n5\n2C\n330\n2A\n8\n0\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n"); + s.push_str("0\nSORTENTSTABLE\n5\nB0\n330\nB1\n100\nAcDbSortentsTable\n330\n2A\n"); + s.push_str(entries); + s.push_str("0\nENDSEC\n0\nEOF\n"); + s +} + +fn block_kinds(d: &Drawing) -> Vec<&'static str> { + let b = d.block("MASKED_B").unwrap(); + b.entities + .iter() + .map(|e| match &e.kind { + EntityKind::Wipeout(_) => "wipeout", + EntityKind::Circle(_) => "circle", + EntityKind::Line(l) if l.b.y == l.a.y => "hline", + EntityKind::Line(_) => "vline", + _ => "other", + }) + .collect() +} + +#[test] +fn a_block_entity_left_out_of_the_table_sorts_by_its_own_handle() { + let d = cadcraft_io::read_dxf(block_dxf().as_bytes()).unwrap(); + assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]); +} + +#[test] +fn the_block_draw_order_survives_dxf_and_dwg() { + let d = cadcraft_io::read_dxf(block_dxf().as_bytes()).unwrap(); + let dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(block_kinds(&dxf), ["wipeout", "circle", "hline", "vline"], "DXF"); + let dwg = cadcraft_io::read(&cadcraft_io::write(&d, "t.dwg").unwrap(), "t.dwg").unwrap(); + assert_eq!(block_kinds(&dwg), ["wipeout", "circle", "hline", "vline"], "DWG"); +} + +/// The table itself (not CADCraft's reordered records) goes through the DWG bridge (#2410). +#[test] +fn a_partial_block_table_survives_the_dwg_bridge() { + let dwg = cadcraft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap(); + let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap(); + let d = cadcraft_io::read_dxf(&dxf).unwrap(); + assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]); +} + +/// A sort handle of 0 draws the entity last, in a DXF and across the DWG bridge (#2439). +#[test] +fn a_zero_sort_handle_draws_last() { + // The horizontal line is keyed 0 (last); the vertical line 25E; the wipeout 25B; the circle its own 25C. + let dxf = block_dxf_with("331\n25B\n5\n0\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n"); + let want = ["wipeout", "circle", "vline", "hline"]; + assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF"); + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + assert_eq!(block_kinds(&back), want, "DWG"); +} + +/// Two entities left out of the table each sort by their own handle, so they land between the +/// listed ones (a single fallback key for every unlisted entity would put them side by side) +/// in a DXF and across the DWG bridge (#2439). +#[test] +fn two_unlisted_block_entities_each_sort_by_their_own_handle() { + // Listed: the horizontal line 25B and the vertical line 25D, each under its own handle. + // Unlisted: the circle 25C and the wipeout 25E. + let dxf = block_dxf_with("331\n25B\n5\n25B\n331\n25D\n5\n25D\n"); + let want = ["hline", "circle", "vline", "wipeout"]; + assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF"); + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + assert_eq!(block_kinds(&back), want, "DWG"); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 955a6b6d2..1359fbdda 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -12,6 +12,7 @@ workspace = true cadcraft-engine = { workspace = true } cadcraft-render = { workspace = true } cadcraft-io = { workspace = true } +clap = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # Secure randomness for the per-session control token (MIT OR Apache-2.0). diff --git a/crates/mcp/src/args.rs b/crates/mcp/src/args.rs new file mode 100644 index 000000000..a47a90bdd --- /dev/null +++ b/crates/mcp/src/args.rs @@ -0,0 +1,51 @@ +//! Helpers for clap parsers shared by the apps: environment variables as flag fallbacks. +//! +//! Apply [`blank_env_is_unset`] to the arguments, so an empty variable counts as unset, and pass a +//! parse error through [`name_env_vars`], so a bad value names the variable it came from. + +use std::ffi::{OsStr, OsString}; + +use clap::{Arg, Command}; + +/// For `Command::mut_args`: an empty or blank variable counts as unset, so `CADCRAFT_CONTROL_PORT=` +/// turns a fallback off without `unset`. clap would pass the empty value to the parser. `var` +/// looks the variable up. +pub fn blank_env_is_unset(arg: Arg, var: impl Fn(&OsStr) -> Option) -> Arg { + let blank = arg.get_env().and_then(var).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty())); + if blank { arg.env(None::<&str>) } else { arg } +} + +/// clap names the flag when a value from its variable is bad or conflicts with another, as if the +/// flag had been given: name the variable instead when the flag isn't among `args` (without the +/// binary name; a first one naming a subcommand selects whose arguments to look in). +pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error { + use clap::error::{ContextKind, ContextValue}; + // `--flag value` or `--flag=value`. + let given = + |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('=')); + let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd); + for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] { + let Some(ContextValue::String(shown)) = error.get(kind) else { continue }; + let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue }; + let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue }; + if !given(long) { + error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned())); + } + } + error +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_variables_count_as_unset() { + let arg = || Arg::new("control").long("control").env("CADCRAFT_CONTROL_PORT"); + for value in ["", " "] { + assert_eq!(blank_env_is_unset(arg(), |_| Some(value.into())).get_env(), None, "{value:?}"); + } + assert!(blank_env_is_unset(arg(), |_| Some(" 50494 ".into())).get_env().is_some()); + assert!(blank_env_is_unset(arg(), |_| None).get_env().is_some()); + } +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 9d012076a..20a86fce7 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,6 +1,6 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; @@ -28,6 +28,8 @@ pub struct Remote { /// The per-session control token for this port, read from the owner-only token file on first /// use so requests can authenticate (CWE-306). token: Option, + /// Longest reply line accepted (`MAX_REPLY_LINE`; smaller in tests). + max_reply: usize, } /// The port component of a `HOST:PORT` address, for locating the token file. @@ -38,10 +40,29 @@ fn port_of(addr: &str) -> Option { /// How long a launched app has to open its control port. const LAUNCH_WAIT: Duration = Duration::from_secs(30); +/// Longest reply line in bytes the client will buffer. It matches the control server's request +/// cap (256 MiB, room for whole drawings and images as base64), so a peer squatting on the port +/// can't make the client allocate without bound (#1925). +const MAX_REPLY_LINE: usize = 256 * 1024 * 1024; + +/// Read one `\n`-terminated reply of at most `max` bytes (newline excluded). `Ok(0)` is end of +/// stream; a longer line is an `InvalidData` error and the rest of it stays unread. +fn read_reply_line(reader: &mut impl BufRead, reply: &mut String, max: usize) -> std::io::Result { + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_line(reply)?; + if n > max && !reply.ends_with('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("reply from the control port is longer than {max} bytes; is that CADCraft's control server?"), + )); + } + Ok(n) +} + impl Remote { /// Connect to a running control server; its session token is read lazily when the first request is sent. pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None }; + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None, max_reply: MAX_REPLY_LINE }; r.reconnect()?; Ok(r) } @@ -49,7 +70,7 @@ impl Remote { /// the server before the app is open. With `launch`, a call that finds nothing at `addr` /// opens the app with that control port first (macOS) and waits for it. pub fn lazy(addr: &str, launch: bool) -> Self { - Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None } + Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None, max_reply: MAX_REPLY_LINE } } /// The per-session token for this port, read (and cached) from the owner-only token file. A @@ -120,7 +141,7 @@ impl Remote { return Err((e, true)); } let mut reply = String::new(); - match reader.read_line(&mut reply) { + match read_reply_line(reader, &mut reply, self.max_reply) { Ok(0) => { self.conn = None; Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"), true)) @@ -430,6 +451,37 @@ mod tests { use std::net::TcpListener; use std::sync::Arc; + /// A peer squatting on the control port can't make the client buffer an endless reply line (#1925). + #[test] + fn an_overlong_reply_is_refused_without_buffering_it() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + // No newline: the reply never ends. A bounded client gives up after the limit. + let _ = conn.write_all(&[b'x'; 4096]); + let _ = conn.flush(); + std::thread::sleep(Duration::from_millis(500)); + }); + let mut remote = Remote::connect(&addr).unwrap(); + remote.max_reply = 100; + let err = remote.roundtrip("{}").unwrap_err().0; + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "{err}"); + assert!(err.to_string().contains("longer than 100 bytes"), "{err}"); + assert!(remote.conn.is_none()); + server.join().unwrap(); + } + + #[test] + fn replies_up_to_the_limit_are_read() { + let mut input: &[u8] = b"0123456789\nrest\n"; + let mut reply = String::new(); + assert_eq!(read_reply_line(&mut input, &mut reply, 10).unwrap(), 11); + assert_eq!(reply, "0123456789\n"); + } + /// A server that reads one request per connection and closes without replying must not see the request twice. #[test] fn lost_reply_to_a_mutation_is_not_resent() { diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 7fcf4849d..932d855e4 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod args; mod backend; pub mod control_token; mod server; diff --git a/crates/render/src/blocks.rs b/crates/render/src/blocks.rs index d8a62fddc..ad4689682 100644 --- a/crates/render/src/blocks.rs +++ b/crates/render/src/blocks.rs @@ -1,7 +1,7 @@ use super::draw::entity; use super::props::{resolve, resolve_common, transparency}; use super::text_entity::{mtext, place_text_entity}; -use super::*; +use super::{Builder, Color, Ctx, Entity, EntityKind, Ink, Lineweight, Mat3, Vec2}; pub(crate) fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { let layer = if e.common.layer == "0" { ctx.block_layer.clone() } else { Some(e.common.layer.clone()) }; diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index a2f0021a2..967a2962c 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -8,9 +8,9 @@ use cadcraft_doc::{DimKind, DimStyle, Dimension}; use cadcraft_fonts::{MTextParams, TextFont}; -use cadcraft_geom::{Arc, Vec2, ccw_sweep}; +use cadcraft_geom::Vec2; -use crate::units::{NumFormat, format_angle_with_separator, format_decimal, format_linear}; +use crate::units::{NumFormat, format_decimal, format_linear}; mod arrowheads; mod text; @@ -18,7 +18,6 @@ mod text; use arrowheads::tick; pub use arrowheads::{ArrowGeom, Arrowhead, arrow, arrowhead}; pub use text::{DimText, format_linear_value}; -use text::{apply_override, inspected, linear_text}; /// Most DIMBREAK gaps drawn per dimension. const MAX_BREAKS: usize = 1000; @@ -216,6 +215,75 @@ fn readable(a: f64) -> f64 { a } +struct DimConfig<'a> { + dimension: &'a Dimension, + style: &'a DimStyle, + font: &'a DimText, + scale: f64, + arrow_size: f64, + text_height: f64, + gap: f64, + tick_size: f64, + ticks: bool, + first_arrow: Arrowhead, + second_arrow: Arrowhead, + width_factor: f64, +} + +impl<'a> DimConfig<'a> { + fn new(dimension: &'a Dimension, style: &'a DimStyle, dimscale: f64, font: &'a DimText) -> Self { + let scale = style.effective_scale(dimscale); + let arrow_size = style.arrow_size * scale; + let text_height = if font.fixed_height > 0.0 { font.fixed_height } else { style.text_height * scale }; + let tick_size = style.tick_size * scale; + let first_arrow = if style.arrow_block1.is_empty() { &style.arrow_block } else { &style.arrow_block1 }; + let second_arrow = if style.arrow_block2.is_empty() { &style.arrow_block } else { &style.arrow_block2 }; + Self { + dimension, + style, + font, + scale, + arrow_size, + text_height, + gap: style.text_gap.abs() * scale, + tick_size, + ticks: tick_size > 0.0, + first_arrow: Arrowhead::parse(first_arrow), + second_arrow: Arrowhead::parse(second_arrow), + width_factor: if font.width_factor > 0.0 && font.width_factor.is_finite() { font.width_factor } else { 1.0 }, + } + } + + fn text_params(&self, at: Vec2, rotation: f64) -> MTextParams { + MTextParams { + attach: 5, + rotation, + font: self.font.font.clone(), + width_factor: self.width_factor, + oblique: self.font.oblique, + ..MTextParams::new(at, self.text_height) + } + } + + fn measure(&self, text: &str) -> (f64, f64) { + if text.is_empty() { + return (0.0, 0.0); + } + let layout = cadcraft_fonts::layout_mtext_with(text, &self.text_params(Vec2::ZERO, 0.0)); + (layout.bounds.width(), layout.bounds.height()) + } + + fn end(&self, geometry: &mut DimGeometry, kind: Arrowhead, tip: Vec2, direction: Vec2) -> f64 { + if self.ticks { + geometry.dim(tick(tip, direction, self.tick_size)); + 0.0 + } else { + geometry.add_arrow(arrowhead(kind, tip, direction, self.arrow_size)); + kind.inset(self.arrow_size) + } + } +} + /// Generate the geometry of a dimension with the stroke font. pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeometry { dimension_geometry_with(d, st, dimscale, &DimText { width_factor: 1.0, ..Default::default() }) @@ -223,470 +291,24 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo /// Generate the geometry of a dimension. The dimension's own overrides are applied to `st`. pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font: &DimText) -> DimGeometry { - let st = &st.with_overrides(&d.overrides); - let k = st.effective_scale(dimscale); - let asz = st.arrow_size * k; - let th = if font.fixed_height > 0.0 { font.fixed_height } else { st.text_height * k }; - let gap = st.text_gap.abs() * k; - let tsz = st.tick_size * k; - let ticks = tsz > 0.0; - let blk1 = Arrowhead::parse(if st.arrow_block1.is_empty() { &st.arrow_block } else { &st.arrow_block1 }); - let blk2 = Arrowhead::parse(if st.arrow_block2.is_empty() { &st.arrow_block } else { &st.arrow_block2 }); + let overridden = st.with_overrides(&d.overrides); + let config = DimConfig::new(d, &overridden, dimscale, font); + let st = config.style; + let th = config.text_height; + let gap = config.gap; let mut g = DimGeometry { text_height: th, ..Default::default() }; - // Measure the text: lay it out once at the origin, centred. - let wf = if font.width_factor > 0.0 && font.width_factor.is_finite() { font.width_factor } else { 1.0 }; - let params = |mt: Vec2, rot: f64| MTextParams { - attach: 5, - rotation: rot, - font: font.font.clone(), - width_factor: wf, - oblique: font.oblique, - ..MTextParams::new(mt, th) - }; - let measure = |text: &str| { - if text.is_empty() { - return (0.0, 0.0); - } - let l = cadcraft_fonts::layout_mtext_with(text, ¶ms(Vec2::ZERO, 0.0)); - (l.bounds.width(), l.bounds.height()) - }; + let params = |at: Vec2, rotation: f64| config.text_params(at, rotation); + let measure = |text: &str| config.measure(text); let mut text_angle = 0.0; let mut text_mid = d.text_mid.xy(); // Place an end marker: tick or arrowhead. Returns how far the dimension line stops short. - let end = |g: &mut DimGeometry, kind: Arrowhead, tip: Vec2, dir: Vec2| -> f64 { - if ticks { - g.dim(tick(tip, dir, tsz)); - 0.0 - } else { - g.add_arrow(arrowhead(kind, tip, dir, asz)); - kind.inset(asz) - } - }; match d.kind { - DimKind::Linear { .. } | DimKind::Aligned => { - let p1 = d.p13.xy(); - let p2 = d.p14.xy(); - let on = d.defpt.xy(); - let dir = match d.kind { - DimKind::Linear { rotation } => Vec2::from_angle(rotation), - _ => (p2 - p1).normalized(), - }; - let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir }; - // DIMEDIT Oblique: extension lines at their own angle (not along the dimension line). - let oblique = d.marks.oblique.map(Vec2::from_angle).filter(|e| e.is_finite() && e.cross(dir).abs() > 1e-6); - let foot = |p: Vec2| match oblique { - Some(e) => p + e * ((on - p).cross(dir) / e.cross(dir)), - None => on + dir * (p - on).dot(dir), - }; - let f1 = foot(p1); - let f2 = foot(p2); - // The measurement is along the dimension direction whatever the extension lines do. - let meas = (p2 - p1).dot(dir).abs(); - g.mtext = linear_text(meas, d, st, ""); - g.measurement = meas; - // From here on: the length drawn between the extension lines. - let meas = f1.dist(f2); - if !st.suppress_ext1 { - g.ext_n(ext_line(p1, f1, st, k), false); - } - if !st.suppress_ext2 { - g.ext_n(ext_line(p2, f2, st, k), true); - } - let u = (f2 - f1).normalized(); - let u = if u == Vec2::ZERO || !u.is_finite() { dir } else { u }; - let n = u.perp(); - let (tw, tht) = measure(&g.mtext); - let has_text = tw > 0.0; - let tick_like = ticks || (blk1.is_tick() && blk2.is_tick()); - let aw = if tick_like { 0.0 } else { asz }; - let arrows_fit = tick_like || meas > 2.0 * asz + 1e-9; - // Text orientation: inside / outside horizontal. - let along_ang = readable(u.angle()); - let extent = |ang: f64| { - // Half-extent of the text box projected on the dimension line. - let r = Vec2::from_angle(ang); - (tw * r.dot(u).abs() + tht * r.perp().dot(u).abs()) / 2.0 - }; - let inside_ang = if st.text_inside_horizontal { 0.0 } else { along_ang }; - // DIMTVP (with DIMTAD 0) lifts the text off the line once it is 0.7 text heights or more. - let tvp = if st.text_above == 0 && st.text_vertical_pos.is_finite() { st.text_vertical_pos } else { 0.0 }; - let centred = st.text_above == 0 && tvp.abs() < 0.7; - let text_need = if centred { 2.0 * (extent(inside_ang) + gap) } else { 2.0 * extent(inside_ang) }; - let fits = !has_text || meas >= text_need + 2.0 * aw; - let text_fits = !has_text || meas >= text_need; - let just = st.text_just.min(4); - // DIMATFIT / DIMTIX: what goes outside the extension lines when text and arrows - // don't both fit between them. Text the user placed, or over an extension line, is - // not part of the fit. - let (text_in, arrows_inside) = if d.user_text_pos || !has_text || just >= 3 { - (true, arrows_fit) - } else if fits { - (true, true) - } else if st.text_inside { - (true, false) - } else { - match st.fit { - 0 => (false, false), - 1 => (text_fits, false), - 2 => (false, arrows_fit), - _ if arrows_fit => (false, true), - _ => (text_fits, false), - } - }; - let text_outside = !text_in; - text_angle = if text_outside { if st.text_outside_horizontal { 0.0 } else { along_ang } } else { inside_ang }; - let half = extent(text_angle); - // DIMTMOVE 1 / 2: text that doesn't fit is moved off the dimension line, which stays put. - let moved = text_outside && st.text_move != 0; - // DIMSOXD: arrowheads that don't fit inside are left out. - let outside_arrows = !arrows_inside && !st.suppress_outside_arrows; - // Text centre along the dimension line. - let along_c = if text_outside { - f2 + u * (2.0 * asz.max(tsz) + gap + half) - } else { - match just { - 1 => f1 + u * (aw + gap + half + asz), - 2 => f2 - u * (aw + gap + half + asz), - _ => f1.mid(f2), - } - }; - // Perpendicular offset per DIMTAD. - let side_out = if (on - p1).dot(n) >= 0.0 { n } else { -n }; - let tr = Vec2::from_angle(text_angle); - let off_along = |m: Vec2| { - let glyph_extent = tw * tr.dot(m).abs() / 2.0 + tht * tr.perp().dot(m).abs() * if st.text_above == 1 { 0.6 } else { 0.5 }; - // Empirical #2004 match: 0.595× height with SHX and 0.601× with TrueType. - gap + glyph_extent - }; - let above = if text_angle.abs() < 1e-12 && n.y.abs() > 1e-9 { - if n.y > 0.0 { n } else { -n } - } else if text_angle.abs() < 1e-12 { - if n.x < 0.0 { n } else { -n } - } else if tr.perp().dot(n) >= 0.0 { - n - } else { - -n - }; - let offset = match st.text_above { - 0 => above * (tvp * th), - 2 => side_out * off_along(side_out), - 4 => -above * off_along(-above), - _ => above * off_along(above), - }; - if just >= 3 && !d.user_text_pos && has_text { - // Over an extension line, aligned with it. - let (p, f) = if just == 3 { (p1, f1) } else { (p2, f2) }; - let e = (f - p).normalized(); - let e = if e == Vec2::ZERO { side_out } else { e }; - text_angle = readable(e.angle()); - let outward = if just == 3 { -u } else { u }; - text_mid = f + e * (st.ext_extend * k + gap + tw / 2.0) + outward * (gap + tht / 2.0); - } else if moved { - // Over the middle of the line, clear of the arrowheads, on the side away from the - // measured points; with DIMTMOVE 1 it starts there and a leader joins it. - let mid = f1.mid(f2); - let lift = off_along(side_out) + 2.0 * asz.max(tsz); - text_mid = if st.text_move == 1 { mid + side_out * lift + u * half } else { mid + side_out * (lift + off_along(side_out)) }; - } else if !d.user_text_pos { - text_mid = along_c + offset; - } - // Dimension line. DIMSD1 / DIMSD2 leave out its first / second half (with the arrow). - let dle = if tick_like { st.dim_line_extend * k } else { 0.0 }; - let lo = if st.suppress_dim1 { meas / 2.0 } else { f64::NEG_INFINITY }; - let hi = if st.suppress_dim2 { meas / 2.0 } else { f64::INFINITY }; - let seg = |g: &mut DimGeometry, a: Vec2, b: Vec2| { - let (ta, tb) = ((a - f1).dot(u), (b - f1).dot(u)); - let (t0, t1) = (ta.min(tb).max(lo), ta.max(tb).min(hi)); - if t1 > t0 + 1e-12 { - g.dim(vec![f1 + u * t0, f1 + u * t1]); - } - }; - // The line between `a` and `b`, broken around centred text between the extension lines. - let inner = |g: &mut DimGeometry, a: Vec2, b: Vec2| { - if centred && has_text && !text_outside && just <= 2 && !d.user_text_pos { - let h = extent(text_angle) + gap; - let (t0, t1) = ((along_c - u * h - f1).dot(u), (along_c + u * h - f1).dot(u)); - if t0 > (a - f1).dot(u) + 1e-9 { - seg(g, a, f1 + u * t0); - } - if t1 < (b - f1).dot(u) - 1e-9 { - seg(g, f1 + u * t1, b); - } - } else { - seg(g, a, b); - } - }; - // Out to text outside the extension lines, from `from` along the line. - let to_text = |g: &mut DimGeometry, from: Vec2| { - let to = text_mid - offset - u * (half + gap); - if text_outside && (to - from).dot(u) > 0.0 { - seg(g, from, from + u * (to - from).dot(u)); - } - }; - if arrows_inside { - let i1 = if st.suppress_dim1 { 0.0 } else { end(&mut g, blk1, f1, -u) }; - let i2 = if st.suppress_dim2 { 0.0 } else { end(&mut g, blk2, f2, u) }; - inner(&mut g, f1 - u * dle + u * i1, f2 + u * dle - u * i2); - to_text(&mut g, f2); - } else { - let stub = asz * 2.0; - if outside_arrows { - if !st.suppress_dim1 { - end(&mut g, blk1, f1, u); - } - if !st.suppress_dim2 { - end(&mut g, blk2, f2, -u); - } - seg(&mut g, f1 - u * stub, f1); - seg(&mut g, f2, f2 + u * stub); - } - // DIMTOFL: the line between the extension lines as well. - if st.force_line_inside { - inner(&mut g, f1, f2); - } - to_text(&mut g, if outside_arrows { f2 + u * stub } else { f2 }); - } - // DIMTMOVE 1: a leader from the dimension line to text moved off it. - if (d.user_text_pos || moved) && st.text_move == 1 && has_text { - let base = f1.mid(f2); - let off = (text_mid - base).dot(n).abs(); - if off > tht / 2.0 + gap + 1e-9 { - let side = if (base - text_mid).dot(tr) >= 0.0 { tr } else { -tr }; - g.dim(vec![base, text_mid + side * (tw / 2.0 + gap)]); - } - } - // DIMJOGLINE: a zigzag in the dimension line, 1.5 text heights tall. - if let Some(j) = d.marks.jog_line { - let at = f1 + u * (j.xy() - f1).dot(u); - let h = 1.5 * th; - if at.is_finite() && h > 0.0 { - let r = h / 2.0; - g.cut(at, r, Some(LineRole::Dim)); - g.dim(vec![at - u * r, at - u * (r / 4.0) + n * (h / 2.0), at + u * (r / 4.0) - n * (h / 2.0), at + u * r]); - } - } - } - DimKind::Radius if d.marks.jogged.is_some() => 'jogged: { - // DIMJOGGED: from the arrowhead on the arc toward the centre, a jog at DIMJOGANG, - // then on to the overriding centre (parallel to the first part). - let center = d.defpt.xy(); - let tip = d.p15.xy(); - let r = center.dist(tip); - let j = d.marks.jogged.clone().unwrap_or_default(); - let (o, jp) = (j.center.xy(), j.jog.xy()); - g.mtext = linear_text(r, d, st, "R"); - // A NaN or infinite radius leaves no line to draw (and NaN is no bound for `clamp`). - if !r.is_finite() { - break 'jogged; - } - let (tw, _) = measure(&g.mtext); - let u = (tip - center).normalized(); - let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; - let n = u.perp(); - let h = (o - center).dot(n); - let ang = if st.jog_angle.is_finite() { st.jog_angle.clamp(5.0, 90.0) } else { 45.0 }.to_radians(); - let run = h.abs() / ang.tan().max(1e-6); - let tj = (jp - center).dot(u).clamp(0.0, r); - let (j1, j2) = (center + u * (tj + run / 2.0), center + u * (tj - run / 2.0) + n * h); - g.dim(vec![tip, j1, j2, o]); - end(&mut g, blk2, tip, u); - let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) }; - if tpos.dist(center) > r { - g.dim(vec![tip, tpos]); - } - text_angle = if st.text_outside_horizontal { 0.0 } else { readable(u.angle()) }; - if !d.user_text_pos { - let x = Vec2::from_angle(text_angle); - let x = if x.dot(u) >= 0.0 { x } else { -x }; - text_mid = tpos + x * (tw / 2.0 + gap); - } - } - DimKind::Radius | DimKind::Diameter => { - let a = d.defpt.xy(); - let b = d.p15.xy(); - let radius = matches!(d.kind, DimKind::Radius); - let (center, r) = if radius { (a, a.dist(b)) } else { (a.mid(b), a.dist(b) / 2.0) }; - let meas = if radius { r } else { 2.0 * r }; - g.mtext = linear_text(meas, d, st, if radius { "R" } else { "%%c" }); - g.measurement = meas; - let (tw, tht) = measure(&g.mtext); - let u = (b - center).normalized(); - let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; - let tip = center + u * r; - let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) }; - // Text placed inside the circle (a dimension line location picked inside): the text - // sits on the dimension line, which runs from the text out to the arrowhead (for a - // diameter it spans the circle, broken around the text). DIMTIH turns it horizontal. - let inside = d.user_text_pos && text_mid.dist(center) < r; - let horizontal = if inside { st.text_inside_horizontal } else { st.text_outside_horizontal }; - if inside { - let tr = Vec2::from_angle(if horizontal { 0.0 } else { readable(u.angle()) }); - let half = if tw > 0.0 { (tw * tr.dot(u).abs() + tht * tr.perp().dot(u).abs()) / 2.0 + gap } else { 0.0 }; - let start = if radius { center } else { center - u * r }; - let (t0, t1) = ((text_mid - start).dot(u) - half, (text_mid - start).dot(u) + half); - if !radius { - if t0 > 0.0 { - g.dim(vec![start, start + u * t0]); - } - end(&mut g, blk1, start, -u); - } - if t1 < tip.dist(start) { - g.dim(vec![start + u * t1.max(0.0), tip]); - } - } else { - // Text outside: a diameter line spans the circle and a radius line runs in to the - // centre only with DIMTOFL; otherwise just the leader to the text. - if st.force_line_inside { - let start = if radius { center } else { center - u * r }; - g.dim(vec![start, tip]); - if !radius { - end(&mut g, blk1, start, -u); - } - } - // Text the user placed: the leader stops short of it by the offset the default - // placement leaves between leader end and text. - let lead = if !d.user_text_pos { - tpos - } else if horizontal { - text_mid - Vec2::new(tw / 2.0 + gap, 0.0) * if u.x >= 0.0 { 1.0 } else { -1.0 } - } else { - text_mid - u * (tw / 2.0 + gap) - }; - g.dim(vec![tip, lead]); - } - end(&mut g, blk2, tip, u); - if horizontal { - text_angle = 0.0; - let sx = if u.x >= 0.0 { 1.0 } else { -1.0 }; - if !d.user_text_pos { - text_mid = tpos + Vec2::new(tw / 2.0 + gap, 0.0) * sx; - } - } else { - text_angle = readable(u.angle()); - if !d.user_text_pos { - text_mid = tpos + u * (tw / 2.0 + gap); - } - } - // The DIMCEN mark is drawn only when no dimension line runs inside the circle (text - // outside without DIMTOFL), never with a line through the centre. - let line_outside = !st.force_line_inside && tpos.dist(center) >= r; - if line_outside && st.center_mark != 0.0 && st.center_mark.is_finite() { - let c = st.center_mark.abs() * k; - g.ext(vec![center - Vec2::X * c, center + Vec2::X * c]); - g.ext(vec![center - Vec2::Y * c, center + Vec2::Y * c]); - if st.center_mark < 0.0 && r > 2.0 * c { - // Centre lines out past the circle. - for dv in [Vec2::X, -Vec2::X, Vec2::Y, -Vec2::Y] { - g.ext(vec![center + dv * (2.0 * c), center + dv * (r + c)]); - } - } - } - } - DimKind::Angular | DimKind::Angular3P => { - // Angular3P: p15 vertex, p13/p14 on the legs, defpt on the arc. - // Angular (2 lines): lines p13-p14 and defpt-p15; p16 on the arc. - let (vertex, a1, a2, arc_pt) = if matches!(d.kind, DimKind::Angular3P) { - (d.p15.xy(), d.p13.xy(), d.p14.xy(), d.defpt.xy()) - } else { - let v = cadcraft_geom::line_line_infinite(d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.p15.xy()).map(|x| x.0).unwrap_or(d.p14.xy()); - (v, d.p14.xy(), d.p15.xy(), d.p16.xy()) - }; - let r = vertex.dist(arc_pt).max(1e-9); - let s = vertex.angle_to(a1); - let e = vertex.angle_to(a2); - let arc = - if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { Arc::new(vertex, r, s, e) } else { Arc::new(vertex, r, e, s) }; - let sweep = ccw_sweep(arc.start, arc.end); - g.measurement = sweep; - // DIMAZIN 1 / 2 suppress leading / trailing zeros (DIMZIN's bits 4 / 8). - let azin = (st.angular_zero_suppression & 3) * 4; - let txt = format_angle_with_separator(sweep, st.angular_unit, st.angular_decimals, azin, &st.decimal_separator); - // DIMPOST is a linear-dimension prefix/suffix; angles have their own format only. - g.mtext = inspected(apply_override(&txt, d), d); - let mut pts = Vec::new(); - arc.tessellate(r * 1e-3, &mut pts); - g.dim(pts); - let sp = arc.start_point(); - let ep = arc.end_point(); - // The arc's ends with the outward tangents there, paired with leg 1 and leg 2: when - // the arc location is outside the leg 1 → leg 2 sweep the arc runs from leg 2 to - // leg 1, and each leg keeps its own extension line, DIMSE1/2 and DIMBLK1/2. - let (start, finish) = ((sp, -(sp - vertex).perp().normalized()), (ep, (ep - vertex).perp().normalized())); - let ((p1, dir1), (p2, dir2)) = - if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { (start, finish) } else { (finish, start) }; - if !st.suppress_ext1 { - g.ext_n(ext_line(vertex + (p1 - vertex).normalized() * vertex.dist(a1).min(r), p1, st, k), false); - } - if !st.suppress_ext2 { - g.ext_n(ext_line(vertex + (p2 - vertex).normalized() * vertex.dist(a2).min(r), p2, st, k), true); - } - end(&mut g, blk1, p1, dir1); - end(&mut g, blk2, p2, dir2); - let (tw, tht) = measure(&g.mtext); - let mid = arc.mid_point(); - let radial = (mid - vertex).normalized(); - text_angle = if st.text_inside_horizontal { 0.0 } else { readable(radial.perp().angle()) }; - if !d.user_text_pos { - let tr = Vec2::from_angle(text_angle); - let off = gap + (tw * tr.dot(radial).abs() + tht * tr.perp().dot(radial).abs()) / 2.0; - text_mid = mid + radial * off; - } - } - DimKind::Ordinate { x_type } => { - let feature = d.p13.xy(); - let leader_end = d.p14.xy(); - let origin = d.defpt.xy(); - let meas = if x_type { (feature.x - origin.x).abs() } else { (feature.y - origin.y).abs() }; - g.mtext = linear_text(meas, d, st, ""); - g.measurement = meas; - let (tw, _) = measure(&g.mtext); - g.dim(vec![feature, leader_end]); - if x_type { - text_angle = std::f64::consts::FRAC_PI_2; - } - if !d.user_text_pos { - let dirv = (leader_end - feature).normalized(); - let dirv = if dirv == Vec2::ZERO { if x_type { Vec2::Y } else { Vec2::X } } else { dirv }; - text_mid = leader_end + dirv * (tw / 2.0 + gap); - } - } - DimKind::ArcLength => { - let c = d.p15.xy(); - let r = c.dist(d.p13.xy()); - let arc = Arc::new(c, c.dist(d.defpt.xy()).max(1e-9), c.angle_to(d.p13.xy()), c.angle_to(d.p14.xy())); - let meas = r * arc.sweep(); - // DIMARCSYM: the arc symbol before the text (0), over it (1) or none (2). - g.mtext = linear_text(meas, d, st, if st.arc_symbol == 0 { "⌒" } else { "" }); - g.measurement = meas; - let mut pts = Vec::new(); - arc.tessellate(arc.radius * 1e-3, &mut pts); - g.dim(pts); - for (i, (p, q)) in [(d.p13.xy(), arc.start_point()), (d.p14.xy(), arc.end_point())].into_iter().enumerate() { - g.ext_n(ext_line(p, q, st, k), i == 1); - } - end(&mut g, blk1, arc.start_point(), -(arc.start_point() - c).perp().normalized()); - end(&mut g, blk2, arc.end_point(), (arc.end_point() - c).perp().normalized()); - let m = arc.mid_point(); - let radial = (m - c).normalized(); - text_angle = if st.text_inside_horizontal { 0.0 } else { readable(radial.perp().angle()) }; - if !d.user_text_pos { - text_mid = m + radial * (gap + th / 2.0); - } - if st.arc_symbol == 1 { - // A small arc over the text. - let (tw, tht) = measure(&g.mtext); - let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp()); - let half = (tw / 2.0).max(th / 2.0); - let base = text_mid + y * (tht / 2.0 + gap / 2.0); - let sym: Vec = (0..=12) - .map(|i| { - let t = f64::from(i) / 6.0 - 1.0; - base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35) - }) - .collect(); - g.text_mark(sym); - } - } + DimKind::Linear { .. } | DimKind::Aligned => linear::draw_linear(&config, &mut g, &mut text_angle, &mut text_mid), + DimKind::Radius if d.marks.jogged.is_some() => variants::draw_jogged_radius(&config, &mut g, &mut text_angle, &mut text_mid), + DimKind::Radius | DimKind::Diameter => variants::draw_radius_or_diameter(&config, &mut g, &mut text_angle, &mut text_mid), + DimKind::Angular | DimKind::Angular3P => variants::draw_angular(&config, &mut g, &mut text_angle, &mut text_mid), + DimKind::Ordinate { x_type } => variants::draw_ordinate(&config, &mut g, &mut text_angle, &mut text_mid, x_type), + DimKind::ArcLength => variants::draw_arc_length(&config, &mut g, &mut text_angle, &mut text_mid), } // DIMTEDIT Angle: an explicit text rotation wins. if d.text_rotation != 0.0 && d.text_rotation.is_finite() { @@ -754,6 +376,8 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font g } +mod linear; +mod variants; /// `%%` codes in the plain value become their characters (`%%c` → ⌀, `%%p` → ±, `%%d` → °). fn decode_value(s: &str) -> String { cadcraft_fonts::decode_controls(s).into_iter().map(|(c, _, _)| c).collect() diff --git a/crates/render/src/dim/arrowheads.rs b/crates/render/src/dim/arrowheads.rs index 5940a27e9..c9c607a48 100644 --- a/crates/render/src/dim/arrowheads.rs +++ b/crates/render/src/dim/arrowheads.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Vec2; /// The arrowheads drawn in code (DIMBLK names; our own geometry). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/render/src/dim/linear.rs b/crates/render/src/dim/linear.rs new file mode 100644 index 000000000..da1b2a746 --- /dev/null +++ b/crates/render/src/dim/linear.rs @@ -0,0 +1,267 @@ +use super::text::linear_text; +use super::{DimConfig, DimGeometry, DimKind, LineRole, Vec2, ext_line, readable}; + +struct LinearDimState { + p1: Vec2, + p2: Vec2, + f1: Vec2, + f2: Vec2, + u: Vec2, + n: Vec2, + measurement: f64, + text_width: f64, + text_height: f64, + has_text: bool, + tick_like: bool, + arrow_width: f64, + arrows_inside: bool, + text_outside: bool, + just: u8, + centred: bool, + moved: bool, + outside_arrows: bool, + angle: f64, + half_extent: f64, + along_center: Vec2, + side_out: Vec2, + text_axis: Vec2, + offset: Vec2, + text_mid: Vec2, +} + +pub(super) fn draw_linear(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2) { + let mut state = prepare_linear(config, g, *text_mid_out); + position_linear_text(config, &mut state); + draw_linear_lines(config, g, &state); + *text_angle_out = state.angle; + *text_mid_out = state.text_mid; +} + +fn prepare_linear(config: &DimConfig<'_>, g: &mut DimGeometry, text_mid: Vec2) -> LinearDimState { + let d = config.dimension; + let st = config.style; + let p1 = d.p13.xy(); + let p2 = d.p14.xy(); + let on = d.defpt.xy(); + let direction = match d.kind { + DimKind::Linear { rotation } => Vec2::from_angle(rotation), + _ => (p2 - p1).normalized(), + }; + let direction = if direction == Vec2::ZERO || !direction.is_finite() { Vec2::X } else { direction }; + let oblique = d.marks.oblique.map(Vec2::from_angle).filter(|axis| axis.is_finite() && axis.cross(direction).abs() > 1e-6); + let foot = |point: Vec2| match oblique { + Some(axis) => point + axis * ((on - point).cross(direction) / axis.cross(direction)), + None => on + direction * (point - on).dot(direction), + }; + let (f1, f2) = (foot(p1), foot(p2)); + let raw_measurement = (p2 - p1).dot(direction).abs(); + g.mtext = linear_text(raw_measurement, d, st, ""); + g.measurement = raw_measurement; + let measurement = f1.dist(f2); + if !st.suppress_ext1 { + g.ext_n(ext_line(p1, f1, st, config.scale), false); + } + if !st.suppress_ext2 { + g.ext_n(ext_line(p2, f2, st, config.scale), true); + } + let dimension_axis = (f2 - f1).normalized(); + let u = if dimension_axis == Vec2::ZERO || !dimension_axis.is_finite() { direction } else { dimension_axis }; + let n = u.perp(); + let (text_width, text_height) = config.measure(&g.mtext); + let has_text = text_width > 0.0; + let tick_like = config.ticks || (config.first_arrow.is_tick() && config.second_arrow.is_tick()); + let arrow_width = if tick_like { 0.0 } else { config.arrow_size }; + let arrows_fit = tick_like || measurement > 2.0 * config.arrow_size + 1e-9; + let along_angle = readable(u.angle()); + let extent = |angle: f64| { + let axis = Vec2::from_angle(angle); + (text_width * axis.dot(u).abs() + text_height * axis.perp().dot(u).abs()) / 2.0 + }; + let inside_angle = if st.text_inside_horizontal { 0.0 } else { along_angle }; + let tvp = if st.text_above == 0 && st.text_vertical_pos.is_finite() { st.text_vertical_pos } else { 0.0 }; + let centred = st.text_above == 0 && tvp.abs() < 0.7; + let text_need = if centred { 2.0 * (extent(inside_angle) + config.gap) } else { 2.0 * extent(inside_angle) }; + let fits = !has_text || measurement >= text_need + 2.0 * arrow_width; + let text_fits = !has_text || measurement >= text_need; + let just = st.text_just.min(4); + let (text_inside, arrows_inside) = if d.user_text_pos || !has_text || just >= 3 { + (true, arrows_fit) + } else if fits { + (true, true) + } else if st.text_inside { + (true, false) + } else { + match st.fit { + 0 => (false, false), + 1 => (text_fits, false), + 2 => (false, arrows_fit), + _ if arrows_fit => (false, true), + _ => (text_fits, false), + } + }; + let text_outside = !text_inside; + LinearDimState { + p1, + p2, + f1, + f2, + u, + n, + measurement, + text_width, + text_height, + has_text, + tick_like, + arrow_width, + arrows_inside, + text_outside, + just, + centred, + moved: text_outside && st.text_move != 0, + outside_arrows: !arrows_inside && !st.suppress_outside_arrows, + angle: 0.0, + half_extent: 0.0, + along_center: f1.mid(f2), + side_out: n, + text_axis: Vec2::X, + offset: Vec2::ZERO, + text_mid, + } +} + +fn position_linear_text(config: &DimConfig<'_>, state: &mut LinearDimState) { + let st = config.style; + let along_angle = readable(state.u.angle()); + let extent = |angle: f64| { + let axis = Vec2::from_angle(angle); + (state.text_width * axis.dot(state.u).abs() + state.text_height * axis.perp().dot(state.u).abs()) / 2.0 + }; + let inside_angle = if st.text_inside_horizontal { 0.0 } else { along_angle }; + state.angle = if state.text_outside { if st.text_outside_horizontal { 0.0 } else { along_angle } } else { inside_angle }; + state.half_extent = extent(state.angle); + state.along_center = if state.text_outside { + state.f2 + state.u * (2.0 * config.arrow_size.max(config.tick_size) + config.gap + state.half_extent) + } else { + match state.just { + 1 => state.f1 + state.u * (state.arrow_width + config.gap + state.half_extent + config.arrow_size), + 2 => state.f2 - state.u * (state.arrow_width + config.gap + state.half_extent + config.arrow_size), + _ => state.f1.mid(state.f2), + } + }; + state.side_out = if (config.dimension.defpt.xy() - state.p1).dot(state.n) >= 0.0 { state.n } else { -state.n }; + state.text_axis = Vec2::from_angle(state.angle); + let clearance = |axis: Vec2, across: f64| { + config.gap + state.text_width * state.text_axis.dot(axis).abs() / 2.0 + state.text_height * state.text_axis.perp().dot(axis).abs() * across + }; + // Empirical #2004 match: 0.595× height with SHX and 0.601× with TrueType. + let off_along = |axis: Vec2| clearance(axis, if st.text_above == 1 { 0.6 } else { 0.5 }); + let above = if state.angle.abs() < 1e-12 && state.n.y.abs() > 1e-9 { + if state.n.y > 0.0 { state.n } else { -state.n } + } else if state.angle.abs() < 1e-12 { + if state.n.x < 0.0 { state.n } else { -state.n } + } else if state.text_axis.perp().dot(state.n) >= 0.0 { + state.n + } else { + -state.n + }; + state.offset = match st.text_above { + 0 => above * (if st.text_vertical_pos.is_finite() { st.text_vertical_pos } else { 0.0 } * config.text_height), + 2 => state.side_out * off_along(state.side_out), + 4 => -above * off_along(-above), + _ => above * off_along(above), + }; + if state.just >= 3 && !config.dimension.user_text_pos && state.has_text { + let (point, foot) = if state.just == 3 { (state.p1, state.f1) } else { (state.p2, state.f2) }; + let extension_axis = (foot - point).normalized(); + let extension_axis = if extension_axis == Vec2::ZERO { state.side_out } else { extension_axis }; + state.angle = readable(extension_axis.angle()); + let outward = if state.just == 3 { -state.u } else { state.u }; + state.text_mid = foot + + extension_axis * (st.ext_extend * config.scale + config.gap + state.text_width / 2.0) + + outward * (config.gap + state.text_height / 2.0); + } else if state.moved { + let mid = state.f1.mid(state.f2); + // Half the text height either way, whatever DIMTAD says (#2222). + let clear = clearance(state.side_out, 0.5); + let lift = clear + 2.0 * config.arrow_size.max(config.tick_size); + state.text_mid = + if st.text_move == 1 { mid + state.side_out * lift + state.u * state.half_extent } else { mid + state.side_out * (lift + clear) }; + } else if !config.dimension.user_text_pos { + state.text_mid = state.along_center + state.offset; + } +} + +fn draw_linear_lines(config: &DimConfig<'_>, g: &mut DimGeometry, state: &LinearDimState) { + let d = config.dimension; + let st = config.style; + let (u, n) = (state.u, state.n); + let dle = if state.tick_like { st.dim_line_extend * config.scale } else { 0.0 }; + let lo = if st.suppress_dim1 { state.measurement / 2.0 } else { f64::NEG_INFINITY }; + let hi = if st.suppress_dim2 { state.measurement / 2.0 } else { f64::INFINITY }; + let seg = |geometry: &mut DimGeometry, a: Vec2, b: Vec2| { + let (ta, tb) = ((a - state.f1).dot(u), (b - state.f1).dot(u)); + let (t0, t1) = (ta.min(tb).max(lo), ta.max(tb).min(hi)); + if t1 > t0 + 1e-12 { + geometry.dim(vec![state.f1 + u * t0, state.f1 + u * t1]); + } + }; + let inner = |geometry: &mut DimGeometry, a: Vec2, b: Vec2| { + if state.centred && state.has_text && !state.text_outside && state.just <= 2 && !d.user_text_pos { + let half = state.half_extent + config.gap; + let (t0, t1) = ((state.along_center - u * half - state.f1).dot(u), (state.along_center + u * half - state.f1).dot(u)); + if t0 > (a - state.f1).dot(u) + 1e-9 { + seg(geometry, a, state.f1 + u * t0); + } + if t1 < (b - state.f1).dot(u) - 1e-9 { + seg(geometry, state.f1 + u * t1, b); + } + } else { + seg(geometry, a, b); + } + }; + let to_text = |geometry: &mut DimGeometry, from: Vec2| { + let to = state.text_mid - state.offset - u * (state.half_extent + config.gap); + if state.text_outside && (to - from).dot(u) > 0.0 { + seg(geometry, from, from + u * (to - from).dot(u)); + } + }; + if state.arrows_inside { + let first_inset = if st.suppress_dim1 { 0.0 } else { config.end(g, config.first_arrow, state.f1, -u) }; + let second_inset = if st.suppress_dim2 { 0.0 } else { config.end(g, config.second_arrow, state.f2, u) }; + inner(g, state.f1 - u * dle + u * first_inset, state.f2 + u * dle - u * second_inset); + to_text(g, state.f2); + } else { + let stub = config.arrow_size * 2.0; + if state.outside_arrows { + if !st.suppress_dim1 { + config.end(g, config.first_arrow, state.f1, u); + } + if !st.suppress_dim2 { + config.end(g, config.second_arrow, state.f2, -u); + } + seg(g, state.f1 - u * stub, state.f1); + seg(g, state.f2, state.f2 + u * stub); + } + if st.force_line_inside { + inner(g, state.f1, state.f2); + } + to_text(g, if state.outside_arrows { state.f2 + u * stub } else { state.f2 }); + } + if (d.user_text_pos || state.moved) && st.text_move == 1 && state.has_text { + let base = state.f1.mid(state.f2); + let offset = (state.text_mid - base).dot(n).abs(); + if offset > state.text_height / 2.0 + config.gap + 1e-9 { + let side = if (base - state.text_mid).dot(state.text_axis) >= 0.0 { state.text_axis } else { -state.text_axis }; + g.dim(vec![base, state.text_mid + side * (state.text_width / 2.0 + config.gap)]); + } + } + if let Some(jog) = d.marks.jog_line { + let at = state.f1 + u * (jog.xy() - state.f1).dot(u); + let height = 1.5 * config.text_height; + if at.is_finite() && height > 0.0 { + let radius = height / 2.0; + g.cut(at, radius, Some(LineRole::Dim)); + g.dim(vec![at - u * radius, at - u * (radius / 4.0) + n * (height / 2.0), at + u * (radius / 4.0) - n * (height / 2.0), at + u * radius]); + } + } +} diff --git a/crates/render/src/dim/tests.rs b/crates/render/src/dim/tests.rs index 4f9143874..fe3b3c74e 100644 --- a/crates/render/src/dim/tests.rs +++ b/crates/render/src/dim/tests.rs @@ -153,6 +153,19 @@ fn text_above_and_centred() { assert!(outside2.text_pos.y < 5.0); } +#[test] +fn dimtvp_uses_the_dimension_text_height() { + let d = lin((0.0, 0.0), (10.0, 0.0), (0.0, 5.0), 0.0); + let style = st_with(|s| { + s.text_above = 0; + s.text_vertical_pos = 2.0; + s.text_height = 0.18; + }); + let font = DimText { fixed_height: 0.8, width_factor: 1.0, ..Default::default() }; + let g = dimension_geometry_with(&d, &style, 1.0, &font); + assert!((g.text_pos.y - 6.6).abs() < 1e-6, "text y={} height={}", g.text_pos.y, g.text_height); +} + #[test] fn dimtad_one_uses_autocad_text_clearance() { let d = lin((0.0, 0.10), (0.0, 0.70), (0.30, 0.40), std::f64::consts::FRAC_PI_2); @@ -220,6 +233,29 @@ fn text_that_does_not_fit_follows_dimtmove_2() { assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.x <= 0.5 + 1e-9 && p.y >= 5.0 - 1e-9), "no leader, no line out to the text"); } +#[test] +fn dimtmove_2_text_clearance_matches_autocad() { + // #2222: the 0.10 dimension of #1998 in the #2004 1:50 style. AutoCAD 2026 recomputes the + // moved text at about 0.358 from the dimension line (0.658214, 0.05). + let st = st_with(|s| { + s.scale = 50.0; + s.text_height = 0.0025; + s.text_gap = 0.0008; + s.arrow_size = 0.0015; + s.text_above = 1; + s.text_inside_horizontal = false; + s.text_outside_horizontal = false; + s.decimals = 2; + s.text_move = 2; + }); + let d = lin((0.0, 0.0), (0.0, 0.10), (0.30, 0.0), std::f64::consts::FRAC_PI_2); + let g = dimension_geometry(&d, &st, 50.0); + assert!(g.text_pos.dist(Vec2::new(0.658214, 0.05)) < 0.005, "vertical: {:?}", g.text_pos); + let d = lin((0.0, 0.0), (0.10, 0.0), (0.0, -0.30), 0.0); + let g = dimension_geometry(&d, &st, 50.0); + assert!(g.text_pos.dist(Vec2::new(0.05, -0.658214)) < 0.005, "horizontal: {:?}", g.text_pos); +} + #[test] fn text_that_does_not_fit_follows_dimtmove_1() { // Moved off the line with a leader from the middle of the dimension line. diff --git a/crates/render/src/dim/text.rs b/crates/render/src/dim/text.rs index fb3d6eda7..91e6b568c 100644 --- a/crates/render/src/dim/text.rs +++ b/crates/render/src/dim/text.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DimStyle, Dimension, NumFormat, TextFont, format_decimal, format_linear}; /// Format a linear measurement per the style (plain text). pub fn format_linear_value(v: f64, st: &DimStyle) -> String { diff --git a/crates/render/src/dim/variants.rs b/crates/render/src/dim/variants.rs new file mode 100644 index 000000000..948483a54 --- /dev/null +++ b/crates/render/src/dim/variants.rs @@ -0,0 +1,292 @@ +use super::text::{apply_override, inspected, linear_text}; +use super::{Arrowhead, DimConfig, DimGeometry, DimKind, Vec2, ext_line, readable}; +use crate::units::format_angle_with_separator; +use cadcraft_geom::{Arc, ccw_sweep}; + +pub(super) fn draw_jogged_radius(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2) { + let d = config.dimension; + let st = config.style; + let asz = config.arrow_size; + let gap = config.gap; + let tsz = config.tick_size; + let blk2 = config.second_arrow; + let measure = |text: &str| config.measure(text); + let end = |geometry: &mut DimGeometry, kind: Arrowhead, tip: Vec2, direction: Vec2| config.end(geometry, kind, tip, direction); + let mut text_angle = 0.0; + let mut text_mid = *text_mid_out; + 'jogged: { + // DIMJOGGED: from the arrowhead on the arc toward the centre, a jog at DIMJOGANG, + // then on to the overriding centre (parallel to the first part). + let center = d.defpt.xy(); + let tip = d.p15.xy(); + let r = center.dist(tip); + let j = d.marks.jogged.clone().unwrap_or_default(); + let (o, jp) = (j.center.xy(), j.jog.xy()); + g.mtext = linear_text(r, d, st, "R"); + // A NaN or infinite radius leaves no line to draw (and NaN is no bound for `clamp`). + if !r.is_finite() { + break 'jogged; + } + g.measurement = r; + let (tw, _) = measure(&g.mtext); + let u = (tip - center).normalized(); + let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; + let n = u.perp(); + let h = (o - center).dot(n); + let ang = if st.jog_angle.is_finite() { st.jog_angle.clamp(5.0, 90.0) } else { 45.0 }.to_radians(); + let run = h.abs() / ang.tan().max(1e-6); + let tj = (jp - center).dot(u).clamp(0.0, r); + let (j1, j2) = (center + u * (tj + run / 2.0), center + u * (tj - run / 2.0) + n * h); + g.dim(vec![tip, j1, j2, o]); + end(g, blk2, tip, u); + let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) }; + if tpos.dist(center) > r { + g.dim(vec![tip, tpos]); + } + text_angle = if st.text_outside_horizontal { 0.0 } else { readable(u.angle()) }; + if !d.user_text_pos { + let x = Vec2::from_angle(text_angle); + let x = if x.dot(u) >= 0.0 { x } else { -x }; + text_mid = tpos + x * (tw / 2.0 + gap); + } + } + *text_angle_out = text_angle; + *text_mid_out = text_mid; +} + +pub(super) fn draw_radius_or_diameter(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2) { + let d = config.dimension; + let st = config.style; + let k = config.scale; + let asz = config.arrow_size; + let gap = config.gap; + let tsz = config.tick_size; + let blk1 = config.first_arrow; + let blk2 = config.second_arrow; + let measure = |text: &str| config.measure(text); + let end = |geometry: &mut DimGeometry, kind: Arrowhead, tip: Vec2, direction: Vec2| config.end(geometry, kind, tip, direction); + let text_angle: f64; + let mut text_mid = *text_mid_out; + + let a = d.defpt.xy(); + let b = d.p15.xy(); + let radius = matches!(d.kind, DimKind::Radius); + let (center, r) = if radius { (a, a.dist(b)) } else { (a.mid(b), a.dist(b) / 2.0) }; + let meas = if radius { r } else { 2.0 * r }; + g.mtext = linear_text(meas, d, st, if radius { "R" } else { "%%c" }); + g.measurement = meas; + let (tw, tht) = measure(&g.mtext); + let u = (b - center).normalized(); + let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; + let tip = center + u * r; + let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) }; + // Text placed inside the circle (a dimension line location picked inside): the text + // sits on the dimension line, which runs from the text out to the arrowhead (for a + // diameter it spans the circle, broken around the text). DIMTIH turns it horizontal. + let inside = d.user_text_pos && text_mid.dist(center) < r; + let horizontal = if inside { st.text_inside_horizontal } else { st.text_outside_horizontal }; + if inside { + let tr = Vec2::from_angle(if horizontal { 0.0 } else { readable(u.angle()) }); + let half = if tw > 0.0 { (tw * tr.dot(u).abs() + tht * tr.perp().dot(u).abs()) / 2.0 + gap } else { 0.0 }; + let start = if radius { center } else { center - u * r }; + let (t0, t1) = ((text_mid - start).dot(u) - half, (text_mid - start).dot(u) + half); + if !radius { + if t0 > 0.0 { + g.dim(vec![start, start + u * t0]); + } + end(g, blk1, start, -u); + } + if t1 < tip.dist(start) { + g.dim(vec![start + u * t1.max(0.0), tip]); + } + } else { + // Text outside: a diameter line spans the circle and a radius line runs in to the + // centre only with DIMTOFL; otherwise just the leader to the text. + if st.force_line_inside { + let start = if radius { center } else { center - u * r }; + g.dim(vec![start, tip]); + if !radius { + end(g, blk1, start, -u); + } + } + // Text the user placed: the leader stops short of it by the offset the default + // placement leaves between leader end and text. + let lead = if !d.user_text_pos { + tpos + } else if horizontal { + text_mid - Vec2::new(tw / 2.0 + gap, 0.0) * if u.x >= 0.0 { 1.0 } else { -1.0 } + } else { + text_mid - u * (tw / 2.0 + gap) + }; + g.dim(vec![tip, lead]); + } + end(g, blk2, tip, u); + if horizontal { + text_angle = 0.0; + let sx = if u.x >= 0.0 { 1.0 } else { -1.0 }; + if !d.user_text_pos { + text_mid = tpos + Vec2::new(tw / 2.0 + gap, 0.0) * sx; + } + } else { + text_angle = readable(u.angle()); + if !d.user_text_pos { + text_mid = tpos + u * (tw / 2.0 + gap); + } + } + // The DIMCEN mark is drawn only when no dimension line runs inside the circle (text + // outside without DIMTOFL), never with a line through the centre. + let line_outside = !st.force_line_inside && tpos.dist(center) >= r; + if line_outside && st.center_mark != 0.0 && st.center_mark.is_finite() { + let c = st.center_mark.abs() * k; + g.ext(vec![center - Vec2::X * c, center + Vec2::X * c]); + g.ext(vec![center - Vec2::Y * c, center + Vec2::Y * c]); + if st.center_mark < 0.0 && r > 2.0 * c { + // Centre lines out past the circle. + for dv in [Vec2::X, -Vec2::X, Vec2::Y, -Vec2::Y] { + g.ext(vec![center + dv * (2.0 * c), center + dv * (r + c)]); + } + } + } + *text_angle_out = text_angle; + *text_mid_out = text_mid; +} + +pub(super) fn draw_angular(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2) { + let d = config.dimension; + let st = config.style; + let k = config.scale; + let gap = config.gap; + let blk1 = config.first_arrow; + let blk2 = config.second_arrow; + let measure = |text: &str| config.measure(text); + let end = |geometry: &mut DimGeometry, kind: Arrowhead, tip: Vec2, direction: Vec2| config.end(geometry, kind, tip, direction); + let mut text_mid = *text_mid_out; + + // Angular3P: p15 vertex, p13/p14 on the legs, defpt on the arc. + // Angular (2 lines): lines p13-p14 and defpt-p15; p16 on the arc. + let (vertex, a1, a2, arc_pt) = if matches!(d.kind, DimKind::Angular3P) { + (d.p15.xy(), d.p13.xy(), d.p14.xy(), d.defpt.xy()) + } else { + let v = cadcraft_geom::line_line_infinite(d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.p15.xy()).map(|x| x.0).unwrap_or(d.p14.xy()); + (v, d.p14.xy(), d.p15.xy(), d.p16.xy()) + }; + let r = vertex.dist(arc_pt).max(1e-9); + let s = vertex.angle_to(a1); + let e = vertex.angle_to(a2); + let arc = if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { Arc::new(vertex, r, s, e) } else { Arc::new(vertex, r, e, s) }; + let sweep = ccw_sweep(arc.start, arc.end); + g.measurement = sweep; + // DIMAZIN 1 / 2 suppress leading / trailing zeros (DIMZIN's bits 4 / 8). + let azin = (st.angular_zero_suppression & 3) * 4; + let txt = format_angle_with_separator(sweep, st.angular_unit, st.angular_decimals, azin, &st.decimal_separator); + // DIMPOST is a linear-dimension prefix/suffix; angles have their own format only. + g.mtext = inspected(apply_override(&txt, d), d); + let mut pts = Vec::new(); + arc.tessellate(r * 1e-3, &mut pts); + g.dim(pts); + let sp = arc.start_point(); + let ep = arc.end_point(); + // The arc's ends with the outward tangents there, paired with leg 1 and leg 2: when + // the arc location is outside the leg 1 → leg 2 sweep the arc runs from leg 2 to + // leg 1, and each leg keeps its own extension line, DIMSE1/2 and DIMBLK1/2. + let (start, finish) = ((sp, -(sp - vertex).perp().normalized()), (ep, (ep - vertex).perp().normalized())); + let ((p1, dir1), (p2, dir2)) = if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { (start, finish) } else { (finish, start) }; + if !st.suppress_ext1 { + g.ext_n(ext_line(vertex + (p1 - vertex).normalized() * vertex.dist(a1).min(r), p1, st, k), false); + } + if !st.suppress_ext2 { + g.ext_n(ext_line(vertex + (p2 - vertex).normalized() * vertex.dist(a2).min(r), p2, st, k), true); + } + end(g, blk1, p1, dir1); + end(g, blk2, p2, dir2); + let (tw, tht) = measure(&g.mtext); + let mid = arc.mid_point(); + let radial = (mid - vertex).normalized(); + let text_angle = if st.text_inside_horizontal { 0.0 } else { readable(radial.perp().angle()) }; + if !d.user_text_pos { + let tr = Vec2::from_angle(text_angle); + let off = gap + (tw * tr.dot(radial).abs() + tht * tr.perp().dot(radial).abs()) / 2.0; + text_mid = mid + radial * off; + } + *text_angle_out = text_angle; + *text_mid_out = text_mid; +} + +pub(super) fn draw_ordinate(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2, x_type: bool) { + let d = config.dimension; + let st = config.style; + let gap = config.gap; + let measure = |text: &str| config.measure(text); + let mut text_angle = 0.0; + let mut text_mid = *text_mid_out; + + let feature = d.p13.xy(); + let leader_end = d.p14.xy(); + let origin = d.defpt.xy(); + let meas = if x_type { (feature.x - origin.x).abs() } else { (feature.y - origin.y).abs() }; + g.mtext = linear_text(meas, d, st, ""); + g.measurement = meas; + let (tw, _) = measure(&g.mtext); + g.dim(vec![feature, leader_end]); + if x_type { + text_angle = std::f64::consts::FRAC_PI_2; + } + if !d.user_text_pos { + let dirv = (leader_end - feature).normalized(); + let dirv = if dirv == Vec2::ZERO { if x_type { Vec2::Y } else { Vec2::X } } else { dirv }; + text_mid = leader_end + dirv * (tw / 2.0 + gap); + } + *text_angle_out = text_angle; + *text_mid_out = text_mid; +} + +pub(super) fn draw_arc_length(config: &DimConfig<'_>, g: &mut DimGeometry, text_angle_out: &mut f64, text_mid_out: &mut Vec2) { + let d = config.dimension; + let st = config.style; + let k = config.scale; + let th = config.text_height; + let gap = config.gap; + let blk1 = config.first_arrow; + let blk2 = config.second_arrow; + let measure = |text: &str| config.measure(text); + let end = |geometry: &mut DimGeometry, kind: Arrowhead, tip: Vec2, direction: Vec2| config.end(geometry, kind, tip, direction); + let mut text_mid = *text_mid_out; + + let c = d.p15.xy(); + let r = c.dist(d.p13.xy()); + let arc = Arc::new(c, c.dist(d.defpt.xy()).max(1e-9), c.angle_to(d.p13.xy()), c.angle_to(d.p14.xy())); + let meas = r * arc.sweep(); + // DIMARCSYM: the arc symbol before the text (0), over it (1) or none (2). + g.mtext = linear_text(meas, d, st, if st.arc_symbol == 0 { "⌒" } else { "" }); + g.measurement = meas; + let mut pts = Vec::new(); + arc.tessellate(arc.radius * 1e-3, &mut pts); + g.dim(pts); + for (i, (p, q)) in [(d.p13.xy(), arc.start_point()), (d.p14.xy(), arc.end_point())].into_iter().enumerate() { + g.ext_n(ext_line(p, q, st, k), i == 1); + } + end(g, blk1, arc.start_point(), -(arc.start_point() - c).perp().normalized()); + end(g, blk2, arc.end_point(), (arc.end_point() - c).perp().normalized()); + let m = arc.mid_point(); + let radial = (m - c).normalized(); + let text_angle = if st.text_inside_horizontal { 0.0 } else { readable(radial.perp().angle()) }; + if !d.user_text_pos { + text_mid = m + radial * (gap + th / 2.0); + } + if st.arc_symbol == 1 { + // A small arc over the text. + let (tw, tht) = measure(&g.mtext); + let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp()); + let half = (tw / 2.0).max(th / 2.0); + let base = text_mid + y * (tht / 2.0 + gap / 2.0); + let sym: Vec = (0..=12) + .map(|i| { + let t = f64::from(i) / 6.0 - 1.0; + base + x * (t * half) + y * ((1.0 - t * t).max(0.0).sqrt() * th * 0.35) + }) + .collect(); + g.text_mark(sym); + } + *text_angle_out = text_angle; + *text_mid_out = text_mid; +} diff --git a/crates/render/src/dim_draw.rs b/crates/render/src/dim_draw.rs index b31ff13b4..b83a33128 100644 --- a/crates/render/src/dim_draw.rs +++ b/crates/render/src/dim_draw.rs @@ -2,7 +2,7 @@ use super::display::Kind; use super::draw::stroke_with; use super::props::default_lineweight; use super::text_entity::{layout_mtext_entity, place_text_entity, text_style}; -use super::*; +use super::{Builder, Color, Ctx, DimGeometry, DimText, Drawing, Entity, EntityKind, LineRole, Lineweight, Vec2, dim, ink}; /// The font settings of a dimension style's text style. pub fn dim_text(d: &Drawing, st: &cadcraft_doc::DimStyle) -> DimText { diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index b260daf8a..e6ba6b7e9 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bounds2, Handle, Pen, Rgb, Sheet, Vec2, outline}; /// Handle carried by model-space geometry drawn inside a paper-space viewport (it belongs to no /// single paper-space entity, so selection highlighting never matches it). diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 5ce0bd6c0..7e00b4c8c 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -3,7 +3,10 @@ use super::dim_draw::{MAX_FILL_SOLIDS, MAX_FILL_TEXTS, block_text_box, dim_text_ use super::display::{Kind, NO_LAYER, VIEWPORT_CONTENT}; use super::props::{resolve, transparency}; use super::text_entity::{mtext, place_text_entity}; -use super::*; +use super::{ + Builder, Ctx, DimGeometry, Drawing, Entity, EntityKind, Handle, Ink, Mat3, Polyline, Prim, Vec2, dim, fill, hatch, linetype, linetype_text, + mline, point, table, tolerance, wide, +}; /// The layer `e` is drawn on, as an index in the layer table: layer 0 inside a block is the /// block reference's layer. @@ -100,6 +103,13 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { if !b.opts.text { return; } + let upright; + let t = if e.common.matches_layout_orientation() && t.rotation != 0.0 { + upright = cadcraft_doc::Text { rotation: 0.0, ..t.clone() }; + &upright + } else { + t + }; let (sh, _) = place_text_entity(ctx.d, t, &t.value); b.shaped(ctx, rgb, lw, &sh); } @@ -111,53 +121,14 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { if !b.opts.text { return; } - mtext(b, ctx, t, rgb, lw); - } - EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb), - EntityKind::Dimension(dm) => { - 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) }; - // 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. - let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() }; - let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0)); - let boxes: Vec> = if g.text_fill.is_empty() { - Vec::new() - } else { - let solids: Vec<&Entity> = - blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect(); - // A text's own box (no gap): a SOLID holding all its corners is its background. - let covered = - |be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p)))); - blk.entities - .iter() - .filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_))) - .take(MAX_FILL_TEXTS) - .filter(|be| !covered(be)) - .filter_map(|be| block_text_box(ctx.d, be, gap)) - .collect() - }; - let mut fill_pending = !boxes.is_empty(); - for be in blk.entities.iter() { - if !b.expand() { - break; - } - if fill_pending && starts_text_marks(be, &g) { - for corners in &boxes { - dim_text_fill(b, ctx, e, &style, corners); - } - fill_pending = false; - } - entity(b, &sub, be); - } + if e.common.matches_layout_orientation() && t.rotation != 0.0 { + mtext(b, ctx, &cadcraft_doc::MText { rotation: 0.0, ..t.clone() }, rgb, lw); } else { - dimension(b, ctx, e, dm, lw, lt.as_ref(), ltscale); + mtext(b, ctx, t, rgb, lw); } } + EntityKind::Insert(ins) => insert(b, ctx, e, ins, rgb), + EntityKind::Dimension(dm) => draw_dimension(b, ctx, e, dm, lw, lt.as_ref(), ltscale), EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), EntityKind::MLine(m) => mline::draw(b, ctx, e, m, rgb, lw, tol), EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { @@ -221,29 +192,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { b.polyline(ctx, rgb, lw, &[q[0], q[1], q[2], q[3], q[0]]); } } - EntityKind::Wipeout(w) => { - // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: - // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. - if b.opts.fill { - let n = b.list.prims.len(); - b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary))); - if let Some(p) = b.list.prims.get_mut(n) { - p.kind = Kind::Mask; - } - } - let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) { - 0 => false, - 2 => !b.plotting, - _ => true, - }; - if frame { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); - } - b.polyline(ctx, rgb, lw, &pts); - } - } + EntityKind::Wipeout(w) => draw_wipeout(b, ctx, w, rgb, lw), EntityKind::Image(i) => { let o = i.insert.xy(); let u = i.u.xy() * i.size.x; @@ -272,29 +221,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { b.tris(ctx, rgb, &g.tris); } } - EntityKind::MLeader(m) => { - // Straight or spline leader lines; block content draws like a block reference. - for pts in m.leader_paths() { - b.polyline(ctx, rgb, lw, &pts); - if let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { - b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), m.arrow_size)); - } - } - if let Some(t) = &m.text { - let land = m.landing.xy(); - let dir = if t.insert.x >= land.x { 1.0 } else { -1.0 }; - b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); - mtext(b, ctx, t, rgb, lw); - } - if let Some(ins) = &m.block { - if m.dogleg > 0.0 && !m.leaders.is_empty() { - let land = m.landing.xy(); - let dir = if ins.insert.x >= land.x { 1.0 } else { -1.0 }; - b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); - } - insert(b, ctx, e, ins, rgb); - } - } + EntityKind::MLeader(m) => draw_mleader(b, ctx, e, m, rgb, lw), EntityKind::Point(p) => { // Relative sizes follow the view: undo the block / viewport scale. let view = b.opts.view_height / ctx.xf.scale_factor().max(1e-12); @@ -338,6 +265,94 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } +fn draw_mleader(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &cadcraft_doc::MLeader, rgb: Ink, lw: f32) { + // Straight or spline leader lines; block content draws like a block reference. + for pts in m.leader_paths() { + b.polyline(ctx, rgb, lw, &pts); + if let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { + b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), m.arrow_size)); + } + } + if let Some(t) = &m.text { + let land = m.landing.xy(); + let dir = if t.insert.x >= land.x { 1.0 } else { -1.0 }; + b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); + mtext(b, ctx, t, rgb, lw); + } + if let Some(ins) = &m.block { + if m.dogleg > 0.0 && !m.leaders.is_empty() { + let land = m.landing.xy(); + let dir = if ins.insert.x >= land.x { 1.0 } else { -1.0 }; + b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); + } + insert(b, ctx, e, ins, rgb); + } +} + +fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, lw: f32) { + // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: + // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. + if b.opts.fill { + let n = b.list.prims.len(); + b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary))); + if let Some(p) = b.list.prims.get_mut(n) { + p.kind = Kind::Mask; + } + } + let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) { + 0 => false, + 2 => !b.plotting, + _ => true, + }; + if frame { + b.polyline(ctx, rgb, lw, &w.closed_boundary()); + } +} + +fn draw_dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimension, lw: f32, lt: Option<&cadcraft_doc::Linetype>, ltscale: f64) { + 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) }; + // 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. + let style = ctx.d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = if style.text_fill != 0 { dimension_in(ctx.d, dm) } else { DimGeometry::default() }; + let gap = style.text_gap.abs() * style.effective_scale(ctx.d.header.f64("DIMSCALE", 1.0)); + let boxes: Vec> = if g.text_fill.is_empty() { + Vec::new() + } else { + let solids: Vec<&Entity> = + blk.entities.iter().map(|be| &**be).filter(|be| matches!(be.kind, EntityKind::Solid(_))).take(MAX_FILL_SOLIDS).collect(); + // A text's own box (no gap): a SOLID holding all its corners is its background. + let covered = |be: &Entity| block_text_box(ctx.d, be, 0.0).is_some_and(|t| solids.iter().any(|s| t.iter().all(|p| solid_covers(s, *p)))); + blk.entities + .iter() + .filter(|be| matches!(be.kind, EntityKind::MText(_) | EntityKind::Text(_))) + .take(MAX_FILL_TEXTS) + .filter(|be| !covered(be)) + .filter_map(|be| block_text_box(ctx.d, be, gap)) + .collect() + }; + let mut fill_pending = !boxes.is_empty(); + for be in blk.entities.iter() { + if !b.expand() { + break; + } + if fill_pending && starts_text_marks(be, &g) { + for corners in &boxes { + dim_text_fill(b, ctx, e, &style, corners); + } + fill_pending = false; + } + entity(b, &sub, be); + } + } else { + dimension(b, ctx, e, dm, lw, lt, ltscale); + } +} + /// 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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5b9dc83a1..6fa236e4c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -235,7 +235,7 @@ impl Builder<'_> { self.tris_at(ctx, color, &mesh.triangles, xf); mesh.triangles.len() } else { - let tris = fill::triangulate_evenodd(g); + let tris = crate::text::triangulate_placed(g); self.tris(ctx, color, &tris); tris.len() }; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 3427c61a4..8cbcb95b9 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,7 +3,7 @@ use super::display::append; use super::display::{DisplayList, Kind, NO_LAYER, PlotText, VIEWPORT_CONTENT}; use super::draw::entity; use super::viewports::{MAX_VIEWPORTS, viewport}; -use super::*; +use super::{Bounds2, Builder, Color, Ctx, Drawing, Entity, EntityKind, Handle, Lineweight, Mat3, Space, Vec2, paper, pen, plotstyle, point}; /// Build options. #[derive(Clone, Debug)] diff --git a/crates/render/src/props.rs b/crates/render/src/props.rs index 91419b0cb..ad001b40c 100644 --- a/crates/render/src/props.rs +++ b/crates/render/src/props.rs @@ -1,5 +1,5 @@ use super::display::MAX_TRANSPARENCY; -use super::*; +use super::{Color, Ctx, Drawing, Entity, Ink, Lineweight, ink}; /// An object's colour, lineweight (mm), linetype, linetype scale, visibility and the transparency /// of the layer it is drawn on (for ByLayer, see [`transparency`]). diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 379c083e6..72ac821d0 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option = Vec::new(); for p in &list.prims { let c = p.display_rgb(o.background); let mut paint = Paint::default(); @@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { let bb = Bounds2::from_points(pts.iter().copied()); if !bb.intersects(&vis) { continue; } + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + screen.clear(); + screen.extend(pts.iter().map(|q| view.to_screen(*q))); + if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) { + hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias); + continue; + } + hairlines.flush(&mut pm, o.antialias); let mut pb = PathBuilder::new(); - for (i, q) in pts.iter().enumerate() { - let (x, y) = view.to_screen(*q); + for (i, (x, y)) in screen.iter().enumerate() { if i == 0 { - pb.move_to(x, y); + pb.move_to(*x, *y); } else { - pb.line_to(x, y); + pb.line_to(*x, *y); } } if let Some(path) = pb.finish() { - let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); } @@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option bool { + width == 0.0 || (antialias && width <= 1.0) +} + +/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a +/// path's integer bounds could overflow, so such a polyline is stroked on its own as before. +const MAX_BATCH_COORD: f32 = 1.0e8; + +fn batchable(v: f32) -> bool { + v.is_finite() && v.abs() <= MAX_BATCH_COORD +} + +/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path. +/// +/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with +/// its own caps, a polyline's first and last segment extended for round caps), so one path of +/// several polylines paints exactly the pixels, in the same order, as stroking them one by one; +/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled +/// outlines, where overlaps would cover once instead of twice, and are never batched. +#[derive(Default)] +struct Hairlines { + key: Option<([u8; 4], f32)>, + pb: PathBuilder, +} + +impl Hairlines { + fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) { + if self.key.is_some_and(|k| k != (rgba, width)) { + self.flush(pm, antialias); + } + self.key = Some((rgba, width)); + for (i, (x, y)) in pts.iter().enumerate() { + if i == 0 { + self.pb.move_to(*x, *y); + } else { + self.pb.line_to(*x, *y); + } + } + } + + fn flush(&mut self, pm: &mut Pixmap, antialias: bool) { + let Some(([r, g, b, a], width)) = self.key.take() else { return }; + let pb = std::mem::take(&mut self.pb); + let Some(path) = pb.finish() else { return }; + let mut paint = Paint::default(); + paint.set_color_rgba8(r, g, b, a); + paint.anti_alias = antialias; + let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + self.pb = path.clear(); + } +} + /// 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 fc4b99156..ae35f27f9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() { assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline"); } } + +/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines. +fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap { + use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + let mut pm = Pixmap::new(view.width, view.height).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255)); + for p in &list.prims { + let c = p.display_rgb(o.background); + let mut paint = Paint::default(); + paint.set_color_rgba8(c.0, c.1, c.2, p.alpha()); + paint.anti_alias = o.antialias; + let mut pb = PathBuilder::new(); + match p.kind { + Kind::Polyline => { + for (i, q) in list.points(p).iter().enumerate() { + let (x, y) = view.to_screen(*q); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + if let Some(path) = pb.finish() { + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + Kind::Tris => { + for t in list.points(p).chunks(3) { + let [a, b, c] = t else { continue }; + let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c)); + pb.move_to(ax, ay); + pb.line_to(bx, by); + pb.line_to(cx, cy); + pb.close(); + } + if let Some(path) = pb.finish() { + pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + } + _ => panic!("not drawn by this reference"), + } + } + pm +} + +fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) { + let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts }; + let start = arr.len() as u32; + arr.extend_from_slice(pts); + for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) { + l.bounds.add(*p); + } + l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 }); +} + +/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly +/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight, +/// fills, unusable coordinates) and with overlaps, caps and dots. +#[test] +fn raster_hairline_batches_match_one_by_one() { + let red = Rgb(255, 0, 0); + let blue = Rgb(0, 0, 255); + let mut l = DisplayList::default(); + // A hatch-like run of crossing, overlapping and touching lines in one colour. + for i in 0..40 { + let x = f64::from(i) * 0.37; + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]); + } + // Zero-length (a dot), single-point (nothing), and repeated lines. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + // Transparent lines build up where they overlap, within a run and across runs. + for i in 0..10 { + let y = f64::from(i) * 0.2; + push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]); + } + push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]); + // A lineweight (a filled outline when lineweights are shown), a fill, then the run again. + push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]); + push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]); + // Unusable or far-out coordinates are stroked on their own, as before. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]); + // Partly and wholly off screen. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]); + let view = View::fit(&l.bounds, 160, 120, 0.05); + let bg = Rgb(33, 40, 48); + for o in [ + RasterOptions::default(), + RasterOptions { antialias: false, ..Default::default() }, + RasterOptions { hairline: 0.5, ..Default::default() }, + RasterOptions { hairline: 0.0, antialias: false, ..Default::default() }, + RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() }, + RasterOptions { hairline: 1.5, background: bg, ..Default::default() }, + ] { + let fast = render(&l, &view, &o).unwrap(); + let reference = render_one_by_one(&l, &view, &o); + assert!(fast.data() == reference.data(), "pixels differ with {o:?}"); + } +} + +/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by +/// line. +#[test] +fn raster_hatch_pattern_matches_one_by_one() { + let mut d = sample(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap(); + let l = build(&d, &Space::Model, &Options::default()); + assert!(l.prims.len() > 20); + let view = View::fit(&l.bounds, 300, 200, 0.05); + for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] { + assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data()); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 6595e145f..8e692facd 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -77,7 +77,7 @@ pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc { let contours = ttf::glyph_contours(&g.face, g.gid); let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add); // Font data is untrusted; don't hand unbounded or invalid paths to the tessellator. - if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) { + if points.is_none_or(|n| n > MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) { return Arc::new(Mesh { contours: Arc::default(), triangles: Vec::new() }); } let contours = match detail { @@ -163,8 +163,10 @@ fn simplify(contour: &[Vec2], tolerance: f64) -> Vec { if result.len() >= 4 { result } else { contour.to_vec() } } -/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32 -/// precision is independent of drawing coordinates; the placement stays f64. The existing +/// Monotone tessellation with the nonzero winding rule that TrueType and OpenType outlines are +/// defined with: overlapping contours stay filled (Arial Unicode's 진 draws its final ㄴ twice, +/// which even-odd cancelled out) and holes are contours wound the other way. Work in font units +/// so f32 precision is independent of drawing coordinates; the placement stays f64. The existing /// slab filler remains the fallback when tessellation fails (including its u16 vertex limit). fn triangulate(contours: &[Vec]) -> Vec { let mut path = Path::builder(); @@ -179,7 +181,7 @@ fn triangulate(contours: &[Vec]) -> Vec { let mut buffers: VertexBuffers = VertexBuffers::new(); let result = FillTessellator::new().tessellate_path( &path.build(), - &FillOptions::even_odd(), + &FillOptions::non_zero(), &mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| { let p = v.position(); Vec2::new(f64::from(p.x), f64::from(p.y)) @@ -190,3 +192,74 @@ fn triangulate(contours: &[Vec]) -> Vec { } buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect() } + +/// [`triangulate`] for a glyph outline already placed in drawing coordinates (complex-script +/// runs carry no [`TextGlyph`]): tessellated relative to its first point so f32 keeps the +/// precision. Outlines past the limits [`mesh`] applies keep the slab filler. +pub(super) fn triangulate_placed(contours: &[Vec]) -> Vec { + let Some(&origin) = contours.iter().flatten().next() else { return Vec::new() }; + let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add); + let out_of_range = |p: &Vec2| { + let d = *p - origin; + !p.is_finite() || d.x.abs().max(d.y.abs()) > 1.0e6 + }; + if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(out_of_range) { + return crate::fill::triangulate_evenodd(contours); + } + let local: Vec> = contours.iter().map(|c| c.iter().map(|&p| p - origin).collect()).collect(); + triangulate(&local).into_iter().map(|p| p + origin).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x: f64, y: f64, s: f64) -> Vec { + // Clockwise, as TrueType draws outer contours. + vec![Vec2::new(x, y), Vec2::new(x, y + s), Vec2::new(x + s, y + s), Vec2::new(x + s, y), Vec2::new(x, y)] + } + + fn area(tris: &[Vec2]) -> f64 { + tris.chunks(3) + .filter_map(|t| match t { + [a, b, c] => Some(((*b - *a).x * (*c - *a).y - (*b - *a).y * (*c - *a).x).abs() / 2.0), + _ => None, + }) + .sum() + } + + #[test] + fn duplicated_contour_stays_filled() { + // A composite glyph that draws one component twice (Arial Unicode's 진 and its ㄴ). + let s = square(0.0, 0.0, 10.0); + assert!((area(&triangulate(&[s.clone(), s])) - 100.0).abs() < 1e-6); + } + + #[test] + fn overlapping_contours_fill_their_union() { + let tris = triangulate(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)]); + assert!((area(&tris) - 175.0).abs() < 1e-6); + } + + #[test] + fn counter_wound_contour_is_still_a_hole() { + let mut hole = square(4.0, 4.0, 2.0); + hole.reverse(); + assert!((area(&triangulate(&[square(0.0, 0.0, 10.0), hole])) - 96.0).abs() < 1e-6); + } + + #[test] + fn placed_outline_far_from_the_origin_keeps_its_place_and_fill() { + let s = square(5.0e5, -3.0e5, 2.0); + let tris = triangulate_placed(&[s.clone(), s]); + assert!((area(&tris) - 4.0).abs() < 1e-6); + assert!(tris.iter().all(|p| (5.0e5..=5.0e5 + 2.0).contains(&p.x) && (-3.0e5..=-3.0e5 + 2.0).contains(&p.y))); + } + + #[test] + fn hostile_placed_outline_never_panics() { + assert!(triangulate_placed(&[]).is_empty()); + let _ = triangulate_placed(&[vec![Vec2::new(f64::NAN, 0.0), Vec2::X, Vec2::Y]]); + let _ = triangulate_placed(&[vec![Vec2::ZERO, Vec2::new(1.0e300, 0.0), Vec2::Y]]); + } +} diff --git a/crates/render/src/text_entity.rs b/crates/render/src/text_entity.rs index 54b556139..f7989e2c8 100644 --- a/crates/render/src/text_entity.rs +++ b/crates/render/src/text_entity.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bounds2, Builder, Color, Ctx, Drawing, Ink, Rgb, ink}; /// The text style of an entity (Standard defaults when missing). pub(crate) fn text_style(d: &Drawing, name: &str) -> cadcraft_doc::TextStyle { diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index e78106fa1..6f1e57517 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -5,7 +5,7 @@ use super::draw::entity; #[cfg(not(target_arch = "wasm32"))] use super::pipeline::PARALLEL_CHUNK; use super::pipeline::{Options, style_table, top_ctx, with_point_view}; -use super::*; +use super::{Bounds2, Builder, Ctx, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_EXPANSION, Mat3, Space, Vec2, clip, paper, pen, plotstyle}; /// Upper bound on viewports drawn per layout (hostile files). pub(crate) const MAX_VIEWPORTS: usize = 256; diff --git a/crates/render/tests/it/annotative_orientation.rs b/crates/render/tests/it/annotative_orientation.rs new file mode 100644 index 000000000..ee28b0624 --- /dev/null +++ b/crates/render/tests/it/annotative_orientation.rs @@ -0,0 +1,66 @@ +//! Annotative text with "Match orientation to layout" (`AcadAnnoPO` xdata = 1) draws upright, +//! ignoring its stored rotation (#2498). + +use cadcraft_doc::{Common, Drawing, EntityKind, RawTag, Space, Text}; +use cadcraft_geom::{Bounds2, Vec3}; +use cadcraft_render::{Options, build}; + +fn tag(code: i32, value: &str) -> RawTag { + RawTag { code, value: value.into() } +} + +fn bounds(rotation: f64, match_orientation: Option<&str>) -> Bounds2 { + let mut d = Drawing::new_metric(); + let mut common = Common::default(); + if let Some(flag) = match_orientation { + common.xdata = vec![ + tag(1001, "AcadAnnotative"), + tag(1000, "AnnotativeData"), + tag(1002, "{"), + tag(1070, "1"), + tag(1070, "1"), + tag(1002, "}"), + tag(1001, "AcadAnnoPO"), + tag(1070, flag), + ]; + } + let text = Text { + insert: Vec3::new(10.0, 5.0, 0.0), + align_pt: None, + height: 2.5, + value: "TITLE".into(), + rotation, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + d.add(&Space::Model, common, EntityKind::Text(text)).unwrap(); + let l = build(&d, &Space::Model, &Options::default()); + let mut b = Bounds2::EMPTY; + for p in &l.prims { + for q in l.points(p) { + b.add(*q); + } + } + b +} + +fn same(a: &Bounds2, b: &Bounds2) -> bool { + (a.min.x - b.min.x).abs() < 1e-9 && (a.min.y - b.min.y).abs() < 1e-9 && (a.max.x - b.max.x).abs() < 1e-9 && (a.max.y - b.max.y).abs() < 1e-9 +} + +#[test] +fn match_orientation_ignores_the_stored_rotation() { + let upright = bounds(0.0, None); + assert!(!upright.is_empty()); + assert!(same(&bounds(std::f64::consts::PI, Some("1")), &upright)); +} + +#[test] +fn stored_rotation_applies_without_the_flag() { + let upright = bounds(0.0, None); + assert!(!same(&bounds(std::f64::consts::PI, None), &upright)); + assert!(!same(&bounds(std::f64::consts::PI, Some("0")), &upright)); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 5f0005496..fbeede7f8 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -1,4 +1,5 @@ mod aci7_display; +mod annotative_orientation; mod annotative_viewport; mod dim_angular_centre; mod dim_post_tolerance; diff --git a/crates/render/tests/it/wipeout_mask.rs b/crates/render/tests/it/wipeout_mask.rs index 508b052b6..6c9531988 100644 --- a/crates/render/tests/it/wipeout_mask.rs +++ b/crates/render/tests/it/wipeout_mask.rs @@ -59,6 +59,43 @@ fn wipeout_hides_what_is_drawn_before_it() { assert!(inside > 0); } +#[test] +fn implicit_and_explicit_closure_draw_the_same_mask_and_frame() { + for reversed in [false, true] { + let mut implicit = drawing(false); + let handle = implicit.model.iter().find(|e| matches!(e.kind, EntityKind::Wipeout(_))).unwrap().handle; + if reversed { + implicit.model.modify(handle, |e| { + let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") }; + w.boundary.reverse(); + }); + } + let mut explicit = implicit.clone(); + explicit.model.modify(handle, |e| { + let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") }; + w.boundary.push(w.boundary[0]); + }); + for frame in [0, 1, 2] { + implicit.header.set_i64("WIPEOUTFRAME", frame); + explicit.header.set_i64("WIPEOUTFRAME", frame); + for plot in [false, true] { + let draw = if plot { build_plot } else { build }; + let a = draw(&implicit, &Space::Model, &Options::default()); + let b = draw(&explicit, &Space::Model, &Options::default()); + assert_eq!(a.prims, b.prims); + assert_eq!(a.verts, b.verts); + assert_eq!(a.tris, b.tris); + for primitive in b.prims.iter().filter(|p| p.handle == handle && p.kind == Kind::Polyline) { + let points = b.points(primitive); + assert_eq!(points.len(), 5, "four corners and exactly one closing point"); + assert_eq!(points.first(), points.last()); + } + assert_eq!(dark_pixels(&a), dark_pixels(&b)); + } + } + } +} + #[test] fn wipeoutframe_controls_the_frame() { // Polylines in the list: the line, plus the frame when it is drawn. diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index d062f69e9..012231683 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,9 @@ 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 GPU error notice says (see [`gpu_notice`]). diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index 6137d5ccc..fa51656ac 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -1,7 +1,9 @@ //! The Edit Block Definition dialog: BEDIT typed or started from a menu or the Tool Sets with no //! block reference selected. It lists the drawing's blocks with a preview and description; a name //! no block has yet starts a new block. OK runs BEDIT with that name; the dialog itself never -//! changes the drawing. JSON calls (`bedit {name}`) and a selected block reference skip it. +//! changes the drawing. In the Block Editor it switches to the block chosen, first asking whether +//! to save the changes not saved yet. JSON calls (`bedit {name}`) and a selected block reference +//! skip it. use std::sync::Arc; @@ -24,6 +26,8 @@ pub struct State { pub name: String, /// Why the last OK failed, shown until the name changes. pub error: Option, + /// OK in the Block Editor with changes not saved: asking whether to save them first. + pub ask_save: bool, /// The preview of the block named, for (drawing uid, revision, block name). preview: Option<((u64, u64, String), Arc)>, } @@ -86,22 +90,19 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) } /// BEDIT typed or from a menu without parameters opens the dialog, unless exactly one block -/// reference is selected (it opens at once) or the Block Editor is open (BEDIT says why not). +/// reference is selected (it opens at once). In the Block Editor it starts on the block edited. pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option> { if !matches!(id, "bedit" | "be") || !params.is_null() { return None; } let st = app.session.state().ok()?; - if st.block_edit.is_some() { - return None; - } let refs = st.selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count(); if refs == 1 && st.selection.len() == 1 { return None; } // Start on the block last edited if it is still there, else the first one. let names = editable_blocks(&st.doc); - let last = app.bedit.name.clone(); + let last = st.block_editor().map(str::to_string).unwrap_or_else(|| app.bedit.name.clone()); let name = if names.iter().any(|n| n.eq_ignore_ascii_case(&last)) { last } else { names.first().cloned().unwrap_or_default() }; app.bedit = State { name, ..State::default() }; app.ui.dialog = Some("bedit".into()); @@ -164,6 +165,10 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let list = existing.as_deref().and_then(|n| preview(app, n)); let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default(); let (mut ok, mut cancel) = (false, false); + if app.bedit.ask_save { + save_question(app, ctx, open); + return; + } egui::Window::new(crate::tl!("Edit Block Definition")) .id(egui::Id::unique("Edit Block Definition")) .open(open) @@ -236,14 +241,72 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } -/// OK: open the Block Editor on the name given (a new name starts a new block). Returns whether -/// it opened; if not, the dialog shows why and stays open. +/// In the Block Editor with changes not saved: whether to save them before switching to the +/// block chosen. Yes saves, No discards, Cancel goes back to the list. +fn save_question(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let current = app.session.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default(); + let mut answer: Option> = None; + egui::Window::new(crate::tl!("Edit Block Definition")) + .id(egui::Id::unique("Edit Block Definition")) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + ui.set_max_width(420.0); + ui.label(crate::tf!("Save changes to block \"{name}\"?", name = current)); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Yes")).clicked() { + answer = Some(Some(true)); + } + if ui.button(crate::tl!("No")).clicked() { + answer = Some(Some(false)); + } + if ui.button(crate::tl!("Cancel")).clicked() { + answer = Some(None); + } + }); + }); + let Some(answer) = answer else { return }; + app.bedit.ask_save = false; + if let Some(save) = answer + && run(app, Some(save)) + { + *open = false; + } +} + +/// OK: open the Block Editor on the name given (a new name starts a new block). In the Block +/// Editor with changes not saved, it asks first whether to save them. Returns whether it opened; +/// if not, the dialog shows why (or asks) and stays open. pub fn confirm(app: &mut CadApp) -> bool { let name = app.bedit.name.trim().to_string(); if name.is_empty() { return false; } - match app.run("bedit", json!({ "name": name, "create": true })) { + let ask = app.session.state().is_ok_and(|st| st.block_unsaved() && st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(&name))); + if ask { + // A block it can't switch to is refused before the question, which would trap the user. + if let Err(e) = app.run("bedit", json!({ "name": name, "create": true, "check": true })) { + app.bedit.error = Some(e); + return false; + } + app.bedit.ask_save = true; + return false; + } + run(app, None) +} + +/// Run BEDIT on the name given, saving (`Some(true)`) or discarding (`Some(false)`) the Block +/// Editor's changes not saved yet. Returns whether it went through; if not, the dialog shows why. +fn run(app: &mut CadApp, save: Option) -> bool { + let name = app.bedit.name.trim().to_string(); + let mut p = json!({ "name": name, "create": true }); + if let (Some(save), Some(o)) = (save, p.as_object_mut()) { + o.insert("save".into(), json!(save)); + } + match app.run("bedit", p) { Ok(_) => true, Err(e) => { app.bedit.error = Some(e); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 787aa1a2f..fcc464be3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -217,6 +217,507 @@ pub struct SnapCycle { index: usize, } +#[derive(Clone, Copy)] +struct PointerInput { + hover_pos: Option, + scroll: egui::Vec2, + mods: egui::Modifiers, + middle_down: bool, + pressed_primary: bool, + pressed_secondary: bool, + dbl_middle: bool, + dbl_primary: bool, +} + +struct Navigation<'a> { + rect: Rect, + tiles: &'a [(Rect, cadcraft_engine::ModelTile)], + response: &'a egui::Response, + xf: Xf, + pointer: PointerInput, +} + +fn navigate_canvas(app: &mut CadApp, ui: &mut egui::Ui, nav: &Navigation<'_>) -> Option<(bool, bool)> { + let p = nav.pointer; + let inside = p.hover_pos.is_some_and(|at| nav.rect.contains(at) && ui.ctx().layer_id_at(at) == Some(ui.layer_id()) && nav.response.hovered()); + let on_menu = p.hover_pos.is_some_and(|at| over_canvas_menu(app, at)); + app.session.ctrl_down = p.mods.ctrl; + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::MouseWheel { unit, .. } = event { + app.canvas.scroll_pans = cfg!(target_os = "macos") && *unit == egui::MouseWheelUnit::Point; + } + } + }); + handle_scroll_zoom(app, ui, nav, inside); + handle_button_navigation(app, nav, inside); + if nav.response.dragged_by(egui::PointerButton::Primary) && app.session.realtime_zoom_active() { + let _ = app.session.realtime_zoom(f64::from(nav.response.drag_delta().y)); + } + if p.pressed_primary + && let Some(at) = p.hover_pos + && !nav.rect.contains(at) + && let Some(i) = nav.tiles.iter().position(|(r, _)| r.contains(at)) + { + if let Err(e) = app.session.click_tile(i) { + app.session.echo(e.to_string()); + } + ui.ctx().request_repaint(); + return None; + } + Some((inside, on_menu)) +} + +fn handle_scroll_zoom(app: &mut CadApp, ui: &mut egui::Ui, nav: &Navigation<'_>, inside: bool) { + let p = nav.pointer; + if inside + && app.canvas.scroll_pans + && p.scroll != egui::Vec2::ZERO + && let Ok(st) = app.session.state_mut() + { + let view = st.view(); + st.set_view_quiet(cadcraft_engine::View { + center: view.center - Vec2::new(f64::from(p.scroll.x) / nav.xf.scale, -f64::from(p.scroll.y) / nav.xf.scale), + height: view.height, + }); + } + let opts = ui.ctx().options(|o| o.input_options); + let wheel = ui.input(|i| wheel_zoom_points(&i.raw.events, &opts, nav.rect.height())); + if inside + && !app.canvas.scroll_pans + && wheel != 0.0 + && let Some(at) = p.hover_pos + { + let factor = app.session.wheel_zoom_factor(f64::from(wheel)); + let about = nav.xf.to_world(at); + let _ = app.session.zoom_about(factor, about); + } + let pinch = ui.input(|i| crate::animation::zoom_delta(i, &opts, app.shows_animations())); + if inside + && (pinch - 1.0).abs() > 1e-4 + && let Some(at) = p.hover_pos + { + let _ = app.session.zoom_about(f64::from(pinch), nav.xf.to_world(at)); + } +} + +fn handle_button_navigation(app: &mut CadApp, nav: &Navigation<'_>, inside: bool) { + let p = nav.pointer; + let middle_pressed = nav.response.ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle)); + let focused = nav.response.ctx.input(|i| i.focused); + if p.middle_down + && focused + && (app.canvas.pan_last.is_some() || (middle_pressed && inside)) + && let Some(at) = p.hover_pos + { + if let Some(last) = app.canvas.pan_last { + let delta = at - last; + if let Ok(st) = app.session.state_mut() { + let view = st.view(); + st.set_view_quiet(cadcraft_engine::View { + center: view.center - Vec2::new(f64::from(delta.x) / nav.xf.scale, -f64::from(delta.y) / nav.xf.scale), + height: view.height, + }); + } + } + app.canvas.pan_last = Some(at); + } else { + app.canvas.pan_last = None; + } + if p.dbl_middle && inside { + let _ = app.session.zoom_extents(); + } +} + +struct CanvasPaint<'a> { + app: &'a mut CadApp, + ui: &'a mut egui::Ui, + painter: &'a egui::Painter, + tokens: Tokens, + full: Rect, + tiles: &'a [(Rect, cadcraft_engine::ModelTile)], + rect: Rect, + xf: Xf, + xf_paper: Xf, + view: cadcraft_engine::View, + active_vp: Option, + active_clip: Option, + inside: bool, + on_menu: bool, + hover_pos: Option, + raw_world: Option, +} + +fn paint_content_highlights(frame: &mut CanvasPaint<'_>, bg: Rgb, sel: &[Handle], preview_look: Look, selection_look: Option) { + let app = &mut *frame.app; + let painter = frame.painter; + let xf_paper = frame.xf_paper; + let rect = frame.rect; + let xf = frame.xf; + let t = frame.tokens; + let ui = &mut *frame.ui; + let active_vp = &frame.active_vp; + let active_clip = &frame.active_clip; + let scale = frame.xf.scale; + // The active viewport's contents, drawn apart from the layout's list, under its own objects. + // `masked`: its outline is in the stencil buffer this frame. + let mut masked = false; + if let Some(f) = ensure_vp_content(app, &xf_paper, ui.ctx()) { + let gpu = app.canvas.gpu.is_some(); + let clipped = painter.with_clip_rect(f.on_screen.intersect(rect)); + draw_vp_content(&mut app.canvas.vp, gpu, &clipped, &f, &xf_paper, bg, app.session.settings.lwdisplay); + masked = gpu && f.outline.is_some(); + } + if app.canvas.gpu.is_some() { + draw_list_gpu(&mut app.canvas, painter, &xf_paper, bg, app.session.settings.lwdisplay); + } else if let Some(list) = &app.canvas.list { + draw_list(painter, &xf_paper, list, bg, app.session.settings.lwdisplay); + } + if let Some(vp) = &active_vp { + // The active viewport: a heavy border, highlights in model units clipped to it. + let r = Rect::from_two_pos( + xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)), + xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)), + ); + let heavy = Stroke::new(3.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)); + match &active_clip { + Some(c) => { + let mut pts: Vec = c.polygon().iter().map(|q| xf_paper.to_screen(*q)).collect(); + pts.extend(pts.first().copied()); + painter.add(egui::Shape::line(pts, heavy)); + } + None => { + painter.rect_stroke(r, 0.0, heavy, egui::StrokeKind::Middle); + } + } + if let Ok(st) = app.session.state() { + // A clipped viewport's outline bounds (its outline is masked when `masked`). + let bounds = active_clip.as_ref().map_or(r, |c| { + let b = c.bounds(); + Rect::from_two_pos(xf_paper.to_screen(Vec2::new(b.min.x, b.max.y)), xf_paper.to_screen(Vec2::new(b.max.x, b.min.y))) + }); + let clipped = painter.with_clip_rect(bounds.intersect(rect)); + let c = &mut app.canvas; + if let Some(h) = c.hover + && !sel.contains(&h) + { + c.vp_hover.update(st, &[h], scale); + let list = &c.vp_hover.list; + draw_highlight(&clipped, &xf, list, &(0..list.prims.len()).collect::>(), t.hover, preview_look); + } + if let Some(look) = selection_look + && !sel.is_empty() + { + c.vp_sel.update(st, sel, scale); + let list = &c.vp_sel.list; + let all = || (0..list.prims.len()).collect::>(); + if c.gpu.is_some() { + // Shares the GPU highlight layer with the layout's own selection; the high bit + // keeps this list's generations apart from the layout list's. + let list_id = c.vp_sel.list_gen | 1 << 63; + let stencil = if masked { crate::gpu::Stencil::Inside } else { crate::gpu::Stencil::Off }; + let hl = (&mut c.hl_key, &c.hl_slot, &mut c.hl_origin, &mut c.hl_infinite); + draw_highlight_gpu(&clipped, &xf, list, list_id, all, sel, t.selection, look, hl, stencil); + draw_highlight(&clipped, &xf, list, &c.hl_infinite, t.selection, look); + } else { + draw_highlight(&clipped, &xf, list, &all(), t.selection, look); + } + } + } + } else if let CanvasState { list: Some(list), list_gen, prim_index, hover, gpu, hl_key, hl_slot, hl_origin, hl_infinite, .. } = &mut app.canvas { + if let Some(h) = *hover + && !sel.contains(&h) + { + draw_highlight(painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, preview_look); + } + if let Some(look) = selection_look { + if gpu.is_some() && !sel.is_empty() { + let list_id = *list_gen; + let hl = (hl_key, &*hl_slot, hl_origin, &mut *hl_infinite); + draw_highlight_gpu( + painter, + &xf, + list, + list_id, + || prim_index.prims(list_id, list, sel), + sel, + t.selection, + look, + hl, + crate::gpu::Stencil::Off, + ); + draw_highlight(painter, &xf, list, hl_infinite, t.selection, look); + } else { + draw_highlight(painter, &xf, list, &prim_index.prims(*list_gen, list, sel), t.selection, look); + } + } + } +} + +fn paint_pointer_overlay( + frame: &mut CanvasPaint<'_>, + paper_rect: Option, + surround: Color32, + selecting: bool, + sheet: Option, +) { + let app = &mut *frame.app; + let ui = &mut *frame.ui; + let painter = frame.painter; + let t = frame.tokens; + let rect = frame.rect; + let full = frame.full; + let tiles = frame.tiles; + let xf = frame.xf; + let inside = frame.inside; + let on_menu = frame.on_menu; + let hover_pos = frame.hover_pos; + let view = frame.view; + // Acquired tracking points and the polar / tracking paths under the cursor. + if app.canvas.cursor.is_some() { + draw_tracking(painter, &xf, &app.session.tracking.acquired, app.canvas.track.as_ref(), view.height * 4.0); + } + if let (Some(hit), Some(_)) = (app.canvas.snap, app.canvas.cursor) { + draw_snap_marker(painter, xf.to_screen(hit.point), &hit); + } + draw_tiles(app, ui, tiles); + // UCS icon, ViewCube and viewport label. + if app.ui.show_ucs_icon { + draw_ucs_icon(painter, rect); + crate::viewcube::ucs_icon(app, ui, rect); + } + if app.ui.show_viewcube && sheet.is_none() { + draw_viewcube(app, ui, rect); + } + viewport_label(painter, rect); + // Typing and Escape reach the command line even when its overlay is hidden. + crate::cmdline::keyboard(app, ui.ctx()); + // Command line overlay. + if app.ui.show_command_line { + crate::cmdline::show(app, ui, full); + } + // Crosshair cursor. + if inside && !on_menu { + // Hide the OS cursor under the drawn crosshair, unless assistive tech needs it (#39). + ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None }); + if let Some(hp) = hover_pos { + // Use the painted sheet even in MSPACE, where the editing transform follows a + // model viewport. The whole cursor takes one colour from its centre. + let ink = paper_rect.map_or(t.crosshair, |paper| crate::theme::crosshair_color(if paper.contains(hp) { t.paper } else { surround })); + draw_crosshair(app, painter, rect, hp, selecting, ink); + if app.canvas.cycle_badge && app.canvas.cycle.is_none() { + cycle_badge(painter, hp); + } + } + } + if inside + && app.session.settings.dynmode + && let Some(hp) = hover_pos + { + // A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking for + // a distance shows its one field and keeps the prompt and its options. + let fields = crate::grip_dyn::draw(app, painter); + if !fields || app.session.current_prompt().is_some_and(|p| p.distance) { + dynamic_input(app, painter, hp, fields); + } + } + // Over the crosshair, so the row under the pointer reads clearly. + cycle_list(app, painter); + // No continuous repaint while hovering: input events repaint by themselves. Repainting every + // frame kept a vsync swapchain's queue full (every frame waited behind queued ones) and would + // spin the CPU with the low-latency present mode (`gpu::surface_config`). +} +struct DoubleClickContext<'a> { + inside: bool, + double: bool, + hover: Option, + xf: Xf, + xf_paper: Xf, + scale: f64, + active_viewport: &'a Option, + active_clip: &'a Option, +} + +fn handle_double_click(app: &mut CadApp, ctx: DoubleClickContext<'_>) -> Option { + let DoubleClickContext { inside, double, hover, xf, xf_paper, scale, active_viewport, active_clip } = ctx; + if inside + && double + && !app.session.prompting() + && matches!(app.session.layout_space(), cadcraft_doc::Space::Paper(_)) + && let Some(hp) = hover + { + let pp = xf_paper.to_world(hp); + let in_rect = |v: &cadcraft_doc::Viewport| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0; + let in_active = active_viewport.as_ref().is_some_and(|v| active_clip.as_ref().map_or(in_rect(v), |c| c.contains(pp))); + if !in_active { + app.canvas.hot_grips.clear(); + app.session.pending_window = None; + app.canvas.drag_window = false; + if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_viewport.is_some() { + let _ = app.run("pspace", serde_json::json!({})); + } + app.session.pending_window = None; + app.canvas.drag_window = false; + app.canvas.list = None; + return None; + } + } + // Double-click text to edit it, as in AutoCAD: TEXTEDIT with that object already picked. + if inside + && double + && app.session.running.is_none() + && let Some(hp) = hover + && edit_text_at(app, xf.to_world(hp)) + { + return None; + } + + let mut hatch_dialog = false; + // Double-click a hatch (no command running) to edit it in the Hatch Edit dialog. The click is + // used up: it must not also start a grip edit on the hatch's centre grip below. + if inside + && double + && app.session.running.is_none() + && let Some(hp) = hover + && let Some(h) = hatch_at(app, xf.to_world(hp), scale) + { + app.session.set_selection(vec![h]); + crate::hatchdlg::open_edit(app); + hatch_dialog = true; + } + + Some(hatch_dialog) +} + +fn update_canvas_cursor( + app: &mut CadApp, + ui: &egui::Ui, + xf: &Xf, + inside: bool, + hover: Option, +) -> (Option, Option) { + let raw_world = hover.filter(|_| inside).map(|p| xf.to_world(p)); + let dyn_frame = crate::cmdline::dyn_frame(app); + if let Some(w) = raw_world { + let mut eff = effective_point(app, w, xf, ui.ctx()); + // A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps. + if let Some(f) = dyn_frame + && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) + && e.first_locked().is_some() + { + eff = crate::dyninput::constrain(&e, &f, eff, app.cmd.second_locked()); + app.canvas.snap = None; + app.canvas.track = None; + } + // Grip dimensional input: typed lengths and angles hold the grip and override snaps. + let grip_held = crate::grip_dyn::constrain(app, eff); + if let Some(q) = grip_held { + eff = q; + app.canvas.snap = None; + app.canvas.track = None; + } + app.canvas.cursor = Some(eff); + app.session.cursor = eff; + app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); + // Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins. + if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) { + override_cursor(app, w, xf); + } + // Typed grip dimensions win over snap overrides too. + if let Some(q) = grip_held { + app.canvas.cursor = Some(q); + app.session.cursor = q; + app.canvas.snap = None; + app.session.cursor_deferred = None; + } + } else { + app.canvas.cursor = None; + app.canvas.snap = None; + app.session.cursor_deferred = None; + } + + (raw_world, dyn_frame) +} + +fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option, raw_world: Option, scale: f64) { + // The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object + // prompts, or no command with PICKFIRST on; SELECTIONPREVIEW says in which of those it shows. + let selecting = app.session.previews_selection(); + if inside && selecting && app.session.pending_window.is_none() { + if hover_pos != app.canvas.hover_at + && let (Some(w), Ok(st)) = (raw_world, app.session.state()) + { + let filter = app.session.settings.previewfilter; + app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale) + .filter(|h| st.doc.entity(*h).is_none_or(|e| !cadcraft_engine::select::preview_filtered(&st.doc, e, filter))); + app.canvas.hover_at = hover_pos; + } + } else { + app.canvas.hover = None; + app.canvas.hover_at = None; + } + // The open selection cycling list highlights the object of the row under the pointer. + if app.canvas.cycle.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.canvas.cycle = None; + } + if let Some(c) = &app.canvas.cycle { + app.canvas.hover = hover_pos.and_then(|hp| c.row_at(hp)); + app.canvas.hover_at = None; + } + shift_space_cycle(app, ui, hover_pos.filter(|_| inside), raw_world); + if inside && hover_pos != app.canvas.cycle_badge_at { + app.canvas.cycle_badge = raw_world.is_some_and(|w| app.session.cycling_badge(w)); + app.canvas.cycle_badge_at = hover_pos; + } else if !inside { + app.canvas.cycle_badge = false; + app.canvas.cycle_badge_at = None; + } +} + +struct PromptClickContext<'a> { + rect: Rect, + xf: Xf, + inside: bool, + middle_down: bool, + primary_clicked: bool, + response: &'a egui::Response, +} + +fn collect_prompt_clicks(app: &mut CadApp, ui: &egui::Ui, context: PromptClickContext<'_>) -> bool { + // egui drops a click whose button comes up a few pixels from where it went down (a drag to it), + // and eframe hands a slow frame's clicks over together, which egui reports as one. + let point_prompt = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p)) + && app.session.pending_window.is_none() + && !app.session.realtime_zoom_active(); + let pressed_primary = context.primary_clicked || (point_prompt && context.response.drag_stopped_by(egui::PointerButton::Primary)); + if point_prompt && context.inside && pressed_primary && !context.middle_down && app.cmd.options.is_none() && app.cmd.buffer.is_empty() { + let releases: Vec = ui.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } => Some(*pos), + _ => None, + }) + .collect() + }); + // The last one is placed below, at the cursor, like any click. + if let Some((_, earlier)) = releases.split_last() { + for pos in earlier.iter().filter(|p| context.rect.contains(**p)) { + let w = effective_point(app, context.xf.to_world(*pos), &context.xf, ui.ctx()); + let result = match app.canvas.snap { + Some(hit) => app.session.input_snapped(hit), + None => app.session.input(Input::Point(w)), + }; + if let Err(e) = result { + app.session.echo(e.to_string()); + } + } + } + } + + pressed_primary +} + /// Draw the drawing area and handle its input (zoom and pan, picking, the crosshair) for this /// frame. /// @@ -247,116 +748,19 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { app.canvas.xf = Some(xf); // ---------- input ---------- - let (hover_pos, scroll, mods, middle_down, pressed_primary, pressed_secondary, dbl_middle, dbl_primary) = ui.input(|i| { - ( - i.pointer.hover_pos(), - i.smooth_scroll_delta, - i.modifiers, - i.pointer.middle_down(), - i.pointer.primary_clicked(), - i.pointer.secondary_clicked(), - i.pointer.button_double_clicked(egui::PointerButton::Middle), - i.pointer.button_double_clicked(egui::PointerButton::Primary), - ) + let pointer = ui.input(|i| PointerInput { + hover_pos: i.pointer.hover_pos(), + scroll: i.smooth_scroll_delta, + mods: i.modifiers, + middle_down: i.pointer.middle_down(), + pressed_primary: i.pointer.primary_clicked(), + pressed_secondary: i.pointer.secondary_clicked(), + dbl_middle: i.pointer.button_double_clicked(egui::PointerButton::Middle), + dbl_primary: i.pointer.button_double_clicked(egui::PointerButton::Primary), }); - // The drawing area alone: a menu popup's frame (between its items and its border), a tooltip - // or a floating palette lives on a layer above the canvas, where `hovered()` alone still - // counts the canvas. Over those the OS cursor stays visible and the crosshair stays put, as - // over the menu bar (#1701). - let inside = hover_pos.is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()) && resp.hovered()); - // The menus the canvas paints itself (selection cycling's rows, Dynamic Input's options) are - // menus too, but they share the canvas's layer: `layer_id_at` reports the drawing under them, - // so only their rectangles tell them apart from it ([`over_canvas_menu`]). They are excluded - // from the cursor contract below, though not from the canvas's input: a click on a row is - // taken by the row. - let on_menu = hover_pos.is_some_and(|p| over_canvas_menu(app, p)); - // Whether Ctrl is held right now. Written every frame (not just while a command prompts), so - // previews that read `Session::ctrl_down` always see the current state. The engine - // deliberately does not carry modifiers through `Input`, which is UI-agnostic (see PR - // #407's Limits note: modifiers used to be dropped on the floor at this point). - app.session.ctrl_down = mods.ctrl; - // Which device is scrolling? On macOS, trackpads (and Magic Mouse) report precise point - // deltas; notched mouse wheels report lines. The answer is sticky because egui spreads a wheel - // notch over several frames. Elsewhere (browsers, Wayland smooth-scroll mice) ordinary wheels - // also report points, so the wheel keeps zooming there. - ui.input(|i| { - for e in &i.raw.events { - if let egui::Event::MouseWheel { unit, .. } = e { - app.canvas.scroll_pans = cfg!(target_os = "macos") && *unit == egui::MouseWheelUnit::Point; - } - } - }); - if inside && app.canvas.scroll_pans && scroll != egui::Vec2::ZERO { - // Two-finger swipe pans: the drawing follows the fingers, like a middle-drag. - // Pinch (and Cmd+scroll) still zoom via `zoom_delta` below. - if let Ok(st) = app.session.state_mut() { - let v = st.view(); - st.set_view_quiet(cadcraft_engine::View { - center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale), - height: v.height, - }); - } - } - let opts = ui.ctx().options(|o| o.input_options); - let wheel = ui.input(|i| wheel_zoom_points(&i.raw.events, &opts, rect.height())); - if inside - && !app.canvas.scroll_pans - && wheel != 0.0 - && let Some(hp) = hover_pos - { - // Zoom with the mouse wheel about the cursor, each notch at once. - let f = app.session.wheel_zoom_factor(f64::from(wheel)); - let about = xf.to_world(hp); - let _ = app.session.zoom_about(f, about); - } - let zoom_pinch = ui.input(|i| crate::animation::zoom_delta(i, &opts, app.shows_animations())); - if inside - && (zoom_pinch - 1.0).abs() > 1e-4 - && let Some(hp) = hover_pos - { - let _ = app.session.zoom_about(f64::from(zoom_pinch), xf.to_world(hp)); - } - // Pan with the middle button (or Shift+right-drag / two-finger drag on trackpads is scroll). - let middle_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle)); - let focused = ui.input(|i| i.focused); - if middle_down - && focused - && (app.canvas.pan_last.is_some() || (middle_pressed && inside)) - && let Some(hp) = hover_pos - { - if let Some(last) = app.canvas.pan_last { - let d = hp - last; - if let Ok(st) = app.session.state_mut() { - let v = st.view(); - st.set_view_quiet(cadcraft_engine::View { - center: v.center - Vec2::new(f64::from(d.x) / scale, -f64::from(d.y) / scale), - height: v.height, - }); - } - } - app.canvas.pan_last = Some(hp); - } else { - app.canvas.pan_last = None; - } - if dbl_middle && inside { - let _ = app.session.zoom_extents(); - } - // ZOOM Realtime: dragging up zooms in, down zooms out. - if resp.dragged_by(egui::PointerButton::Primary) && app.session.realtime_zoom_active() { - let _ = app.session.realtime_zoom(f64::from(resp.drag_delta().y)); - } - // A click in another tile makes it current (or answers -VPORTS Join's prompts). - if pressed_primary - && let Some(hp) = hover_pos - && !rect.contains(hp) - && let Some(i) = tiles.iter().position(|(r, _)| r.contains(hp)) - { - if let Err(e) = app.session.click_tile(i) { - app.session.echo(e.to_string()); - } - ui.ctx().request_repaint(); - return; - } + let navigation = Navigation { rect, tiles: &tiles, response: &resp, xf, pointer }; + let Some((inside, on_menu)) = navigate_canvas(app, ui, &navigation) else { return }; + let PointerInput { hover_pos, mods, middle_down, pressed_primary, pressed_secondary, dbl_primary, .. } = pointer; // Re-read the view after navigation. let view = app.session.state().map(|s| s.view()).unwrap_or(view); let scale = f64::from(rect.height()) / view.height.max(1e-12); @@ -373,124 +777,26 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }; // A clipped active viewport's outline (picks and its heavy border follow it). let active_clip = app.session.state().ok().and_then(|st| st.active_viewport().and_then(|(h, v)| cadcraft_render::viewport_clip(&st.doc, h, &v))); - // Double-click a viewport to work inside it; double-click the sheet outside it to return. - if inside - && dbl_primary - && !app.session.prompting() - && matches!(app.session.layout_space(), cadcraft_doc::Space::Paper(_)) - && let Some(hp) = hover_pos - { - let pp = xf_paper.to_world(hp); - let in_rect = |v: &cadcraft_doc::Viewport| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0; - let in_active = active_vp.as_ref().is_some_and(|v| active_clip.as_ref().map_or(in_rect(v), |c| c.contains(pp))); - if !in_active { - app.canvas.hot_grips.clear(); - app.session.pending_window = None; - app.canvas.drag_window = false; - if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_vp.is_some() { - let _ = app.run("pspace", serde_json::json!({})); - } - app.session.pending_window = None; - app.canvas.drag_window = false; - app.canvas.list = None; - return; - } - } - // Double-click text to edit it, as in AutoCAD: TEXTEDIT with that object already picked. - if inside - && dbl_primary - && app.session.running.is_none() - && let Some(hp) = hover_pos - && edit_text_at(app, xf.to_world(hp)) - { + let Some(hatch_dialog) = handle_double_click( + app, + DoubleClickContext { + inside, + double: dbl_primary, + hover: hover_pos, + xf, + xf_paper, + scale, + active_viewport: &active_vp, + active_clip: &active_clip, + }, + ) else { return; - } + }; - // Double-click a hatch (no command running) to edit it in the Hatch Edit dialog. The click is - // used up: it must not also start a grip edit on the hatch's centre grip below. - let mut hatch_dialog = false; - if inside - && dbl_primary - && app.session.running.is_none() - && let Some(hp) = hover_pos - && let Some(h) = hatch_at(app, xf.to_world(hp), scale) - { - app.session.set_selection(vec![h]); - crate::hatchdlg::open_edit(app); - hatch_dialog = true; - } + let pressed_primary = + collect_prompt_clicks(app, ui, PromptClickContext { rect, xf, inside, middle_down, primary_clicked: pressed_primary, response: &resp }); - // While a command asks for a point, every click places one, however fast the drafting (#815). - // egui drops a click whose button comes up a few pixels from where it went down (a drag to it), - // and eframe hands a slow frame's clicks over together, which egui reports as one. - let point_prompt = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p)) - && app.session.pending_window.is_none() - && !app.session.realtime_zoom_active(); - let pressed_primary = pressed_primary || (point_prompt && resp.drag_stopped_by(egui::PointerButton::Primary)); - if point_prompt && inside && pressed_primary && !middle_down && app.cmd.options.is_none() && app.cmd.buffer.is_empty() { - let releases: Vec = ui.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } => Some(*pos), - _ => None, - }) - .collect() - }); - // The last one is placed below, at the cursor, like any click. - if let Some((_, earlier)) = releases.split_last() { - for pos in earlier.iter().filter(|p| rect.contains(**p)) { - let w = effective_point(app, xf.to_world(*pos), &xf, ui.ctx()); - let result = match app.canvas.snap { - Some(hit) => app.session.input_snapped(hit), - None => app.session.input(Input::Point(w)), - }; - if let Err(e) = result { - app.session.echo(e.to_string()); - } - } - } - } - - let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p)); - let dyn_frame = crate::cmdline::dyn_frame(app); - if let Some(w) = raw_world { - let mut eff = effective_point(app, w, &xf, ui.ctx()); - // A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps. - if let Some(f) = dyn_frame - && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) - && e.first_locked().is_some() - { - eff = crate::dyninput::constrain(&e, &f, eff, app.cmd.second_locked()); - app.canvas.snap = None; - app.canvas.track = None; - } - // Grip dimensional input: typed lengths and angles hold the grip and override snaps. - let grip_held = crate::grip_dyn::constrain(app, eff); - if let Some(q) = grip_held { - eff = q; - app.canvas.snap = None; - app.canvas.track = None; - } - app.canvas.cursor = Some(eff); - app.session.cursor = eff; - app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); - // Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins. - if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) { - override_cursor(app, w, &xf); - } - // Typed grip dimensions win over snap overrides too. - if let Some(q) = grip_held { - app.canvas.cursor = Some(q); - app.session.cursor = q; - app.canvas.snap = None; - app.session.cursor_deferred = None; - } - } else { - app.canvas.cursor = None; - app.canvas.snap = None; - app.session.cursor_deferred = None; - } + let (raw_world, dyn_frame) = update_canvas_cursor(app, ui, &xf, inside, hover_pos); // A click on the Dynamic Input options menu picks the keyword; elsewhere it closes the menu. let mut picked = false; @@ -610,41 +916,49 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { crate::context_menu::show(app, &resp); } - // Hover highlight (throttled to cursor movement). - // The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object - // prompts, or no command with PICKFIRST on; SELECTIONPREVIEW says in which of those it shows. - let selecting = app.session.previews_selection(); - if inside && selecting && app.session.pending_window.is_none() { - if hover_pos != app.canvas.hover_at - && let (Some(w), Ok(st)) = (raw_world, app.session.state()) - { - let filter = app.session.settings.previewfilter; - app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale) - .filter(|h| st.doc.entity(*h).is_none_or(|e| !cadcraft_engine::select::preview_filtered(&st.doc, e, filter))); - app.canvas.hover_at = hover_pos; - } - } else { - app.canvas.hover = None; - app.canvas.hover_at = None; - } - // The open selection cycling list highlights the object of the row under the pointer. - if app.canvas.cycle.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.canvas.cycle = None; - } - if let Some(c) = &app.canvas.cycle { - app.canvas.hover = hover_pos.and_then(|hp| c.row_at(hp)); - app.canvas.hover_at = None; - } - shift_space_cycle(app, ui, hover_pos.filter(|_| inside), raw_world); - if inside && hover_pos != app.canvas.cycle_badge_at { - app.canvas.cycle_badge = raw_world.is_some_and(|w| app.session.cycling_badge(w)); - app.canvas.cycle_badge_at = hover_pos; - } else if !inside { - app.canvas.cycle_badge = false; - app.canvas.cycle_badge_at = None; - } + update_hover(app, ui, inside, hover_pos, raw_world, scale); - // ---------- paint ---------- + paint_canvas(CanvasPaint { + app, + ui, + painter: &painter, + tokens: t, + full, + tiles: &tiles, + rect, + xf, + xf_paper, + view, + active_vp, + active_clip, + inside, + on_menu, + hover_pos, + raw_world, + }); +} + +fn paint_canvas(frame: CanvasPaint<'_>) { + let CanvasPaint { + app, + ui, + painter, + tokens: t, + full, + tiles, + rect, + xf, + xf_paper, + view, + active_vp, + active_clip, + inside, + on_menu, + hover_pos, + raw_world, + } = frame; + let scale = xf.scale; + let selecting = app.session.previews_selection(); painter.rect_filled(rect, 0.0, t.canvas); let t0 = crate::now_ms(); let mut views = vec![(xf_paper.world_bounds(), 1.0 / xf_paper.scale)]; @@ -673,8 +987,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { (Rgb(t.paper.r(), t.paper.g(), t.paper.b()), Some(paper)) } None => { - blockedit::wash(app, &painter, rect); - draw_grid(app, &painter, &xf); + blockedit::wash(app, painter, rect); + draw_grid(app, painter, &xf); (Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()), None) } }; @@ -682,102 +996,33 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // How the object under the cursor and the selected ones are highlighted (PREVIEWEFFECT, // HIGHLIGHT, SELECTIONEFFECT); with HIGHLIGHT off only their grips show. let (preview_look, selection_look) = (Look::preview(&app.session.settings), Look::selection(&app.session.settings)); - // The active viewport's contents, drawn apart from the layout's list, under its own objects. - // `masked`: its outline is in the stencil buffer this frame. - let mut masked = false; - if let Some(f) = ensure_vp_content(app, &xf_paper, ui.ctx()) { - let gpu = app.canvas.gpu.is_some(); - let clipped = painter.with_clip_rect(f.on_screen.intersect(rect)); - draw_vp_content(&mut app.canvas.vp, gpu, &clipped, &f, &xf_paper, bg, app.session.settings.lwdisplay); - masked = gpu && f.outline.is_some(); - } - if app.canvas.gpu.is_some() { - draw_list_gpu(&mut app.canvas, &painter, &xf_paper, bg, app.session.settings.lwdisplay); - } else if let Some(list) = &app.canvas.list { - draw_list(&painter, &xf_paper, list, bg, app.session.settings.lwdisplay); - } - if let Some(vp) = &active_vp { - // The active viewport: a heavy border, highlights in model units clipped to it. - let r = Rect::from_two_pos( - xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)), - xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)), - ); - let heavy = Stroke::new(3.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)); - match &active_clip { - Some(c) => { - let mut pts: Vec = c.polygon().iter().map(|q| xf_paper.to_screen(*q)).collect(); - pts.extend(pts.first().copied()); - painter.add(egui::Shape::line(pts, heavy)); - } - None => { - painter.rect_stroke(r, 0.0, heavy, egui::StrokeKind::Middle); - } - } - if let Ok(st) = app.session.state() { - // A clipped viewport's outline bounds (its outline is masked when `masked`). - let bounds = active_clip.as_ref().map_or(r, |c| { - let b = c.bounds(); - Rect::from_two_pos(xf_paper.to_screen(Vec2::new(b.min.x, b.max.y)), xf_paper.to_screen(Vec2::new(b.max.x, b.min.y))) - }); - let clipped = painter.with_clip_rect(bounds.intersect(rect)); - let c = &mut app.canvas; - if let Some(h) = c.hover - && !sel.contains(&h) - { - c.vp_hover.update(st, &[h], scale); - let list = &c.vp_hover.list; - draw_highlight(&clipped, &xf, list, &(0..list.prims.len()).collect::>(), t.hover, preview_look); - } - if let Some(look) = selection_look - && !sel.is_empty() - { - c.vp_sel.update(st, &sel, scale); - let list = &c.vp_sel.list; - let all = || (0..list.prims.len()).collect::>(); - if c.gpu.is_some() { - // Shares the GPU highlight layer with the layout's own selection; the high bit - // keeps this list's generations apart from the layout list's. - let list_id = c.vp_sel.list_gen | 1 << 63; - let stencil = if masked { crate::gpu::Stencil::Inside } else { crate::gpu::Stencil::Off }; - let hl = (&mut c.hl_key, &c.hl_slot, &mut c.hl_origin, &mut c.hl_infinite); - draw_highlight_gpu(&clipped, &xf, list, list_id, all, &sel, t.selection, look, hl, stencil); - draw_highlight(&clipped, &xf, list, &c.hl_infinite, t.selection, look); - } else { - draw_highlight(&clipped, &xf, list, &all(), t.selection, look); - } - } - } - } else if let CanvasState { list: Some(list), list_gen, prim_index, hover, gpu, hl_key, hl_slot, hl_origin, hl_infinite, .. } = &mut app.canvas { - if let Some(h) = *hover - && !sel.contains(&h) - { - draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, preview_look); - } - if let Some(look) = selection_look { - if gpu.is_some() && !sel.is_empty() { - let list_id = *list_gen; - let hl = (hl_key, &*hl_slot, hl_origin, &mut *hl_infinite); - draw_highlight_gpu( - &painter, - &xf, - list, - list_id, - || prim_index.prims(list_id, list, &sel), - &sel, - t.selection, - look, - hl, - crate::gpu::Stencil::Off, - ); - draw_highlight(&painter, &xf, list, hl_infinite, t.selection, look); - } else { - draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, look); - } - } - } + paint_content_highlights( + &mut CanvasPaint { + app, + ui, + painter, + tokens: t, + full, + tiles, + rect, + xf, + xf_paper, + view, + active_vp: active_vp.clone(), + active_clip: active_clip.clone(), + inside, + on_menu, + hover_pos, + raw_world, + }, + bg, + &sel, + preview_look, + selection_look, + ); app.canvas.draw_ms = crate::now_ms() - t0; - compare::paint(app, ui.ctx(), &painter, &xf_paper, rect); - blockedit::paint(app, ui.ctx(), &painter, &xf_paper, rect); + compare::paint(app, ui.ctx(), painter, &xf_paper, rect); + blockedit::paint(app, ui.ctx(), painter, &xf_paper, rect); // Constraint bars and dynamic dimensional constraints (model space, or inside a viewport). if sheet.is_none() || active_vp.is_some() { let clip = match &active_vp { @@ -831,7 +1076,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } } - crate::dyngrips::draw(app, &painter, &xf, rect, hover_pos); + crate::dyngrips::draw(app, painter, &xf, rect, hover_pos); } // Rubber band preview of the active command. // `xf` is the space being edited: paper space, or model space inside the active viewport. @@ -843,9 +1088,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if let Ok(st) = app.session.state() { let serial = app.session.clipboard_source.as_ref().map_or(0, |c| c.serial); let transparency = app.session.settings.transparency_display; - draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg, transparency); + draw_drag_gpu(&mut app.canvas, painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg, transparency); let list = rebuilt_preview(&st.doc, extra.iter(), scale); - draw_list(&painter, &xf, &list, bg, false); + draw_list(painter, &xf, &list, bg, false); } } else if let (Some(c), true) = (app.canvas.cursor, app.session.prompting()) { let ents = app.session.preview(c); @@ -853,7 +1098,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Ok(st) = app.session.state() { let list = rebuilt_preview(&st.doc, ents.iter(), scale); - draw_list(&painter, &xf, &list, bg, false); + draw_list(painter, &xf, &list, bg, false); } } // Pending selection window. @@ -873,59 +1118,30 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { painter.rect_stroke(r, 0.0, st, egui::StrokeKind::Middle); } } - // Acquired tracking points and the polar / tracking paths under the cursor. - if app.canvas.cursor.is_some() { - draw_tracking(&painter, &xf, &app.session.tracking.acquired, app.canvas.track.as_ref(), view.height * 4.0); - } - if let (Some(hit), Some(_)) = (app.canvas.snap, app.canvas.cursor) { - draw_snap_marker(&painter, xf.to_screen(hit.point), &hit); - } - draw_tiles(app, ui, &tiles); - // UCS icon, ViewCube and viewport label. - if app.ui.show_ucs_icon { - draw_ucs_icon(&painter, rect); - crate::viewcube::ucs_icon(app, ui, rect); - } - if app.ui.show_viewcube && sheet.is_none() { - draw_viewcube(app, ui, rect); - } - viewport_label(&painter, rect); - // Typing and Escape reach the command line even when its overlay is hidden. - crate::cmdline::keyboard(app, ui.ctx()); - // Command line overlay. - if app.ui.show_command_line { - crate::cmdline::show(app, ui, full); - } - // Crosshair cursor. - if inside && !on_menu { - // Hide the OS cursor under the drawn crosshair, unless assistive tech needs it (#39). - ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None }); - if let Some(hp) = hover_pos { - // Use the painted sheet even in MSPACE, where the editing transform follows a - // model viewport. The whole cursor takes one colour from its centre. - let ink = paper_rect.map_or(t.crosshair, |paper| crate::theme::crosshair_color(if paper.contains(hp) { t.paper } else { surround })); - draw_crosshair(app, &painter, rect, hp, selecting, ink); - if app.canvas.cycle_badge && app.canvas.cycle.is_none() { - cycle_badge(&painter, hp); - } - } - } - if inside - && app.session.settings.dynmode - && let Some(hp) = hover_pos - { - // A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking for - // a distance shows its one field and keeps the prompt and its options. - let fields = crate::grip_dyn::draw(app, &painter); - if !fields || app.session.current_prompt().is_some_and(|p| p.distance) { - dynamic_input(app, &painter, hp, fields); - } - } - // Over the crosshair, so the row under the pointer reads clearly. - cycle_list(app, &painter); - // No continuous repaint while hovering: input events repaint by themselves. Repainting every - // frame kept a vsync swapchain's queue full (every frame waited behind queued ones) and would - // spin the CPU with the low-latency present mode (`gpu::surface_config`). + paint_pointer_overlay( + &mut CanvasPaint { + app, + ui, + painter, + tokens: t, + full, + tiles, + rect, + xf, + xf_paper, + view, + active_vp: active_vp.clone(), + active_clip: active_clip.clone(), + inside, + on_menu, + hover_pos, + raw_world, + }, + paper_rect, + surround, + selecting, + sheet, + ); } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 427445561..02f1cf4c2 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CadApp, Color32, Pos2, Rect, Shape, SnapCycle, SnapHit, Stroke, Tokens, Vec2, Xf, pos2, snap, vec2}; pub(super) fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { let t = Tokens::get(); diff --git a/crates/ui-egui/src/canvas/cycle.rs b/crates/ui-egui/src/canvas/cycle.rs index f32123c8d..e7dae44bc 100644 --- a/crates/ui-egui/src/canvas/cycle.rs +++ b/crates/ui-egui/src/canvas/cycle.rs @@ -1,5 +1,5 @@ use super::grips::grip_at; -use super::*; +use super::{CadApp, Handle, Pos2, Rect, Stroke, Tokens, Vec2, Xf, pos2, vec2}; /// The objects under a click that selection cycling lets the user choose between: where the click /// was, each object with its row's label (type and layer), Shift held, and the rows as drawn. diff --git a/crates/ui-egui/src/canvas/draw.rs b/crates/ui-egui/src/canvas/draw.rs index 0c11eb635..9ca1d9314 100644 --- a/crates/ui-egui/src/canvas/draw.rs +++ b/crates/ui-egui/src/canvas/draw.rs @@ -1,5 +1,5 @@ use super::list::preview_options; -use super::*; +use super::{Bounds2, CanvasState, Color32, DisplayList, Handle, Kind, ListKey, Pos2, Rect, Rgb, Shape, Stroke, Vec2, Xf, vec2}; fn color32(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) @@ -336,6 +336,7 @@ pub(super) fn selection_area_fill(s: &cadcraft_engine::Settings, crossing: bool) #[cfg(test)] mod highlight_stability_tests { use super::*; + use egui::pos2; fn frame(list: &DisplayList, look: Look, pointer: Pos2) -> Vec { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/canvas/dyn_boxes.rs b/crates/ui-egui/src/canvas/dyn_boxes.rs index 686f09110..b0bcfb9e2 100644 --- a/crates/ui-egui/src/canvas/dyn_boxes.rs +++ b/crates/ui-egui/src/canvas/dyn_boxes.rs @@ -1,5 +1,5 @@ use super::widgets::draw_lock; -use super::*; +use super::{CadApp, Pos2, Rect, Shape, Stroke, Tokens, Vec2, pos2, vec2}; /// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt /// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]); diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index c2e616aaf..a07f4e092 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Bounds2, CadApp, Shape, Stroke, Tokens, Vec2, Xf, pos2}; /// Grid spacing adapted to the zoom: multiply the unit until lines are at least `min_px` apart. fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { diff --git a/crates/ui-egui/src/canvas/grips.rs b/crates/ui-egui/src/canvas/grips.rs index 65e1cbc16..8bd0e6467 100644 --- a/crates/ui-egui/src/canvas/grips.rs +++ b/crates/ui-egui/src/canvas/grips.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2}; /// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts /// editing it, with the other hot grips when it is one of them. diff --git a/crates/ui-egui/src/canvas/list.rs b/crates/ui-egui/src/canvas/list.rs index 0e15fc01e..2d548264e 100644 --- a/crates/ui-egui/src/canvas/list.rs +++ b/crates/ui-egui/src/canvas/list.rs @@ -1,5 +1,5 @@ use super::viewport::{split_viewport, stencil_clip}; -use super::*; +use super::{Bounds2, CadApp, DisplayList, Handle, ListKey, Rgb, Tokens, Xf, rebuild, same_content}; /// Primitives of `list` sorted by entity handle, built on the first highlight after each list /// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms diff --git a/crates/ui-egui/src/canvas/pick.rs b/crates/ui-egui/src/canvas/pick.rs index ea970a8d0..3bd28b017 100644 --- a/crates/ui-egui/src/canvas/pick.rs +++ b/crates/ui-egui/src/canvas/pick.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CadApp, Handle, Input, Vec2}; /// The vertical wheel scroll this frame that zooms, in points, read from the raw wheel events so /// each notch zooms by its whole step at once, as in standard CAD (#660). egui's smoothed delta diff --git a/crates/ui-egui/src/canvas/viewport.rs b/crates/ui-egui/src/canvas/viewport.rs index c96a2bc83..c79354b6c 100644 --- a/crates/ui-egui/src/canvas/viewport.rs +++ b/crates/ui-egui/src/canvas/viewport.rs @@ -1,5 +1,5 @@ use super::draw::draw_list; -use super::*; +use super::{Bounds2, CadApp, DisplayList, Handle, Kind, Rect, Rgb, Vec2, Xf}; /// The active MSPACE viewport when its contents are drawn apart from the layout's list (see /// [`VpContent`]), with its clip outline when it is clipped. A clipped viewport is clipped with diff --git a/crates/ui-egui/src/canvas/widgets.rs b/crates/ui-egui/src/canvas/widgets.rs index cd55a7f67..d10e40af3 100644 --- a/crates/ui-egui/src/canvas/widgets.rs +++ b/crates/ui-egui/src/canvas/widgets.rs @@ -1,6 +1,6 @@ use super::draw::draw_list; use super::grid::draw_grid; -use super::*; +use super::{CadApp, Color32, Pos2, Rect, Rgb, Shape, Stroke, Tokens, Xf, pos2, vec2}; pub(super) fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile)> { let x = |f: f64| full.left() + full.width() * f.clamp(0.0, 1.0) as f32; diff --git a/crates/ui-egui/src/capture/tests.rs b/crates/ui-egui/src/capture/tests.rs index 6e70aea7b..b85961c44 100644 --- a/crates/ui-egui/src/capture/tests.rs +++ b/crates/ui-egui/src/capture/tests.rs @@ -168,6 +168,7 @@ fn focus_change_cancels_an_unissued_capture() { /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature. #[test] fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); @@ -190,7 +191,6 @@ fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files( assert_ne!(a, b, "one file per request"); for path in [&a, &b] { assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}"); - let _ = std::fs::remove_file(path); } assert!(app.shots.is_empty()); } @@ -342,6 +342,7 @@ fn raw_geometry_uses_screen_rect_when_inner_rect_is_absent() { /// not issue, and the settled second pass must issue exactly once. #[test] fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); let (rx, _) = admit_one(&mut app, &ctx); @@ -365,13 +366,15 @@ fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass"); complete(callbacks, [800, 600]); drain(&mut app, &ctx); - assert_eq!(rx.recv().expect("a reply")["ok"], true); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); } /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate. #[test] fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); app.options.interface_scale = 2.0; @@ -387,8 +390,6 @@ fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { drain(&mut app, &ctx); let reply = rx.recv().expect("a reply"); assert_eq!(reply["ok"], true, "{reply}"); - let path = reply["result"]["path"].as_str().expect("a file").to_string(); - let _ = std::fs::remove_file(path); } /// A→B→A: a switch that returns to the same drawing still invalidates the queued request. @@ -447,6 +448,7 @@ fn a_settled_window_does_not_keep_changing_the_surface_epoch() { /// A window resized while the request is still settling is issued against the new size. #[test] fn a_resize_while_settling_is_absorbed_at_issue() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); let (rx, _) = admit_one(&mut app, &ctx); @@ -464,6 +466,4 @@ fn a_resize_while_settling_is_absorbed_at_issue() { drain(&mut app, &ctx); let reply = rx.recv().expect("a reply"); assert_eq!(reply["ok"], true, "{reply}"); - let path = reply["result"]["path"].as_str().expect("a file").to_string(); - let _ = std::fs::remove_file(path); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f3a935d79..7639dbba1 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -290,6 +290,43 @@ fn visible_tabs(widths: &[f32], active: Option, room: f32) -> std::ops::R start..active + 1 } +fn draw_status_toggles( + app: &mut CadApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + t: Tokens, + toggles: &[(Icon, bool, &str, &str)], + layout: (Rect, f32, f32), +) { + let (rect, mut rx, size) = layout; + let mut toggle_cmd = None; + for &(icon, on, cmd, tip) in toggles { + let br = Rect::from_min_size(pos2(rx, rect.center().y - size / 2.0), vec2(size, size)); + let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click()); + if on { + painter.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35)); + } else if resp.hovered() { + painter.rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.5)); + } + icons::paint(painter, br.shrink(2.5), icon, false); + if on { + // Re-tint the icon blue-ish by an underline. + painter.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on)); + } + icons::describe_control(ui, &resp, tip, Some(on), false); + if resp.on_hover_text(crate::i18n::t(tip)).clicked() { + toggle_cmd = Some(cmd); + } + rx += size + 4.0; + } + // The toggles don't cancel the active command (#1108); the dialog buttons start theirs. + match toggle_cmd { + Some(c) if c.starts_with("ui.") => app.start(c), + Some(c) => crate::menus::run_transparent(app, c), + None => {} + } +} + /// Status bar: layout tabs follow the left-hand layout actions, independent of palette docking. /// Coordinates and drafting toggles remain on the right. pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { @@ -343,7 +380,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { (Icon::Gear, false, "ui.dialog.dsettings", "Drafting Settings"), ]; let size = 20.0; - let mut rx = r.right() - 8.0 - toggles.len() as f32 * (size + 4.0); + let rx = r.right() - 8.0 - toggles.len() as f32 * (size + 4.0); // Coordinates. let coord = app.canvas.cursor.map(|c| { let (lu, lp) = app.session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)); @@ -486,32 +523,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text); } } - let mut toggle_cmd = None; - for (icon, on, cmd, tip) in toggles { - let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size)); - let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click()); - if on { - p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35)); - } else if resp.hovered() { - p.rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.5)); - } - icons::paint(&p, br.shrink(2.5), icon, false); - if on { - // Re-tint the icon blue-ish by an underline. - p.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on)); - } - icons::describe_control(ui, &resp, tip, Some(on), false); - if resp.on_hover_text(crate::i18n::t(tip)).clicked() { - toggle_cmd = Some(cmd); - } - rx += size + 4.0; - } - // The toggles don't cancel the active command (#1108); the dialog buttons start theirs. - match toggle_cmd { - Some(c) if c.starts_with("ui.") => app.start(c), - Some(c) => crate::menus::run_transparent(app, c), - None => {} - } + draw_status_toggles(app, ui, &p, t, &toggles, (r, rx, size)); }); } @@ -563,7 +575,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { y += 46.0; } if let Some(path) = recent { - app.open_path(&path); + app.open_path(std::path::Path::new(&path)); } else if app.ui.recent_files.is_empty() { p.text( pos2(main.center().x, main.center().y), diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index d78561551..40397df99 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -379,13 +379,11 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { app.cmd.buffer.pop(); } } - Key::ArrowUp if !modifiers.any() => { - if !app.cmd.history.is_empty() { - let n = app.cmd.history.len(); - let pos = app.cmd.history_pos.map(|p| p.saturating_sub(1)).unwrap_or(n - 1); - app.cmd.history_pos = Some(pos); - app.cmd.buffer = app.cmd.history.get(pos).cloned().unwrap_or_default(); - } + Key::ArrowUp if !modifiers.any() && !app.cmd.history.is_empty() => { + let n = app.cmd.history.len(); + let pos = app.cmd.history_pos.map(|p| p.saturating_sub(1)).unwrap_or(n - 1); + app.cmd.history_pos = Some(pos); + app.cmd.buffer = app.cmd.history.get(pos).cloned().unwrap_or_default(); } Key::ArrowDown if !modifiers.any() => { if !options_key(app, 1) @@ -445,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 { @@ -926,4 +926,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 2dc1f898a..8d0a39bf4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -500,7 +500,8 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou 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; @@ -619,6 +620,35 @@ fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool { } } +/// Pathless screenshots share one directory per process. Tests that write them hold this guard, +/// so no other test writes while it is cleaned up. Dropping it, even while a failed test unwinds, +/// removes every screenshot file in the directory and then the directory itself. +#[cfg(test)] +pub(crate) struct PathlessShots { + _lock: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +pub(crate) fn lock_pathless_shots() -> PathlessShots { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + PathlessShots { _lock: LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +} + +#[cfg(test)] +impl Drop for PathlessShots { + fn drop(&mut self) { + let Ok(dir) = private_shot_dir() else { return }; + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with("cadcraft-shot-") { + let _ = std::fs::remove_file(entry.path()); + } + } + } + let _ = std::fs::remove_dir(&dir); + } +} + /// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG. fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result { use std::io::Write as _; @@ -641,8 +671,13 @@ fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Resu let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce())); match create_private_file(&path) { Ok(mut file) => { - img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?; - file.flush()?; + let written = img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other).and_then(|()| file.flush()); + if let Err(e) = written { + // The reply carries no path on failure, so nobody else could remove the partial file. + drop(file); + let _ = std::fs::remove_file(&path); + return Err(e); + } return Ok(path); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, @@ -735,6 +770,7 @@ mod tests { /// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700. #[test] fn pathless_screenshots_are_unique_private_png_files() { + let _shots = lock_pathless_shots(); let img = egui::ColorImage::filled([4, 3], egui::Color32::RED); let first = save_screenshot(&img, None, 1); let second = save_screenshot(&img, None, 1); @@ -759,9 +795,6 @@ mod tests { let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777; assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only"); } - for path in [&a, &b] { - let _ = std::fs::remove_file(path); - } } /// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged. diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index bbbf08902..50fb52a25 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -350,9 +350,9 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] for files in std::mem::take(&mut output.window_files) { for file in files.dropped { - let path = file.path().to_string_lossy().to_string(); - if !path.is_empty() { - app.open_path(&path); + let path = file.path(); + if !path.as_os_str().is_empty() { + app.open_path(path); } } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 3c0be7611..30f3b0a85 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -23,6 +23,8 @@ use cadcraft_render::{DisplayList, Kind}; use egui_wgpu::wgpu; use egui_wgpu::wgpu::util::DeviceExt; +use crate::switch::Switch; + /// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8, /// premultiplied by its alpha: transparent objects blend with what is under them). const VERTEX_SIZE: usize = 12; @@ -44,12 +46,8 @@ const POINT_SIZE: f32 = 1.5; /// then Mailbox, then falls back to FIFO, so it is valid on every surface; under a compositor the /// compositor still syncs to the display. `vsync` is the `CADCRAFT_VSYNC` override: `1`/`on` /// forces FIFO (tear-free without a compositor), `0`/`off` forces the low-latency mode. -pub fn surface_config(vsync: Option<&str>, low_latency: bool) -> egui_wgpu::SurfaceConfig { - let no_vsync = match vsync.map(|v| v.trim().to_ascii_lowercase()).as_deref() { - Some("1" | "on" | "true" | "yes") => false, - Some("0" | "off" | "false" | "no") => true, - _ => low_latency, - }; +pub fn surface_config(vsync: Switch, low_latency: bool) -> egui_wgpu::SurfaceConfig { + let no_vsync = !vsync.or(!low_latency); egui_wgpu::SurfaceConfig { present_mode: if no_vsync { wgpu::PresentMode::AutoNoVsync } else { wgpu::PresentMode::AutoVsync }, desired_maximum_frame_latency: Some(1), @@ -801,17 +799,11 @@ mod tests { #[test] fn surface_config_prefers_low_latency_where_asked_and_honours_override() { use wgpu::PresentMode::{AutoNoVsync, AutoVsync}; - assert_eq!(surface_config(None, true).present_mode, AutoNoVsync); - assert_eq!(surface_config(None, false).present_mode, AutoVsync); - for on in ["1", "on", " TRUE ", "yes"] { - assert_eq!(surface_config(Some(on), true).present_mode, AutoVsync, "{on}"); - } - for off in ["0", "Off", "false", "no"] { - assert_eq!(surface_config(Some(off), false).present_mode, AutoNoVsync, "{off}"); - } - assert_eq!(surface_config(Some("maybe"), true).present_mode, AutoNoVsync); - assert_eq!(surface_config(Some(""), false).present_mode, AutoVsync); - assert_eq!(surface_config(None, true).desired_maximum_frame_latency, Some(1)); + assert_eq!(surface_config(Switch::Default, true).present_mode, AutoNoVsync); + assert_eq!(surface_config(Switch::Default, false).present_mode, AutoVsync); + assert_eq!(surface_config(Switch::On, true).present_mode, AutoVsync); + assert_eq!(surface_config(Switch::Off, false).present_mode, AutoNoVsync); + assert_eq!(surface_config(Switch::Default, true).desired_maximum_frame_latency, Some(1)); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ed252f88b..7bb00ec17 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -45,10 +45,12 @@ pub enum Preference { Hungarian, #[serde(rename = "tr")] Turkish, + #[serde(rename = "bs")] + Bosnian, } impl Preference { - pub const ALL: [Self; 17] = [ + pub const ALL: [Self; 18] = [ Self::Auto, Self::English, Self::Greek, @@ -66,6 +68,7 @@ impl Preference { Self::Arabic, Self::Hungarian, Self::Turkish, + Self::Bosnian, ]; /// Every preference in the order the Interface Language dialog lists them: Auto, then English @@ -113,6 +116,7 @@ impl Preference { Self::Arabic => "ar", Self::Hungarian => "hu", Self::Turkish => "tr", + Self::Bosnian => "bs", } } @@ -141,6 +145,7 @@ impl Preference { Self::Arabic => "العربية", Self::Hungarian => "Magyar", Self::Turkish => "Türkçe", + Self::Bosnian => "Bosanski", } } @@ -171,6 +176,7 @@ impl Preference { Self::Arabic => "ar", Self::Hungarian => "hu", Self::Turkish => "tr", + Self::Bosnian => "bs", Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"), } } @@ -187,7 +193,7 @@ pub struct Language { catalog: OnceLock>, } -pub static LANGUAGES: [Language; 16] = [ +pub static LANGUAGES: [Language; 17] = [ 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 { @@ -234,6 +240,9 @@ pub static LANGUAGES: [Language; 16] = [ Language { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, // Turkish keeps a noun singular after a number ("3 commit"), so like Korean it has one form. Language { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() }, + // 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() }, ]; /// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`. @@ -437,6 +446,7 @@ mod picker_order_tests { assert!(pos(Preference::Czech) < pos(Preference::German), "Čeština sorts under C"); 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_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 9ada44442..40e14cdb5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -96,6 +96,7 @@ Blend دمج تدريجي Block كتلة Block Attribute Manager... مدير سمات الكتلة... + Block Description... وصف الكتلة... Block Editor محرر الكتل Block Reference مرجع كتلة Block... كتلة... @@ -587,6 +588,7 @@ Name الاسم Name (A–Z) الاسم (أ–ي) Named مسمى + Named Viewports... أطر العرض المسماة... Named Views... طرق عرض مسماة... Nearest الأقرب New جديد diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv new file mode 100644 index 000000000..14565ce2f --- /dev/null +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -0,0 +1,1449 @@ +# CADCraft Bosnian interface catalog — original translations by Nine (@nine-codes) 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. +# Latin script, Ijekavian; the wording is shared with storytold/craft-i18n (locales/bs/cadcraft.tsv +# and common.tsv). Not yet reviewed by a native speaker. +# UTF-8: contextEnglish sourceBosnian translation. +# @plural = one|few|other forms for integer counts. + Apply Primijeni + Attach DWG... Priloži DWG... + Bind Veži + Detach Odvoji + Loaded Učitano + Make it a block of this drawing, keeping its layer names Pretvori u blok ovog crteža, zadržavajući nazive slojeva + Make it a block of this drawing; its layers become NAME$0$LAYER Pretvori u blok ovog crteža; slojevi postaju NAZIV$0$SLOJ + No external references are attached. Nisu priložene vanjske reference. + Not Found Nije pronađeno + Not loaded Nije učitano + Open Otvori + Overlay Prekrivanje + Reference Manager Upravitelj referenci + Reference Name Naziv reference + References: {n} Reference: {n} + Reload Ponovo učitaj + Reload All Ponovo učitaj sve + Saved Path Sačuvana putanja + Unload Isprazni iz memorije + Unloaded Ispražnjeno iz memorije + Unreferenced Nereferencirano + Unresolved Neriješeno + Cancel Otkaži + OK U redu + or [ ili [ + % of all commits % svih commitova + * Wildcard Match * Podudaranje zamjenskim znakom + *VARIES* *RAZLIČITO* + + | Top | 2D Wireframe + | Vrh | 2D žičani model + +Bin +Bin + +LOC +LOC + 1 Viewport 1 prozor prikaza + 2 Points 2 tačke + 2 Viewports 2 prozora prikaza + 2D Solid 2D puno tijelo + 3 Points 3 tačke + 3 Viewports 3 prozora prikaza + 3D Face 3D lice + 3D Operations 3D operacije + 3D Polyline 3D polilinija + 4 Viewports 4 prozora prikaza + < Less than < Manje od + <> Not Equal <> Nije jednako + = Equals = Jednako + > Greater than > Veće od + A variable name starts with a letter and has only letters, digits and _ Naziv varijable počinje slovom i sadrži samo slova, cifre i _ + About O programu + About CADCraft O CADCraftu + Absolute Apsolutno + Add Leader Dodaj pokaznu liniju + Additional angles Dodatni uglovi + Align Poravnaj + Align Text Poravnaj tekst + Aligned Poravnato + All Sve + All Annotation Objects Svi objekti napomena + All Used Layers Svi korišteni slojevi + All files Sve datoteke + Angle Ugao + Angular Ugaono + Annotation Visibility Vidljivost napomena + Annotation scale Skala napomena + Apparent Intersection Prividni presjek + Append to current selection set Dodaj trenutnom skupu odabira + Apply to: Primijeni na: + Apply true color Primijeni pravu boju + Arc Luk + Arc Length Dužina luka + Area Područje + Arrange Panel Panel rasporeda + Array Niz + Attach Priloži + Attribute Atribut + Attribute Definition Definicija atributa + Attribute Display Prikaz atributa + Audit Revizija + Auto (system language) Automatski (jezik sistema) + Auto Constrain Automatska ograničenja + AutoConstrain Automatska ograničenja + Axis, End Osa, kraj + Base Baza + Baseline Osnovna linija + Batch Publish Paketno objavljivanje + Binary assets added Dodani binarni resursi + Binary assets removed Uklonjeni binarni resursi + Bind... Veži... + Blend Prelaz + Block Blok + Block Attribute Manager... Upravitelj atributa blokova... + Block Description... Opis bloka... + Block Editor Uređivač blokova + Block Reference Referenca bloka + Block... Blok... + Blocks Blokovi + Blue Plava + Bold Podebljano + Boolean Booleova operacija + Boundary Granica + Boundary... Granica... + Box Kvadar + Break Prekini + Break At Point Prekini u tački + Bring Above Objects Postavi iznad objekata + Bring Annotations to Front Postavi napomene na vrh + Bring to Front Postavi na vrh + Bulleted list Lista s oznakama + ByBlock PoBloku + ByColor PoBoji + ByLayer PoSloju + CADCraft Help Pomoć za CADCraft + Cancel Otkaži + Cartesian (x, y) Kartezijske (x, y) + Center Sredina + Center Line Središnja linija + Center Mark Oznaka središta + Center X Središte X + Center Y Središte Y + Center Z Središte Z + Center, Diameter Središte, prečnik + Center, Radius Središte, poluprečnik + Center, Start, Angle Središte, početak, ugao + Center, Start, End Središte, početak, kraj + Center, Start, Length Središte, početak, dužina + Chamfer Zakošenje + Change Space Promijeni prostor + Change to Current Layer Promijeni u trenutni sloj + Changed: {n} Promijenjeno: {n} + Circle Krug + Circumference Obim kruga + Clear History Očisti historiju + Clear Očisti + Clip Odsijeci + Clip Viewport Odsijeci prozor prikaza + Clipboard Međuspremnik + Close Zatvori + Close All Zatvori sve + Close All Other Drawings Zatvori sve ostale crteže + Closed Zatvoreno + Close Comparison Zatvori poređenje + Export Comparison Izvezi poređenje + Coincident Podudarno + Collapse Tool Sets Skupi skupove alata + Collinear Kolinearno + Color Boja + Color 8 Boja 8 + Color 9 Boja 9 + Color Index Indeks boje + Color... Boja... + Command Aliases Pseudonimi naredbi + Command History Historija naredbi + Clear Command History Očisti historiju naredbi + Command Line Komandna linija + Command Line History Lines Redovi historije komandne linije + Command Reference Referenca naredbi + Commits Commitovi + Company Firma + Compare Drawings... Uporedi crteže... + Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Računarski potpomognuto projektovanje i crtanje: CAD aplikacija otvorenog koda, napisana od nule u čistom Rustu. + Concentric Koncentrično + Cone Kupa + Constraint Bars Trake ograničenja + Constraint Settings Postavke ograničenja + Construction Line Konstrukcijska linija + Contents Sadržaj + Continue Nastavi + Contributors Saradnici + Control Vertices Kontrolna tjemena + Convert Pretvori + Copy Kopiraj + Copy Full File Path Kopiraj punu putanju datoteke + Copy History Kopiraj historiju + Copy Layout Kopiraj raspored + Copy Nested Objects Kopiraj ugniježđene objekte + Copy Selection Kopiraj odabir + Copy with Base Point Kopiraj s baznom tačkom + Count Broj + Count List Lista brojanja + Count Table Tabela brojanja + Create Block Kreiraj blok + Creates a user parameter Kreira korisnički parametar + Ctrl+Enter to finish Ctrl+Enter za završetak + Current layer Trenutni sloj + Current selection Trenutni odabir + Current: {color} Trenutno: {color} + Cut Izreži + Cut with Base Point Izreži s baznom tačkom + Cyan Cijan + Cylinder Valjak + DWG DWG + DWG Reference... DWG referenca... + DXF DXF + Dark Tamno + Data Extraction... Izdvajanje podataka... + Default Zadano + Data Link... Veza s podacima... + Default Save Format Zadani format čuvanja + Default Save Version Zadana verzija čuvanja + Define Attributes Definiši atribute + Define Attributes... Definiši atribute... + Defined width Definisana širina + Degree Stepen + Degrees of Freedom Stepeni slobode + Degrees of freedom: {n} Stepeni slobode: {n} + Delete Izbriši + Delete Column Izbriši kolonu + Delete Constraints Izbriši ograničenja + Delete Dimension Style Izbriši stil kotiranja + Delete Duplicate Objects Izbriši duplikate objekata + Delete Layer Izbriši sloj + Delete Layer State Izbriši stanje slojeva + Delete Layout Izbriši raspored + Delete Row Izbriši red + Delete Text Style Izbriši stil teksta + Delta X Delta X + Delta Y Delta Y + Description Opis + Deselect All Poništi sav odabir + Diameter Prečnik + Dim style Stil kote + Dimension Break Prekid kote + Dimension Edit Uređivanje kote + Dimension Kota + Dimension Objects Only Samo objekti kota + Dimension Space Razmak kota + Dimension Style Override Zamjena stila kotiranja + Dimension Style... Stil kotiranja... + Dimension Text Edit Uređivanje teksta kote + Dimension style Stil kotiranja + Dimensional Constraint Dimenzionalno ograničenje + Dimensional Constraint Parameters Parametri dimenzionalnih ograničenja + Dimensional Constraints Dimenzionalna ograničenja + Disassociate Dimensions Razdruži kote + Display name Ime za prikaz + Distance Udaljenost + Divide Podijeli + Do not plot (click: plot) Ne iscrtavaj (klik: iscrtaj) + Donut Prsten + Double-click to edit the description Dvostruki klik za uređivanje opisa + Down Dolje + Drafting Crtanje + Drafting Settings Postavke crtanja + Drafting Settings... Postavke crtanja... + Draw Crtaj + Draw Order Redoslijed crtanja + Drawing (DWG) Crtež (DWG) + Drawing (DXF) Crtež (DXF) + Drawing Limits Granice crteža + Drawing Template (DWT) Predložak crteža (DWT) + Drawing Utilities Uslužni programi za crteže + Drawings (DWG, DXF) Crteži (DWG, DXF) + Dynamic Dinamično + Dynamic Dimensions Dinamičke kote + Dynamic Input Dinamički unos + Dynamic Input (F12) Dinamički unos (F12) + E E + Edit Uredi + Edit Attributes... Uredi atribute... + Edit Text... Uredi tekst... + Edit Table Cell Uredi ćeliju tabele + Elevation Elevacija + Ellipse Elipsa + Elliptical Arc Eliptični luk + Enable Dynamic Input (F12) Omogući dinamički unos (F12) + End Object Isolation Završi izolaciju objekata + End Width Krajnja širina + End X Kraj X + End Y Kraj Y + End angle Krajnji ugao + Endpoint Krajnja tačka + Enter Enter + Enter Top, Zero or Bottom. Unesite Vrh, Nula ili Dno. + Enter a fit data option Unesite opciju podataka uklapanja + Enter a pattern name Unesite naziv uzorka + 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 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 + Enter attribute value Unesite vrijednost atributa + Enter block name Unesite naziv bloka + Enter constraint type Unesite vrstu ograničenja + Enter continuity Unesite kontinuitet + 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 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] + Enter dimension variable name to override Unesite naziv varijable kotiranja za zamjenu + Enter justification type Unesite vrstu poravnanja + Enter leader text Unesite tekst pokazne linije + Enter mline scale Unesite skalu višestruke linije + Enter mode Unesite način + Enter name of block to insert Unesite naziv bloka za umetanje + Enter new text Unesite novi tekst + Enter next point Unesite sljedeću tačku + Enter number of columns Unesite broj kolona + Enter number of data rows Unesite broj redova podataka + Enter number of items Unesite broj stavki + Enter number of items along path Unesite broj stavki duž putanje + Enter number of sides Unesite broj stranica + Enter number of turns Unesite broj zavoja + Enter percentage length Unesite postotak dužine + Enter rectangle length Unesite dužinu pravougaonika + Enter rectangle width Unesite širinu pravougaonika + Enter scale factor Unesite faktor skale + Enter text Unesite tekst + Enter text (use \P for new paragraphs) Unesite tekst (koristite \P za nove pasuse) + Enter the number of segments Unesite broj segmenata + Enter twist direction of helix Unesite smjer uvijanja spirale + Entire drawing Cijeli crtež + Equal Jednako + Erase Briši + Evaluate Izračunaj + Exclude from new selection set Isključi iz novog skupa odabira + Explode Rastavi + Export Izvezi + Export Table Izvezi tabelu + Export to PDF... Izvezi u PDF... + Expression Izraz + Extend Produži + Extension Produžetak + Extents Opseg + External Reference Vanjska referenca + External References Vanjske reference + Extrude Istisni + File Datoteka + File Tab Kartica datoteke + Fillet Zaobljenje + Filter Filter + Filters Filteri + Find and Replace Pronađi i zamijeni + Find... Pronađi... + First Prvo + First commit Prvi commit + Fit Points Tačke uklapanja + Fix Fiksiraj + Flatten Objects Spljošti objekte + Format Format + Freehand Prostoručno + Freeze Zamrzni + Freeze Layer Zamrzni sloj + Freeze in new viewports Zamrzni u novim prozorima prikaza + Freeze/Thaw Zamrzni/odmrzni + Freeze/Thaw in all viewports Zamrzni/odmrzni u svim prozorima prikaza + Freeze/Thaw in the current viewport Zamrzni/odmrzni u trenutnom prozoru prikaza + From Od + From Template... Iz predloška... + General Općenito + Geometric Center Geometrijsko središte + Geometric Constraint Geometrijsko ograničenje + Geometric Constraints Geometrijska ograničenja + Geometry Geometrija + Get from the Drawing Preuzmi iz crteža + Get Variable Preuzmi varijablu + Get View Preuzmi pogled + Global Globalno + Global width Globalna širina + Grab bag Svaštara + Gradient Gradijent + Gradient... Gradijent... + Green Zelena + Grid (F7) Mreža (F7) + Grid Display Prikaz mreže + Grid On (F7) Mreža uključena (F7) + Grid snap Hvatanje mreže + Grid spacing Razmak mreže + Grip Edit Uređivanje hvataljkama + Grip Mirror Zrcaljenje hvataljkom + Grip Option Opcija hvataljke + Grip Rotate Rotacija hvataljkom + Grip Scale Skaliranje hvataljkom + Grip Stretch Razvlačenje hvataljkom + Group Grupiši + Group Edit Uređivanje grupe + Group Options Opcije grupe + Group Selection Odabir grupe + Hatch Šrafura + Hatch Edit Uređivanje šrafure + Hatch Patterns Uzorci šrafure + Hatch... Šrafura... + Height Visina + Height: {height} Visina: {height} + Helix Spirala + Help Pomoć + Hide All Sakrij sve + Hide All Constraint Bars Sakrij sve trake ograničenja + Hide All Dynamic Dimensions Sakrij sve dinamičke kote + Hide Layer List Sakrij listu slojeva + Hide Objects Sakrij objekte + Hide Palettes Sakrij palete + History Historija + Home Početna + Horizontal Vodoravno + HorizontalDistance Vodoravna udaljenost + How to apply: Kako primijeniti: + ID Point Identifikuj tačku + Import Uvezi + In In + In-window Menu Bar Traka menija u prozoru + Include in new selection set Uključi u novi skup odabira + Increment angle Ugao koraka + Index Color Indeksna boja + Index color: {index} Indeksna boja: {index} + Infer Constraints Zaključi ograničenja + Inquiry Upit + Insert Umetni + Insert Block Umetni blok + Insert Column Umetni kolonu + Insert Row Umetni red + Insert tab Umetni tabulator + Insert this block Umetni ovaj blok + Insert Table Formula Umetni formulu tabele + Insertion Umetanje + Insertion X Umetanje X + Insertion Y Umetanje Y + Insertion Z Umetanje Z + Inspect Constraints Pregledaj ograničenja + Inspect Drawing Pregledaj crtež + Inspection Inspekcija + Interface Language Jezik interfejsa + Interface Theme Tema interfejsa + Intersect Presijeci + Intersection Presjek + Isolate Izoliraj + Isolate Layer Izoliraj sloj + Isolate Objects Izoliraj objekte + Isometric Drafting Izometrijsko crtanje + Jogged Prelomljeno + Jogged Linear Prelomljeno linearno + Join Spoji + Join the ArtCraft Discord… Pridružite se ArtCraft Discordu… + Join us on Discord Pridružite nam se na Discordu + Isometric Plane Izometrijska ravan + Isometric snap Izometrijsko hvatanje + Language Jezik + Last Posljednje + Last commit Posljednji commit + Layer Sloj + Layer Delete Brisanje sloja + Layer Match Uskladi sloj + Layer Merge Spoji slojeve + Layer Off Isključi sloj + Layer Properties Svojstva sloja + Layer Properties Manager Upravitelj svojstava slojeva + Layer Properties Manager — current viewport Upravitelj svojstava slojeva — trenutni prozor prikaza + Layer State Stanje slojeva + Layer States Manager... Upravitelj stanja slojeva... + Layer Tools Alati za slojeve + Layer contains objects (double-click to make current) Sloj sadrži objekte (dvostruki klik da postane trenutni) + Layer is empty (double-click to make current) Sloj je prazan (dvostruki klik da postane trenutni) + Layers Slojevi + Layers Palette Paleta slojeva + Hide Palette Sakrij paletu + Layers displayed: {shown} of {total}. Current layer: {layer} Prikazani slojevi: {shown} od {total}. Trenutni sloj: {layer} + Layout Raspored + Layout list Lista rasporeda + Leader Pokazna linija + Leaders Only Samo pokazne linije + Left Lijevo + Length Dužina + Length X Dužina X + Length Y Dužina Y + Lengthen Produlji + Light Svijetlo + Line Linija + Line delta Razlika u linijama + Linear Linearno + Lines +/− Linije +/− + Lines added Dodane linije + Lines deleted Izbrisane linije + Lines of Command History Redovi historije naredbi + Linetype Vrsta linije + Linetype Scale Skala vrste linije + Linetype scale Skala vrste linije + Ltscale Ltscale + Linetype... Vrsta linije... + Lineweight Debljina linije + Lineweight... Debljina linije... + List Lista + List Blocks Izlistaj blokove + List Dimension Styles Izlistaj stilove kotiranja + List Groups Izlistaj grupe + List Layer States Izlistaj stanja slojeva + List Layouts Izlistaj rasporede + List System Variables Izlistaj sistemske varijable + List Text Styles Izlistaj stilove teksta + Load all Učitaj sve + Load... Učitaj... + Loaded linetypes Učitane vrste linija + Lock Zaključaj + Lock Layer Zaključaj sloj + Lock viewport Zaključaj prozor prikaza + Lock/Unlock Zaključaj/otključaj + Loft Prelazno tijelo + MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Nije povezano s Autodesk, Inc. + MODEL MODEL + MText MText + Magenta Magenta + Major line every Glavna linija svakih + Make Current Postavi kao trenutno + Make Object's Layer Current Postavi sloj objekta kao trenutni + Make... Napravi... + Match Layer Uskladi sloj + Match Properties Uskladi svojstva + Measure Mjeri + Measure Distance Izmjeri udaljenost + Measure Geometry Izmjeri geometriju + Merge Cells Spoji ćelije + Merged PRs Spojeni PR-ovi + Mid Between 2 Points Sredina između 2 tačke + Midpoint Srednja tačka + Mirror Zrcali + Model Model + Model Space (in viewport) Prostor modela (u prozoru prikaza) + Modeling Modeliranje + Models Modeli + Modify Izmijeni + Move Pomakni + Multileader Višestruka pokazna linija + Multileader Style... Stil višestruke pokazne linije... + Multileader style Stil višestruke pokazne linije + Multiline Višestruka linija + Multiline Text Višeredni tekst + Multiline Text... Višeredni tekst... + Multiple Višestruko + Multiple Point Višestruka tačka + My Moje + My properties: not available yet Moja svojstva: još nisu dostupna + N N + Name Naziv + Name (A–Z) Naziv (A–Z) + Named Viewports... Imenovani prozori prikaza... + Named Views... Imenovani pogledi... + Nearest Najbliže + New Novo + New (metric) Novo (metrički) + New Drawing Novi crtež + New Drawing (⌘N) Novi crtež (⌘N) + New Drawing... Novi crtež... + New Layer Novi sloj + New Layout Novi raspored + New VP Freeze Zamrzni u novom PP + New Viewports... Novi prozori prikaza... + New from Template... Novo iz predloška... + New layout Novi raspored + New state name Naziv novog stanja + Next Change Sljedeća promjena + No Ne + No blocks defined in this drawing. U ovom crtežu nisu definisani blokovi. + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o saradnicima. + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge za modele. + No saved layer states Nema sačuvanih stanja slojeva + No selection Nema odabira + Node Čvor + None Ništa + Normal Normalno + Not available Nije dostupno + Not available yet Još nije dostupno + Numbered list Numerisana lista + OK U redu + Object Objekat + Object Snap Hvatanje objekata + Object Snap (F3) Hvatanje objekata (F3) + Object Snap On (F3) Hvatanje objekata uključeno (F3) + Object Snap Tracking Praćenje hvatanja objekata + Object Snap Tracking (F11) Praćenje hvatanja objekata (F11) + Object Snap Tracking Settings Postavke praćenja hvatanja objekata + Object type: Vrsta objekta: + Oblique Koso + Off Isključeno + Offset Pomak + On Uključeno + On/Off Uključi/isključi + 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} + Open (⌘O) Otvori (⌘O) + Open Sample Drawing Otvori primjer crteža + Open sample drawing Otvori primjer crteža + Open Xref Otvori vanjsku referencu + Open... Otvori... + Operator: Operator: + Orbit Orbita + Ordinate Ordinata + Ortho (F8) Orto (F8) + Ortho Mode Orto način + Ortho Mode (F8) Orto način (F8) + Osnap Settings... Postavke hvatanja objekata... + Out Out + Over-constrained Previše ograničeno + Override Zamjena + PAPER PAPIR + Percent Postotak + Polar (distance < angle) Polarne (udaljenost < ugao) + PRs PR-ovi + Page Setup Manager Upravitelj postavki stranice + Page Setup Manager... Upravitelj postavki stranice... + Pan Pomicanje pogleda + Pan Down Pomakni pogled dolje + Pan Left Pomakni pogled lijevo + Pan Right Pomakni pogled desno + Pan Up Pomakni pogled gore + Paper Space Prostor papira + Parallel Paralelno + Parameters Manager Upravitelj parametara + Parametric Parametarsko + Paste Zalijepi + Paste as Block Zalijepi kao blok + Paste to Command Line Zalijepi u komandnu liniju + Paste to Original Coordinates Zalijepi na originalne koordinate + Path Array Niz po putanji + Pattern Uzorak + Perpendicular Okomito + Plot Iscrtaj + Plot (click: do not plot) Iscrtaj (klik: ne iscrtavaj) + Plot Preview Pregled iscrtavanja + Plot Window Prozor iscrtavanja + Plot style Stil iscrtavanja + Plot style attached to Stil iscrtavanja priložen uz + Plot style table Tabela stilova iscrtavanja + Plot Style... Stil iscrtavanja... + Plot Style Manager Upravitelj stilova iscrtavanja + Plot Style Manager... Upravitelj stilova iscrtavanja... + Plot Styles... Stilovi iscrtavanja... + Plotter Manager Upravitelj plotera + Plotter Manager... Upravitelj plotera... + New CTB Novi CTB + New STB Novi STB + New style Novi stil + Reset Panel Layout Vrati raspored panela na početno + Send Hatches to Back Postavi šrafure na dno + Send Under Objects Postavi ispod objekata + Text Objects Only Samo tekstualni objekti + Use for this layout Koristi za ovaj raspored + Display plot styles Prikaži stilove iscrtavanja + No plot style tables. Nema tabela stilova iscrtavanja. + A built-in table: editing it keeps an edited copy in the drawing. Ugrađena tabela: njeno uređivanje čuva uređenu kopiju u crtežu. + Keep an editable copy of this table in the drawing Zadrži kopiju ove tabele za uređivanje u crtežu + Layout {name}: {table} Raspored {name}: {table} + Grayscale Nijanse sive + Screening Prigušenje + Use object linetype Koristi vrstu linije objekta + Use object lineweight Koristi debljinu linije objekta + Remove Ukloni + Add Dodaj + Open a drawing first. Prvo otvorite crtež. + Plot table type Vrsta tabele iscrtavanja + Color-dependent Zavisno od boje + Named Imenovano + Plus/minus Plus/minus + Point Tačka + Point Filters Filteri tačaka + Point Style... Stil tačke... + Polar Array Polarni niz + Polar Tracking Polarno praćenje + Polar Tracking (F10) Polarno praćenje (F10) + Polar Tracking On (F10) Polarno praćenje uključeno (F10) + Polar angle measurement Mjerenje polarnog ugla + Polar distance Polarna udaljenost + Polar: {distance} < {angle}° Polarno: {distance} < {angle}° + PolarSnap Polarno hvatanje + Polygon Mnogougao + Polygonal Mnogougaono + Polygonal Viewport Mnogougaoni prozor prikaza + Polyline Izlomljena linija + Polyline Edit Uređivanje polilinije + Position X Položaj X + Position Y Položaj Y + Position Z Položaj Z + Previous Prethodno + Previous Change Prethodna promjena + Previous Layer Prethodni sloj + Primitives Primitivi + Print Štampaj + Print... Štampaj... + Properties Svojstva + Properties Inspector Inspektor svojstava + Properties: Svojstva: + Proxy Proksi + Purge Očisti neiskorišteno + Pyramid Piramida + Quadrant Kvadrant + Query Entities Upit o entitetima + Quick Brzo + Quick Dimension Brza kota + Quick Properties Brza svojstva + Quick Select Brzi odabir + Quick Select Properties Svojstva brzog odabira + Quick Select... Brzi odabir... + QuickCalc Brzi kalkulator + QuickCalc Pick Odabir za brzi kalkulator + Quit CADCraft Zatvori CADCraft + Radius Poluprečnik + Raster Image Rasterska slika + Ray Zraka + Read-only Samo za čitanje + Real name Pravo ime + Realtime U stvarnom vremenu + Reassociate Dimensions Ponovo poveži kote + Recent Nedavno + Recent Commands Nedavne naredbe + Recent Input Nedavni unos + Recover... Oporavi... + Rectangle Pravougaonik + Recalculate Table Formulas Ponovo izračunaj formule tabele + Rectangular Pravougaono + Rectangular snap Pravougaono hvatanje + Rectangular Array Pravougaoni niz + Red Crvena + Redo Ponovi + Redo (⇧⌘Z) Ponovi (⇧⌘Z) + Redraw Ponovo iscrtaj + Regen Regeneriši + Regen All Regeneriši sve + Region Regija + Region/Mass Properties Svojstva regije/mase + Reinitialize Ponovo inicijaliziraj + Relative Relativno + Relative to last segment U odnosu na posljednji segment + Remove Leader Ukloni pokaznu liniju + Remove the viewport override Ukloni zamjenu za prozor prikaza + Rename Dimension Style Preimenuj stil kotiranja + Rename Layer Preimenuj sloj + Rename Layer State Preimenuj stanje slojeva + Rename Layout Preimenuj raspored + Rename Text Style Preimenuj stil teksta + Rename... Preimenuj... + Repeat {command} Ponovi {command} + Replace Block... Zamijeni blok... + Requires a point (x,y, @dx,dy or @d Navedite drugu tačku ili + Specify second source point Navedite drugu izvornu tačku + Specify side to offset Navedite stranu za odmak + Specify start angle Navedite početni ugao + Specify start point Navedite početnu tačku + Specify start point of arc Navedite početnu tačku luka + Specify start point of polyline Navedite početnu tačku polilinije + Specify start point of text Navedite početnu tačku teksta + Specify starting width Navedite početnu širinu + Specify starting width for next segment Navedite početnu širinu za sljedeći segment + Specify tangent direction for the start point of arc Navedite smjer tangente za početnu tačku luka + Specify the elevation for rectangles Navedite elevaciju za pravougaonike + Specify thickness for rectangles Navedite debljinu za pravougaonike + Specify third point on circle Navedite treću tačku na krugu + Specify third point on circle: _tan to Navedite treću tačku na krugu: _tan na + 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 total angle Navedite ukupni ugao + Specify total length or Navedite ukupnu dužinu ili + Specify width for rectangles Navedite širinu za pravougaonike + Sphere Sfera + Spline Splajn + Spline Edit Uređivanje splajna + Stack (type 1/2 then select) Složi (upišite 1/2 pa odaberite) + Standard Standardno + Start Početak + Start Width Početna širina + Start X Početak X + Start Y Početak Y + Start angle Početni ugao + Start, Center, Angle Početak, središte, ugao + Start, Center, End Početak, središte, kraj + Start, Center, Length Početak, središte, dužina + Start, End, Angle Početak, kraj, ugao + Start, End, Direction Početak, kraj, smjer + Start, End, Radius Početak, kraj, poluprečnik + Status Status + Status Bar Statusna traka + Stretch Razvuci + Style: Standard Stil: Standard + Subtract Oduzmi + Sweep Izvlačenje po putanji + Switch Drawing Promijeni crtež + Switch Layout Promijeni raspored + Switch between model space in a viewport and paper space Prebacuj između prostora modela u prozoru prikaza i prostora papira + Symmetric Simetrično + Synchronize Attributes Sinhronizuj atribute + To Do + Toggle PICKADD (off: each pick replaces the selection) Uključi/isključi PICKADD (isključeno: svaki odabir zamjenjuje odabir) + Toggle PICKADD (on: picks add to the selection) Uključi/isključi PICKADD (uključeno: odabiri se dodaju u odabir) + TOP VRH + Table Tabela + Table Breaks Prelomi tabele + Table Cell Style Stil ćelije tabele + Table Style... Stil tabele... + Table style Stil tabele + Table... Tabela... + Tan, Tan, Radius Tan, tan, poluprečnik + Tan, Tan, Tan Tan, tan, tan + Tangent Tangenta + Temporary track point Privremena tačka praćenja + Text Tekst + Text Editor Uređivač teksta + Text Height Visina teksta + Text Style... Stil teksta... + Text height Visina teksta + Text override Zamjena teksta + Text style Stil teksta + Thaw All Layers Odmrzni sve slojeve + Thaw Layer Odmrzni sloj + There is nothing here yet. Ovdje još nema ničega. + Thickness Debljina + Time Vrijeme + To get started, create or open a drawing. Za početak kreirajte ili otvorite crtež. + Tool Bar Traka s alatima + Tool Sets Skupovi alata + Tools Alati + Torus Torus + Trace Trag + Track orthogonally only Prati samo pravougaono + Track using all polar angle settings Prati koristeći sve postavke polarnih uglova + Transparency Prozirnost + Trim Odsijeci + True Color Prava boja + Turn All Layers On Uključi sve slojeve + Turn Off Layer Isključi sloj + Turn On Layer Uključi sloj + Turn Quick Properties off (QPMODE) Isključi brza svojstva (QPMODE) + Type Vrsta + Type a command Upišite naredbu + Type text; Enter starts a new paragraph Upišite tekst; Enter započinje novi pasus + UCS Icon Ikona KKS + Undo Poništi + Undo (⌘Z) Poništi (⌘Z) + Ungroup Razgrupiši + Union Unija + Unisolate Layer Prekini izolaciju sloja + Units Conversion Pretvaranje jedinica + Units... Jedinice... + Unlock Layer Otključaj sloj + Unlock viewport Otključaj prozor prikaza + Unmerge Cells Razdvoji spojene ćelije + Unsaved Layer State Nesačuvano stanje slojeva + Up Gore + Update Data Links Ažuriraj veze s podacima + Update Ažuriraj + Use System Setting Koristi sistemsku postavku + User Korisnik + User Parameters Korisnički parametri + Username Korisničko ime + Volume Zapremina + VP Color Boja u PP + VP Freeze Zamrzni u PP + Value Vrijednost + Value: Vrijednost: + Variables Varijable + Version Verzija + Version 2000 Verzija 2000 + Version 2004 Verzija 2004 + Version 2007 Verzija 2007 + Version 2010 Verzija 2010 + Version 2013 Verzija 2013 + Version 2018 Verzija 2018 + Version {version} Verzija {version} + Vertical Okomito + VerticalDistance Okomita udaljenost + View Prikaz + ViewCube ViewCube + Viewport Prozor prikaza + Viewport Layer Freeze Zamrzavanje slojeva u prozoru prikaza + Viewport Properties Svojstva prozora prikaza + Viewport is locked Prozor prikaza je zaključan + Viewport scale Skala prozora prikaza + Viewport {id} Prozor prikaza {id} + Viewports Prozori prikaza + W W + WCS WCS + White Bijela + Width factor Faktor širine + Window Prozor + Wipeout Prekrivanje + Workspace Switching Promjena radnog prostora + Write Block Zapiši blok + Xline Konstrukcijska linija + Yellow Žuta + Yes Da + Zoom Zumiranje + Zoom All Zumiraj sve + Zoom Dynamic Dinamičko zumiranje + Zoom Extents Zumiraj na opseg + Zoom In Uvećaj + Zoom Object Zumiraj objekat + Zoom Out Umanji + Zoom Previous Prethodno zumiranje + Zoom Realtime Zumiranje u stvarnom vremenu + Zoom Window Zumiraj prozor + error greška + {contributors} · {commits} {contributors} · {commits} + {kind} (constraint {id}) {kind} (ograničenje {id}) + {kind} constraint {name} = {expression} (double-click to edit) {kind} ograničenje {name} = {expression} (dvostruki klik za uređivanje) + {name}: {distance} < {angle}° {name}: {distance} < {angle}° + ƒx New User Parameter ƒx Novi korisnički parametar + ΔLOC ΔLOC + −Bin −Bin + −LOC −LOC + ⌘↩ to finish ⌘↩ za završetak +@plural {n} commit {n} commit|{n} commita|{n} commitova +@plural {n} contributor {n} saradnik|{n} saradnika|{n} saradnika + Drawing as bytes Crtež kao bajtovi + Services Usluge + Hide CADCraft Sakrij CADCraft + Hide Others Sakrij ostale + Accessibility Pristupačnost + Show System Cursor Prikaži sistemski kursor + Animation Effects Animacijski efekti + Animation effects Animacijski efekti + Import... Uvezi... + Export... Izvezi... + Batch Publish... Paketno objavljivanje... + Scale List... Lista skala... + Annotative Object Scale Skala objekta napomene + Add Current Scale Dodaj trenutnu skalu + Delete Current Scale Izbriši trenutnu skalu + Add/Delete Scales... Dodaj/izbriši skale... + Synchronize Multiple-scale Positions Sinhronizuj položaje za više skala + Annotative Update Ažuriranje napomena + Show Annotative Objects Prikaži objekte napomena + Current Scale Only Samo trenutna skala + All Scales Sve skale + Drawing Recovery Manager... Upravitelj oporavka crteža... + Tolerance... Tolerancija... + Add Vertex Dodaj tjeme + Remove Vertex Ukloni tjeme + Convert to Arc Pretvori u luk + Convert to Line Pretvori u liniju + Stretch Vertex Razvuci tjeme + Edit Array Uredi niz + Close Array Editing Zatvori uređivanje niza + Multiline... Višestruka linija... + Multiline Style... Stil višestruke linije... + Solid Editing Uređivanje punih tijela + X X + Y Y + B B + CADCraft CADCraft + More toolbar commands Više naredbi trake s alatima +menu View Prikaz +menu Draw Crtaj +menu Dimension Kotiranje +menu Modify Izmijeni + Hatch Pattern Uzorak šrafure + Search for pattern Traži uzorak + Not a number or distance: {text} Nije broj ili udaljenost: {text} + Query Texts Upit o tekstovima + Analyze Shapes Analiziraj oblike + Check for Updates… Provjeri ažuriranja… + CADCraft is up to date. CADCraft je ažuran. + Could not check for updates. Nije moguće provjeriti ažuriranja. + 3D Mouse 3D miš + 3D Mouse Navigation Navigacija 3D mišem + 3D Mouse... 3D miš... + 3D mouse busy, possibly 3DxWare or missing device permissions 3D miš je zauzet, možda zbog 3DxWare ili nedostajućih dozvola za uređaj + 3D mouse connected 3D miš je povezan + 3D mouse disconnected 3D miš je isključen + 3D mouse input could not be started Unos 3D mišem nije moguće pokrenuti + 3D mouse input is unavailable on this platform Unos 3D mišem nije dostupan na ovoj platformi + Automatic save every Automatsko čuvanje svakih + Automatic save file location Lokacija datoteke automatskog čuvanja + Calibrate Kalibriraj + Calibrate 3D Mouse Kalibriraj 3D miš + Collapse Palettes Skupi palete + Default save version Zadana verzija čuvanja + Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Crteži koji još nemaju datoteku čuvaju se u zadanoj verziji. 0 minuta isključuje automatsko čuvanje. + Enable 3D mouse navigation Omogući navigaciju 3D mišem + Left device button: zoom extents. Right device button: calibrate. Lijevo dugme uređaja: zumiraj na opseg. Desno dugme uređaja: kalibriraj. + Move sideways to pan; push/pull or twist to zoom. Pomjerite postrance za pomicanje pogleda; gurnite/povucite ili zakrenite za zumiranje. + No supported 3D mouse found Nije pronađen podržani 3D miš + Pan sensitivity Osjetljivost pomicanja pogleda + Release the 3D mouse to calibrate its resting position Pustite 3D miš da kalibrirate njegov položaj mirovanja + Reverse zoom direction Obrni smjer zumiranja + Run Script... Pokreni skriptu... + SVG drawings (SVG, SVGZ) SVG crteži (SVG, SVGZ) + Show and hide the tools of this category Prikaži i sakrij alate ove kategorije + Show in Tool Sets Prikaži u skupovima alata + Time-sensitive right-click: a quick click is Enter, holding opens the menu Desni klik osjetljiv na vrijeme: brzi klik je Enter, držanje otvara meni + Turned off, a right-click while a command runs always opens the shortcut menu. Kad je isključeno, desni klik dok se naredba izvršava uvijek otvara kontekstni meni. + Zoom sensitivity Osjetljivost zumiranja + min min + Align Multileaders Poravnaj višestruke pokazne linije + Collect Prikupi + Collect Multileaders Prikupi višestruke pokazne linije + Edit Multileader Uredi višestruku pokaznu liniju + Italic Kurziv + Underline Podvučeno + Overline Nadvučeno + Justify Poravnaj obostrano + Text color Boja teksta + Click the ruler to add a tab stop, right-click to remove one Kliknite ravnalo da dodate tabulator, desni klik da ga uklonite + Add: Pick points Dodaj: odaberi tačke + Add: Select objects Dodaj: odaberi objekte + Associative Asocijativno + Block to create or edit Blok za kreiranje ili uređivanje + Bottom left Dolje lijevo + Bottom right Dolje desno + Boundaries Granice + Boundaries picked: {n} Odabrane granice: {n} + Centered Centrirano + Clear Recent Files Očisti nedavne datoteke + Click to set new origin Kliknite da postavite novo ishodište + Close Block Editor Zatvori uređivač blokova + Color 1 Boja 1 + Color 2 Boja 2 + contains authoring elements. Open in Block Editor? sadrži elemente za izradu. Otvoriti u uređivaču blokova? + Create separate hatches Kreiraj zasebne šrafure + Crossing selection color: Boja odabira presijecanjem: + Curved Zakrivljeno + Custom Prilagođeno + Default to boundary extents Zadano na opseg granice + Drag Dynamic Grip Prevuci dinamičku hvataljku + Dynamic Block Data Podaci dinamičkog bloka + Dynamic Block Grips Hvataljke dinamičkog bloka + Dynamic Block Reference Referenca dinamičkog bloka + Edit Block Definition Uredi definiciju bloka + Evaluate LISP Izračunaj LISP + Exclude: Isključi: + Grips Hvataljke + Groups Grupe + Hatch and Gradient Šrafura i gradijent + Hatch origin Ishodište šrafure + Hatch Pick Odabir šrafure + Hatches Šrafure + Hemispherical Polusferno + Ignore Zanemari + Indicate selection area Označite područje odabira + Inverted Obrnuto + Island detection Otkrivanje ostrva + Load Application... Učitaj aplikaciju... + Multiline text Višeredni tekst + New block Novi blok + Object grouping Grupisanje objekata + Object selection limit for display of grips Ograničenje odabira objekata za prikaz hvataljki + Objects on locked layers Objekti na zaključanim slojevima + Open in Block Editor Otvori u uređivaču blokova + Open Recent Otvori nedavno + Origin: {x}, {y} Ishodište: {x}, {y} + Outer Vanjsko + Preview Pregled + Save Block Sačuvaj blok + Save Block As Sačuvaj blok kao + Selection Area Effect Efekat područja odabira + Selection area opacity: Neprozirnost područja odabira: + Selection effect color Boja efekta odabira + Selection modes Načini odabira + Selection preview Pregled odabira + Selection Preview Filter Filter pregleda odabira + Set Dynamic Property Postavi dinamičko svojstvo + Shape Oblik + Sheet Books to PDF... Knjige listova u PDF... + Show grips Prikaži hvataljke + Specified origin Navedeno ishodište + Spherical Sferno + Tables Tabele + This drawing Ovaj crtež + Top left Gore lijevo + Top right Gore desno + Unsaved changes Nesačuvane promjene + Use current origin Koristi trenutno ishodište + Visual Effect Settings Postavke vizuelnih efekata + Visual Effect Settings... Postavke vizuelnih efekata... + When a command is active Kad je naredba aktivna + When no command is active Kad nijedna naredba nije aktivna + Window selection color: Boja odabira prozorom: + Xrefs Vanjske reference + Arrow size Veličina strelice + Dim scale overall Ukupna skala kote + Precision Preciznost + Break-line Symbol Simbol linije prekida + Convert Text to Mtext Pretvori tekst u višeredni tekst + Explode Attributes to Text Rastavi atribute u tekst + Express Tools Express alati + Move/Copy/Rotate Pomjeri/kopiraj/rotiraj + Type an alias name. Unesite naziv aliasa. + Choose the command the alias starts. Odaberite naredbu koju alias pokreće. + {command} is an unknown command. {command} je nepoznata naredba. + {alias} is already defined; select it and use Edit. {alias} je već definisan; odaberite ga i koristite Uredi. + More than {n} aliases. Više od {n} aliasa. + Alias file: {file} Datoteka aliasa: {file} + No alias file loaded. REINIT replaces these aliases with a file's. Nije učitana datoteka aliasa. REINIT zamjenjuje ove aliase onima iz datoteke. + Alias Alias + Command Naredba + overrides built-in zamjenjuje ugrađeni + built-in ugrađeni + Add… Dodaj… + Edit… Uredi… + Save alias Sačuvaj alias + Discard Odbaci + Customize Prilagodi + Edit Command Aliases (PGP)... Uredi aliase naredbi (PGP)... + Interface scale Skala interfejsa + Select boundaries to remove Odaberite granice za uklanjanje + Island Ostrvo + Select using two clicks only Odabir samo s dva klika + Define each corner of the selection window with a separate click. Definišite svaki ugao prozora za odabir posebnim klikom. + Apply to Layout Primijeni na raspored + Center the plot Centriraj iscrtavanje + Enter block names to extract Unesite nazive blokova za izdvajanje + Enter geometric characteristic Unesite geometrijsku karakteristiku + Enter name of LISP file to load Unesite naziv LISP datoteke za učitavanje + First corner Prvi ugao + Fit to paper Uklopi na papir + Landscape Položeno + Limits Granice + Opposite corner Suprotni ugao + Orientation Orijentacija + Paper size Veličina papira + Plot area Oblast iscrtavanja + Plot object lineweights Iscrtaj debljine linija objekata + Plot offset Pomak iscrtavanja + Plot transparency Iscrtaj prozirnost + Portrait Uspravno + Printer/plotter Štampač/ploter + Window < Prozor < + X offset (mm) X pomak (mm) + Y offset (mm) Y pomak (mm) + AutoLISP (LSP) AutoLISP (LSP) + Units of new drawings Jedinice novih crteža + Imperial (inches) Imperijalne (inči) + Metric (millimetres) Metričke (milimetri) + New and the first drawing at startup use these units unless a default template is set (QNEWTEMPLATE). Novi i prvi crtež pri pokretanju koriste ove jedinice osim ako je postavljen zadani šablon (QNEWTEMPLATE). + Analyze Usage Analiziraj upotrebu + Implied Loops Implicirane petlje + Angstroms Angstremi + Architectural Arhitektonske + Astronomical Units Astronomske jedinice + Centimeters Centimetri + Decameters Dekametri + Decimal Decimalne + Decimal Degrees Decimalni stepeni + Decimeters Decimetri + Deg/Min/Sec Step/Min/Sek + Don't Save Nemoj sačuvati + Drawing Units Jedinice crteža + Engineering Inženjerske + Feet Stope + Fractional Razlomačke + Gigameters Gigametri + Grads Gradi + Hectometers Hektometri + Inches Inči + Insertion scale Skala umetanja + Kilometers Kilometri + Light Years Svjetlosne godine + Meters Metri + Microinches Mikroinči + Microns Mikroni + Miles Milje + Millimeters Milimetri + Mils Milsi + Nanometers Nanometri + Parsecs Parseci + Radians Radijani + Sample output Primjer izlaza + Save changes to {name}? Sačuvati promjene u {name}? + Scientific Naučne + Surveyor's Units Geodetske jedinice + Unitless Bez jedinica + Units to scale inserted content Jedinice za skaliranje umetnutog sadržaja + US Survey Feet Američke geodetske stope + US Survey Inch Američki geodetski inč + US Survey Mile Američka geodetska milja + US Survey Yard Američki geodetski jard + Yards Jardi + Your changes will be lost if you don't save them. Vaše promjene će biti izgubljene ako ih ne sačuvate. + Save changes to block "{name}"? Sačuvati promjene u bloku „{name}”? + Layouts Rasporedi + Associate Poveži diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6df6a4168..c979cd786 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -92,6 +92,7 @@ Blend Spojit křivky Block Blok Block Attribute Manager... Správce atributů bloků... + Block Description... Popis bloku... Block Editor Editor bloků Block Reference Reference bloku Block... Blok... @@ -537,6 +538,7 @@ N S Name Název Name (A–Z) Název (A–Z) + Named Viewports... Pojmenované výřezy... Named Views... Pojmenované pohledy... Nearest Nejbližší New Nový diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 860b23136..84b944529 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -67,6 +67,7 @@ Blend Übergang Block Block Block Attribute Manager... Blockattribut-Manager... + Block Description... Blockbeschreibung... Block Editor Blockeditor Block Reference Blockreferenz Block... Block... @@ -457,6 +458,7 @@ N N Name Name Name (A–Z) Name (A–Z) + Named Viewports... Benannte Ansichtsfenster... Nearest Nächstgelegener New Neu New (metric) Neu (metrisch) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fe09face6..2825e8fd2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -100,6 +100,7 @@ Blend Ομαλή σύνδεση (Blend) Block Μπλοκ (Block) Block Attribute Manager... Διαχείριση ιδιοτήτων μπλοκ (Block Attributes)... + Block Description... Περιγραφή μπλοκ... Block Editor Επεξεργαστής μπλοκ (Block Editor) Block Reference Αναφορά μπλοκ (Block Reference) Block to create or edit Μπλοκ προς δημιουργία ή επεξεργασία @@ -640,6 +641,7 @@ Name Όνομα Name (A–Z) Όνομα (Α–Ω) Named Με όνομα + Named Viewports... Αποθηκευμένα παράθυρα προβολής... Named Views... Αποθηκευμένες όψεις... Nearest Πλησιέστερο (Nearest) New Νέο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index afa3c90a5..b24970979 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 @@ -87,6 +89,7 @@ Blend Fusión de curvas Block Bloque Block Attribute Manager... Administrador de atributos de bloque... + Block Description... Descripción de bloque... Block Editor Editor de bloques Block Reference Referencia de bloque Block... Bloque... @@ -567,6 +570,7 @@ N N Name Nombre Name (A–Z) Nombre (A–Z) + Named Viewports... Ventanas gráficas con nombre... Named Views... Vistas con nombre... Nearest Cercano New Nuevo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 30639fe83..42ad7ee48 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -67,6 +67,7 @@ Blend Raccord lissé Block Bloc Block Attribute Manager... Gestionnaire des attributs de bloc... + Block Description... Description de bloc... Block Editor Éditeur de blocs Block Reference Référence de bloc Block... Bloc... @@ -452,6 +453,7 @@ N N Name Nom Name (A–Z) Nom (A–Z) + Named Viewports... Fenêtres nommées... Nearest Plus proche New Nouveau New (metric) Nouveau (métrique) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 67769707e..fc27bd7ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -114,6 +114,7 @@ Blend Átmenetgörbe Block Blokk Block Attribute Manager... Blokkattribútum-kezelő... + Block Description... Blokkleírás... Block Editor Blokkszerkesztő Block Reference Blokkhivatkozás Block to create or edit Létrehozandó vagy szerkesztendő blokk @@ -650,6 +651,7 @@ Name Név Name (A–Z) Név (A–Z) Named Elnevezett + Named Viewports... Elnevezett nézetablakok... Named Views... Elnevezett nézetek... Nearest Legközelebbi New Új diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 27786b2e5..634249093 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -97,6 +97,7 @@ menu Modify Edita Blend Fondi Block Blocco Block Attribute Manager... Gestore attributi blocco... + Block Description... Descrizione blocco... Block Editor Editor blocchi Block Reference Riferimento di blocco Block... Blocco... @@ -549,6 +550,7 @@ menu Modify Edita N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Finestre con nome... Named Views... Viste con nome... Nearest Vicino New Nuovo diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7c5c9016c..1bd7e0fd5 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -92,6 +92,7 @@ Blend 곡선 연결 Block 블록 Block Attribute Manager... 블록 속성 관리자... + Block Description... 블록 설명... Block Editor 블록 편집기 Block Reference 블록 참조 Block... 블록... @@ -539,6 +540,7 @@ N 북 Name 이름 Name (A–Z) 이름(A–Z) + Named Viewports... 명명된 뷰포트... Named Views... 명명된 뷰... Nearest 근처점 New 새로 만들기 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 04fafff2f..8f6533d51 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -86,6 +86,7 @@ Blend Curva de transição Block Bloco Block Attribute Manager... Gerenciador de atributos de bloco... + Block Description... Descrição do bloco... Block Editor Editor de blocos Block Reference Referência de bloco Block... Bloco... @@ -565,6 +566,7 @@ N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Viewports nomeados... Named Views... Vistas nomeadas... Nearest Mais próximo New Novo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a5feb4589..b8f97bf73 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -67,6 +67,7 @@ Blend Сгладить Block Блок Block Attribute Manager... Диспетчер атрибутов блоков... + Block Description... Описание блока... Block Editor Редактор блоков Block Reference Вхождение блока Block... Блок... @@ -452,6 +453,7 @@ N С Name Название Name (A–Z) Название (А–Я) + Named Viewports... Именованные видовые экраны... Nearest Ближайшая точка New Создать New (metric) Создать (метрический) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2896520d3..6bea4c3da 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -90,6 +90,7 @@ Blend Mjuk övergång Block Block Block Attribute Manager... Hantera blockattribut... + Block Description... Blockbeskrivning... Block Editor Blockredigerare Block Reference Blockreferens Block... Block... @@ -569,6 +570,7 @@ N N Name Namn Name (A–Z) Namn (A–Ö) + Named Viewports... Namngivna vyfönster... Named Views... Namngivna vyer... Nearest Närmaste New Ny diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 9524b2e9f..6bf85f2c3 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -157,8 +157,7 @@ fn module_files(dir: &std::path::Path, files: &mut Vec) { } } -#[test] -fn locale_and_saved_preference_resolution() { +fn assert_primary_locale_aliases() { for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", " uk-UA ", "uk_UA@euro"] { assert_eq!(locale_language(tag), Some("uk"), "{tag}"); } @@ -168,12 +167,18 @@ fn locale_and_saved_preference_resolution() { for (tag, code) in [("ru", "ru"), ("ru-RU", "ru"), ("ru_RU.UTF-8", "ru"), ("fr", "fr"), ("fr-FR", "fr"), ("fr_CA.UTF-8", "fr")] { assert_eq!(locale_language(tag), Some(code), "{tag}"); } +} + +fn assert_portuguese_and_spanish_aliases() { for tag in ["pt", "pt-BR", "pt_BR.UTF-8", "PT-br", "pt-PT"] { assert_eq!(locale_language(tag), Some("pt-BR"), "{tag}"); } for tag in ["es", "es-ES", "es_MX.UTF-8", "ES-ar"] { assert_eq!(locale_language(tag), Some("es"), "{tag}"); } +} + +fn assert_additional_locale_aliases() { for (code, tags) in [ ("cs", &["cs", "cs-CZ", "cs_CZ.UTF-8", "CS-cz", " cs-CZ ", "cs_CZ@euro"][..]), ("it", &["it", "it-IT", "it_IT.UTF-8", "IT-ch", " it-CH ", "it_IT@euro"]), @@ -181,6 +186,7 @@ fn locale_and_saved_preference_resolution() { ("sv", &["sv", "sv-SE", "sv_SE.UTF-8", "SV-se", " sv-SE ", "sv-FI"]), ("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"]), ("el", &["el", "el-GR", "el_GR.UTF-8", "EL-cy"]), ("zh", &["zh", "zh-CN"]), ] { @@ -188,12 +194,18 @@ fn locale_and_saved_preference_resolution() { assert_eq!(locale_language(tag), Some(code), "{tag}"); } } +} + +fn assert_unrecognized_locale_tags() { // Look-alike codes, three-letter codes and endonyms pick nothing: `se` is Northern Sami. let look_alikes = ["ua", "ukulele", "pl-PL", "", "garbage", "💙", "ptolemy", "cz", "csharp", "csomething", "italic"]; let others = ["kr", "korean", "kotlin", "se", "swe", "svenska", "hun", "magyar", "tur", "türkçe", "elvish"]; for tag in look_alikes.into_iter().chain(others) { assert_eq!(locale_language(tag), None, "{tag}"); } +} + +fn assert_auto_locale_precedence() { assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "pt-BR".into()]), "pt-BR"); assert_eq!(Preference::Auto.resolve(&["fr-FR".into(), "pt-BR".into()]), "fr"); assert_eq!(Preference::Auto.resolve(&["es-ES".into(), "pt-BR".into()]), "es"); @@ -202,30 +214,51 @@ fn locale_and_saved_preference_resolution() { assert_eq!(Preference::English.resolve(&locales), "en"); assert_eq!(Preference::Auto.resolve(&["en-US".into(), "uk-UA".into()]), "en"); assert_eq!(Preference::Auto.resolve(&[]), "en"); +} + +fn assert_saved_preference_restoration(preference: Preference) { + assert_eq!(Preference::restore(Some(preference.code())), preference); + let saved = serde_json::to_string(&preference).unwrap(); + assert_eq!(saved, format!("\"{}\"", preference.code())); + assert_eq!(serde_json::from_str::(&saved).unwrap(), preference); + assert_eq!(serde_json::from_value::(json!(preference.code())).unwrap(), preference); +} + +fn assert_preference_resolution(preference: Preference) { + if preference == Preference::Auto { + return; + } + assert_eq!(Preference::parse(preference.code()), Some(preference)); + // A chosen language wins over the system's; Auto takes the first system language there is. + assert_eq!(preference.resolve(&["uk-UA".into()]), preference.code()); + if preference != Preference::English { + assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), preference.code().into(), "en-US".into()]), preference.code()); + } +} + +fn assert_saved_preference_persistence(preference: Preference) { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = preference; + let mut restored = CadApp::new(Session::new(), Services::default()); + restored.load_prefs(&app.prefs_json()); + assert_eq!(restored.ui.interface_language, preference); + restored.load_prefs(r#"{"interfaceLanguage":"klingon"}"#); + assert_eq!(restored.ui.interface_language, preference); +} + +#[test] +fn locale_and_saved_preference_resolution() { + assert_primary_locale_aliases(); + assert_portuguese_and_spanish_aliases(); + assert_additional_locale_aliases(); + assert_unrecognized_locale_tags(); + assert_auto_locale_precedence(); assert_eq!(Preference::restore(None), Preference::Auto); assert_eq!(Preference::restore(Some("obsolete")), Preference::Auto); for preference in Preference::ALL { - assert_eq!(Preference::restore(Some(preference.code())), preference); - let saved = serde_json::to_string(&preference).unwrap(); - assert_eq!(saved, format!("\"{}\"", preference.code())); - assert_eq!(serde_json::from_str::(&saved).unwrap(), preference); - if preference == Preference::Auto { - continue; - } - assert_eq!(Preference::parse(preference.code()), Some(preference)); - // A chosen language wins over the system's; Auto takes the first system language there is. - assert_eq!(preference.resolve(&["uk-UA".into()]), preference.code()); - if preference != Preference::English { - assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), preference.code().into(), "en-US".into()]), preference.code()); - } - // Saved with the host's other preferences; a bad saved value keeps the current choice. - let mut a = CadApp::new(Session::new(), Services::default()); - a.ui.interface_language = preference; - let mut b = CadApp::new(Session::new(), Services::default()); - b.load_prefs(&a.prefs_json()); - assert_eq!(b.ui.interface_language, preference); - b.load_prefs(r#"{"interfaceLanguage":"klingon"}"#); - assert_eq!(b.ui.interface_language, preference); + assert_saved_preference_restoration(preference); + assert_preference_resolution(preference); + assert_saved_preference_persistence(preference); } assert!(serde_json::from_str::("\"unsupported\"").is_err()); assert_eq!(serde_json::from_value::(json!({})).unwrap().interface_language, Preference::Auto); @@ -524,3 +557,12 @@ fn production_font_chain_covers_latin_and_shortcut_symbols() { assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace"); }); } + +/// Bosnian counts: 1, 21 `one`; 2–4, 22–24 (not 12–14) `few`; everything else `other`. +#[test] +fn bosnian_counts_take_one_few_other() { + let forms: Vec = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 111].into_iter().map(plural_uk).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 2]); + assert_eq!(Preference::parse("bs"), Some(Preference::Bosnian)); + assert_eq!(Preference::Bosnian.native_name(), "Bosanski"); +} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cdf4ec36f..11369e751 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -114,6 +114,7 @@ Blend Harmanla Block Blok Block Attribute Manager... Blok Öznitelik Yöneticisi... + Block Description... Blok Açıklaması... Block Editor Blok Düzenleyici Block Reference Blok Referansı Block to create or edit Oluşturulacak ya da düzenlenecek blok @@ -651,6 +652,7 @@ Name Ad Name (A–Z) Ad (A–Z) Named Adlandırılmış + Named Viewports... Adlandırılmış Görünüm Pencereleri... Named Views... Adlandırılmış Görünümler... Nearest En Yakın New Yeni diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f75d7c529..714ce6c4c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -94,6 +94,7 @@ Blend Плавне з'єднання Block Блок Block Attribute Manager... Диспетчер атрибутів блоків... + Block Description... Опис блока... Block Editor Редактор блоків Block Reference Посилання на блок Block... Блок... @@ -553,6 +554,7 @@ N Пн Name Назва Name (A–Z) Назва (А–Я) + Named Viewports... Іменовані видові екрани... Named Views... Іменовані види... Nearest Найближча точка New Створити @@ -1281,6 +1283,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. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню. @@ -1371,3 +1375,4 @@ Check for Updates… Перевірити оновлення… CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення. + Save changes to block "{name}"? Зберегти зміни в блоці «{name}»? diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 80183d064..4d8da1698 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -67,6 +67,7 @@ Blend 过渡 Block 块 Block Attribute Manager... 块属性管理器... + Block Description... 块描述... Block Editor 块编辑器 Block Reference 块参照 Block... 块... @@ -1121,6 +1122,7 @@ Open Recent 打开最近的图形 Sheet Books to PDF... 图纸册转 PDF... Drawing Recovery Manager... 图形恢复管理器... + Named Viewports... 命名视口... Named Views... 命名视图... Polygonal Viewport 多边形视口 From Template... 来自模板... diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1f018df2e..8e0f89963 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -230,6 +230,19 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { let alpha = |c: Color32| if dim { c.gamma_multiply(0.4) } else { c }; let pen = Pen { p, r: rect, w: (rect.width() / 16.0).clamp(1.0, 1.6), base: alpha(t.icon), acc: alpha(t.icon_accent), pt: alpha(t.icon_point) }; let (b, a) = (pen.base, pen.acc); + paint_file_view(&pen, icon, b, a, dim); + paint_document_tools(&pen, icon, b, a, dim); + paint_draw_tools(&pen, icon, b, a, dim); + paint_edit_tools(&pen, icon, b, a, dim); + paint_dimensions(&pen, icon, b, a, dim); + paint_block_tools(&pen, icon, b, a, dim); + paint_layer_tools(&pen, icon, b, a, dim); + paint_display_toggles(&pen, icon, b, a, dim); + paint_navigation_controls(&pen, icon, b, a, dim); + paint_constraints(&pen, icon, b, a, dim); +} + +fn paint_file_view(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { use Icon::*; match icon { New => { @@ -331,6 +344,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { b, ); } + _ => {} + } +} + +fn paint_document_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { + use Icon::*; + match icon { Orbit => { pen.c(12.0, 12.0, 4.0, b); pen.arc(12.0, 12.0, 9.0, 20.0, 160.0, a); @@ -355,6 +375,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.poly(&[(3.0, y + 2.0), (12.0, *y - 2.0), (21.0, y + 2.0), (12.0, y + 6.0)], true, c); } } + _ => {} + } +} + +fn paint_draw_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, dim: bool) { + use Icon::*; + match icon { Help => { pen.c(12.0, 12.0, 9.0, b); pen.text(12.0, 12.5, "?", 12.0, b); @@ -485,6 +512,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.la(14.0, 18.0, 21.0, 18.0); pen.la(14.0, 21.0, 19.0, 21.0); } + _ => {} + } +} + +fn paint_edit_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { + use Icon::*; + match icon { Move => { pen.la(12.0, 3.0, 12.0, 21.0); pen.la(3.0, 12.0, 21.0, 12.0); @@ -604,6 +638,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.poly(&[(20.0, 6.0), (20.0, 11.0), (11.0, 11.0), (11.0, 14.0)], false, b); pen.fill_rect(9.5, 14.0, 3.0, 8.0, a); } + _ => {} + } +} + +fn paint_dimensions(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { + use Icon::*; + match icon { DimLinear | DimAligned | DimBaseline | DimContinue | QDim => { let (y0, y1) = (8.0, 18.0); pen.l(4.0, y1, 4.0, 5.0); @@ -673,6 +714,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { _ => pen.text(19.0, 19.0, "A", 9.0, a), } } + _ => {} + } +} + +fn paint_block_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { + use Icon::*; + match icon { LayerOff | LayerIso | LayerFreeze | LayerLock | LayerMatch | MakeCurrent | LayerPrev => { pen.poly(&[(2.0, 9.0), (10.0, 5.0), (18.0, 9.0), (10.0, 13.0)], true, b); pen.poly(&[(2.0, 13.0), (10.0, 17.0), (18.0, 13.0)], false, b); @@ -691,11 +739,27 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { } } } + _ => {} + } +} + +fn paint_layer_tools(pen: &Pen<'_>, icon: Icon, _b: Color32, _a: Color32, dim: bool) { + let alpha = |c: Color32| if dim { c.gamma_multiply(0.4) } else { c }; + use Icon::*; + match icon { Bulb | BulbOff => { let c = if icon == Bulb { Color32::from_rgb(0xf5, 0xd0, 0x3b) } else { Color32::from_rgb(0x70, 0x78, 0x86) }; pen.fill_c(12.0, 10.0, 6.0, alpha(c)); pen.fill_rect(9.5, 15.0, 5.0, 4.0, alpha(Color32::from_rgb(0xb0, 0xb4, 0xba))); } + _ => {} + } +} + +fn paint_display_toggles(pen: &Pen<'_>, icon: Icon, b: Color32, _a: Color32, dim: bool) { + let alpha = |c: Color32| if dim { c.gamma_multiply(0.4) } else { c }; + use Icon::*; + match icon { Lock | Unlock => { let c = alpha(Color32::from_rgb(0xe6, 0xb4, 0x3c)); pen.fill_rect(6.0, 11.0, 12.0, 9.0, c); @@ -762,7 +826,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { Lineweight => { for (k, w) in [1.0f32, 2.0, 3.5].iter().enumerate() { let y = 6.0 + k as f32 * 6.0; - p.line_segment([pen.at(3.0, y), pen.at(21.0, y)], Stroke::new(w * pen.sc(), b)); + pen.p.line_segment([pen.at(3.0, y), pen.at(21.0, y)], Stroke::new(w * pen.sc(), b)); } } Transparency => { @@ -784,7 +848,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.c(12.0, 12.0, 4.0, b); for i in 0..8 { let ang = (i as f32 * 45.0).to_radians(); - p.line_segment( + pen.p.line_segment( [pen.at(12.0 + 6.0 * ang.cos(), 12.0 + 6.0 * ang.sin()), pen.at(12.0 + 9.0 * ang.cos(), 12.0 + 9.0 * ang.sin())], Stroke::new(2.2 * pen.sc(), b), ); @@ -801,6 +865,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.l(9.0, 4.0, 9.0, 20.0); pen.l(9.0, 9.0, 21.0, 9.0); } + _ => {} + } +} + +fn paint_navigation_controls(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) { + use Icon::*; + match icon { ChevronDown => pen.poly(&[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)], false, b), ChevronUp => pen.poly(&[(6.0, 15.0), (12.0, 9.0), (18.0, 15.0)], false, b), ChevronLeft => pen.poly(&[(15.0, 6.0), (9.0, 12.0), (15.0, 18.0)], false, b), @@ -827,6 +898,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.l(4.0, y, 20.0, y); } } + _ => {} + } +} + +fn paint_constraints(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, dim: bool) { + let alpha = |c: Color32| if dim { c.gamma_multiply(0.4) } else { c }; + use Icon::*; + match icon { Constraint | Fix => { pen.fill_rect(7.0, 11.0, 10.0, 9.0, Color32::from_rgb(0xe6, 0xb4, 0x3c)); pen.arc(12.0, 11.0, 4.0, 0.0, 180.0, Color32::from_rgb(0xe6, 0xb4, 0x3c)); @@ -940,6 +1019,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.poly(&[(3.0, 4.0), (19.0, 4.0), (13.0, 11.0), (13.0, 19.0), (9.0, 17.0), (9.0, 11.0)], true, b); pen.poly(&[(20.0, 10.0), (16.5, 15.5), (20.0, 15.5), (17.0, 21.0)], false, a); } + _ => {} } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8a95138eb..1801265fd 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -377,7 +377,19 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } ui.end_row(); for l in &shown { - row(ui, l, &cur, &used, vp.as_ref(), &linetypes, &library, &mut st, &mut actions); + row( + ui, + LayerRow { + layer: l, + current: &cur, + used: &used, + viewport: vp.as_ref(), + linetypes: &linetypes, + library: &library, + state: &mut st, + actions: &mut actions, + }, + ); ui.end_row(); } }, @@ -405,91 +417,22 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } -/// One Layer Properties Manager row: status/name, on/freeze/lock/plot, colour, linetype, lineweight, transparency, viewport overrides, new-viewport freeze and description. -/// Edits update `st` and push commands into `actions`. -#[allow(clippy::too_many_arguments)] -fn row( - ui: &mut egui::Ui, - l: &Layer, - cur: &str, - used: &std::collections::HashSet, - vp: Option<&(Handle, cadcraft_doc::Viewport)>, - linetypes: &[cadcraft_doc::Linetype], - library: &[String], - st: &mut LpmState, - actions: &mut Vec<(&'static str, Value)>, -) { +/// One Layer Properties Manager row. +struct LayerRow<'a> { + layer: &'a Layer, + current: &'a str, + used: &'a std::collections::HashSet, + viewport: Option<&'a (Handle, cadcraft_doc::Viewport)>, + linetypes: &'a [cadcraft_doc::Linetype], + library: &'a [String], + state: &'a mut LpmState, + actions: &'a mut Vec<(&'static str, Value)>, +} + +fn row(ui: &mut egui::Ui, args: LayerRow<'_>) { + let LayerRow { layer: l, current: cur, used, viewport: vp, linetypes, library, state: st, actions } = args; let t = Tokens::get(); - let name = l.name.clone(); - let current = name.eq_ignore_ascii_case(cur); - let is_used = used.contains(&name.to_ascii_lowercase()); - let selected = st.selected.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(&name)); - // Status. - let (sr, sresp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); - if selected { - ui.painter().rect_filled(sr, 2.0, t.accent.gamma_multiply(0.35)); - } - status_icon(ui.painter(), sr.shrink(2.0), current, is_used); - let tip = if current { - "Current layer" - } else if is_used { - "Layer contains objects (double-click to make current)" - } else { - "Layer is empty (double-click to make current)" - }; - if sresp.on_hover_text(crate::i18n::t(tip)).double_clicked() { - actions.push(("layer.current", json!({ "name": name }))); - } - // Name (double-click or F2-style rename). - match st.renaming.clone() { - Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui - .add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name)))) - .accessible_name(crate::tl!("Name")); - if !r.has_focus() && !r.lost_focus() { - r.request_focus(); - } - if r.lost_focus() { - let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); - if !cancel && buf.trim() != name && !buf.trim().is_empty() { - actions.push(("layer.set", json!({ "name": name, "newName": buf.trim() }))); - st.selected = Some(buf.trim().to_string()); - } - st.renaming = None; - } else { - st.renaming = Some((n, buf)); - } - } - _ => { - let (nr, r) = ui.allocate_exact_size(vec2(150.0, 20.0), Sense::click()); - if selected { - ui.painter().rect_filled(nr, 2.0, t.accent.gamma_multiply(0.45)); - } else if r.hovered() { - ui.painter().rect_filled(nr, 2.0, t.control_hover.gamma_multiply(0.5)); - } - ui.painter().text(pos2(nr.left() + 4.0, nr.center().y), egui::Align2::LEFT_CENTER, &name, crate::theme::body(), t.text); - if r.clicked() { - st.selected = Some(name.clone()); - } - if r.double_clicked() && name != "0" && !name.eq_ignore_ascii_case("Defpoints") { - st.renaming = Some((name.clone(), name.clone())); - } - r.context_menu(|ui| { - if ui.button(crate::tl!("Set Current")).clicked() { - actions.push(("layer.current", json!({ "name": name }))); - ui.close(); - } - if ui.button(crate::tl!("Rename Layer")).clicked() { - st.renaming = Some((name.clone(), name.clone())); - ui.close(); - } - if ui.button(crate::tl!("Delete Layer")).clicked() { - actions.push(("layer.delete", json!({ "name": name }))); - ui.close(); - } - }); - } - } + let name = layer_identity(ui, l, cur, used, st, actions); // On / Freeze / Lock / Plot. if icons::button(ui, if l.on { Icon::Bulb } else { Icon::BulbOff }, 20.0, "On/Off", false).clicked() { actions.push(("layer.set", json!({ "name": name, "on": !l.on }))); @@ -684,7 +627,88 @@ fn row( } } -/// Lower-case names of layers with objects on them. +fn layer_identity( + ui: &mut egui::Ui, + l: &Layer, + cur: &str, + used: &std::collections::HashSet, + st: &mut LpmState, + actions: &mut Vec<(&'static str, Value)>, +) -> String { + let t = Tokens::get(); + let name = l.name.clone(); + let current = name.eq_ignore_ascii_case(cur); + let is_used = used.contains(&name.to_ascii_lowercase()); + let selected = st.selected.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(&name)); + // Status. + let (sr, sresp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); + if selected { + ui.painter().rect_filled(sr, 2.0, t.accent.gamma_multiply(0.35)); + } + status_icon(ui.painter(), sr.shrink(2.0), current, is_used); + let tip = if current { + "Current layer" + } else if is_used { + "Layer contains objects (double-click to make current)" + } else { + "Layer is empty (double-click to make current)" + }; + if sresp.on_hover_text(crate::i18n::t(tip)).double_clicked() { + actions.push(("layer.current", json!({ "name": name }))); + } + // Name (double-click or F2-style rename). + match st.renaming.clone() { + Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name)))) + .accessible_name(crate::tl!("Name")); + if !r.has_focus() && !r.lost_focus() { + r.request_focus(); + } + if r.lost_focus() { + let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if !cancel && buf.trim() != name && !buf.trim().is_empty() { + actions.push(("layer.set", json!({ "name": name, "newName": buf.trim() }))); + st.selected = Some(buf.trim().to_string()); + } + st.renaming = None; + } else { + st.renaming = Some((n, buf)); + } + } + _ => { + let (nr, r) = ui.allocate_exact_size(vec2(150.0, 20.0), Sense::click()); + if selected { + ui.painter().rect_filled(nr, 2.0, t.accent.gamma_multiply(0.45)); + } else if r.hovered() { + ui.painter().rect_filled(nr, 2.0, t.control_hover.gamma_multiply(0.5)); + } + ui.painter().text(pos2(nr.left() + 4.0, nr.center().y), egui::Align2::LEFT_CENTER, &name, crate::theme::body(), t.text); + if r.clicked() { + st.selected = Some(name.clone()); + } + if r.double_clicked() && name != "0" && !name.eq_ignore_ascii_case("Defpoints") { + st.renaming = Some((name.clone(), name.clone())); + } + r.context_menu(|ui| { + if ui.button(crate::tl!("Set Current")).clicked() { + actions.push(("layer.current", json!({ "name": name }))); + ui.close(); + } + if ui.button(crate::tl!("Rename Layer")).clicked() { + st.renaming = Some((name.clone(), name.clone())); + ui.close(); + } + if ui.button(crate::tl!("Delete Layer")).clicked() { + actions.push(("layer.delete", json!({ "name": name }))); + ui.close(); + } + }); + } + } + name +} + fn used_layers(d: &cadcraft_doc::Drawing) -> std::collections::HashSet { let mut set = std::collections::HashSet::new(); let ents = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 017cf5a42..97ab2faf4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// CADCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png")), + accent: Some([0x3e, 0xa6, 0xe8]), + ..craft_update::suite::CADCRAFT +}; + pub mod aliasdlg; pub mod animation; pub mod autosave; @@ -27,6 +36,7 @@ pub mod hatchdlg; mod mtext_editor; mod panel_state; pub mod spacemouse; +pub mod switch; // The External References palette. pub mod xrefs; @@ -46,6 +56,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"))] @@ -55,6 +66,7 @@ pub mod vpscale; pub mod exchange; +use std::path::Path; use std::sync::mpsc::Receiver; use cadcraft_engine::Session; @@ -88,7 +100,7 @@ pub struct UiState { /// Keep the system cursor visible over the drawing area (a small crosshair at the centre of the /// drawn one) instead of hiding it. Screen magnifiers (Windows Magnifier "follow the mouse /// pointer"), screen readers, recorders and remote desktops track the system cursor and lose it - /// when it is hidden. Off by default; `CADCRAFT_SYSTEM_CURSOR=1` turns it on at startup. + /// when it is hidden. Off unless the host defaults to it ([`CadApp::set_system_cursor_default`]). pub system_cursor: bool, /// "Drafting" or "Modeling". pub toolset_tab: String, @@ -107,7 +119,7 @@ pub struct UiState { /// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS /// setting), "on" or "off". pub animations: animation::AnimationPref, - /// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg". + /// The format Save As suggests: "dxf" or "dwg", for new drawings and opened files alike. pub save_format: SaveFormat, /// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a /// drawing opened from a file keeps that file's version. @@ -121,8 +133,8 @@ pub struct UiState { /// A DXF/DWG file version (2000 … 2018). pub use cadcraft_engine::cmd::file::Version as SaveVersion; -/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as". -/// A drawing opened from a file keeps that file's format. +/// The default save format, like AutoCAD's Options > Open and Save > "Save as": the format Save As +/// suggests for every drawing, an opened DWG or DXF too. Save (QSAVE) of an opened file keeps its file. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SaveFormat { @@ -147,13 +159,19 @@ impl SaveFormat { SaveFormat::ALL.into_iter().find(|f| f.as_str().eq_ignore_ascii_case(s.trim().trim_start_matches('.'))) } - /// The name Save As suggests: a title that already has an extension (a drawing opened from a - /// file) keeps it; an untitled drawing gets this format's extension. + /// The name Save As suggests: this format's extension, replacing a drawing's own (DWG, DXF or + /// DWT, from a drawing opened from a file); any other extension is kept. pub fn suggested_name(self, title: Option<&str>) -> String { - match title.map(str::trim).filter(|t| !t.is_empty()) { - Some(t) if std::path::Path::new(t).extension().is_some() => t.to_owned(), - Some(t) => format!("{t}.{}", self.as_str()), - None => format!("Drawing.{}", self.as_str()), + let Some(t) = title.map(str::trim).filter(|t| !t.is_empty()) else { return format!("Drawing.{}", self.as_str()) }; + let path = std::path::Path::new(t); + match path.extension().and_then(|e| e.to_str()) { + Some(e) if ["dwg", "dxf", "dwt"].iter().any(|d| d.eq_ignore_ascii_case(e)) => { + // the extension is ASCII, so this is a char boundary + let stem = t.get(..t.len().saturating_sub(e.len() + 1)).unwrap_or(t); + format!("{stem}.{}", self.as_str()) + } + Some(_) => t.to_owned(), + None => format!("{t}.{}", self.as_str()), } } } @@ -173,7 +191,7 @@ impl Default for UiState { show_viewcube: true, show_ucs_icon: true, show_layer_list: false, - system_cursor: std::env::var("CADCRAFT_SYSTEM_CURSOR").is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off")), + system_cursor: false, toolset_tab: "Drafting".into(), collapsed_groups: Vec::new(), hidden_tools: Vec::new(), @@ -211,6 +229,8 @@ pub struct CadApp { /// Whether the operating system shows animations, as the host reads it; `None` when the OS /// states nothing or the host cannot ask ([`animation`]). pub system_animations: Option, + /// What `ui.system_cursor` goes back to when the interface is reset ([`Self::set_system_cursor_default`]). + pub system_cursor_default: bool, pub session: Session, pub ui: UiState, /// The interface's own preferences from the Options dialog ([`options`]). @@ -271,6 +291,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. @@ -296,6 +320,7 @@ impl CadApp { CadApp { system_languages: Vec::new(), system_animations: None, + system_cursor_default: false, session, ui: UiState::default(), options: options::UiOptions::default(), @@ -330,6 +355,8 @@ 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(), @@ -395,6 +422,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. @@ -403,7 +437,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) } @@ -469,6 +507,15 @@ impl CadApp { } } + /// Make the system cursor the interface default (`on`) and turn it on; saved preferences and + /// resets never turn it off then. + pub fn set_system_cursor_default(&mut self, on: bool) { + self.system_cursor_default = on; + if on { + self.ui.system_cursor = true; + } + } + pub fn set_status(&mut self, s: impl Into) { self.status = Some((s.into(), now_ms())); } @@ -481,12 +528,12 @@ impl CadApp { /// Open the drawing at `path`. Natively the file is read on a worker thread while an /// "Opening ..." card shows ([`opening`]); [`Self::finish_opening`] adds it when ready. On the /// web it opens at once. - pub fn open_path(&mut self, path: &str) { + pub fn open_path(&mut self, path: &Path) { // A LISP file (dropped on the window): loaded as APPLOAD loads it. if is_lisp_file(path) { match cadcraft_engine::lisp::builtins::read_text_file(path) { - Some(text) => self.load_lisp(path, &text), - None => self.open_failed(format!("APPLOAD: can't read {path}")), + Some(text) => self.load_lisp(&path.to_string_lossy(), &text), + None => self.open_failed(format!("APPLOAD: can't read {}", path.display())), } return; } @@ -496,7 +543,10 @@ impl CadApp { } #[cfg(target_arch = "wasm32")] { - let r = self.run("open", json!({ "path": path })); + let r = match path.to_str() { + Some(text) => self.run("open", json!({ "path": text })), + None => Err(format!("open: {}: the path is not valid Unicode", path.display())), + }; self.opened(r); } } @@ -659,11 +709,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' @@ -800,9 +850,9 @@ impl CadApp { { let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); for file in dropped { - let path = file.path().to_string_lossy().to_string(); - if !path.is_empty() { - self.open_path(&path); + let path = file.path(); + if !path.as_os_str().is_empty() { + self.open_path(path); } } } @@ -843,6 +893,7 @@ 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()); self.frame_ms = now_ms() - t0; @@ -889,8 +940,8 @@ pub fn now_ms() -> f64 { } /// A LISP source file, by its name. -fn is_lisp_file(name: &str) -> bool { - std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp")) +fn is_lisp_file(name: impl AsRef) -> bool { + name.as_ref().extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp")) } #[cfg(test)] @@ -1116,11 +1167,17 @@ mod tests { b.start("ui.saveformat.dwg"); b.start("ui.resetpalettes"); assert_eq!(b.ui.save_format, SaveFormat::Dwg); - // Untitled drawings get the format's extension; files keep their own. + // Untitled drawings get the format's extension, and so do opened drawings (#2199). assert_eq!(SaveFormat::Dwg.suggested_name(Some("Drawing1")), "Drawing1.dwg"); assert_eq!(SaveFormat::Dxf.suggested_name(Some("Drawing1")), "Drawing1.dxf"); - assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); - assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.DWG"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dwg"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Title block.dwt")), "Title block.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("v1.2 plan.Dxf")), "v1.2 plan.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("平面図.dxf")), "平面図.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some(".dxf")), ".dxf.dwg", "a dot file has no extension"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("logo.svg")), "logo.svg", "other files keep their type"); assert_eq!(SaveFormat::Dwg.suggested_name(None), "Drawing.dwg"); assert_eq!(SaveFormat::Dwg.suggested_name(Some(" ")), "Drawing.dwg"); let ui: UiState = serde_json::from_value(json!({ "saveFormat": "dwg" })).unwrap_or_default(); diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 342e9e069..42266d3b6 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -1,5 +1,6 @@ //! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style, -//! Table Style, Multileader Style, Page Setup Manager, Plot and Constraint Settings. +//! Table Style, Multileader Style, Page Setup Manager, Plot, Constraint Settings and Named +//! Viewports. //! //! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run //! the command. The dialogs read the drawing and change it only through the same commands @@ -16,6 +17,8 @@ use crate::theme::Tokens; /// The Block Attribute Manager (BATTMAN). mod battman; +/// Named Viewports (View > Viewports > Named Viewports...). +mod named_vports; /// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the /// call carries parameters. @@ -32,6 +35,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option "mleaderstyle", "pagesetup" => "pagesetup", "constraintsettings" | "csettings" => "csettings", + "vports.named" => "namedvports", "plot" | "print" => { PLOT_GEN.fetch_add(1, Ordering::Relaxed); "plot" @@ -53,6 +57,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool "mleaderstyle" => mleader_style(app, ctx, open), "pagesetup" => page_setup(app, ctx, open), "csettings" => constraint_settings(app, ctx, open), + "namedvports" => named_vports::dialog(app, ctx, open), "plot" => plot(app, ctx, open), _ => { *open = false; diff --git a/crates/ui-egui/src/managers/named_vports.rs b/crates/ui-egui/src/managers/named_vports.rs new file mode 100644 index 000000000..6bffa5c4e --- /dev/null +++ b/crates/ui-egui/src/managers/named_vports.rs @@ -0,0 +1,112 @@ +//! Named Viewports (View > Viewports > Named Viewports...): the drawing's saved model space +//! viewport configurations, with the selected one's tiles drawn as a preview. Restore runs +//! `vports.named` in the Model tab; in a layout it starts MVIEW Restore with the name, so the +//! viewports' corners are picked next. Delete runs `vports.named` with `delete`. + +use cadcraft_doc::{Space, VportConfig, VportTile}; +use cadcraft_engine::Input; +use egui::{RichText, Sense, Stroke, vec2}; +use serde_json::{Value, json}; + +use super::{set_temp, temp}; +use crate::CadApp; +use crate::theme::Tokens; + +/// Draws the tiles of a configuration in a 180 x 120 box; tile coordinates (0, 0) are the bottom left. +fn preview(ui: &mut egui::Ui, tiles: &[VportTile], t: &Tokens) { + let (rect, _) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 0.0, t.panel); + let at = |x: f64, y: f64| egui::pos2(rect.min.x + x.clamp(0.0, 1.0) as f32 * rect.width(), rect.max.y - y.clamp(0.0, 1.0) as f32 * rect.height()); + for tile in tiles.iter().filter(|t| t.min.is_finite() && t.max.is_finite()) { + let r = egui::Rect::from_two_pos(at(tile.min.x, tile.min.y), at(tile.max.x, tile.max.y)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + } + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); +} + +/// Show the dialog; returns the command to run. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> { + let t = Tokens::get(); + let Ok(d) = app.session.doc() else { + egui::Window::new("Named Viewports").open(open).collapsible(false).resizable(false).show(ctx, |ui| { + ui.label("Open a drawing first."); + }); + return None; + }; + let configs: Vec<(String, Vec)> = d.vports.iter().filter(|c| !c.is_active()).map(|c| (c.name.clone(), c.tiles.clone())).collect(); + let model = app.session.layout_space() == Space::Model; + let current = if model { app.session.viewport_configuration() } else { None }; + let mut sel = temp::(ctx, "namedvp_sel") + .filter(|s| configs.iter().any(|c| c.0 == *s)) + .or_else(|| current.clone()) + .or_else(|| configs.first().map(|c| c.0.clone())); + let mut action: Option<(&'static str, Value)> = None; + let mut restore = false; + let mut close = false; + egui::Window::new("Named Viewports").open(open).default_width(400.0).resizable(false).show(ctx, |ui| { + if model { + ui.label(format!("Current name: {}", current.as_deref().unwrap_or(VportConfig::ACTIVE))); + } else { + ui.label(RichText::new("Saved model space configurations to place in this layout").color(t.text_dim)); + } + ui.separator(); + if configs.is_empty() { + ui.label("This drawing has no named viewport configurations."); + } + ui.horizontal_top(|ui| { + egui::ScrollArea::vertical().id_salt("namedvp_list").max_height(120.0).min_scrolled_width(180.0).show(ui, |ui| { + ui.set_min_width(180.0); + for (name, _) in &configs { + let r = ui.selectable_label(sel.as_deref() == Some(name.as_str()), name); + if r.clicked() { + sel = Some(name.clone()); + } + if r.double_clicked() { + sel = Some(name.clone()); + restore = true; + } + } + }); + let tiles = sel.as_ref().and_then(|s| configs.iter().find(|c| c.0 == *s)).map(|c| c.1.as_slice()).unwrap_or_default(); + preview(ui, tiles, &t); + }); + ui.separator(); + ui.horizontal(|ui| { + let hint = if model { "Show this configuration's viewports" } else { "Place this configuration's viewports in the layout" }; + if ui.add_enabled(sel.is_some(), egui::Button::new("Restore")).on_hover_text(hint).clicked() { + restore = true; + } + if ui.add_enabled(sel.is_some(), egui::Button::new("Delete")).on_hover_text("Delete the saved configuration").clicked() + && let Some(n) = &sel + { + action = Some(("vports.named", json!({ "name": n, "delete": true }))); + } + if ui.button("Close").clicked() { + close = true; + } + }); + }); + if let Some(n) = sel.clone() { + set_temp(ctx, "namedvp_sel", n); + } + if close { + *open = false; + } + let Some(name) = sel.filter(|_| restore) else { return action }; + *open = false; + if model { + return Some(("vports.named", json!({ "name": name }))); + } + // The viewports' corners are picked in the layout, as for MVIEW Restore. + app.start("mview"); + if app.session.running.as_ref().is_some_and(|r| r.id == "mview") { + for i in [Input::Keyword("Restore".into()), Input::Text(name)] { + if let Err(e) = app.session.input(i) { + app.session.echo(e.to_string()); + break; + } + } + } + None +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 43b135d19..d7c1c9078 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,7 +85,7 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option Option> { +fn route_ui_subsystems(app: &mut CadApp, id: &str, params: &Value) -> Option> { if let Some(result) = crate::spacemouse::route(app, id, params) { return Some(result); } @@ -118,163 +118,24 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Option> { if id == RECENT_CLEAR { app.ui.recent_files.clear(); return Some(Ok(Value::Null)); } if let Some(i) = id.strip_prefix(RECENT_ITEM).and_then(|n| n.parse::().ok()) { let Some(path) = app.ui.recent_files.get(i).cloned() else { return Some(Err(format!("no recent file {i}"))) }; - app.open_path(&path); + app.open_path(std::path::Path::new(&path)); return Some(Ok(Value::Null)); } + None +} + +fn run_preference_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { let r = match id { - "ui.language" => { - let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { - return Some(Err(format!("language must be {}", crate::i18n::Preference::codes()))); - }; - app.ui.interface_language = preference; - let shown = app.language(); - if shown != preference.resolve(&app.system_languages) { - app.set_status(if preference.resolve(&app.system_languages) == "ko" { - "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." - } else { - "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese." - }); - } - Ok(json!({"interfaceLanguage": preference.code(), "shown": shown})) - } - "ui.open" | "open" if no_path => { - let picked = app.services.pick_open.as_ref().and_then(|f| f()); - if let Some(p) = picked { - app.open_path(&p); - } - Ok(Value::Null) - } - // APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last - // one came from. JSON calls carry `path` and never open one. A picked file is loaded as a - // typed name is, through the interactive command, so a routine it starts that asks for a - // point waits for it; on the web (no file system) it says to drop the file instead. - "appload" | "ap" if params.is_null() => { - if app.services.no_file_system { - let hint = "APPLOAD: there is no file system here; drop a .lsp file on the window to load it"; - app.session.echo(hint.to_string()); - app.set_status(hint); - return Some(Ok(Value::Null)); - } - let pick = app.services.pick_lisp.as_ref()?; - let dir = app.ui.last_lisp_dir.clone().unwrap_or_default(); - let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) }; - if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) { - app.ui.last_lisp_dir = Some(dir.to_string()); - } - if let Err(e) = app.session.start("appload") { - return Some(Err(e.to_string())); - } - app.cmdline(&path); - Ok(Value::Null) - } - // Pick a template (.dwt or .dxf) and start a new drawing from it. - "ui.newtemplate" => { - if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { - return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string())); - } - Ok(Value::Null) - } - // RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the - // file name on the command line. - "recover" if no_path && app.services.pick_open.is_some() => { - if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { - let r = app.run("recover", json!({ "path": p })); - app.opened(r); - } - Ok(Value::Null) - } - "ui.saveas" | "saveas" if no_path => { - let title = app.session.state().map(|s| s.title.clone()).ok(); - let name = app.ui.save_format.suggested_name(title.as_deref()); - if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) { - return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())); - } - Ok(Value::Null) - } - "qsave" if no_path && app.session.state().is_ok_and(|s| s.path.is_none()) => return run_ui_command(app, "ui.saveas", &Value::Null), - // 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. - "exportpdf" if params.is_null() && app.session.state().is_ok() => { - let pick = app.services.pick_save.as_ref()?; - let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default(); - let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()); - let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) }; - let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string()); - app.session.echo(match &r { - Ok(_) => format!("Plotted to {path}"), - Err(e) => e.clone(), - }); - r - } - "ui.sample" => { - let d = cadcraft_engine::sample::default_sample(); - app.session.open_drawing(d, "Bracket", None); - app.canvas.zoom_pending = true; - app.ui.start_tab = false; - Ok(Value::Null) - } - "ui.toggle.toolsets" => { - if params.get("on").and_then(Value::as_bool) == Some(true) { - return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":"toolSets"}))); - } - toggle(&mut app.ui.show_toolsets, params); - Ok(Value::Null) - } - "ui.toggle.palettes" => { - toggle(&mut app.ui.show_palettes, params); - Ok(Value::Null) - } - "ui.toggle.toolbar" => { - toggle(&mut app.ui.show_toolbar, params); - Ok(Value::Null) - } - "ui.toggle.filetabs" => { - toggle(&mut app.ui.show_file_tabs, params); - Ok(Value::Null) - } - // Typed or chosen from a menu or the tool bar: ask for the file (or show the preview). - "export" | "exp" | "import" | "imp" | "publish" | "preview" | "pre" if params.is_null() && app.session.state().is_ok() => { - return Some(crate::exchange::route(app, id)); - } - "ui.toggle.statusbar" => { - toggle(&mut app.ui.show_status_bar, params); - Ok(Value::Null) - } - "ui.toggle.cmdline" => { - toggle(&mut app.ui.show_command_line, params); - Ok(Value::Null) - } - "ui.toggle.systemcursor" => { - toggle(&mut app.ui.system_cursor, params); - Ok(Value::Null) - } - "ui.toggle.viewcube" => { - toggle(&mut app.ui.show_viewcube, params); - Ok(Value::Null) - } - "ui.toggle.ucsicon" => { - toggle(&mut app.ui.show_ucs_icon, params); - Ok(Value::Null) - } - "ui.toggle.menubar" => { - toggle(&mut app.ui.in_window_menu, params); - Ok(Value::Null) - } "ui.theme.system" | "ui.theme.light" | "ui.theme.dark" => { let pref = crate::theme::ThemePref::parse(id.trim_start_matches("ui.theme.")).unwrap_or_default(); set_theme(app, pref) @@ -331,6 +192,220 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Ok(json!({ "saveVersion": app.ui.save_version })), } } + _ => return None, + }; + Some(r) +} + +fn run_ui_toggle_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { + let toggle = |b: &mut bool, p: &Value| { + *b = p.get("on").and_then(Value::as_bool).unwrap_or(!*b); + }; + let result = match id { + "ui.toggle.toolsets" => { + if params.get("on").and_then(Value::as_bool) == Some(true) { + return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":"toolSets"}))); + } + toggle(&mut app.ui.show_toolsets, params); + Ok(Value::Null) + } + "ui.toggle.palettes" => { + toggle(&mut app.ui.show_palettes, params); + Ok(Value::Null) + } + "ui.toggle.toolbar" => { + toggle(&mut app.ui.show_toolbar, params); + Ok(Value::Null) + } + "ui.toggle.filetabs" => { + toggle(&mut app.ui.show_file_tabs, params); + Ok(Value::Null) + } + "ui.toggle.statusbar" => { + toggle(&mut app.ui.show_status_bar, params); + Ok(Value::Null) + } + "ui.toggle.cmdline" => { + toggle(&mut app.ui.show_command_line, params); + Ok(Value::Null) + } + "ui.toggle.systemcursor" => { + toggle(&mut app.ui.system_cursor, params); + Ok(Value::Null) + } + "ui.toggle.viewcube" => { + toggle(&mut app.ui.show_viewcube, params); + Ok(Value::Null) + } + "ui.toggle.ucsicon" => { + toggle(&mut app.ui.show_ucs_icon, params); + Ok(Value::Null) + } + "ui.toggle.menubar" => { + toggle(&mut app.ui.in_window_menu, params); + Ok(Value::Null) + } + "ui.toggle.history" | "textscr" => { + let open = json!({"on": true}); + toggle(&mut app.cmd.expanded, if id == "textscr" { &open } else { params }); + Ok(json!({ "on": app.cmd.expanded })) + } + _ => return None, + }; + Some(result) +} + +fn run_language_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { + if id != "ui.language" { + return None; + } + let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { + return Some(Err(format!("language must be {}", crate::i18n::Preference::codes()))); + }; + app.ui.interface_language = preference; + let shown = app.language(); + if shown != preference.resolve(&app.system_languages) { + app.set_status(if preference.resolve(&app.system_languages) == "ko" { + "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." + } else { + "No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese." + }); + } + Some(Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))) +} + +fn run_close_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { + match id { + "close" if params.is_null() => { + app.request_close(None); + Some(Ok(Value::Null)) + } + "closeall" if params.is_null() => { + app.request_close_all(); + Some(Ok(Value::Null)) + } + _ => None, + } +} + +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); + } + // When a file command should ask for a file. The `ui.*` commands are the pickers; the engine's + // OPEN/SAVEAS/QSAVE ask only when typed or chosen from a menu (no parameters), so JSON calls + // never open a picker. + let no_path = should_pick_file_path(id, params); + if let Some(result) = run_recent_file_command(app, id) { + return Some(result); + } + if let Some(result) = run_preference_command(app, id, params) { + return Some(result); + } + if let Some(result) = run_ui_toggle_command(app, id, params) { + return Some(result); + } + if let Some(result) = run_language_command(app, id, params) { + return Some(result); + } + if let Some(result) = run_close_command(app, id, params) { + return Some(result); + } + let r = match id { + "ui.open" | "open" if no_path => { + let picked = app.services.pick_open.as_ref().and_then(|f| f()); + if let Some(p) = picked { + app.open_path(std::path::Path::new(&p)); + } + Ok(Value::Null) + } + // APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last + // one came from. JSON calls carry `path` and never open one. A picked file is loaded as a + // typed name is, through the interactive command, so a routine it starts that asks for a + // point waits for it; on the web (no file system) it says to drop the file instead. + "appload" | "ap" if params.is_null() => { + if app.services.no_file_system { + let hint = "APPLOAD: there is no file system here; drop a .lsp file on the window to load it"; + app.session.echo(hint.to_string()); + app.set_status(hint); + return Some(Ok(Value::Null)); + } + let pick = app.services.pick_lisp.as_ref()?; + let dir = app.ui.last_lisp_dir.clone().unwrap_or_default(); + let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) }; + if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) { + app.ui.last_lisp_dir = Some(dir.to_string()); + } + if let Err(e) = app.session.start("appload") { + return Some(Err(e.to_string())); + } + app.cmdline(&path); + Ok(Value::Null) + } + // Pick a template (.dwt or .dxf) and start a new drawing from it. + "ui.newtemplate" => { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string())); + } + Ok(Value::Null) + } + // RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the + // file name on the command line. + "recover" if no_path && app.services.pick_open.is_some() => { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + let r = app.run("recover", json!({ "path": p })); + app.opened(r); + } + Ok(Value::Null) + } + "ui.saveas" | "saveas" if no_path => { + let title = app.session.state().map(|s| s.title.clone()).ok(); + let name = app.ui.save_format.suggested_name(title.as_deref()); + if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) { + return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())); + } + Ok(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. + "exportpdf" if params.is_null() && app.session.state().is_ok() => { + let pick = app.services.pick_save.as_ref()?; + let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default(); + let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) }; + let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string()); + app.session.echo(match &r { + Ok(_) => format!("Plotted to {path}"), + Err(e) => e.clone(), + }); + r + } + "ui.sample" => { + let d = cadcraft_engine::sample::default_sample(); + app.session.open_drawing(d, "Bracket", None); + app.canvas.zoom_pending = true; + app.ui.start_tab = false; + Ok(Value::Null) + } + "export" | "exp" | "import" | "imp" | "publish" | "preview" | "pre" if params.is_null() && app.session.state().is_ok() => { + return Some(crate::exchange::route(app, id)); + } "ui.hidepalettes" => { app.ui.show_palettes = false; app.ui.show_toolsets = false; @@ -339,7 +414,17 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format); let (animations, save_version) = (app.ui.animations, std::mem::take(&mut app.ui.save_version)); - app.ui = crate::UiState { in_window_menu: menu, theme, animations, interface_language, save_format, save_version, ..Default::default() }; + let system_cursor = app.system_cursor_default; + app.ui = crate::UiState { + in_window_menu: menu, + theme, + animations, + interface_language, + save_format, + save_version, + system_cursor, + ..Default::default() + }; Ok(Value::Null) } "ui.start" => { @@ -347,12 +432,6 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { - let open = json!({"on": true}); - toggle(&mut app.cmd.expanded, if id == "textscr" { &open } else { params }); - Ok(json!({ "on": app.cmd.expanded })) - } - // `{"lines": n}`: how many history lines show above the command line (0–12); without it, reports it. "ui.cmdline.lines" => { if let Some(n) = params.get("lines").and_then(Value::as_u64) { app.ui.history_lines = n.min(crate::cmdline::MAX_LINES as u64) as usize; @@ -389,14 +468,6 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Ok(Value::Null), // From a menu or typed, closing asks about unsaved changes; JSON calls close without asking. - "close" if params.is_null() => { - app.request_close(None); - Ok(Value::Null) - } - "closeall" if params.is_null() => { - app.request_close_all(); - Ok(Value::Null) - } "layer" | "la" | "layers" if params.is_null() => { app.ui.dialog = Some("layers".into()); Ok(Value::Null) @@ -459,7 +530,7 @@ fn saveas_params(ui: &crate::UiState, session: &cadcraft_engine::Session, path: fn set_save_format(app: &mut CadApp, format: crate::SaveFormat) -> Value { app.ui.save_format = format; - app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase())); + app.session.echo(format!("Save As will suggest {}", format.as_str().to_ascii_uppercase())); json!({ "saveFormat": format.as_str() }) } @@ -719,18 +790,20 @@ 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"), @@ -755,7 +828,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; @@ -770,7 +851,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); } @@ -824,6 +905,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/opening.rs b/crates/ui-egui/src/opening.rs index b0b2f8781..d6ae999c8 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -3,6 +3,7 @@ //! a spinner and the elapsed time and keeps painting. The finished drawing is added to the session //! on the UI thread. The web build has no threads and opens synchronously. +use std::path::{Path, PathBuf}; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use std::time::{Duration, Instant}; @@ -13,7 +14,7 @@ use crate::theme::Tokens; type Outcome = Result; struct Job { - path: String, + path: PathBuf, started: Instant, rx: Receiver, } @@ -26,20 +27,20 @@ pub struct Opening { impl Opening { /// Start reading `path` on a worker thread. - pub fn start(&mut self, path: &str) { + pub fn start(&mut self, path: &Path) { let (tx, rx) = channel(); - let p = path.to_string(); + let p = path.to_path_buf(); let spawned = std::thread::Builder::new().name("cadcraft-open".into()).spawn(move || { let _ = tx.send(read_file(&p)); }); if spawned.is_err() { // No thread to read on: report it like any other failed open. let (tx, rx2) = channel(); - let _ = tx.send(Err(format!("open: {path}: could not start a reader thread"))); - self.jobs.push(Job { path: path.to_string(), started: Instant::now(), rx: rx2 }); + let _ = tx.send(Err(format!("open: {}: could not start a reader thread", path.display()))); + self.jobs.push(Job { path: path.to_path_buf(), started: Instant::now(), rx: rx2 }); return; } - self.jobs.push(Job { path: path.to_string(), started: Instant::now(), rx }); + self.jobs.push(Job { path: path.to_path_buf(), started: Instant::now(), rx }); } /// Whether any file is still being read. @@ -48,7 +49,7 @@ impl Opening { } /// The finished reads, as (path, result). A reader that died without answering is an error. - pub fn poll(&mut self) -> Vec<(String, Outcome)> { + pub fn poll(&mut self) -> Vec<(PathBuf, Outcome)> { let mut done = Vec::new(); self.jobs.retain(|j| match j.rx.try_recv() { Ok(r) => { @@ -57,7 +58,7 @@ impl Opening { } Err(TryRecvError::Empty) => true, Err(TryRecvError::Disconnected) => { - done.push((j.path.clone(), Err(format!("open: {}: the file could not be read", j.path)))); + done.push((j.path.clone(), Err(format!("open: {}: the file could not be read", j.path.display())))); false } }); @@ -67,7 +68,7 @@ impl Opening { /// The text of the card: the file and how long it has taken, or how many files are queued. fn label(&self) -> Option { let first = self.jobs.first()?; - let name = std::path::Path::new(&first.path).file_name().map_or_else(|| first.path.clone(), |n| n.to_string_lossy().to_string()); + let name = first.path.file_name().unwrap_or(first.path.as_os_str()).to_string_lossy(); let secs = first.started.elapsed().as_secs_f32(); let more = self.jobs.len() - 1; Some(if more == 0 { format!("Opening {name}... {secs:.0} s") } else { format!("Opening {name} and {more} more... {secs:.0} s") }) @@ -100,6 +101,7 @@ impl Opening { #[cfg(test)] mod tests { + use std::path::Path; use std::time::{Duration, Instant}; use cadcraft_engine::Session; @@ -114,7 +116,7 @@ mod tests { let before = app.session.docs.len(); let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id())); let p = missing.to_string_lossy().to_string(); - app.open_path(&p); + app.open_path(&missing); assert!(app.opening.busy() || app.status.is_some(), "the read starts in the background"); let t = Instant::now(); while app.opening.busy() && t.elapsed() < Duration::from_secs(10) { @@ -133,8 +135,8 @@ mod tests { fn card_text_names_the_file_and_counts_queued_ones() { let mut o = super::Opening::default(); assert!(o.label().is_none()); - o.start("/nonexistent/dir/a.dxf"); - o.start("/nonexistent/dir/b.dxf"); + o.start(Path::new("/nonexistent/dir/a.dxf")); + o.start(Path::new("/nonexistent/dir/b.dxf")); let l = o.label().unwrap_or_default(); assert!(l.starts_with("Opening a.dxf and 1 more"), "{l}"); } diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index c8b94c310..dba0814b7 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"` or `"el"` | +//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"`, `"sv"`, `"ar"`, `"hu"`, `"tr"`, `"bs"` 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; } @@ -310,7 +320,7 @@ fn reset_ui(app: &mut CadApp) { app.ui.animations = d.animations; app.ui.save_format = d.save_format; app.ui.save_version = d.save_version; - app.ui.system_cursor = d.system_cursor; + app.ui.system_cursor = app.system_cursor_default; app.ui.history_lines = d.history_lines; app.options = UiOptions::default(); } @@ -353,7 +363,7 @@ impl CadApp { let _ = parse_ui(key, value, &mut c); } } - // CADCRAFT_SYSTEM_CURSOR=1 turns the system cursor on whatever was saved. + // A saved `false` leaves the host's default (`set_system_cursor_default`) in place. if c.system_cursor == Some(true) { self.ui.system_cursor = true; } 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.rs b/crates/ui-egui/src/palettes.rs index 78e2d7e93..7e01a15d4 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -727,6 +727,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let tb_styles: Vec = d.table_styles.iter().map(|l| l.name.clone()).collect(); let mut action: Option<(&str, Value)> = None; let mut act = |c: &'static str, p: Value| action = Some((c, p)); + // In the Block Editor, the edited block's name and description come first. + let edited = app.session.state().ok().and_then(|st| { + let be = st.block_edit.as_ref().filter(|be| !be.current)?; + let b = d.block(&be.name).or_else(|| be.host.doc.block(&be.name)); + Some((be.name.clone(), b.map(|b| b.description.clone()).unwrap_or_default())) + }); + if let Some((name, description)) = edited { + prop_row(ui, "Name", |ui| { + value_box(ui, &name, false, false).on_hover_text(crate::tl!("Read-only")); + }); + prop_row(ui, "Description", |ui| { + if let Some(text) = edit_field(ui, ui.scope_id().with("block_description"), description) { + act("blocks.description", json!({ "description": text })); + } + }); + } prop_row(ui, "Color", |ui| { ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| { for (n, c) in [ diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index ade7a39fa..fb9edec5b 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -259,18 +259,20 @@ enum Act { } /// Draw constraint bars and dynamic constraints. `xf` maps model space to the screen. -pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter, xf: &Xf) { - refresh(app); - let Ok(d) = app.session.doc() else { return }; - if d.constraints.is_empty() { - app.canvas.param.editing = None; - return; - } - let t = Tokens::get(); - let set = &d.parametric.settings; - let mut act: Option = None; - let glyphs = &app.canvas.param.glyphs; - let (lu, lp) = (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4)); +struct DimensionalContext<'a> { + doc: &'a Drawing, + settings: &'a cadcraft_doc::ParametricSettings, + glyphs: &'a [Glyph], + xf: &'a Xf, + painter: &'a egui::Painter, + ui: &'a mut egui::Ui, + tokens: Tokens, + units: (i64, i64), +} + +fn draw_dimensional_constraints(frame: DimensionalContext<'_>) -> Option { + let DimensionalContext { doc: d, settings: set, glyphs, xf, painter, ui, tokens: t, units: (lu, lp) } = frame; + let mut act = None; // ----- dynamic (dimensional) constraints ----- let dim_c = Color32::from_rgb(0x9f, 0xb6, 0xd6); let off_px: f32 = 26.0; @@ -403,6 +405,13 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter expression = c.expr )); } + act +} + +fn draw_geometric_constraints(app: &CadApp, ui: &mut egui::Ui, painter: &egui::Painter, xf: &Xf, t: Tokens) { + let Ok(d) = app.session.doc() else { return }; + let set = &d.parametric.settings; + let glyphs = &app.canvas.param.glyphs; // ----- geometric constraint bars ----- let mut bars: Vec<(Handle, Vec2, Vec<(&str, u32)>)> = Vec::new(); let mut dots: Vec = Vec::new(); @@ -488,6 +497,21 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); painter.galley(at + vec2(4.0, 2.0), galley, col); } +} + +pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter, xf: &Xf) { + refresh(app); + let Ok(d) = app.session.doc() else { return }; + if d.constraints.is_empty() { + app.canvas.param.editing = None; + return; + } + let t = Tokens::get(); + let set = &d.parametric.settings; + let glyphs = &app.canvas.param.glyphs; + let (lu, lp) = (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4)); + let act = draw_dimensional_constraints(DimensionalContext { doc: d, settings: set, glyphs, xf, painter, ui, tokens: t, units: (lu, lp) }); + draw_geometric_constraints(app, ui, painter, xf, t); let _ = act_take(app, act, ui); } diff --git a/crates/ui-egui/src/save_loss.rs b/crates/ui-egui/src/save_loss.rs new file mode 100644 index 000000000..50e77978b --- /dev/null +++ b/crates/ui-egui/src/save_loss.rs @@ -0,0 +1,154 @@ +//! 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`), 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())); + ui.label(RichText::new(crate::tl!("DXF keeps them. Use Save As to keep them in the drawing.")).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/switch.rs b/crates/ui-egui/src/switch.rs new file mode 100644 index 000000000..0095a14e5 --- /dev/null +++ b/crates/ui-egui/src/switch.rs @@ -0,0 +1,54 @@ +//! Boolean environment switches (`CADCRAFT_VSYNC`, `CADCRAFT_WAYLAND`, ...): one parser, so every +//! variable accepts the same spellings. + +/// `1/on/true/yes` or `0/off/false/no`, case- and space-insensitive. Blank or unset is `Default`; +/// anything else is `Default` and logged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Switch { + On, + Off, + Default, +} + +impl Switch { + /// Parse the value of the variable `name`; `name` only appears in the warning. + pub fn parse(name: &str, value: Option<&str>) -> Switch { + let Some(value) = value else { return Switch::Default }; + match value.trim().to_ascii_lowercase().as_str() { + "1" | "on" | "true" | "yes" => Switch::On, + "0" | "off" | "false" | "no" => Switch::Off, + "" => Switch::Default, + _ => { + log::warn!("{name}={value:?} is not 1/on/true/yes or 0/off/false/no; using the default"); + Switch::Default + } + } + } + + /// Whether the switch is on, with `default` for `Default`. + pub fn or(self, default: bool) -> bool { + match self { + Switch::On => true, + Switch::Off => false, + Switch::Default => default, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_every_spelling() { + for v in ["1", "on", "true", "yes", "ON", "True", " Yes ", "\t1\n"] { + assert_eq!(Switch::parse("X", Some(v)), Switch::On, "{v:?}"); + } + for v in ["0", "off", "false", "no", "OFF", "False", " No ", " 0 "] { + assert_eq!(Switch::parse("X", Some(v)), Switch::Off, "{v:?}"); + } + for v in [None, Some(""), Some(" "), Some("maybe"), Some("2"), Some("onn")] { + assert_eq!(Switch::parse("X", v), Switch::Default, "{v:?}"); + } + } +} diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 1bd05c6f8..ffc625767 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,7 +2,6 @@ //! Updates… (desktop only; the web build is always the latest). The flow itself is //! `craft_update::updater::Updater`. -use craft_update::suite::CADCRAFT; use craft_update::updater::{Action, Status, Updater}; use crate::CadApp; @@ -21,7 +20,7 @@ pub struct Runtime { impl Default for Runtime { fn default() -> Self { - Self { updater: Updater::new(&CADCRAFT, VERSION), enabled: false, check_requested: false } + Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false } } } diff --git a/crates/ui-egui/tests/bedit_dialog.rs b/crates/ui-egui/tests/bedit_dialog.rs index 85367e407..21cd32155 100644 --- a/crates/ui-egui/tests/bedit_dialog.rs +++ b/crates/ui-egui/tests/bedit_dialog.rs @@ -95,9 +95,10 @@ fn a_selected_reference_and_json_calls_skip_the_dialog() { app.start("bedit"); assert!(app.ui.dialog.is_none()); assert_eq!(app.session.state().unwrap().block_editor(), Some("Window")); - // In the editor BEDIT says why it can't run instead of opening the dialog. + // In the editor BEDIT opens the dialog, to switch to another block. app.start("bedit"); - assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.dialog.as_deref(), Some("bedit")); + app.ui.dialog = None; app.run("bclose", json!({})).unwrap(); app.run("bedit", json!({ "name": "door" })).unwrap(); @@ -133,7 +134,7 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { let symbol = dir.join("Symbol.dxf"); std::fs::write(&symbol, "authoring").unwrap(); let mut app = CadApp::new(Session::new(), Services::default()); - app.open_path(&symbol.to_string_lossy()); + app.open_path(&symbol); // The file is read in the background (#797): wait for it to be added. let t = std::time::Instant::now(); while app.opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { @@ -163,7 +164,7 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { let plain = dir.join("Plain.dxf"); std::fs::write(&plain, "plain").unwrap(); let mut app = CadApp::new(Session::new(), Services::default()); - app.open_path(&plain.to_string_lossy()); + app.open_path(&plain); // The file is read in the background (#797): wait for it to be added. let t = std::time::Instant::now(); while app.opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { @@ -175,3 +176,65 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { assert!(app.ui.dialog.is_none()); let _ = std::fs::remove_dir_all(&dir); } + +/// A name the Block Editor can't switch to is refused before the dialog asks about the changes not +/// saved, so the question never traps the user. +#[test] +fn a_bad_name_is_refused_before_the_dialog_asks_to_save() { + let mut app = app_with_blocks(); + app.run("bedit", json!({ "name": "door" })).unwrap(); + app.run("line", json!({ "points": [[0, 0], [0, 4]] })).unwrap(); + app.start("bedit"); + app.bedit.name = "*U1".into(); + assert!(!bedit::confirm(&mut app)); + assert!(!app.bedit.ask_save, "no question"); + assert!(app.bedit.error.is_some(), "the dialog says why"); + assert!(app.session.state().unwrap().block_unsaved(), "nothing lost"); +} + +/// In the Block Editor, BEDIT opens the dialog on the block being edited; OK on another block +/// switches to it, first asking whether to save the changes not saved yet (Cancel goes back). +#[test] +fn in_the_block_editor_the_dialog_switches_blocks_asking_to_save_first() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut app = app_with_blocks(); + app.run("bedit", json!({ "name": "door" })).unwrap(); + app.start("bedit"); + assert_eq!((app.ui.dialog.as_deref(), app.bedit.name.as_str()), (Some("bedit"), "door"), "starts on the block edited"); + // Nothing changed: OK switches at once. + app.bedit.name = "Window".into(); + assert!(bedit::confirm(&mut app)); + assert_eq!(app.session.state().unwrap().block_editor(), Some("Window")); + + app.run("line", json!({ "points": [[0, 0], [0, 4]] })).unwrap(); + app.start("bedit"); + app.bedit.name = "door".into(); + assert!(!bedit::confirm(&mut app), "asks first"); + assert!(app.bedit.ask_save); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(2); + assert!(h.query_by_label_contains("Save changes to block \"Window\"").is_some()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(!h.state().bedit.ask_save, "back to the list"); + assert_eq!(h.state().session.state().unwrap().block_editor(), Some("Window")); + assert!(h.state().session.state().unwrap().block_unsaved(), "nothing lost"); + + assert!(!bedit::confirm(h.state_mut())); + h.run_steps(2); + h.get_by_label("Yes").click(); + h.run_steps(2); + let st = h.state().session.state().unwrap(); + assert_eq!(st.block_editor(), Some("door")); + assert!(h.state().ui.dialog.is_none(), "the dialog closed"); + let window = h.state().session.doc().unwrap().block("Window").map(|b| b.entities.len()); + assert_eq!(window, Some(2), "Window saved with its new line before switching"); +} diff --git a/crates/ui-egui/tests/it/block_description_palette.rs b/crates/ui-egui/tests/it/block_description_palette.rs new file mode 100644 index 000000000..bcf4259ef --- /dev/null +++ b/crates/ui-egui/tests/it/block_description_palette.rs @@ -0,0 +1,49 @@ +//! Properties in the Block Editor: with nothing selected it shows the edited block's name and +//! description, and a description typed there is saved into the block by BSAVE. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{Queryable, by}; +use serde_json::json; + +/// Block "Door", described "Hinged door", open in the Block Editor. +fn palettes() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + let l = app.run("line", json!({ "points": [[0, 0], [2, 0]] })).unwrap(); + app.run("block", json!({ "name": "Door", "base": [0, 0], "handles": l["handles"], "description": "Hinged door" })).unwrap(); + app.run("bedit", json!({ "name": "Door" })).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + cadcraft_ui_egui::palettes::right_palettes(app, ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn field<'h>(h: &'h Harness<'static, CadApp>, shown: &'h str) -> egui_kittest::Node<'h> { + h.get(by().role(egui::accesskit::Role::TextInput).value(shown)) +} + +#[test] +fn the_block_editor_shows_and_changes_the_block_description() { + let mut h = palettes(); + field(&h, "Hinged door").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, Key::A); + h.run_steps(1); + field(&h, "Hinged door").type_text("Sliding door"); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + field(&h, "Sliding door"); + + let app = h.state_mut(); + assert!(app.session.state().unwrap().block_unsaved()); + app.run("bclose", json!({ "save": true })).unwrap(); + assert_eq!(app.session.doc().unwrap().block("Door").unwrap().description, "Sliding door"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 92554f7dd..c32012e1a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -1,4 +1,5 @@ mod animation_effects; +mod block_description_palette; mod chrome_geometry; mod context_menu; mod context_menus; @@ -17,6 +18,7 @@ mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod named_viewports_dialog; mod native_menu_text_keys; mod native_windows; mod options_dialog; diff --git a/crates/ui-egui/tests/it/named_viewports_dialog.rs b/crates/ui-egui/tests/it/named_viewports_dialog.rs new file mode 100644 index 000000000..19b0a6e67 --- /dev/null +++ b/crates/ui-egui/tests/it/named_viewports_dialog.rs @@ -0,0 +1,74 @@ +//! View > Viewports > Named Viewports... opens a dialog that lists the saved viewport +//! configurations: Restore shows one in the Model tab or starts placing it in a layout, and Delete +//! deletes it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, dialogs, menus}; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) saved, showing one view, and the dialog open. +fn dialog() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("line", json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + app.run("-vports", json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + app.run("-vports", json!({ "option": "save", "name": "Two" })).unwrap(); + app.run("-vports", json!({ "option": "single" })).unwrap(); + menus::activate(&mut app, "vports.named"); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports")); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn restore_and_delete_in_the_model_tab() { + let mut h = dialog(); + h.get_by_label("Current name: *Active"); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.session.model_tiles().len(), 2); + assert_eq!(app.ui.dialog, None, "Restore closes the dialog"); + + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Current name: Two"); + h.get_by_label("Delete").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.doc().unwrap().vports.iter().all(|c| c.name != "Two")); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"), "Delete keeps the dialog open"); + h.get_by_label("This drawing has no named viewport configurations."); +} + +#[test] +fn restore_in_a_layout_asks_where_the_viewports_go() { + let mut h = dialog(); + let app = h.state_mut(); + app.ui.dialog = None; + app.run("layout.set", json!({ "name": "Layout1" })).unwrap(); + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.prompt_text().contains("Specify first corner"), "{}", app.session.prompt_text()); + let before = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + app.cmdline(""); + assert!(app.session.running.is_none()); + let after = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + assert_eq!(after, before + 2, "Enter fits the two viewports to the printable area"); +} diff --git a/crates/ui-egui/tests/it/system_cursor.rs b/crates/ui-egui/tests/it/system_cursor.rs index ff2c8942f..e2067e519 100644 --- a/crates/ui-egui/tests/it/system_cursor.rs +++ b/crates/ui-egui/tests/it/system_cursor.rs @@ -35,3 +35,23 @@ fn system_cursor_stays_visible_over_the_canvas_when_requested() { assert!(app.ui.system_cursor); assert_eq!(canvas_cursor(&mut app), egui::CursorIcon::Crosshair, "systemCursor keeps an OS cursor for magnifiers"); } + +/// The host's default (the desktop app's `CADCRAFT_SYSTEM_CURSOR`) turns it on; a saved `false` +/// and the resets keep it. +#[test] +fn the_host_default_survives_saved_profiles_and_resets() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("options", serde_json::json!({ "reset": true })).unwrap(); + assert!(!app.ui.system_cursor, "off without a host default"); + + app.set_system_cursor_default(true); + assert!(app.ui.system_cursor); + app.load_profile(r#"{"systemCursor": false}"#); + assert!(app.ui.system_cursor); + app.ui.system_cursor = false; + app.run("options", serde_json::json!({ "reset": true })).unwrap(); + assert!(app.ui.system_cursor); + app.ui.system_cursor = false; + app.run("ui.resetpalettes", serde_json::json!({})).unwrap(); + assert!(app.ui.system_cursor); +} diff --git a/crates/ui-egui/tests/recent_files.rs b/crates/ui-egui/tests/recent_files.rs index 6211ab899..34d460255 100644 --- a/crates/ui-egui/tests/recent_files.rs +++ b/crates/ui-egui/tests/recent_files.rs @@ -9,17 +9,18 @@ use cadcraft_ui_egui::{CadApp, Services}; use egui::{Rect, Shape, vec2}; use egui_kittest::Harness; -/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter). -fn drawings(test: &str) -> (String, String) { +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter), +/// in a folder that is removed when the returned guard is dropped. +fn drawings(test: &str) -> (TempDir, String, String) { set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(Vec::new()), plot: None }); - let dir = std::env::temp_dir().join(format!("cadcraft-recent-{test}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| { - let p = dir.join(name); - std::fs::write(&p, b"0\nEOF\n").unwrap(); - p.to_string_lossy().to_string() - }; - (path("plan.dxf"), path("section.dwg")) + let dir = TempDir::new(&format!("recent-{test}")); + let (plan, section) = (dir.file("plan.dxf", b"0\nEOF\n"), dir.file("section.dwg", b"0\nEOF\n")); + (dir, plan, section) } /// Wait for the drawings being read in the background to be added (or reported). @@ -49,19 +50,19 @@ fn open_recent(app: &CadApp) -> Vec<(String, String)> { #[test] fn opened_drawings_are_listed_newest_first_and_survive_a_restart() { - let (plan, section) = drawings("menu"); + let (_dir, plan, section) = drawings("menu"); let mut app = CadApp::new(Session::empty(), Services::default()); assert!(app.ui.recent_files.is_empty()); - app.open_path(&plan); + app.open_path(std::path::Path::new(&plan)); settle(&mut app); - app.open_path(§ion); + app.open_path(std::path::Path::new(§ion)); settle(&mut app); - app.open_path(&plan); + app.open_path(std::path::Path::new(&plan)); settle(&mut app); assert_eq!(app.ui.recent_files, [plan.clone(), section.clone()], "newest first, each file once"); // A file that could not be opened is not listed. - app.open_path("/nonexistent/missing.dxf"); + app.open_path(std::path::Path::new("/nonexistent/missing.dxf")); settle(&mut app); assert_eq!(app.ui.recent_files, [plan.clone(), section.clone()]); @@ -99,7 +100,7 @@ fn texts(shape: &Shape, out: &mut Vec<(String, Rect)>) { #[test] fn start_page_lists_recent_files_and_opens_one_on_click() { - let (plan, section) = drawings("start"); + let (_dir, plan, section) = drawings("start"); let mut app = CadApp::new(Session::empty(), Services::default()); app.ui.recent_files = vec![section.clone(), plan.clone()]; let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_ui_state( 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/save_version.rs b/crates/ui-egui/tests/save_version.rs index 47b716b89..402c43623 100644 --- a/crates/ui-egui/tests/save_version.rs +++ b/crates/ui-egui/tests/save_version.rs @@ -44,3 +44,37 @@ fn default_save_version_applies_to_new_drawings_and_persists() { b.start("ui.resetpalettes"); assert_eq!(b.ui.save_version, "2004"); } + +/// The default save format (#2199) is what Save As suggests for an opened DWG or DXF too, as +/// AutoCAD's Options > Open and Save > "Save as" does; Save keeps the opened file. +#[test] +fn default_save_format_applies_to_opened_drawings() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"plain".to_vec()), plot: None }); + set_write_as(with_options); + let dir = std::env::temp_dir().join(format!("cadcraft-ui-save-format-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let suggested = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (seen, to) = (suggested.clone(), dir.clone()); + let pick = move |name: &str| { + seen.lock().unwrap().push(name.to_string()); + Some(to.join(name).to_string_lossy().to_string()) + }; + let mut app = CadApp::new(Session::empty(), Services { pick_save: Some(Box::new(pick)), ..Services::default() }); + for (opened, format, expected) in + [("Plan.dwg", "dxf", "Plan.dxf"), ("Plan.DXF", "dwg", "Plan.dwg"), ("Plan.dxf", "dxf", "Plan.dxf"), ("Sheet.dwt", "dwg", "Sheet.dwg")] + { + let file = dir.join(opened); + std::fs::write(&file, b"0\nEOF\n").unwrap(); + app.run("open", json!({ "path": file.to_string_lossy() })).unwrap(); + app.run("ui.saveformat", json!({ "format": format })).unwrap(); + // the picker's path decides the format, so the echo promises only the suggestion + let echo = format!("Save As will suggest {}", format.to_ascii_uppercase()); + assert_eq!(app.session.log.last(), Some(&echo), "{opened} with {format}"); + app.run("ui.saveas", Value::Null).unwrap(); + assert_eq!(suggested.lock().unwrap().last().map(String::as_str), Some(expected), "{opened} with {format}"); + let path = app.session.state().unwrap().path.clone().unwrap_or_default(); + assert!(path.ends_with(expected), "{opened}: saved as {path}"); + assert!(dir.join(expected).exists()); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/support/temp_dir.rs b/crates/ui-egui/tests/support/temp_dir.rs new file mode 100644 index 000000000..e1f16b8c1 --- /dev/null +++ b/crates/ui-egui/tests/support/temp_dir.rs @@ -0,0 +1,46 @@ +//! A test's own folder in the system temp directory, removed when the test ends, even when an +//! assertion fails. Shared by the integration tests through `#[path]`. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `cadcraft---`: the process id keeps parallel test binaries apart and the counter +/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the test's temp folder"); + Self(dir) + } + + /// Write `contents` to `name` in the folder; returns the file's path as text. + pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String { + let p = self.0.join(name); + std::fs::write(&p, contents).expect("write a test file"); + p.to_string_lossy().into_owned() + } + + /// The path of `name` in the folder as text, without creating it. + pub fn path_str(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for TempDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/ui-egui/tests/xref_palette.rs b/crates/ui-egui/tests/xref_palette.rs index 2f242b277..f950427c4 100644 --- a/crates/ui-egui/tests/xref_palette.rs +++ b/crates/ui-egui/tests/xref_palette.rs @@ -11,6 +11,11 @@ use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + /// Test drawings: any file holds one line on layer Walls. fn read(_bytes: &[u8], _name: &str) -> Result { let mut d = Drawing::new_metric(); @@ -21,14 +26,6 @@ fn read(_bytes: &[u8], _name: &str) -> Result { Ok(d) } -fn site_file() -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-xref-palette-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join("site.dxf"); - std::fs::write(&p, "site").unwrap(); - p.to_string_lossy().into_owned() -} - fn status(app: &CadApp) -> Option { app.session.doc().ok()?.block("site").map(|b| b.xref_status) } @@ -37,7 +34,8 @@ fn status(app: &CadApp) -> Option { fn xref_opens_the_palette_and_its_buttons_manage_the_xref() { set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); let mut app = CadApp::new(Session::default(), Services::default()); - app.run("xattach", json!({ "path": site_file(), "at": [10, 10] })).unwrap(); + let dir = TempDir::new("xref-palette"); + app.run("xattach", json!({ "path": dir.file("site.dxf", "site"), "at": [10, 10] })).unwrap(); assert_eq!(status(&app), Some(XrefStatus::Loaded)); // Typed (by its alias) it opens the palette instead of listing. assert!(app.cmdline("XR").is_none()); diff --git a/docs/autolisp.md b/docs/autolisp.md index 8dd79129a..d89684d00 100644 --- a/docs/autolisp.md +++ b/docs/autolisp.md @@ -73,14 +73,17 @@ reached", malformed source is a reader error, and every built-in checks its argu - **Output and files:** `princ prin1 print prompt terpri alert open close read-line write-line findfile` - **Errors:** `*error*`, `vl-catch-all-apply vl-catch-all-error-p vl-catch-all-error-message` -- **Drawing:** `command` (also `command-s`, `vl-cmdf`) with `pause`, a command left open for the - next call, and `(command)` to cancel; `getvar setvar`; `entget entmake entmod entdel entlast +- **Drawing:** `command` (also `command-s`, `vl-cmdf`) with `pause`, entity names and `entsel` + lists at prompts picking one object (a bare name picks at its DXF group 10 point, as in AutoCAD), + a command left open for the next call, and `(command)` to cancel; `getvar setvar`; `entget entmake entmod entdel entlast entnext entupd handent`; `ssget` (modes X, L, P, I, W, C, F, WP, CP, a point, or the user selecting; filters with wildcards and `-4` operators) `ssadd ssdel sslength ssname ssmemb sssetfirst ssgetfirst`; `tblsearch tblnext` (LAYER, LTYPE, STYLE, BLOCK, DIMSTYLE, APPID) - **Input:** `initget getpoint getcorner getdist getangle getorient getreal getint getstring getkword entsel nentsel` - **Units:** `rtos angtos distof angtof` +- **Angle variables:** `getvar` and `setvar` give and take ANGBASE, SNAPANG, POLARANG, HPANG, + CHAMFERD and DIMJOGANG in radians, as in AutoCAD; the SETVAR command shows them in degrees. - **No-ops:** `vl-load-com gc redraw`; `ver` Entity lists use the DXF group codes of CADCraft's DXF files: what `entget` returns is what a diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..ba68a1d09 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,58 @@ +# cadcraft-cli + +`cadcraft-cli` is headless CADCraft: it reads, converts and exports drawings, runs scripts and +commands, prints the command catalog and serves MCP. It follows +[RFC-0012: Command-line conventions](https://github.com/storytold/craft-contrib/pull/18). + +`cadcraft-cli --help` and `cadcraft-cli help ` (or ` --help`) are the +reference for every subcommand, flag and value; this page doesn't repeat them. Examples are in the +[README](../README.md#drive-it-from-agents-mcp-and-the-cli), MCP setup in [mcp.md](mcp.md), +scripts and the JSON command forms in [customization.md](customization.md). + +## Conventions + +| | | +|---|---| +| Exit codes | `0` success, `1` the command failed, `2` usage error. | +| Streams | Results on stdout. `info` and `commands` print pretty JSON, or one line with `--compact`; `run` prints one JSON line per `--cmd` result (JSON lines); a command with no result (JSON `null`) prints no line. Errors (`error: …`), warnings and `wrote …` lines on stderr. `mcp` speaks MCP on stdout and writes nothing else there. | +| Usage errors | An unknown or repeated flag, a missing or malformed value (`--window`, `--view`, `--cmd` JSON, a sample name), flags that conflict (`--window` with `--view`) or that need another (`run --window` without `--export`) stop before any work, with exit 2. `--script`, `--script-file`, `--cmd` and `run`'s files repeat and run in command-line order. | +| Outputs that replace an input | `convert IN OUT` and `run FILE… --save OUT` / `--export OUT` refuse an OUT that is the same file as an input (any spelling or symlink of it): usage error, exit 2, nothing opened or written. `--in-place` allows it. A save a script or `--cmd` runs is the engine's. | +| Values starting with `-` | Written with `=`: `--window=-10,-10,10,10`, `--export=-plan.png`. | +| Paths | Read as given, including names that aren't valid Unicode. The engine opens files by text name, so such a name is reported as an error (exit 1) where a drawing is opened; an output takes any name whose extension is ASCII. | +| Completions and man pages | The Linux packages install them. To generate them yourself: `cargo run -p cadcraft-cli --example man-and-completions -- `. | + +## Environment variables + +Variables are fallbacks for flags; `--help` shows each next to its flag. An empty value counts as +unset and the flag wins over the variable, except for `CADCRAFT_CONFIG_DIR` (see below). + +| Variable | Flag | Subcommands | +|---|---|---| +| `CADCRAFT_CONFIG_DIR` | `--config-dir` (the settings folder; plotter configurations are in its `plotters` folder; empty means no settings folder) | `convert`, `run`, `mcp`, `sample` | +| `CADCRAFT_FONTALT` | `--fontalt` (FONTALT) | `convert`, `run`, `mcp`, `sample` | +| `CADCRAFT_FONTFALLBACK` | `--fontfallback` (FONTFALLBACK) | `convert`, `run`, `mcp`, `sample` | + +The desktop app's are listed by `cadcraft --help`; its one flag fallback is +`CADCRAFT_CONTROL_PORT` for `--control`, where a bad value is a usage error that names the +variable. + +## Not followed yet + +- **`CADCRAFT_CONFIG_DIR` is the one flag fallback where an empty variable doesn't count as + unset** (RFC-0012 rule 1.7): empty (or `--config-dir ""`) means no settings folder, as it did + before the port. +- **No variable for `mcp --connect`.** Photocraft's `mcp --bridge` falls back to its + `*_CONTROL_PORT` variable. Here an exported `CADCRAFT_CONTROL_PORT` would silently switch `mcp` + from a headless session to the running app, so `--connect` stays a flag only. +- **The desktop app on Windows** is built without a console, so a usage error in its launch flags + isn't visible there; the app exits with code 2. +- **The `string-error` ast-grep rule** covers the CLI code only. The engine's file hooks + (`cadcraft_engine::cmd::file::IoHooks`) still return `Result<_, String>`, so `install_io` maps + errors to strings at that boundary. +- **No AGENTS.md line for the ast-grep scan.** RFC-0012 asks an app's AGENTS.md to have coding + agents run the scan before pushing (`ast-grep scan -c lint/ast-grep/sgconfig.yml`); CI runs it + on every PR that touches the CLI code. +- **`perf` prints a text table**, with no `--json` (whether text listings get one is an open + question in RFC-0012). +- **Saves a command or script runs** (`--cmd 'saveas {…}'`, a script's `SAVEAS`) are the engine's + and are not checked against the opened drawings; only `--save` and `--export` are. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1c3f30626..98db28330 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -124,7 +124,7 @@ height and related chrome choices. It does not store drawings, command buffers, current Start tab. The default directory is `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%/CADCraft` on Windows, and `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` elsewhere. Set `CADCRAFT_CONFIG_DIR` to a private directory for an isolated profile; an explicitly empty -value disables workspace reads and writes. An invalid, oversized or newer-version file is +value disables all settings reads and writes (workspace, logs, autosave, plotters). An invalid, oversized or newer-version file is reported and left untouched, with persistence disabled for that launch. Repair or move that file before restarting to resume saving workspace choices. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index eda4a0872..880364d6f 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-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **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,7 +64,7 @@ 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`) | 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 | @@ -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) | ATTDEF prompt kept (#137) | +| 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 | | 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 | @@ -87,7 +87,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | ACAD_TABLE | yes | yes (entity + block) | | | VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | | IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) | -| WIPEOUT | yes | yes (#147, fixes #79) | | +| WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). | | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | | | REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | | | 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); **dropped by DWG save** (acadrust) | only their layer follows edits | @@ -108,7 +108,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) | |---|---|---|---| -| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 | +| DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552); LAYOUT SAveas writes one layout and the named objects it uses as a `.dwt` (`layout.saveas`) | 0–1 | | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | | PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | @@ -140,6 +140,9 @@ 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 | diff --git a/docs/gaps.md b/docs/gaps.md index a4a12cff8..a7548c4d2 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-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 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) | @@ -26,7 +26,7 @@ do them first. |---|---|---|---|---| | F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 | | F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | -| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | +| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks and, in the Block Editor, switches to another block after asking to save), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | | F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 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 (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | @@ -37,12 +37,12 @@ do them first. | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 | -| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | +| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 | | 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 | -| 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); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | +| 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 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | @@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 | | U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 | | U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 | -| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 | +| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 | @@ -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 | @@ -150,7 +150,10 @@ 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 | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 74dfc1585..6c11686a6 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ELLIPSE axis endpoints, other-axis point and Included angle as AutoCAD stores them) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -18,10 +18,13 @@ drawings behave differently from mid-sized ones. 3D geometry doesn't exist. Rema ## How this was measured -Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. No -numeric comparison against AutoCAD has been run yet: the plan is a set of synthetic drawings whose -exact answers are known analytically, measured in both apps black-box (LIST, ID, AREA results) -and in CADCraft headless. Until then the percentages are judgements. +Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. The +one numeric comparison against AutoCAD so far is ELLIPSE from axis endpoints (2026-10-26): 11 +typed inputs measured black-box in AutoCAD 2024 (accoreconsole, read back with `entget`) and +pinned by `ellipse_axis_endpoints_match_autocad`. Everything else is still to be measured: the +plan is a set of synthetic drawings whose exact answers are known analytically, measured in both +apps black-box (LIST, ID, AREA results) and in CADCraft headless. Until then the percentages are +judgements. Weights (how much AutoCAD users lean on each area in 2D drafting): numeric robustness 15%, curve representation 10%, intersections 15%, offsets 15%, trim/extend/fillet/chamfer/break/join @@ -157,6 +160,8 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | ELLIPSE from axis endpoints matches AutoCAD's stored geometry: the major axis points at the first endpoint (arcs drew on the opposite side of the center), a point at the other-axis prompt gives its distance from the center, and the end-angle prompt offers Included angle | +| 2026-10-26 | minor | PEDIT Multiple at the command line (#2182): select objects, lines and arcs turned into polylines on Yes, then Close/Open/Join/Width/Fit/Spline/Decurve/Ltype gen/Reverse/Undo apply to all; Join joins the selected objects with each other within a fuzz distance | | 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 121c4d5ec..a6bcb5079 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Bosnian added; previously minor: the command line enables the OS IME and handles composition/commit, so single-line TEXT edits accept CJK input; previously: Brazilian Portuguese row updated and terminology reviewed; previously: Swedish catalog completed with the 95 strings added since; previously: Swedish catalog added: complete catalog, menu headings, native-speaker terminology review; previously: complete current Korean catalog with Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in [ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization). @@ -74,9 +74,10 @@ plus native review by a human; extending the catalog to the remaining English-on | Italian | it | 1,227 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings (Visualizza / Vista, Edita / Modifica); command reference searchable in Italian | Latin: yes (bundled egui fonts; accented letters tested) | AI-assisted by Emanuele Bottelli (Claude), written from the English strings with Italian desktop-CAD vocabulary; no native proofreading pass yet | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work | | Hungarian | hu | 1,326 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings; command reference searchable in Hungarian | Latin: yes (bundled egui proportional and monospace fonts; ő ű Ő Ű and the other accented letters tested) | translated by @Skaywalker from the English strings; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work | | Turkish | tr | 1,330 catalog rows; reference catalog, marked UI literals and menu headings covered (no gaps reported) | dialogs and tooltips yes; contextual menu headings (Ölçülendirme / Ölçü); command reference searchable in Turkish | Latin: yes (bundled egui proportional and monospace fonts; Ç Ğ İ Ö Ş Ü ç ğ ı ö ş ü tested) | AI-assisted by @ogzhnklvz, written from the English strings with Turkish desktop-CAD vocabulary; the running app reviewed by a native speaker | complete current catalog; English-only interface gaps remain | line-by-line proofreading + shared English-only UI work | +| Bosnian | bs | 1,442 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes | Latin: yes (bundled egui fonts) | AI-assisted; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native proofreading + shared English-only UI work | -Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian and Turkish. -Czech, Swedish, Hungarian and Brazilian Portuguese cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text +Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian, Korean, Swedish, Hungarian, Turkish and Bosnian. +Czech, Swedish, Hungarian, Brazilian Portuguese and Bosnian (1,442 rows, AI-assisted, not yet native-reviewed) cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text remain English. AutoCAD for Mac ships 7 non-English languages (zh-Hans, es, fr, ja, de, ko, it); of those, CADCraft now ships German and Korean, and additionally Ukrainian, which AutoCAD for Mac doesn't ship. @@ -89,6 +90,7 @@ each language, which needs humans. Translations parallelise fully once the catal | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Bosnian (`bs`, Latin, Ijekavian) added: 1,442 rows, one/few/other plurals, `bs-*` locales; AI-assisted, native proofreading pending | | 2026-10-22 | minor | Command line IME: enabled while a prompt collects text, composition shown but not saved, commit inserted once; single-line TEXT edits accept CJK input (#1761) | | 2026-10-19 | minor | Brazilian Portuguese: row brought up to date (it still read 0%); native-speaker terminology pass on 69 rows (Bind as Integrar, prompt keywords and SPDX kept in English, Thaw Layer, Snap type, Revolve fixed; snap, viewport, grip, offset, layer on/off and multiline wording made consistent with Brazilian usage) | | 2026-10-15 | minor | Greek (`el`) catalog: 1,330 rows, context/plural/locale/font tests; review pending | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9c2186c90..53ffffc96 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,22 +1,22 @@ # Parity checklist -> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-30 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: 296 / 491 items (60%)** · 441 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | | Edit | 13 | 12 | 92% | -| View | 75 | 36 | 48% | +| View | 75 | 37 | 49% | | Insert | 11 | 5 | 45% | | Format | 27 | 26 | 96% | | Tools | 85 | 66 | 78% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | -| Modify | 113 | 52 | 46% | +| Modify | 113 | 53 | 47% | | Window | 22 | 3 | 14% | | Help | 8 | 0 | 0% | @@ -37,7 +37,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Orbit > Free Orbit - View > Camera > Adjust Distance - View > Camera > Swivel -- View > Viewports > Named Viewports... - View > 3D Views > Viewpoint - View > 3D Views > Plan View > Current UCS - View > 3D Views > Plan View > World UCS @@ -134,7 +133,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Modify > Object > Image > Quality - Modify > Object > Image > Transparency - Modify > Object > Image > Frame -- Modify > Object > Block Description... - Modify > Clip > Image - Modify > Clip > Xref - Modify > 3D Operations > 3D Move diff --git a/docs/plotters.md b/docs/plotters.md index 7ca7c785d..92e10afce 100644 --- a/docs/plotters.md +++ b/docs/plotters.md @@ -32,7 +32,8 @@ settings directory: | macOS | `~/Library/Application Support/CADCraft/plotters` | | Windows | `%APPDATA%\CADCraft\plotters` | -`CADCRAFT_CONFIG_DIR` replaces the settings directory. On the web, devices last for the +`CADCRAFT_CONFIG_DIR` replaces the settings directory; empty means none (`cadcraft-cli`: +`--config-dir`, which falls back to the same variable). On the web, devices last for the session unless the host provides storage (`cmd::plotters::set_plotter_store`). Binary `.pc3` files are not read; we haven't found a public specification of that format. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c06e8f242..3522b5091 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure-aware grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD @@ -111,7 +111,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Feature | AutoCAD | CADCraft | |---|---|---| -| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout, spline (`grips.rs:79-270`) | +| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout (one grip per logical corner, including coincident corners; an explicit closing marker follows the first vertex), spline (`grips.rs`) | | Text/MText grips | position, width, column grips | move the whole object | | Hot-grip modes with Space/Enter cycling | Stretch, Move, Rotate, Scale, Mirror | yes (`canvas.rs:58-67`) | | Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | **advertised in the prompt but not parsed**; the engine's JSON grip commands accept `copy`, the UI never passes it | @@ -166,6 +166,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | ViewCube | interactive 3D | clickable for 2D (Top/Home → extents); dragging does nothing (#7) | | Navigation bar, SteeringWheels | yes | no | | Workspaces | Drafting & Annotation, 3D Modeling | no | +| Browser panel layout storage | not applicable | Versioned, validated panel placement and visibility saved through the browser host; floating palettes remain embedded. Requires available browser storage and a completed host save; named workspaces and drawing persistence are separate. | | Options / Preferences dialog | many tabs | **no** (issue #23) | | Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); no CUI or tool palettes | | Themes | Dark, Light | Dark, Light, System (#179) | @@ -203,6 +204,8 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | WIPEOUT grips treat implicit and explicit closure alike; moving the first vertex carries the closing marker, and moving endpoints together keeps both corners editable. Open/closed polyline flags and coincident polyline vertices remain independent (#2256) | +| 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) | diff --git a/lint/ast-grep/rule-tests/args-os-test.yml b/lint/ast-grep/rule-tests/args-os-test.yml new file mode 100644 index 000000000..03ff2c4da --- /dev/null +++ b/lint/ast-grep/rule-tests/args-os-test.yml @@ -0,0 +1,19 @@ +id: args-os +valid: + - let code = photocraft_cli::run(std::env::args_os().skip(1), &mut out, &mut err); + - "let args: Vec = env::args_os().collect();" + - | + use std::env::args_os; + fn main() { let a: Vec<_> = args_os().collect(); } + - | + fn args() -> Vec { Vec::new() } + fn main() { let a = args(); } +invalid: + - "let args: Vec = std::env::args().collect();" + - for a in env::args().skip(1) {} + - | + use std::env::args; + fn main() { let a: Vec = args().collect(); } + - | + use std::env::{args, var}; + fn main() { for a in args() {} } diff --git a/lint/ast-grep/rule-tests/clap-error-message-test.yml b/lint/ast-grep/rule-tests/clap-error-message-test.yml new file mode 100644 index 000000000..385d46e96 --- /dev/null +++ b/lint/ast-grep/rule-tests/clap-error-message-test.yml @@ -0,0 +1,13 @@ +id: clap-error-message +valid: + - cmd.error(ErrorKind::ArgumentConflict, UsageError::BridgeRoots) + - cmd.error(ErrorKind::ArgumentConflict, Conflict { a, b }) + - sub.error(kind, self) + - log.error("not a clap error") +invalid: + - cmd.error(ErrorKind::ArgumentConflict, "configure automation roots on the desktop app") + - sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires {transport}")) + - sub.error(ErrorKind::ArgumentConflict, BRIDGE_ROOTS) + - cmd.error(ErrorKind::InvalidValue, e.to_string()) + - cmd.error(ErrorKind::InvalidValue, String::from("bad value")) + - clap::Error::raw(ErrorKind::InvalidValue, "bad value") diff --git a/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml b/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml new file mode 100644 index 000000000..18d6e443a --- /dev/null +++ b/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml @@ -0,0 +1,47 @@ +id: clap-error-message-variable +valid: + - | + fn conflict(cmd: &mut Command, a: String, b: String) -> clap::Error { + cmd.error(ErrorKind::ArgumentConflict, Conflict { a, b }) + } + - | + fn wrap(cmd: &mut Command, e: UsageError) -> clap::Error { + cmd.error(ErrorKind::ValueValidation, e) + } + - | + fn unrelated(cmd: &mut Command, e: UsageError) -> clap::Error { + let shown = format!("{e}"); + eprintln!("{shown}"); + cmd.error(ErrorKind::ValueValidation, e) + } + - | + fn cloned(cmd: &mut Command, e: &UsageError) -> clap::Error { + let owned = e.to_owned(); + cmd.error(ErrorKind::ValueValidation, owned) + } +invalid: + - | + fn conflict(cmd: &mut Command, a: &str, b: &str) -> clap::Error { + let message = format!("the argument '{a}' cannot be used with '{b}'"); + cmd.error(ErrorKind::ArgumentConflict, message) + } + - | + fn bad(cmd: &mut Command, e: UsageError) -> clap::Error { + let message = e.to_string(); + cmd.error(ErrorKind::ValueValidation, message) + } + - | + fn typed(cmd: &mut Command, flag: &str) -> clap::Error { + let message: String = flag.into(); + clap::Error::raw(ErrorKind::InvalidValue, message) + } + - | + fn literal(cmd: &mut Command) -> clap::Error { + let message = "bad value".to_owned(); + cmd.error(ErrorKind::InvalidValue, message) + } + - | + fn from(cmd: &mut Command) -> clap::Error { + let message = String::from("bad value"); + cmd.error(ErrorKind::InvalidValue, message) + } diff --git a/lint/ast-grep/rule-tests/string-error-test.yml b/lint/ast-grep/rule-tests/string-error-test.yml new file mode 100644 index 000000000..4f14197c8 --- /dev/null +++ b/lint/ast-grep/rule-tests/string-error-test.yml @@ -0,0 +1,14 @@ +id: string-error +valid: + - "fn parse(s: &str) -> Result { todo!() }" + - "fn name() -> Option { None }" + - "fn ok() -> Result { Ok(String::new()) }" + - Err(PortError::Range) + - Err(e) +invalid: + - "fn parse(s: &str) -> Result { todo!() }" + - "type R = Result<(), String>;" + - Err(format!("bad port {s}")) + - Err(e.to_string()) + - Err(String::from("bad port")) + - Err("bad port") diff --git a/lint/ast-grep/rules/args-os.yml b/lint/ast-grep/rules/args-os.yml new file mode 100644 index 000000000..ab1916c9e --- /dev/null +++ b/lint/ast-grep/rules/args-os.yml @@ -0,0 +1,21 @@ +id: args-os +language: rust +severity: error +message: std::env::args() panics on an argument that isn't valid Unicode +note: | + Read std::env::args_os() and let clap parse the OsStrings, so a Latin-1 file name reaches the + command (or is a usage error) instead of aborting before anything is printed (photocraft#1108). +rule: + any: + - pattern: std::env::args() + - pattern: env::args() + # A bare args() only where the file imports std::env::args, not any function named args. + - all: + - pattern: args() + - inside: + kind: source_file + stopBy: end + has: + kind: use_declaration + stopBy: end + regex: std::env::(args\b|\{[^}]*\bargs\b) diff --git a/lint/ast-grep/rules/clap-error-message-variable.yml b/lint/ast-grep/rules/clap-error-message-variable.yml new file mode 100644 index 000000000..3f476be4b --- /dev/null +++ b/lint/ast-grep/rules/clap-error-message-variable.yml @@ -0,0 +1,35 @@ +id: clap-error-message-variable +language: rust +severity: error +message: clap error built from a string held in a variable +note: | + The message was built with format!, .to_string(), String::from or a literal's .to_owned() a + few lines up. Give it a variant on a + #[derive(thiserror::Error)] enum instead, with the parts as fields, and pass the variant. +rule: + # In this order: the call binds $MSG, then the function must hold a `let` of that same name. + all: + - any: + - pattern: $CMD.error($KIND, $MSG) + - pattern: Error::raw($KIND, $MSG) + - pattern: clap::Error::raw($KIND, $MSG) + - inside: + kind: function_item + stopBy: end + has: + stopBy: end + any: + - pattern: let $MSG = format!($$$); + - pattern: "let $MSG: String = $VAL;" + - pattern: let $MSG = $SRC.to_string(); + - pattern: let $MSG = String::from($$$); + # .to_owned() only on a string literal: on anything else it may clone a typed error. + - all: + - kind: let_declaration + - regex: '=\s*"(?s:.*)"\s*\.to_owned\(\)\s*;?$' + - has: + field: pattern + pattern: $MSG +constraints: + MSG: + kind: identifier diff --git a/lint/ast-grep/rules/clap-error-message.yml b/lint/ast-grep/rules/clap-error-message.yml new file mode 100644 index 000000000..e18b824b3 --- /dev/null +++ b/lint/ast-grep/rules/clap-error-message.yml @@ -0,0 +1,26 @@ +id: clap-error-message +language: rust +severity: error +message: clap error built from a string +note: | + Give the message a variant on a #[derive(thiserror::Error)] enum and pass the variant, e.g. + `cmd.error(ErrorKind::ArgumentConflict, UsageError::BridgeRoots)`. A string literal, format!, + .to_string() or a SCREAMING_CASE const keeps the message apart from the error's type. +rule: + any: + - pattern: $CMD.error($KIND, $MSG) + - pattern: Error::raw($KIND, $MSG) + - pattern: clap::Error::raw($KIND, $MSG) +constraints: + MSG: + any: + - kind: string_literal + - all: + - kind: macro_invocation + - regex: ^format! + - all: + - kind: identifier + - regex: ^[A-Z][A-Z0-9_]*$ + - all: + - kind: call_expression + - regex: \.(to_string|to_owned)\(\)$|^String::from\( diff --git a/lint/ast-grep/rules/string-error.yml b/lint/ast-grep/rules/string-error.yml new file mode 100644 index 000000000..e79b83d05 --- /dev/null +++ b/lint/ast-grep/rules/string-error.yml @@ -0,0 +1,21 @@ +id: string-error +language: rust +severity: error +message: String as an error type +note: | + Return a #[derive(thiserror::Error)] enum, so a caller can match on what failed and the + messages live with their variants. +rule: + any: + - pattern: + context: type T = Result<$OK, String>; + selector: generic_type + - pattern: Err(format!($$$)) + - pattern: Err($S.to_string()) + - pattern: Err(String::from($$$)) + - all: + - pattern: Err($S) + - has: + kind: arguments + has: + kind: string_literal diff --git a/lint/ast-grep/sgconfig.yml b/lint/ast-grep/sgconfig.yml new file mode 100644 index 000000000..9c94bd0ae --- /dev/null +++ b/lint/ast-grep/sgconfig.yml @@ -0,0 +1,6 @@ +# Structural rules for the command-line code (RFC-0012), run by .github/workflows/ast-grep.yml. +# Each rule in rules/ has its cases in rule-tests/, which CI checks before the scan. +ruleDirs: + - rules +testConfigs: + - testDir: rule-tests diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh new file mode 100755 index 000000000..e1718648f --- /dev/null +++ b/packaging/freebsd/package.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Build and package CADCraft for FreeBSD: +# +# $DIST/cadcraft--freebsd-.tar.gz a /usr/local-style tree ( is x86_64, or +# aarch64 when CARGO_BUILD_TARGET=aarch64-unknown-freebsd): +# cadcraft--freebsd-/{bin, man/man1, share/applications, share/icons, +# share/mime, share/metainfo, share/doc/cadcraft, share/{bash-completion,zsh,fish}} +# +# Install by copying the tree's contents into /usr/local, e.g. +# tar -xzf cadcraft-*-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local +# +# Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run] +# --dry-run print the resolved target, archive and cargo commands, then exit without building +# +# Needs: bash, cargo and the build dependencies listed in .github/workflows/freebsd.yml. FreeBSD's +# install(1) has no -D, so directories are created first. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +LINUX="$ROOT/packaging/linux" +APP_ID=ai.storyteller.cadcraft + +SKIP_BUILD=0 +DRY_RUN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) SKIP_BUILD=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +# FreeBSD calls x86_64 "amd64"; release names use the Rust/Linux spelling. +release_arch() { + case "$1" in + amd64 | x86_64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) echo "unsupported architecture $1" >&2; return 2 ;; + esac +} +# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to +# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +# host-tuple means the build host's triple, as rustc names it. +if [[ "${CARGO_BUILD_TARGET:-}" == host-tuple ]]; then + CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +fi +case "${CARGO_BUILD_TARGET:-}" in + "") ARCH="$(release_arch "$(uname -m)")" ;; + aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;; +esac +# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built +# on another OS never does. +HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" +if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then + HOST_ARCH="$(uname -s) $HOST_ARCH" +fi +BASENAME="cadcraft-$VERSION-freebsd-$ARCH" + +echo "==> CADCraft $VERSION for FreeBSD $ARCH" + +# cadcraft-cli's man-and-completions example is built on its own, not with the binaries: its +# dev-dependencies would otherwise be unified into them (packaging/linux/package.sh). It runs on +# this machine, so a cross build builds it without the target. +CARGO_BUILD_FLAGS=(--release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"}) +if [[ "$DRY_RUN" = 1 ]]; then + echo "dry run: archive $DIST/$BASENAME.tar.gz" + echo "dry run: (cd $ROOT && cargo build ${CARGO_BUILD_FLAGS[*]} -p cadcraft -p cadcraft-cli)" + echo "dry run: (cd $ROOT && env -u CARGO_BUILD_TARGET cargo build --release --locked -p cadcraft-cli --example man-and-completions)" + exit 0 +fi +if [[ "$SKIP_BUILD" = 0 ]]; then + (cd "$ROOT" && cargo build "${CARGO_BUILD_FLAGS[@]}" -p cadcraft -p cadcraft-cli) + (cd "$ROOT" && env -u CARGO_BUILD_TARGET cargo build --release --locked -p cadcraft-cli --example man-and-completions) +fi +# Cargo puts a cross build under target//release. +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" +WORK="$CARGO_TARGET_DIR/freebsd-package" +STAGE="$WORK/$BASENAME" +rm -rf "$WORK" + +# ---- stage a /usr/local-style tree --------------------------------------------------------------- +mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/mime/packages" "$STAGE/share/metainfo" \ + "$STAGE/share/icons" "$STAGE/share/doc/cadcraft" +install -m 755 "$BIN/cadcraft" "$BIN/cadcraft-cli" "$STAGE/bin/" +strip "$STAGE/bin/cadcraft" "$STAGE/bin/cadcraft-cli" 2>/dev/null || true +# The desktop entry, MIME type and metainfo are the freedesktop files the Linux packages use. +install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop" +install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$CADCRAFT_BUILD_DATE/g" \ + "$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml" +cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/" +copy_docs "$STAGE/share/doc/cadcraft" +# cadcraft-cli's man pages and shell completions, from its clap definition, written by the +# example built above. +CLI_DOCS="$CARGO_TARGET_DIR/release/examples/man-and-completions" +if [[ ! -x "$CLI_DOCS" ]]; then + echo "error: $CLI_DOCS is missing: build it with the binaries (without --skip-build)" >&2 + exit 1 +fi +"$CLI_DOCS" "$WORK/cli-docs" +mkdir -p "$STAGE/man/man1" "$STAGE/share/bash-completion/completions" \ + "$STAGE/share/zsh/site-functions" "$STAGE/share/fish/vendor_completions.d" +install -m 644 "$WORK"/cli-docs/*.1 "$STAGE/man/man1/" +install -m 644 "$WORK/cli-docs/cadcraft-cli.bash" "$STAGE/share/bash-completion/completions/cadcraft-cli" +install -m 644 "$WORK/cli-docs/_cadcraft-cli" "$STAGE/share/zsh/site-functions/_cadcraft-cli" +install -m 644 "$WORK/cli-docs/cadcraft-cli.fish" "$STAGE/share/fish/vendor_completions.d/cadcraft-cli.fish" + +# ---- .tar.gz ------------------------------------------------------------------------------------- +mkdir -p "$DIST" +tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME" +echo "wrote $DIST/$BASENAME.tar.gz" + +if [[ -z "${CARGO_BUILD_TARGET:-}" ]] && [[ "$ARCH" = "$HOST_ARCH" ]]; then + "$STAGE/bin/cadcraft-cli" --version +else + # They cannot run here, so check what they are instead: executables, not libraries or objects. + # An older file(1) calls a PIE a shared object; its interpreter still marks it as a program. + want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64 + for b in "$STAGE"/bin/*; do + case "$(file -b "$b")" in + ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;; + *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; + esac + done + echo "skipping --version: a CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET build does not run on this $HOST_ARCH machine" +fi +echo "==> done" +ls -lh "$DIST" diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml index b6a64d8fb..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 @@ -24,15 +25,15 @@ finish-args: - --share=ipc # GPU access for the wgpu canvas (Vulkan, or GL as a fallback). - --device=dri + # Outbound network access (HTTPS). + - --share=network # Open/Save dialogs go through the file-chooser portal (rfd) and drag-and-drop through the # document portal, so picked files work anywhere without broad filesystem access. Documents # and Pictures are granted so drawings passed by path (the command line, Open Recent, xrefs # and images next to a DXF) work in the usual places. - --filesystem=xdg-documents - --filesystem=xdg-pictures - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.cadcraft` exposes it to - # host agents) and home/host filesystem access. + # Deliberately not granted: home/host filesystem access. modules: - name: cadcraft buildsystem: simple diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index 75a6ff690..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 @@ -25,15 +26,15 @@ finish-args: - --share=ipc # GPU access for the wgpu canvas (Vulkan, or GL as a fallback). - --device=dri + # Outbound network access (HTTPS). + - --share=network # Open/Save dialogs go through the file-chooser portal (rfd) and drag-and-drop through the # document portal, so picked files work anywhere without broad filesystem access. Documents # and Pictures are granted so drawings passed by path (the command line, Open Recent, xrefs # and images next to a DXF) work in the usual places. - --filesystem=xdg-documents - --filesystem=xdg-pictures - # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in - # the sandbox; `flatpak override --user --share=network ai.storyteller.cadcraft` exposes it to - # host agents) and home/host filesystem access. + # Deliberately not granted: home/host filesystem access. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 7ad0c4016..f9146c86a 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -44,11 +44,16 @@ BASENAME="cadcraft-$VERSION-linux-$ARCH" echo "==> CADCraft $VERSION for Linux $ARCH ($FORMATS)" +# The man-and-completions example writes cadcraft-cli's man pages and shell completions below. It +# is built in its own cargo run: built with the binaries, its dev-dependencies' features would be +# unified into them. if [[ "$SKIP_BUILD" = 0 ]]; then if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p cadcraft -p cadcraft-cli --target "$CROSS_TARGET") + (cd "$ROOT" && cargo build --release --locked -p cadcraft-cli --example man-and-completions --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p cadcraft -p cadcraft-cli) + (cd "$ROOT" && cargo build --release --locked -p cadcraft-cli --example man-and-completions) fi fi BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" @@ -69,6 +74,26 @@ mkdir -p "$STAGE/usr/share/icons" cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/" mkdir -p "$STAGE/usr/share/doc/cadcraft" copy_docs "$STAGE/usr/share/doc/cadcraft" +# cadcraft-cli's man pages and shell completions, from its clap definition, written by the +# example built above. A cross build runs it through $EMULATOR, like the --version check at the +# end, and leaves them out without one, as a --skip-build run whose prebuilt binaries came without +# the example does. zsh reads site-functions on Fedora and openSUSE. +CLI_DOCS="$BIN/examples/man-and-completions" +if [[ -n "${CROSS_TARGET:-}" ]] && [[ -z "${EMULATOR:-}" ]]; then + warn "no EMULATOR to run the $CROSS_TARGET build, so cadcraft-cli's man pages and completions are left out" +elif [[ ! -x "$CLI_DOCS" ]]; then + warn "$CLI_DOCS is not built, so cadcraft-cli's man pages and completions are left out" +else + if [[ -n "${CROSS_TARGET:-}" ]]; then + "$EMULATOR" "$CLI_DOCS" "$WORK/cli-docs" + else + "$CLI_DOCS" "$WORK/cli-docs" + fi + install -Dm644 -t "$STAGE/usr/share/man/man1" "$WORK"/cli-docs/*.1 + install -Dm644 "$WORK/cli-docs/cadcraft-cli.bash" "$STAGE/usr/share/bash-completion/completions/cadcraft-cli" + install -Dm644 "$WORK/cli-docs/_cadcraft-cli" "$STAGE/usr/share/zsh/site-functions/_cadcraft-cli" + install -Dm644 "$WORK/cli-docs/cadcraft-cli.fish" "$STAGE/usr/share/fish/vendor_completions.d/cadcraft-cli.fish" +fi if command -v desktop-file-validate >/dev/null; then desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 014f91bd9..5ecb13b7a 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,21 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped +# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked. +# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try. +stapler_retry() { + local try + for try in 1 2 3 4; do + xcrun stapler "$1" "$2" && return 0 + [[ "$try" = 4 ]] && break + echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2 + sleep $((try * 20)) + done + echo "error: stapler $1 $(basename "$2") failed 4 times" >&2 + exit 1 +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -122,8 +137,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/CADCraft-notarize.zip" notarize "$WORK/CADCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -148,8 +163,8 @@ sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi diff --git a/packaging/web/README.md b/packaging/web/README.md index 024e00c01..73a74f493 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -50,6 +50,18 @@ location /cadcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Panel layout storage + +The browser host saves docked, floating and hidden palette arrangements alongside interface +preferences in its local storage. Floating palettes remain embedded in the browser canvas; +they do not become native operating-system windows. Window > Reset Palettes restores the +default arrangement. This record contains panel placement and visibility, not drawing data. + +The optional `cadcraft.panel-layout` record uses the shared docking layout version and is +limited to 64 KiB. Malformed, incompatible or invalid arrangements are ignored atomically, +leaving the startup defaults usable. Storage availability and autosave follow the browser +host's existing eframe persistence; clearing site data removes the saved layout. + ## Embedding in a page (iframe) ```html diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/cadcraft.wxs b/packaging/windows/cadcraft.wxs index d59cc41b3..152a67dc2 100644 --- a/packaging/windows/cadcraft.wxs +++ b/packaging/windows/cadcraft.wxs @@ -1,35 +1,14 @@ - - - + + + - + @@ -39,7 +18,9 @@ - + + + @@ -50,44 +31,42 @@ - - - + + - + - + - + + + - + - + - - - - - + + + + + + @@ -97,12 +76,9 @@ - - - + + + @@ -137,18 +113,14 @@ - - + + - - + + diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..cc9416e9c --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,296 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' + } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..e5c63e72d --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,56 @@ +{ + "id": "cadcraft", + "name": "CADCraft", + "upgradeCode": "119D7D76-0167-4A9E-8F37-09DA0235F68E", + "sources": [ + "cadcraft.wxs" + ], + "icon": "assets/app-icon/cadcraft.ico", + "localization": [], + "extensions": [ + "UI" + ], + "payloads": [ + "cadcraft-cli.exe", + "cadcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\cadcraft.exe", + "", + "[#CadcraftExe]" + ], + [ + "-1", + "Software\\Classes\\Applications\\cadcraft.exe\\shell\\open\\command", + "", + "\"[#CadcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Classes\\.dxf\\OpenWithProgids", + "CADCraft.Drawing", + "" + ], + [ + "-1", + "Software\\Classes\\.dwg\\OpenWithProgids", + "CADCraft.Drawing", + "" + ], + [ + "-1", + "Software\\Classes\\CADCraft.Drawing\\shell\\open\\command", + "", + "\"[#CadcraftExe]\" \"%1\"" + ], + [ + "-1", + "Software\\Learning Machines LLC\\CADCraft", + "DesktopShortcut", + "1" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..407c41cf3 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 92b48e134..042b46f73 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -115,6 +115,8 @@ Invoke-Native 'wix build' { -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..d455d19d1 --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 701013cdc..aaa577fa7 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true publish = false +[lints] +workspace = true + [dependencies] serde_json = { workspace = true } chrono = { workspace = true } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index aab245784..d8db1c526 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -167,11 +167,11 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + // Test support is allowed as a dev-dependency, regardless of its layer. + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 39f8e8713..8c174d290 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -136,7 +136,9 @@ mod tests { assert_eq!(day(20_736), "2026-10-10"); assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01"); // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`). - assert_eq!(day(115_740_740), "318857-05-20"); + // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that + // SystemTime panics there. + assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20)); // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) { assert_eq!(date(far), "292277026596-12-04"); diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index c2c6de3d2..6136de15a 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{Counts, count_tests, walk}; + use super::{Counts, count_tests}; #[test] fn counts_attributes_and_proptest_blocks() { @@ -155,7 +155,7 @@ mod tests { // A directory symlink pointing back at `real` would be re-scanned if followed. symlink(&real, base.join("alias")).unwrap(); let mut c = Counts::default(); - walk(&base, &mut c).unwrap(); + super::walk(&base, &mut c).unwrap(); assert_eq!(c.files, 1, "the aliased directory was followed and counted twice"); std::fs::remove_dir_all(&base).ok(); }