mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 06:16:51 -06:00
Merge main into #1693
This commit is contained in:
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -30,3 +30,5 @@ jobs:
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-dir: CADCraft
|
||||
install-relative-path: Programs/ArtCraft/CADCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -17,9 +17,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- 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.
|
||||
|
||||
@@ -65,6 +65,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
|
||||
@@ -17,9 +17,9 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- 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.
|
||||
|
||||
Generated
+187
-27
@@ -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",
|
||||
|
||||
+10
-3
@@ -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."*"]
|
||||
|
||||
@@ -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-<ver>-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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 -- <out-dir>
|
||||
//! ```
|
||||
|
||||
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 <out-dir>");
|
||||
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)
|
||||
}
|
||||
@@ -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<String>, 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<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "The drawing to read ", Format::READ, "")
|
||||
}
|
||||
|
||||
fn open_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "Drawings to open ", Format::READ, "")
|
||||
}
|
||||
|
||||
fn write_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<PathBuf>,
|
||||
/// The font that replaces a missing one (FONTALT)
|
||||
#[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")]
|
||||
pub(crate) fontalt: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<x1, y0<y1), centred and scaled to fit the image
|
||||
/// without distortion. A negative x0 needs the `=` form: --window=-10,0,10,5
|
||||
#[arg(long, value_name = "x0,y0,x1,y1", value_parser = parse_window, conflicts_with = "view")]
|
||||
pub(crate) window: Option<Bounds2>,
|
||||
/// 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<ViewMode>,
|
||||
}
|
||||
|
||||
/// `--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<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// A script file (.scr) to run
|
||||
#[arg(long, value_name = "F.scr", action = ArgAction::Append)]
|
||||
pub(crate) script_file: Vec<PathBuf>,
|
||||
/// 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<CmdStep>,
|
||||
#[arg(long, value_name = "OUT.dxf", help = save_help())]
|
||||
pub(crate) save: Option<PathBuf>,
|
||||
#[arg(long, value_name = "OUT.png", help = export_help())]
|
||||
pub(crate) export: Option<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 drawings, in command-line order, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) drawings: Vec<Drawing>,
|
||||
/// The steps, in command-line order, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) steps: Vec<Step>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<PerfTarget>,
|
||||
}
|
||||
|
||||
/// 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::<usize>().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<Bounds2, WindowError> {
|
||||
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<f64, WindowError> {
|
||||
match v.parse::<f64>() {
|
||||
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<CmdStep, ValueError> {
|
||||
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<UsageError> {
|
||||
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<UsageError> {
|
||||
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<String, ValueError> {
|
||||
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::<u16>().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<Item = (usize, &'a T)> {
|
||||
let at = m.indices_of(id).into_iter().flatten();
|
||||
let values = m.try_get_many::<T>(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<usize> {
|
||||
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<Drawing>, Vec<Step>) {
|
||||
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<Cli, clap::Error> {
|
||||
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<OsString> + 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<Cli, clap::Error> {
|
||||
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<Cli, clap::Error> {
|
||||
parse(&a.iter().map(OsString::from).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn mcp(a: &[&str]) -> Result<McpTarget, clap::Error> {
|
||||
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 <HOST:PORT>"), "{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<PerfTarget> {
|
||||
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<Framing, clap::Error> {
|
||||
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::<Vec<_>>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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<Value> = 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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<T = (), E = CliError> = std::result::Result<T, E>;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
// Help and `--version` (`help`, `-h`, `<subcommand> --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<usize> {
|
||||
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<String> = ["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);
|
||||
}
|
||||
}
|
||||
+5
-1108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
use super::args::{McpArgs, McpTarget};
|
||||
use super::error::{CliError, Result};
|
||||
|
||||
pub(crate) fn mcp(a: &McpArgs) -> Result {
|
||||
let backend: Box<dyn cadcraft_mcp::Backend> = 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(())
|
||||
}
|
||||
@@ -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<View>)> {
|
||||
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<Bounds2> {
|
||||
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<View>)]>) -> Result<Option<Bounds2>, 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<Bounds2>, 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<String> {
|
||||
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<String> = 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<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = 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(", ")))
|
||||
}
|
||||
@@ -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<cadcraft_engine::doc::Drawing, cadcraft_engine::doc::DocError> {
|
||||
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<Vec2> = (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<Vec2> = (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(())
|
||||
}
|
||||
@@ -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<Handle>) {
|
||||
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<Handle>) {
|
||||
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<Handle>) {
|
||||
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<Handle> {
|
||||
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<u64> {
|
||||
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<Handle>) -> Result<Value, NoLastHandle> {
|
||||
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::<Result<Vec<_>, _>>().map(Value::Array),
|
||||
Value::Object(m) => {
|
||||
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().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<Ready> = 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<Handle> = 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ mod help;
|
||||
mod mcp_connect;
|
||||
mod skipped_records;
|
||||
mod undrawn_entities;
|
||||
mod usage;
|
||||
|
||||
@@ -33,12 +33,15 @@ fn replies(out: &Output) -> Vec<Value> {
|
||||
#[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 <HOST:PORT>"), (&["--connect", ""], "--connect <HOST:PORT>"), (&["--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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = 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 <subcommand>`: 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 <x0,y0,x1,y1>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"convert --view last",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--view"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--view <extents|current>' 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 <x0,y0,x1,y1>' 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 <extents|current>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --window last",
|
||||
&["run", "--sample", "--window"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --view last",
|
||||
&["run", "--sample", "--view"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script last",
|
||||
&["run", "--sample", "--script"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script then flag",
|
||||
&["run", "--sample", "--script", "--sample"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script-file last",
|
||||
&["run", "--script-file"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script-file <F.scr>' 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 <F.scr>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --cmd last",
|
||||
&["run", "--sample", "--cmd"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --cmd then flag",
|
||||
&["run", "--sample", "--cmd", "--sample"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --save last",
|
||||
&["run", "--sample", "--save"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--save <OUT.dxf>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --export last",
|
||||
&["run", "--sample", "--export"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--export <OUT.png>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect last",
|
||||
&["mcp", "--connect"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect then flag",
|
||||
&["mcp", "--connect", "--lazy"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect empty",
|
||||
&["mcp", "--connect", " "],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value ' ' for '--connect <HOST:PORT>': 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 <HOST:PORT>': 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 <HOST:PORT>': 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 <HOST:PORT>': 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 <OUT.dxf>' 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 <OUT.png>' 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 <x0,y0,x1,y1>' 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 <x0,y0,x1,y1>' 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 <extents|current>' 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 <extents|current>' 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 <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"convert --window and --view",
|
||||
&["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
||||
),
|
||||
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 <HOST:PORT>' 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 '<bracket|floorplan>'"),
|
||||
),
|
||||
case(
|
||||
"sample names are case-sensitive",
|
||||
&["sample", "Bracket", "{dir}/o.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'Bracket' for '<bracket|floorplan>'"),
|
||||
),
|
||||
case(
|
||||
"sample with an empty output",
|
||||
&["sample", "bracket", ""],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '<OUT>' but none was supplied"),
|
||||
),
|
||||
case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '<FILE>' 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 <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"run --view sideways",
|
||||
&["run", "--sample", "--view", "sideways"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"--view sideways before the output check",
|
||||
&["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"--window 3 numbers",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': 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 <x0,y0,x1,y1>': 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 <x0,y0,x1,y1>': 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,y0,x1,y1>': 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,y0,x1,y1>': 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 <x0,y0,x1,y1>': 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 <x0,y0,x1,y1>': 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 <x0,y0,x1,y1>': 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 <id {json}>': 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 <id {json}>': "),
|
||||
),
|
||||
case(
|
||||
"--cmd without an id",
|
||||
&["run", "--sample", "--cmd", " {}"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value ' {}' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
|
||||
),
|
||||
case(
|
||||
"--cmd empty",
|
||||
&["run", "--sample", "--cmd="],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '' for '--cmd <id {json}>': 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<OsString>| {
|
||||
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<OsString>| {
|
||||
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<OsString>, 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");
|
||||
}
|
||||
@@ -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<String> = 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<String> = 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();
|
||||
}
|
||||
@@ -14,6 +14,9 @@ pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod panel_layout;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use cadcraft_engine::Session;
|
||||
@@ -59,10 +62,14 @@ mod web {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
|
||||
match crate::panel_layout::save(&self.0.ui) {
|
||||
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
|
||||
Err(error) => log::warn!("panel layout was not saved: {error}"),
|
||||
}
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
@@ -160,6 +167,11 @@ mod web {
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
|
||||
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
|
||||
{
|
||||
log::warn!("panel layout was not restored: {error}");
|
||||
}
|
||||
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
|
||||
if !crate::wants_cpu(&query())
|
||||
&& let Some(rs) = &cc.wgpu_render_state
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Browser panel layout storage, independent of drawings and native workspace files.
|
||||
|
||||
use cadcraft_ui_egui::{UiState, docking};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const KEY: &str = "cadcraft.panel-layout";
|
||||
const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Serialize only validated panel placement and visibility, with a bounded record size.
|
||||
pub fn save(ui: &UiState) -> Result<String, String> {
|
||||
docking::validate(&ui.docking)?;
|
||||
let saved = json!({
|
||||
"version": docking::LAYOUT_VERSION,
|
||||
"docking": ui.docking,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
})
|
||||
.to_string();
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Validate the entire optional record before changing any UI field.
|
||||
pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;
|
||||
if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {
|
||||
return Err("unsupported panel layout version".into());
|
||||
}
|
||||
let workspace: docking::Workspace =
|
||||
serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;
|
||||
docking::validate(&workspace)?;
|
||||
let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;
|
||||
let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;
|
||||
ui.docking = workspace;
|
||||
ui.show_toolsets = tools;
|
||||
ui.show_palettes = palettes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
#[test]
|
||||
fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {
|
||||
let mut source = CadApp::new(Session::default(), Services::default());
|
||||
source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();
|
||||
source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
let saved = save(&source.ui).unwrap();
|
||||
let mut destination = CadApp::new(Session::default(), Services::default());
|
||||
destination.session.start("line").unwrap();
|
||||
let before = destination.session.doc().unwrap().clone();
|
||||
restore(&mut destination.ui, &saved).unwrap();
|
||||
assert_eq!(destination.ui.docking, source.ui.docking);
|
||||
assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);
|
||||
assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);
|
||||
assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
|
||||
assert_eq!(destination.session.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_unsupported_storage_is_rejected_atomically() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
for (field, bad) in [
|
||||
("version", json!(0)),
|
||||
("version", json!(docking::LAYOUT_VERSION + 1)),
|
||||
("docking", json!({})),
|
||||
("showToolsets", json!("false")),
|
||||
("showPalettes", Value::Null),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[field] = bad;
|
||||
assert!(restore(&mut ui, &invalid.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {
|
||||
assert!(restore(&mut ui, &invalid).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_cannot_remove_the_document_view() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let mut invalid = ui.clone();
|
||||
invalid.docking.layout.root = None;
|
||||
assert!(save(&invalid).is_err());
|
||||
let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
record["docking"] = serde_json::to_value(&invalid.docking).unwrap();
|
||||
assert!(restore(&mut ui, &record.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -24,6 +24,9 @@ struct Limits {
|
||||
max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
max_line: usize,
|
||||
/// Request-line bytes buffered at once across all connections; a request that would pass it
|
||||
/// is refused with an error reply.
|
||||
total_bytes: usize,
|
||||
/// Requests waiting for the app at once; more are answered `busy`.
|
||||
queue: usize,
|
||||
/// How long a connection may go without sending a byte before it is closed.
|
||||
@@ -40,6 +43,8 @@ impl Default for Limits {
|
||||
max_clients: 16,
|
||||
// Generous, so requests that carry whole drawings or images as base64 still fit.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
// Room for one maximal request plus smaller ones, not `max_clients` maximal ones.
|
||||
total_bytes: 512 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
@@ -106,6 +111,7 @@ impl Drop for Slot {
|
||||
/// Accept clients within the connection limit and give each worker the same session token.
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let budget = Budget::new(limits.total_bytes);
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
@@ -117,39 +123,102 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let budget = budget.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits, &token);
|
||||
serve(stream, &tx, &ctx, limits, &token, budget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
/// A total-bytes budget for request lines buffered across all connections, so
|
||||
/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Budget {
|
||||
used: Arc<AtomicUsize>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn new(total: usize) -> Self {
|
||||
Self { used: Arc::new(AtomicUsize::new(0)), total }
|
||||
}
|
||||
|
||||
/// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total.
|
||||
fn reserve(&self, n: usize) -> bool {
|
||||
self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the
|
||||
/// next line starts or the connection ends.
|
||||
struct Held {
|
||||
budget: Budget,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(budget: Budget) -> Self {
|
||||
Self { budget, bytes: 0 }
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst);
|
||||
self.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging
|
||||
/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long,
|
||||
/// the shared budget is used up, or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, held: &mut Held) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
})?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
held.release();
|
||||
// A line is at most `max` bytes plus its newline; never buffer more than that.
|
||||
let cap = max.saturating_add(1);
|
||||
loop {
|
||||
let chunk = match reader.fill_buf() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
|
||||
Err(e) => {
|
||||
return Err(match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if chunk.is_empty() {
|
||||
// End of stream: nothing read, or a last line without a newline.
|
||||
return Ok(!buf.is_empty());
|
||||
}
|
||||
let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1);
|
||||
let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len()));
|
||||
buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk));
|
||||
reader.consume(n);
|
||||
// The chunk is already buffered (a few KiB), so it is added before it is charged: a
|
||||
// refusal still leaves `buf` non-empty and the connection gets an error reply.
|
||||
if !held.budget.reserve(n) {
|
||||
return Err("too many large requests in flight; try again later".to_string());
|
||||
}
|
||||
held.bytes += n;
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
@@ -204,7 +273,7 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|
||||
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
|
||||
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -212,8 +281,9 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
let mut held = Held::new(budget);
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) {
|
||||
Ok(false) => break,
|
||||
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
|
||||
// A line nobody asked for would be read as the reply to the client's next request.
|
||||
@@ -264,6 +334,7 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
@@ -281,13 +352,13 @@ mod tests {
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -295,11 +366,15 @@ mod tests {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
fn held() -> Held {
|
||||
Held::new(Budget::new(usize::MAX))
|
||||
}
|
||||
|
||||
fn short() -> Limits {
|
||||
Limits {
|
||||
queue: 1,
|
||||
@@ -414,6 +489,40 @@ mod tests {
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// Connections can't together buffer more request-line bytes than the budget: a second large
|
||||
/// request is refused with an error reply while the first still holds its bytes (#1925).
|
||||
#[test]
|
||||
fn requests_past_the_total_budget_are_refused() {
|
||||
let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() });
|
||||
// The first peer sends 200 bytes of an unfinished line and keeps the connection open.
|
||||
let mut first = TcpStream::connect(addr).unwrap();
|
||||
first.write_all(&[b'a'; 200]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
// The second would take 200 more, past the 300 byte total.
|
||||
let mut second = TcpStream::connect(addr).unwrap();
|
||||
second.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
second.write_all(&[b'b'; 200]).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(second).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["ok"], json!(false));
|
||||
assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}");
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_is_given_back_when_the_connection_ends() {
|
||||
let budget = Budget::new(100);
|
||||
{
|
||||
let mut h = Held::new(budget.clone());
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = &[b'x'; 80];
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true));
|
||||
assert!(!budget.reserve(30), "80 of 100 bytes are held");
|
||||
}
|
||||
assert!(budget.reserve(100), "all given back on drop");
|
||||
}
|
||||
|
||||
/// Replies on one authenticated connection preserve the ID of each request.
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
/// The interface language, instead of the system's.
|
||||
pub(crate) locale: Option<String>,
|
||||
/// 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<String>,
|
||||
/// FONTFALLBACK at startup.
|
||||
pub(crate) font_fallback: Option<String>,
|
||||
}
|
||||
|
||||
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<OsString>) -> 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:<width$}{line}");
|
||||
help
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + '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<String> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
/// 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<u16>,
|
||||
/// 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<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
|
||||
let mut options = true;
|
||||
let args: Vec<OsString> = 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<u16, NotAPort> {
|
||||
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<LaunchArgs, clap::Error> {
|
||||
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 <PORT>'")), "{bad}");
|
||||
}
|
||||
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
#[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 <PORT>'"));
|
||||
// A following flag is never taken as the value.
|
||||
assert!(usage_error(&["--control", "--sample"]).contains("a value is required for '--control <PORT>'"));
|
||||
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 <PORT>") && 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 <PORT>'"), "{text}");
|
||||
assert_eq!(parse(path_fallback(), ["--control", "7"].map(OsString::from)).unwrap().control, Some(7));
|
||||
}
|
||||
}
|
||||
@@ -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: `<settings dir>/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.
|
||||
//!
|
||||
|
||||
+35
-138
@@ -1,6 +1,6 @@
|
||||
//! CADCraft desktop app.
|
||||
//!
|
||||
//! Usage: `cadcraft [--control <port>] [--sample] [files…]`
|
||||
//! Usage: `cadcraft [--control <port>] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`])
|
||||
//!
|
||||
//! `--control <port>` (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 `<settings dir>/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<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
/// `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<bool>,
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<std::ffi::OsString>) -> Option<std::path::PathBuf> {
|
||||
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<egui::IconData> {
|
||||
}
|
||||
|
||||
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 <port>` 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<String>) -> Option<u16> {
|
||||
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<u16>, value: Option<String>) -> Option<u16> {
|
||||
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::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
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 `<settings dir>/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 `<settings>/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<OsString> + '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]
|
||||
|
||||
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
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<Value, String> {
|
||||
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<UiState>, Option<String>) {
|
||||
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<UiState>, Option<String>) {
|
||||
Self::at(config_dir.map(|root| root.join("workspace.json")))
|
||||
}
|
||||
|
||||
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,3 +91,11 @@ sync_github_name = false
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.2ez4gcx]
|
||||
real_name = "Doan Huynh Huu Khuong"
|
||||
display_name = "Doan Huynh Huu Khuong"
|
||||
sync_github_name = false
|
||||
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -52,15 +52,11 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {
|
||||
g.curves.push((GeomRef::whole(h), ga.center, a.radius));
|
||||
}
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
if c.center.is_finite() && c.radius.is_finite() {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
}
|
||||
EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
if p.p.is_finite() {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
}
|
||||
EntityKind::Point(p) if p.p.is_finite() => {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
|
||||
+341
-185
@@ -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<f64>) -> 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<f64>) -> 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<Built, String> {
|
||||
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<Built, String> {
|
||||
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<f64>,
|
||||
}
|
||||
|
||||
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<Vec2> {
|
||||
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<Ell> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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::<i64>().is_ok_and(|v| v != 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, PolyVertex, Serialize, Vec3};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<Vec2>,
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
|
||||
//! One exact terminal copy of the first point is a closure marker, not an editable corner.
|
||||
//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
|
||||
//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
/// Ordered boundary, implicitly closed or ending in a repeated first point.
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Wipeout {
|
||||
/// Editable vertices, excluding one exactly repeated closing point, without changing storage.
|
||||
pub fn vertices(&self) -> &[Vec2] {
|
||||
match self.boundary.split_last() {
|
||||
Some((last, vertices)) if vertices.first() == Some(last) => vertices,
|
||||
_ => &self.boundary,
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicitly closed outline for geometry, rendering and export. Already closed or
|
||||
/// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
|
||||
pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
|
||||
if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
|
||||
Cow::Borrowed(&self.boundary)
|
||||
} else {
|
||||
let mut points = self.boundary.clone();
|
||||
points.extend(self.boundary.first());
|
||||
Cow::Owned(points)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
|
||||
/// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
|
||||
/// Invalid indices/non-finite points leave the boundary unchanged.
|
||||
pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
|
||||
let count = self.vertices().len();
|
||||
if index >= count || !to.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let explicit = count < self.boundary.len();
|
||||
*self.boundary.get_mut(index)? = to;
|
||||
if explicit
|
||||
&& index == 0
|
||||
&& let Some(last) = self.boundary.last_mut()
|
||||
{
|
||||
*last = to;
|
||||
}
|
||||
if !explicit && self.vertices().len() < count {
|
||||
// The moved endpoint is a logical corner, not the newly implied closing marker.
|
||||
if let Some(first) = self.boundary.first().copied() {
|
||||
self.boundary.push(first);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EntityKind;
|
||||
|
||||
#[test]
|
||||
fn vertices_and_grips_exclude_only_the_exact_closing_point() {
|
||||
let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
|
||||
let near_a = Vec2::new(1e-13, 0.0);
|
||||
for (boundary, expected) in [
|
||||
(vec![], vec![]),
|
||||
(vec![a], vec![a]),
|
||||
(vec![a, a], vec![a]),
|
||||
(vec![a, b, c], vec![a, b, c]),
|
||||
(vec![a, b, c, a], vec![a, b, c]),
|
||||
(vec![a, b, a, c, a], vec![a, b, a, c]),
|
||||
(vec![a, b, c, a, a], vec![a, b, c, a]),
|
||||
(vec![a, b, c, near_a], vec![a, b, c, near_a]),
|
||||
] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
|
||||
assert_eq!(wipeout.boundary, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
|
||||
let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
|
||||
let implicit = Wipeout { boundary: points.clone() };
|
||||
let mut expected = points.clone();
|
||||
expected.push(points[0]);
|
||||
assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
|
||||
assert_eq!(implicit.closed_boundary().as_ref(), expected);
|
||||
assert_eq!(implicit.boundary, points);
|
||||
let explicit = Wipeout { boundary: expected.clone() };
|
||||
assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(explicit.closed_boundary().as_ref(), expected);
|
||||
let a = EntityKind::Wipeout(implicit).prims();
|
||||
let b = EntityKind::Wipeout(explicit).prims();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 3);
|
||||
for boundary in [vec![], vec![Vec2::ZERO]] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
|
||||
assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_endpoints_together_keeps_both_corners_editable() {
|
||||
let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
|
||||
for explicit in [false, true] {
|
||||
for index in [0, 3] {
|
||||
let other = 3 - index;
|
||||
let mut boundary = points.to_vec();
|
||||
if explicit {
|
||||
boundary.push(points[0]);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
let mut expected = points;
|
||||
expected[index] = points[other];
|
||||
for _ in 0..2 {
|
||||
assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
|
||||
}
|
||||
expected[other] = Vec2::new(-2.0, 4.0);
|
||||
assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5);
|
||||
assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
|
||||
let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
|
||||
for explicit in [false, true] {
|
||||
let mut boundary = vec![a, b, a, c];
|
||||
if explicit {
|
||||
boundary.push(a);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
assert_eq!(wipeout.set_vertex(0, to), Some(()));
|
||||
let mut expected = vec![to, b, a, c];
|
||||
if explicit {
|
||||
expected.push(to);
|
||||
}
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
|
||||
assert_eq!(wipeout.set_vertex(index, point), None);
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-14
@@ -36,11 +36,26 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
|
||||
/// entities a visibility state hides are invisible this way, and they sit far from the block's
|
||||
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
|
||||
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
|
||||
/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]).
|
||||
fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool {
|
||||
// Attribute definitions are not drawn in an insert (its attribs are, see below).
|
||||
!matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& e.common.visible
|
||||
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
|
||||
!matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts)
|
||||
}
|
||||
|
||||
/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which
|
||||
/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`.
|
||||
fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool {
|
||||
layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts))
|
||||
}
|
||||
|
||||
/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its
|
||||
/// definition's (layer "0" being the insert's).
|
||||
fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str {
|
||||
a.props.layer.as_deref().unwrap_or_else(|| {
|
||||
d.block(&ins.block)
|
||||
.and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))))
|
||||
.map_or("0", |be| be.common.layer.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
|
||||
@@ -52,7 +67,9 @@ fn blank(s: &str) -> bool {
|
||||
fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
let lines = t.contents.split("\\P").count().max(1);
|
||||
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
|
||||
let mut w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
|
||||
let estimate = t.height * 0.8 * longest as f64;
|
||||
// The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465).
|
||||
let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate };
|
||||
let mut h = t.height * 1.66 * lines as f64;
|
||||
if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) {
|
||||
// Columns side by side, the text flowing down each in turn.
|
||||
@@ -75,16 +92,27 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
Bounds2::from_points(pts)
|
||||
}
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()),
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new())
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
@@ -93,11 +121,16 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
/// definition per depth instead of one per reference (which grows exponentially with the nesting
|
||||
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
|
||||
/// bounds must not stand in for a shallower reference to the same block (#706).
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
|
||||
// Text that matches the layout's orientation is drawn upright (#2498).
|
||||
EntityKind::Text(t) if e.common.matches_layout_orientation() => {
|
||||
text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count())
|
||||
}
|
||||
EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }),
|
||||
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
|
||||
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
|
||||
EntityKind::MText(t) => mtext_box(t),
|
||||
@@ -115,8 +148,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -131,7 +164,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible {
|
||||
if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) {
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
@@ -172,8 +205,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
Some(cached) => *cached,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
@@ -193,7 +226,7 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks));
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks, off_counts));
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -309,6 +342,37 @@ mod tests {
|
||||
entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0)
|
||||
}
|
||||
|
||||
fn mtext(contents: &str, width: f64) -> crate::MText {
|
||||
crate::MText {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
height: 2.5,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not
|
||||
/// stretch the drawing extents (#2465).
|
||||
#[test]
|
||||
fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() {
|
||||
let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0)));
|
||||
// "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide.
|
||||
assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x);
|
||||
assert!(b.min.x >= 10.0 - 1e-9);
|
||||
}
|
||||
|
||||
/// A wrap width narrower than the text still bounds the box to that width.
|
||||
#[test]
|
||||
fn a_narrow_mtext_wrap_width_still_sets_the_box_width() {
|
||||
let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0)));
|
||||
assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x);
|
||||
}
|
||||
#[test]
|
||||
fn right_justified_text_is_left_of_alignment_point() {
|
||||
let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline)));
|
||||
|
||||
@@ -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<Path>) -> Option<Format> {
|
||||
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::<Vec<_>>().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");
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-21
@@ -44,15 +44,85 @@ fn add_and_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_layers_excluded_from_extents() {
|
||||
fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let c = Common { layer: "Hidden".into(), ..Default::default() };
|
||||
d.add(&Space::Model, c, line((100.0, 100.0), (200.0, 200.0))).unwrap();
|
||||
d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap();
|
||||
d.layer_mut("Hidden").unwrap().on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(200.0, 200.0));
|
||||
d.layer_mut("Hidden").unwrap().frozen = true;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
|
||||
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
|
||||
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
|
||||
/// entity inside a block, and for a block reference itself.
|
||||
#[test]
|
||||
fn extents_count_layers_turned_off_as_autocad() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("OFFL", false, false), ("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
for (layer, b) in [("0", (10.0, 10.0)), ("OFFL", (100.0, 0.0)), ("FRZ", (0.0, 200.0))] {
|
||||
d.add(&Space::Model, Common { layer: layer.into(), ..Default::default() }, line((0.0, 0.0), b)).unwrap();
|
||||
}
|
||||
// AutoCAD: (-0.013, -0.009)…(100.027, 10.006).
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(100.0, 10.0)));
|
||||
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("FRZ", true, true), ("BOFF", false, false), ("BFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let mut blk = Block::new("BX");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("BOFF", line((0.0, 0.0), (50.0, 0.0)), 2));
|
||||
blk.entities.push(on_layer("BFRZ", line((0.0, 0.0), (0.0, 70.0)), 3));
|
||||
d.blocks.insert("BX".into(), std::sync::Arc::new(blk));
|
||||
let at = |x: f64| match plain_insert("BX") {
|
||||
EntityKind::Insert(i) => EntityKind::Insert(Insert { insert: Vec3::new(x, x, 0.0), ..i }),
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), at(1000.0)).unwrap();
|
||||
d.add(&Space::Model, Common { layer: "FRZ".into(), ..Default::default() }, at(-500.0)).unwrap();
|
||||
// AutoCAD: (1000, 1000)…(1050, 1001).
|
||||
assert_eq!(extents_pair(&d), (Vec2::new(1000.0, 1000.0), Vec2::new(1050.0, 1001.0)));
|
||||
|
||||
// An insert's attributes lie on their own layer, else on their definition's: one on a frozen
|
||||
// layer either way does not count, one on a layer turned off does.
|
||||
let mut d = Drawing::new_imperial();
|
||||
for (name, on, frozen) in [("IOFF", false, false), ("AOFF", false, false), ("AFRZ", true, true)] {
|
||||
let mut l = Layer::new(name);
|
||||
(l.on, l.frozen) = (on, frozen);
|
||||
d.layers.push(l);
|
||||
}
|
||||
let attrib = |tag: &str, at: (f64, f64), layer: Option<&str>| {
|
||||
let mut a = plain_attrib(tag);
|
||||
a.text.insert = Vec3::new(at.0, at.1, 0.0);
|
||||
a.props.layer = layer.map(str::to_string);
|
||||
a
|
||||
};
|
||||
let mut blk = Block::new("BA");
|
||||
blk.entities.push(on_layer("0", line((0.0, 0.0), (1.0, 1.0)), 1));
|
||||
blk.entities.push(on_layer("AFRZ", EntityKind::AttDef(plain_attrib("DEF")), 2));
|
||||
d.blocks.insert("BA".into(), std::sync::Arc::new(blk));
|
||||
let ins = match plain_insert("BA") {
|
||||
EntityKind::Insert(i) => {
|
||||
let attribs =
|
||||
vec![attrib("OWN", (0.0, 300.0), Some("AFRZ")), attrib("DEF", (0.0, -400.0), None), attrib("OFF", (40.0, 0.0), Some("AOFF"))];
|
||||
EntityKind::Insert(Insert { attribs, ..i })
|
||||
}
|
||||
k => k,
|
||||
};
|
||||
d.add(&Space::Model, Common { layer: "IOFF".into(), ..Default::default() }, ins).unwrap();
|
||||
let (min, max) = extents_pair(&d);
|
||||
assert!(min.y > -1.0 && max.y < 50.0 && max.x > 40.0, "{min:?} {max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rays_and_xlines_do_not_stretch_extents() {
|
||||
// ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away.
|
||||
@@ -315,6 +385,31 @@ fn plain_insert(block: &str) -> EntityKind {
|
||||
})
|
||||
}
|
||||
|
||||
/// An attribute (or its definition) `tag` at the origin, one unit high, reading "x".
|
||||
fn plain_attrib(tag: &str) -> Attrib {
|
||||
Attrib {
|
||||
tag: tag.into(),
|
||||
text: Text {
|
||||
insert: Vec3::ZERO,
|
||||
align_pt: None,
|
||||
height: 1.0,
|
||||
value: "x".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
},
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity {
|
||||
Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind }
|
||||
}
|
||||
@@ -383,24 +478,18 @@ fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_on_off_or_frozen_layer_is_ignored() {
|
||||
for frozen in [false, true] {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
if frozen {
|
||||
layer.frozen = true;
|
||||
} else {
|
||||
layer.on = false;
|
||||
}
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}");
|
||||
// Turned back on, it counts again.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.on = true;
|
||||
l.frozen = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
fn block_entity_on_frozen_layer_is_ignored_and_on_a_layer_turned_off_counts() {
|
||||
let mut d = drawing_with_block(far_line("Hidden"));
|
||||
assert!(d.layer("Hidden").is_none(), "block contents do not create layers");
|
||||
let mut layer = Layer::new("Hidden");
|
||||
layer.frozen = true;
|
||||
d.layers.push(layer);
|
||||
assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)));
|
||||
// Thawed but turned off, it counts.
|
||||
let l = d.layer_mut("Hidden").unwrap();
|
||||
l.frozen = false;
|
||||
l.on = false;
|
||||
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -558,10 +647,10 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
|
||||
hidden.common.visible = false;
|
||||
dim(&mut d, hidden);
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
// Hidden by its layer state.
|
||||
// Hidden by its layer state (frozen; a layer turned off still counts).
|
||||
let mut d = Drawing::new_imperial();
|
||||
let mut l = Layer::new("*ADSK_CONSTRAINTS");
|
||||
l.on = false;
|
||||
l.frozen = true;
|
||||
d.layers.push(l);
|
||||
dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2));
|
||||
assert!(d.extents(&Space::Model).max.x < 100.0);
|
||||
|
||||
+172
-11
@@ -104,10 +104,7 @@ mod native {
|
||||
}
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
current_style_names(&mut doc);
|
||||
table_merges(&mut doc);
|
||||
table_style_rows(&mut doc, false);
|
||||
let orphans = drop_orphaned_dgn_components(&mut doc);
|
||||
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
|
||||
@@ -237,6 +234,33 @@ mod native {
|
||||
/// cells already carry merges are left alone. Each range's first cell gets the span; the
|
||||
/// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does.
|
||||
fn table_merges(doc: &mut acadrust::CadDocument) {
|
||||
table_merges_within(doc, MAX_MERGE_CELLS);
|
||||
}
|
||||
|
||||
/// The most table cells [`table_merges`] visits in one document: each range is capped, but
|
||||
/// can cover rows × cols cells, so the total is bounded too.
|
||||
const MAX_MERGE_CELLS: usize = 1 << 24;
|
||||
|
||||
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
|
||||
/// components were dropped. The document comes from a third-party reader and is hostile.
|
||||
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
|
||||
super::fail_in_test("post_process");
|
||||
current_style_names(doc);
|
||||
table_merges(doc);
|
||||
table_style_rows(doc, false);
|
||||
keep_sort_handles(doc);
|
||||
drop_orphaned_dgn_components(doc)
|
||||
}
|
||||
|
||||
/// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way).
|
||||
fn guarded<R>(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result<R, ()> {
|
||||
std::panic::catch_unwind(f).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
|
||||
/// left unmerged. Returns the cells visited.
|
||||
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
|
||||
let mut left = budget;
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Table(t) = e else { continue };
|
||||
if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) {
|
||||
@@ -252,6 +276,11 @@ mod native {
|
||||
if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) {
|
||||
continue;
|
||||
}
|
||||
let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1);
|
||||
if area > left {
|
||||
continue;
|
||||
}
|
||||
left -= area;
|
||||
let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX);
|
||||
for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) {
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) {
|
||||
@@ -263,6 +292,28 @@ mod native {
|
||||
}
|
||||
}
|
||||
}
|
||||
budget - left
|
||||
}
|
||||
|
||||
/// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF
|
||||
/// writer renumbers them past every entity handle, but an entity left out of a table sorts by
|
||||
/// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving
|
||||
/// the table its codes verbatim makes the writer emit them unchanged.
|
||||
fn keep_sort_handles(doc: &mut acadrust::CadDocument) {
|
||||
use acadrust::objects::ObjectType;
|
||||
let version = doc.version;
|
||||
for o in doc.objects.values_mut() {
|
||||
let ObjectType::SortEntitiesTable(t) = o else { continue };
|
||||
let hex = |h: acadrust::types::Handle| format!("{:X}", h.value());
|
||||
let mut codes =
|
||||
vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))];
|
||||
for e in t.entries() {
|
||||
codes.push((331, hex(e.entity_handle)));
|
||||
codes.push((5, hex(e.sort_handle)));
|
||||
}
|
||||
t.raw_dxf_codes = Some(codes);
|
||||
t.raw_dxf_version = Some(version);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps
|
||||
@@ -512,6 +563,17 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
|
||||
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
|
||||
/// the document, so the drawing reopened with layer "0" current (#2252).
|
||||
fn current_layer(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_layer_name.clone();
|
||||
// The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä").
|
||||
if let Some(layer) = doc.layers.get(&name) {
|
||||
doc.header.current_layer_handle = layer.handle;
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||
// Reject a pathologically large DXF before handing it to the third-party decoder, which
|
||||
// allocates tables and section buffers from the input's declared sizes and cannot be
|
||||
@@ -536,13 +598,19 @@ mod native {
|
||||
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
|
||||
None => {}
|
||||
}
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
current_layer(&mut doc);
|
||||
}))
|
||||
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
@@ -639,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<Option<&'static str>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise.
|
||||
#[cfg(not(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<Vec<u8>, 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"));
|
||||
|
||||
+21
-3
@@ -182,8 +182,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
// Every group code takes two lines (code, value).
|
||||
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
|
||||
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
|
||||
let mut lines = bytes.split(|&b| b == b'\n');
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
@@ -265,7 +277,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
@@ -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![
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
file.write_all(bytes)?;
|
||||
fill(&mut file)?;
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
})();
|
||||
@@ -55,9 +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() {
|
||||
|
||||
@@ -460,17 +460,13 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
m.style = "Standard".into();
|
||||
}
|
||||
}
|
||||
EntityKind::Table(t) => {
|
||||
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => {
|
||||
note("table style", &t.style);
|
||||
t.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Leader(l) => {
|
||||
if !ok_dim(d, &l.style) {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Leader(l) if !ok_dim(d, &l.style) => {
|
||||
note("dimension style", &l.style);
|
||||
l.style = "Standard".into();
|
||||
}
|
||||
EntityKind::Dimension(dim) => {
|
||||
if !ok_dim(d, &dim.style) {
|
||||
|
||||
@@ -34,6 +34,18 @@ pub const STALE_LOCK_SECS: u64 = 5 * 60;
|
||||
const HEARTBEAT_SECS: f64 = 60.0;
|
||||
|
||||
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
|
||||
static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// ISAVEBAK: whether saving over a drawing keeps the previous file as `<name>.bak` (on by
|
||||
/// default, as in AutoCAD). A profile setting like SAVETIME.
|
||||
pub fn isavebak() -> bool {
|
||||
ISAVEBAK.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Turn ISAVEBAK on or off.
|
||||
pub fn set_isavebak(on: bool) {
|
||||
ISAVEBAK.store(on, Ordering::Relaxed);
|
||||
}
|
||||
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
|
||||
|
||||
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
|
||||
@@ -59,10 +71,22 @@ pub fn set_savefilepath(path: &str) {
|
||||
|
||||
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
|
||||
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
|
||||
let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect();
|
||||
// File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary.
|
||||
let suffix = format!("_{instance}_{uid}.{EXT}");
|
||||
let budget = 255usize.saturating_sub(suffix.len());
|
||||
let mut used = 0;
|
||||
let stem: String = title
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' })
|
||||
.take(80)
|
||||
.take_while(|c| {
|
||||
used += c.len_utf8();
|
||||
used <= budget
|
||||
})
|
||||
.collect();
|
||||
let stem = stem.trim().trim_matches('.');
|
||||
let stem = if stem.is_empty() { "Drawing" } else { stem };
|
||||
format!("{stem}_{instance}_{uid}.{EXT}")
|
||||
format!("{stem}{suffix}")
|
||||
}
|
||||
|
||||
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Drawing> {
|
||||
@@ -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<Drawing>| {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<EntityKind> {
|
||||
|
||||
@@ -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<Handle> {
|
||||
{
|
||||
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
|
||||
}
|
||||
// The measurement is what the DXF/DWG writer computes from the geometry (group 42); filling it
|
||||
// in now means queries show it before the drawing is saved and reopened (#2421).
|
||||
if let EntityKind::Dimension(dm) = &mut k
|
||||
&& let Ok(d) = s.doc()
|
||||
{
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
|
||||
let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0));
|
||||
dm.measurement = g.measurement;
|
||||
// The text position the writer puts in group 11 when the user has not placed the text.
|
||||
if !dm.user_text_pos {
|
||||
dm.text_mid = g.text_pos.to3(0.0);
|
||||
}
|
||||
}
|
||||
let h = s.add_entity(k)?;
|
||||
s.last_dim = Some(h);
|
||||
Ok(h)
|
||||
@@ -94,7 +107,7 @@ pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
|
||||
|
||||
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
|
||||
s.pick_object(&s.space(), p, ap).ok().flatten()
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
|
||||
@@ -1,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<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
|
||||
@@ -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<Value> {
|
||||
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))) {
|
||||
|
||||
@@ -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<EntityKind> {
|
||||
|
||||
@@ -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<Value> {
|
||||
let id = if diameter { "dimdiameter" } else { "dimradius" };
|
||||
|
||||
@@ -328,16 +328,19 @@ fn run_array(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(spec.run)(s, p)
|
||||
}
|
||||
|
||||
/// A count parameter (at least 1).
|
||||
fn count_or(p: &Value, key: &str, default: u64) -> u64 {
|
||||
p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1)
|
||||
/// A count parameter (at least 1); a missing key gives the default, an invalid value is refused.
|
||||
fn count_or(cmd: &str, p: &Value, key: &str, default: u64) -> Result<u64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v.as_u64().filter(|n| *n >= 1).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number of at least 1"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let rows = count_or(p, "rows", 3);
|
||||
let cols = count_or(p, "cols", 4);
|
||||
let levels = count_or(p, "levels", 1);
|
||||
let rows = count_or("arrayrect", p, "rows", 3)?;
|
||||
let cols = count_or("arrayrect", p, "cols", 4)?;
|
||||
let levels = count_or("arrayrect", p, "levels", 1)?;
|
||||
check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?;
|
||||
let kind = ArrayKind::Rect(RectArray {
|
||||
cols: count32(cols),
|
||||
@@ -360,9 +363,9 @@ fn run_arrayrect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let c = point_req("arraypolar", p, "center")?;
|
||||
let n = count_or(p, "count", 6);
|
||||
let rows = count_or(p, "rows", 1);
|
||||
let levels = count_or(p, "levels", 1);
|
||||
let n = count_or("arraypolar", p, "count", 6)?;
|
||||
let rows = count_or("arraypolar", p, "rows", 1)?;
|
||||
let levels = count_or("arraypolar", p, "levels", 1)?;
|
||||
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
|
||||
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
|
||||
let row_spacing = f64_or(p, "rowSpacing", 1.0);
|
||||
|
||||
+112
-19
@@ -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> {
|
||||
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<CommandSpec> {
|
||||
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<Vec2>) -> Insert {
|
||||
fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option<Vec2>) -> Insert {
|
||||
let mut ins = Insert {
|
||||
block: blk.name.clone(),
|
||||
insert: v3(at),
|
||||
scale: Vec3::new(scale.x, scale.y, scale.x),
|
||||
scale,
|
||||
rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
@@ -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<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
|
||||
}
|
||||
|
||||
/// [`insert_with`] with a Z scale factor of its own.
|
||||
fn insert_scaled(
|
||||
s: &mut Session,
|
||||
name: &str,
|
||||
at: Vec2,
|
||||
scale: Vec3,
|
||||
rotation: f64,
|
||||
base: Option<Vec2>,
|
||||
values: &serde_json::Map<String, Value>,
|
||||
) -> Result<Handle> {
|
||||
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut ins = insert_entity(&blk, at, scale, rotation, base);
|
||||
@@ -266,7 +282,9 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2>,
|
||||
x_scale: Option<f64>,
|
||||
scale: Option<Vec2>,
|
||||
/// The Z scale factor when given (a preset or XYZ); otherwise the X factor's size. AutoCAD
|
||||
/// keeps a Z factor typed at XYZ's Z prompt as given, and stores the size of any other.
|
||||
z_scale: Option<f64>,
|
||||
/// X/Y/Z presets given so far.
|
||||
axes: [Option<f64>; 3],
|
||||
/// XYZ at the X scale prompt: X, Y and Z are asked in turn.
|
||||
xyz: bool,
|
||||
/// Where the block was dropped to pick a Basepoint on it.
|
||||
dropped: Option<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
attdefs: Vec<(String, String, String)>,
|
||||
values: serde_json::Map<String, Value>,
|
||||
@@ -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<Step> {
|
||||
self.scale = Some(sc);
|
||||
if self.xyz {
|
||||
self.x_scale = Some(sc.x);
|
||||
self.stage = InsStage::ZScale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.advance(s)
|
||||
}
|
||||
|
||||
fn scale3(&self, sc: Vec2) -> Vec3 {
|
||||
Vec3::new(sc.x, sc.y, self.z_scale.unwrap_or(sc.x.abs()))
|
||||
}
|
||||
|
||||
/// Scale factors from an opposite corner: the X and Y distances from the insertion point.
|
||||
fn corner(a: Vec2, p: Vec2) -> Result<Vec2> {
|
||||
let d = p - a;
|
||||
@@ -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))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD.
|
||||
|
||||
use cadcraft_doc::color::Color;
|
||||
use cadcraft_doc::{AttribProps, Common, EntityKind, Handle};
|
||||
use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::curves::KW;
|
||||
@@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in &mut i.attribs {
|
||||
if let Some(v) = value_for(&vals, &a.tag) {
|
||||
a.text.value = v;
|
||||
set_value(&mut a.text, v);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option
|
||||
&& let Some(a) = ins.attribs.get_mut(k)
|
||||
&& let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value)
|
||||
{
|
||||
a.text.value = v;
|
||||
set_value(&mut a.text, v);
|
||||
changed = true;
|
||||
}
|
||||
})?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Refresh the derived baseline after a value change, even one within the writer's
|
||||
/// imported-font tolerance. Equal XY points request recomputation on export; retain
|
||||
/// both elevations and the independent endpoints used by Aligned/Fit text.
|
||||
fn set_value(text: &mut Text, value: String) {
|
||||
if text.value == value {
|
||||
return;
|
||||
}
|
||||
text.value = value;
|
||||
if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _))
|
||||
&& let Some(anchor) = text.align_pt
|
||||
{
|
||||
text.insert.x = anchor.x;
|
||||
text.insert.y = anchor.y;
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the properties set on attribute `k` of block reference `h`.
|
||||
fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> {
|
||||
s.doc_mut()?.modify_entity(h, |e| {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
|
||||
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
|
||||
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
|
||||
//! BEDIT while the Block Editor is open switches it to another block: changes not saved yet are
|
||||
//! saved or discarded first (asked at the command line; `save` in JSON).
|
||||
//! The state is [`crate::blockedit`].
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
@@ -16,10 +18,9 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("bedit", "Block Editor", run_bedit)
|
||||
.menu(&["Tools", "Block Editor"])
|
||||
.alias(&["be", "-bedit"])
|
||||
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
|
||||
.enabled(not_editing)
|
||||
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it), save?: bool (in the Block Editor, switching to another block: save (true) or discard (false) the changes not saved yet; required when there are some), check?: bool (in the Block Editor: only check that the block can be switched to)} → {block, new, saved?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(BeditM))),
|
||||
.interactive(|_| Ok(Box::new(BeditM::default()))),
|
||||
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
|
||||
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
|
||||
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
|
||||
@@ -53,16 +54,6 @@ fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn not_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.state() {
|
||||
Ok(st) => match st.block_editor() {
|
||||
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
|
||||
None => Ok(()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that leave model space, which the Block Editor is: not available while it is open.
|
||||
const NOT_IN_EDITOR: &[&str] = &[
|
||||
"layout",
|
||||
@@ -85,7 +76,7 @@ pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The block a block reference refers to.
|
||||
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||
pub(super) fn block_of(d: &Drawing, h: Handle) -> Option<String> {
|
||||
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<String> {
|
||||
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects.
|
||||
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
||||
/// Whether the open Block Editor can switch to block `name`: the block is there (or `create` makes
|
||||
/// it) and isn't one the editor refuses. Checked before anything is saved or closed, or asked.
|
||||
fn check_target(s: &Session, name: &str, create: bool) -> Result<()> {
|
||||
let host = s.state()?.block_edit.as_ref().map(|b| b.host.doc.clone()).ok_or_else(|| bad("bedit", "the Block Editor is not open"))?;
|
||||
if name.trim().eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) {
|
||||
return Ok(());
|
||||
}
|
||||
match host.block(name) {
|
||||
None if !create => Err(bad("bedit", format!("Block \"{name}\" not found."))),
|
||||
None => Ok(()),
|
||||
Some(_) => crate::blockedit::refusal(&host, name).map_or(Ok(()), |why| Err(bad("bedit", why))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Block Editor on block `name` and frame its objects. While it is open on another
|
||||
/// block it switches: the changes not saved yet are saved first with `save` Some(true),
|
||||
/// discarded with Some(false); with None there must be none. The block to switch to is checked
|
||||
/// before anything is saved or closed, so a refused switch leaves the editor as it was.
|
||||
fn open(s: &mut Session, name: &str, create: bool, save: Option<bool>) -> Result<Value> {
|
||||
let mut saved = None;
|
||||
if let Some(current) = s.state()?.block_editor().map(str::to_string) {
|
||||
if current.eq_ignore_ascii_case(name.trim()) {
|
||||
s.echo(format!("The Block Editor is already editing \"{current}\"."));
|
||||
return Ok(json!({ "block": current, "new": false }));
|
||||
}
|
||||
check_target(s, name, create)?;
|
||||
let save = match (s.state()?.block_unsaved(), save) {
|
||||
(false, _) => false,
|
||||
(true, Some(save)) => save,
|
||||
(true, None) => {
|
||||
return Err(bad(
|
||||
"bedit",
|
||||
format!("Block \"{current}\" has changes not saved; pass `save: true` to save them or `save: false` to discard them"),
|
||||
));
|
||||
}
|
||||
};
|
||||
saved = close(s, save)?.get("saved").cloned();
|
||||
}
|
||||
let mut r = open_closed(s, name, create)?;
|
||||
if let (Some(v), Some(o)) = (saved, r.as_object_mut()) {
|
||||
o.insert("saved".into(), v);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// [`open`] with the Block Editor closed.
|
||||
fn open_closed(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
||||
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
|
||||
s.state_mut()?.open_block_editor(name, create)?;
|
||||
let name = s.state()?.block_editor().unwrap_or(name).to_string();
|
||||
@@ -129,7 +165,7 @@ fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The one block reference selected, if exactly one is.
|
||||
fn selected_reference(s: &Session) -> Option<String> {
|
||||
pub(super) fn selected_reference(s: &Session) -> Option<String> {
|
||||
let d = s.doc().ok()?;
|
||||
let refs: Vec<String> = 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<Value> {
|
||||
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
|
||||
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
|
||||
}
|
||||
open(s, name, create)
|
||||
if p.get("save").is_some_and(|v| !v.is_null() && !v.is_boolean()) {
|
||||
return Err(bad("bedit", "`save` must be true or false"));
|
||||
}
|
||||
// Only whether the Block Editor, when open, can switch to the block: nothing opens or closes.
|
||||
if bool_or(p, "check", false) && s.state()?.block_editor().is_some() {
|
||||
check_target(s, name, create)?;
|
||||
return Ok(json!({ "block": name, "checked": true }));
|
||||
}
|
||||
open(s, name, create, p.get("save").and_then(Value::as_bool))
|
||||
}
|
||||
|
||||
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -206,7 +250,27 @@ fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
|
||||
struct BeditM;
|
||||
/// In the Block Editor with changes not saved yet, it asks whether to save them before switching.
|
||||
#[derive(Default)]
|
||||
struct BeditM {
|
||||
/// The block to switch to, waiting for the answer to "Save changes?".
|
||||
pending: Option<String>,
|
||||
}
|
||||
|
||||
impl BeditM {
|
||||
/// Go to block `name`: at once, or after asking about the changes not saved yet.
|
||||
fn go(&mut self, s: &mut Session, name: &str) -> Result<Step> {
|
||||
let st = s.state()?;
|
||||
let other = st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(name.trim()));
|
||||
if other && st.block_unsaved() {
|
||||
// A block it can't switch to is refused before the question, which would trap the user.
|
||||
check_target(s, name.trim(), false)?;
|
||||
self.pending = Some(name.trim().to_string());
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
open(s, name, false, Some(false)).map(|_| Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
@@ -214,14 +278,30 @@ impl Interactive for BeditM {
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match selected_reference(s) {
|
||||
Some(name) => open(s, &name, false).map(|_| Step::Done),
|
||||
Some(name) => self.go(s, &name),
|
||||
None => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.pending.is_some() {
|
||||
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
|
||||
return Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y");
|
||||
}
|
||||
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(name) = self.pending.clone() {
|
||||
let answer = match i {
|
||||
Input::Enter => "Y".to_string(),
|
||||
Input::Keyword(k) | Input::Text(k) => k,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
return match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
|
||||
Some('Y') => open(s, &name, false, Some(true)).map(|_| Step::Done),
|
||||
Some('N') => open(s, &name, false, Some(false)).map(|_| Step::Done),
|
||||
_ => Err(EngineError::Other("Enter Yes or No.".into())),
|
||||
};
|
||||
}
|
||||
match i {
|
||||
Input::Text(t) if t.trim() == "?" => {
|
||||
let names: Vec<String> = {
|
||||
@@ -231,14 +311,14 @@ impl Interactive for BeditM {
|
||||
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
|
||||
Input::Text(t) | Input::Keyword(t) => self.go(s, t.trim()),
|
||||
Input::Pick(hs) => {
|
||||
let name = {
|
||||
let d = s.doc()?;
|
||||
hs.iter().find_map(|h| block_of(d, *h))
|
||||
};
|
||||
match name {
|
||||
Some(n) => open(s, &n, false).map(|_| Step::Done),
|
||||
Some(n) => self.go(s, &n),
|
||||
None => Err(EngineError::Other("Select a block reference.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Arc<Block>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl Interactive for DescriptionM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLOCKS.DESCRIPTION"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
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<Step> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,7 +461,7 @@ impl Interactive for GcM {
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, aperture(s)) else {
|
||||
let Some(h) = s.pick_object(&space, p, aperture(s))? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
@@ -706,11 +706,11 @@ impl Interactive for DcM {
|
||||
Input::Point(p) => {
|
||||
let space = s.space();
|
||||
let ap = aperture(s);
|
||||
let d = s.doc()?;
|
||||
let Some(h) = crate::select::pick(d, &space, p, ap) else {
|
||||
let Some(h) = s.pick_object(&space, p, ap)? else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let want = match dc {
|
||||
Dc::Radius | Dc::Diameter => Want::Curve,
|
||||
Dc::Angular if self.refs.is_empty() => {
|
||||
|
||||
@@ -18,10 +18,9 @@ pub fn pick_aperture(s: &Session) -> f64 {
|
||||
s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5
|
||||
}
|
||||
|
||||
/// The object under `p` in the current space.
|
||||
/// The object under `p` in the current space (or the one named for `p`: [`Session::pick_object`]).
|
||||
pub fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let space = s.space();
|
||||
crate::select::pick(s.doc().ok()?, &space, p, pick_aperture(s))
|
||||
s.pick_object(&s.space(), p, pick_aperture(s)).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn entity(s: &Session, h: Handle) -> Result<Entity> {
|
||||
@@ -479,6 +478,19 @@ impl Chain {
|
||||
}
|
||||
best.1
|
||||
}
|
||||
/// The curve parameter of the point on the chain nearest `p`: the index of its segment plus
|
||||
/// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc).
|
||||
pub fn param_of(&self, p: Vec2) -> f64 {
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, s) in self.segs.iter().enumerate() {
|
||||
let c = s.closest(p);
|
||||
let dd = c.dist(p);
|
||||
if dd < best.0 {
|
||||
best = (dd, i as f64 + seg_param(s, c));
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
/// Reversed copy.
|
||||
pub fn reversed(&self) -> Chain {
|
||||
Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed }
|
||||
@@ -506,6 +518,29 @@ impl Chain {
|
||||
}
|
||||
}
|
||||
|
||||
/// The polyline parameter of the point nearest `p` on spans `(start, end, bulge)` taken from the
|
||||
/// source vertices: the span's index plus the fraction along it. Unlike [`Chain::param_of`] it
|
||||
/// counts zero-length spans, which a polyline's parameter range includes (one per vertex) but its
|
||||
/// chain drops.
|
||||
pub fn vertex_param_of(spans: &[(Vec2, Vec2, f64)], p: Vec2) -> f64 {
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, &(a, b, bulge)) in spans.iter().enumerate() {
|
||||
if a.near(b, EPS) {
|
||||
continue;
|
||||
}
|
||||
let seg = match cadcraft_geom::bulge_to_arc(a, b, bulge) {
|
||||
Some((arc, ccw)) => Segment::Arc { arc, ccw },
|
||||
None => Segment::Line(Line::new(a, b)),
|
||||
};
|
||||
let c = seg.closest(p);
|
||||
let dd = c.dist(p);
|
||||
if dd < best.0 {
|
||||
best = (dd, i as f64 + seg_param(&seg, c));
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// Parameter (0..1 along travel) of a point on a segment.
|
||||
pub fn seg_param(s: &Segment, p: Vec2) -> f64 {
|
||||
match s {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Draw commands: LINE, PLINE, CIRCLE, ARC, RECTANG, POLYGON, ELLIPSE, POINT, XLINE, RAY,
|
||||
//! SPLINE, DONUT, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
//! SPLINE, DONUT, SOLID, TEXT, MTEXT. Each has a JSON form and an interactive prompt sequence.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HAlign, MText, Point, RayLine, Text, VAlign};
|
||||
use cadcraft_geom::{Arc, Circle, Ellipse, PolyVertex, Polyline, Spline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
@@ -21,6 +21,7 @@ mod ellipse;
|
||||
mod line_pline;
|
||||
mod point;
|
||||
mod polygon;
|
||||
mod solid;
|
||||
mod spline;
|
||||
mod text;
|
||||
|
||||
@@ -30,6 +31,7 @@ use ellipse::{EllipseM, run_ellipse};
|
||||
use line_pline::{LineM, PlineM, run_line, run_pline};
|
||||
use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
|
||||
use polygon::{PolygonM, run_polygon};
|
||||
use solid::{SolidM, run_solid};
|
||||
use spline::{SplineM, run_spline};
|
||||
use text::{MTextM, TextM, run_mtext, run_text};
|
||||
|
||||
@@ -100,6 +102,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.alias(&["do", "doughnut"])
|
||||
.params("{center, inside, outside}")
|
||||
.interactive(|_| Ok(Box::new(DonutM::default()))),
|
||||
CommandSpec::new("solid", "2D Solid", run_solid)
|
||||
.menu(&["Draw", "2D Solid"])
|
||||
.alias(&["so"])
|
||||
.params("{points: [[x,y],...]} (three or four for the first solid, two more for each next one, chained on its last two corners)")
|
||||
.interactive(|_| Ok(Box::new(SolidM::default()))),
|
||||
CommandSpec::new("text", "Single Line Text", run_text)
|
||||
.menu(&["Draw", "Text", "Single Line Text"])
|
||||
.alias(&["dt", "dtext"])
|
||||
|
||||
@@ -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<Value> {
|
||||
let c = if let Some(center) = point_param(p, "center") {
|
||||
|
||||
@@ -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<Value> {
|
||||
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<f64>,
|
||||
/// The `Included angle` option: the next value is the arc's angle from its start angle.
|
||||
included: bool,
|
||||
/// The Isocircle option (isometric snap style) in progress.
|
||||
iso: Option<crate::cmd::settings::iso::IsoCircle>,
|
||||
}
|
||||
|
||||
impl EllipseM {
|
||||
fn axis(&self) -> Option<(Vec2, Vec2)> {
|
||||
// (center, major vector)
|
||||
// (center, major vector): towards the first axis endpoint, as AutoCAD stores it.
|
||||
match (self.center_mode, self.pts.as_slice()) {
|
||||
(true, [c, e, ..]) => Some((*c, *e - *c)),
|
||||
(false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
|
||||
(false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The other half axis reaches as far as `other` is from the center (AutoCAD measures the
|
||||
/// picked point's distance from the center, not from the first axis). Longer than the
|
||||
/// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise.
|
||||
fn build(&self, other: Vec2) -> Option<Ellipse> {
|
||||
let (c, m) = self.axis()?;
|
||||
let ml = m.len();
|
||||
@@ -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<Step> {
|
||||
if let Some(e) = self.ellipse {
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
if self.included {
|
||||
let incl = match i {
|
||||
Input::Point(p) => (p - e.center).angle(),
|
||||
Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?,
|
||||
Input::Enter => std::f64::consts::PI,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
// A true angle added to the start angle (AutoCAD: start 214.659°, included
|
||||
// 232.716° ends the arc at 87.375° from the major axis).
|
||||
let st = self.start.unwrap_or(0.0);
|
||||
let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl));
|
||||
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
ratio: e.ratio,
|
||||
start: st,
|
||||
end,
|
||||
}))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let ang = match i {
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Parameter" => self.param = true,
|
||||
"Angle" => self.param = false,
|
||||
"Included angle" if self.start.is_some() && !self.param => self.included = true,
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
|
||||
@@ -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 ----------------
|
||||
|
||||
|
||||
@@ -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<Value> {
|
||||
let pts = match point_param(p, "at") {
|
||||
|
||||
@@ -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<Value> {
|
||||
// A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! SOLID: filled triangles and quadrilaterals (2D solids), chained as AutoCAD 2024 chains them.
|
||||
//!
|
||||
//! The prompts are "Specify first point", "Specify second point", "Specify third point" and
|
||||
//! "Specify fourth point or <exit>"; Enter there makes a triangle (the fourth corner repeats the
|
||||
//! third). Then "Specify third point" and "Specify fourth point or <exit>" come again for the next
|
||||
//! solid, whose first two corners are the previous one's last two, until Enter at "Specify third
|
||||
//! point". Enter there right after the second point makes nothing.
|
||||
|
||||
use cadcraft_doc::Solid;
|
||||
|
||||
use super::{Accept, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Value, Vec2, bad, json, points_param, v3};
|
||||
|
||||
/// A 2D solid from its corners in AutoCAD's order (the fourth is drawn opposite the first).
|
||||
fn solid(c: [Vec2; 4]) -> EntityKind {
|
||||
EntityKind::Solid(Solid { corners: c.map(v3) })
|
||||
}
|
||||
|
||||
/// `{points}`: the first solid takes three or four points, each further one two more, its first
|
||||
/// two corners being the previous one's last two; a solid given three points is a triangle.
|
||||
pub(super) fn run_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("solid", "`points` is required"))?;
|
||||
let mut it = pts.into_iter();
|
||||
let (Some(mut a), Some(mut b), Some(_)) = (it.next(), it.next(), it.clone().next()) else {
|
||||
return Err(bad("solid", "`points` needs at least three points"));
|
||||
};
|
||||
let mut hs = Vec::new();
|
||||
while let Some(c) = it.next() {
|
||||
let d = it.next().unwrap_or(c);
|
||||
hs.push(s.add_entity(solid([a, b, c, d]))?.hex());
|
||||
(a, b) = (c, d);
|
||||
}
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SolidM {
|
||||
/// The corners of the solid being drawn (the first two from the previous one after it).
|
||||
pts: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for SolidM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SOLID"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let last = self.pts.last().copied();
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT),
|
||||
1 => Prompt::new("Specify second point", Accept::POINT).base_opt(last),
|
||||
2 => Prompt::new("Specify third point", Accept::POINT).base_opt(last),
|
||||
_ => Prompt::new("Specify fourth point or", Accept::POINT).default("exit").base_opt(last),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (i, self.pts.as_slice()) {
|
||||
(Input::Point(p), &[a, b, c]) => self.make(s, [a, b, c, p])?,
|
||||
(Input::Point(p), _) => self.pts.push(p),
|
||||
(Input::Enter, &[a, b, c]) => self.make(s, [a, b, c, c])?,
|
||||
(Input::Enter, _) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, cursor: Vec2) -> Vec<EntityKind> {
|
||||
match *self.pts.as_slice() {
|
||||
[a, b] => vec![solid([a, b, cursor, cursor])],
|
||||
[a, b, c] => vec![solid([a, b, c, cursor])],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SolidM {
|
||||
fn make(&mut self, s: &mut Session, c: [Vec2; 4]) -> Result<()> {
|
||||
s.add_entity(solid(c))?;
|
||||
self.pts = vec![c[2], c[3]];
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
if p.get("object").is_some() {
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(TanCircleM::new(false)))),
|
||||
CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis)
|
||||
.menu(&["Draw", "Ellipse", "Axis, End"])
|
||||
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)")
|
||||
.params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)")
|
||||
.interactive(|_| Ok(super::draw::ellipse_machine(false, false))),
|
||||
CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc)
|
||||
.menu(&["Draw", "Ellipse", "Arc"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value> {
|
||||
}
|
||||
let block = str_param(p, "block").map(str::to_string);
|
||||
check_block(s, block.as_deref())?;
|
||||
if let Some(from) = point_param(p, "from")
|
||||
&& !c.closed
|
||||
&& let (Some(a), Some(b)) = (c.start(), c.end())
|
||||
&& from.dist(b) < from.dist(a)
|
||||
{
|
||||
c = c.reversed();
|
||||
// AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index
|
||||
// plus the fraction along it), not by distance: an open curve from its end once the pick is
|
||||
// halfway or further, a closed one from its first vertex, backwards from halfway on. A
|
||||
// circle always runs counterclockwise from angle 0.
|
||||
if let Some(from) = point_param(p, "from") {
|
||||
let past_half = match curves::entity(s, h)?.kind {
|
||||
EntityKind::Circle(_) => false,
|
||||
// At an arc's centre, where a bare name given from AutoLISP stands (its DXF group 10
|
||||
// point), every point of it is as near: AutoCAD measures from its end.
|
||||
EntityKind::Arc(a) if from.dist(a.center.xy()) <= a.radius * 1e-9 => true,
|
||||
// A spline by its own parameter: its tessellation drops zero-length pieces, so the
|
||||
// segment count is not the spline's parameter range.
|
||||
EntityKind::Spline(sp) => {
|
||||
let (lo, hi) = sp.domain();
|
||||
curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0
|
||||
}
|
||||
// Polylines by their vertices, coincident ones included (one parameter unit each,
|
||||
// as in AutoCAD), which the chain drops.
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
let n = pl.vertices.len();
|
||||
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
|
||||
.filter_map(|i| Some((pl.vertices.get(i)?.p, pl.vertices.get((i + 1) % n)?.p, pl.vertices.get(i)?.bulge)))
|
||||
.collect();
|
||||
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
|
||||
}
|
||||
EntityKind::Polyline3d(pl) => {
|
||||
let n = pl.points.len();
|
||||
let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) })
|
||||
.filter_map(|i| Some((pl.points.get(i)?.xy(), pl.points.get((i + 1) % n)?.xy(), 0.0)))
|
||||
.collect();
|
||||
curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0
|
||||
}
|
||||
_ => c.param_of(from) >= c.segs.len() as f64 / 2.0,
|
||||
};
|
||||
if past_half {
|
||||
c = c.reversed();
|
||||
}
|
||||
}
|
||||
let pts = measure_points(&c, step);
|
||||
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::*;
|
||||
use super::{Ellipse, EntityKind, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
|
||||
|
||||
// ---------------- ellipse entries ----------------
|
||||
|
||||
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
|
||||
let c = p1.mid(p2);
|
||||
let m = p2 - c;
|
||||
// The major axis points at the first endpoint, as AutoCAD stores it.
|
||||
let m = p1 - c;
|
||||
let ml = m.len();
|
||||
let half = half.abs();
|
||||
if ml < 1e-12 || half < 1e-12 || !half.is_finite() {
|
||||
@@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {
|
||||
let p1 = point_req(cmd, p, "p1")?;
|
||||
let p2 = point_req(cmd, p, "p2")?;
|
||||
let half = match point_param(p, "p3") {
|
||||
Some(p3) => {
|
||||
let ax = Line::new(p1, p2);
|
||||
ax.project(p3).dist(p3)
|
||||
}
|
||||
// As at the prompt: the point's distance from the center, not from the first axis.
|
||||
Some(p3) => p3.dist(p1.mid(p2)),
|
||||
None => f64_req(cmd, p, "distance")?,
|
||||
};
|
||||
ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value> {
|
||||
let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) {
|
||||
|
||||
@@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic
|
||||
/// input in accoreconsole, read back with `entget`). The major axis points at the first axis
|
||||
/// endpoint; a point at the distance prompt gives its distance from the center; an other axis
|
||||
/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the
|
||||
/// Included angle is added to the start angle.
|
||||
#[test]
|
||||
fn ellipse_axis_endpoints_match_autocad() {
|
||||
type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>);
|
||||
// (input after ELLIPSE, center, major axis, ratio, arc start/end parameters)
|
||||
let cases: [Case; 10] = [
|
||||
// The other axis (27.02) is longer than the first half axis (26.42).
|
||||
(
|
||||
"16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268",
|
||||
[5.712514913615239, 3.985817249587547],
|
||||
[-24.6850622596004, 10.99222700689644],
|
||||
0.9776866627982613,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949",
|
||||
[17.27018583472502, -18.93707805189049],
|
||||
[16.1799716942092, -9.060587202842658],
|
||||
0.8052876303433125,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256",
|
||||
[0.5074363325764704, 0.2533737894427027],
|
||||
[0.00868846668462886, 0.02043019027014691],
|
||||
0.1200276615332929,
|
||||
None,
|
||||
),
|
||||
// Arcs: the angles are taken from the major axis, so a reversed axis draws the arc
|
||||
// on the opposite side of the center.
|
||||
(
|
||||
"_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204",
|
||||
[612359.8301610444, 2398774.152511537],
|
||||
[-5.160876934416592, 22.44292051764205],
|
||||
0.1535548572193783,
|
||||
Some([4.683678586016272, 2.014340486820176]),
|
||||
),
|
||||
(
|
||||
"_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654",
|
||||
[-8.068629780716847, 9.577702534161936],
|
||||
[-24.42926629106581, 16.24859713260318],
|
||||
0.317871961092373,
|
||||
Some([4.107654908771007, 5.952182383669234]),
|
||||
),
|
||||
(
|
||||
"_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933",
|
||||
[15.89513193856356, -16.13523652037361],
|
||||
[2.53204749973717, -14.23341561039267],
|
||||
0.9785367111972643,
|
||||
Some([3.756694588158375, 1.5259701966906878]),
|
||||
),
|
||||
(
|
||||
"_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075",
|
||||
[1.882264537326193, -7.920292708078534],
|
||||
[19.15838502833398, 33.68853210335513],
|
||||
0.8017236937148501,
|
||||
Some([2.267136066480607, 4.657903136501946]),
|
||||
),
|
||||
// The other axis given by a point.
|
||||
(
|
||||
"-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656",
|
||||
[-15.97844303459893, 15.52013568457562],
|
||||
[-0.3990375230131046, 39.92686201604709],
|
||||
0.7411292927991227,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266",
|
||||
[2.843616795535609, 19.31530728820197],
|
||||
[4.602217644463162, 7.450176331143755],
|
||||
0.7105482899194384,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446",
|
||||
[15.41818958649589, -18.74113376930122],
|
||||
[27.87548765369622, 24.07822440243505],
|
||||
0.3302549104883693,
|
||||
None,
|
||||
),
|
||||
];
|
||||
use std::f64::consts::{PI, TAU};
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
|
||||
let same_angle = |a: f64, b: f64| {
|
||||
let d = (a - b).rem_euclid(TAU);
|
||||
d < 1e-9 || TAU - d < 1e-9
|
||||
};
|
||||
for (input, center, major, ratio, arc) in cases {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(&format!("ellipse {input}")).unwrap();
|
||||
assert!(s.running.is_none(), "{input}: {}", s.prompt_text());
|
||||
let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") };
|
||||
assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center);
|
||||
assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major);
|
||||
assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio);
|
||||
let [start, end] = arc.unwrap_or([0.0, TAU]);
|
||||
assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end);
|
||||
}
|
||||
// Enter takes the default included angle, 180°: half the ellipse.
|
||||
let mut s = Session::new();
|
||||
s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap();
|
||||
assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text());
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") };
|
||||
assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end);
|
||||
}
|
||||
|
||||
// ---------------- DIVIDE / MEASURE ----------------
|
||||
|
||||
#[test]
|
||||
@@ -302,6 +414,279 @@ fn divide_and_measure_json() {
|
||||
assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err());
|
||||
}
|
||||
|
||||
/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick
|
||||
/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`,
|
||||
/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not
|
||||
/// by distance; a closed polyline runs from its first vertex, a circle from angle 0.
|
||||
#[test]
|
||||
fn divide_and_measure_match_autocad() {
|
||||
type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]);
|
||||
// ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points)
|
||||
let cases: Vec<Case> = vec![
|
||||
// DIVIDE-001: (list ename point).
|
||||
(
|
||||
("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})),
|
||||
"divide",
|
||||
5.0,
|
||||
[-2.090569265353071, 19.890437907365467],
|
||||
4,
|
||||
&[
|
||||
[-19.021130325903, 6.180339887499],
|
||||
[-17.975880925983, -8.767422935782],
|
||||
[-9.389431255718, 17.658951857179],
|
||||
[5.51274711634, 19.225233918766],
|
||||
],
|
||||
),
|
||||
// DIVIDE-002: (list ename point).
|
||||
(
|
||||
("circle", json!({"center": [0.0, 0.0], "radius": 15.0})),
|
||||
"divide",
|
||||
5.0,
|
||||
[-4.63525491562421, 14.265847744427305],
|
||||
5,
|
||||
&[
|
||||
[-12.135254915624, -8.816778784387],
|
||||
[-12.135254915624, 8.816778784387],
|
||||
[4.635254915624, -14.265847744427],
|
||||
[4.635254915624, 14.265847744427],
|
||||
[15.0, 0.0],
|
||||
],
|
||||
),
|
||||
// MEASURE-016: (list ename point).
|
||||
(
|
||||
("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})),
|
||||
"measure",
|
||||
7.688652713279647,
|
||||
[25.9, 9.1],
|
||||
5,
|
||||
&[
|
||||
[0.730313187001, 0.256596525162],
|
||||
[7.984250549601, 2.80527722013],
|
||||
[15.2381879122, 5.353957915097],
|
||||
[22.4921252748, 7.902638610065],
|
||||
[29.7460626374, 10.451319305032],
|
||||
],
|
||||
),
|
||||
// MEASURE-024: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}),
|
||||
),
|
||||
"measure",
|
||||
7.0,
|
||||
[90.0, 0.0],
|
||||
29,
|
||||
&[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]],
|
||||
),
|
||||
// MEASURE-026: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}),
|
||||
),
|
||||
"measure",
|
||||
7.0,
|
||||
[10.0, 30.0],
|
||||
15,
|
||||
&[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]],
|
||||
),
|
||||
// MEASURE-030: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[5.0, 17.5],
|
||||
17,
|
||||
&[
|
||||
[2.06040845923, 7.211429607306],
|
||||
[4.120816918461, 14.422859214612],
|
||||
[5.957390538273, 0.0],
|
||||
[41.854257779673, 5.445746712402],
|
||||
[45.137412221497, 19.497902817843],
|
||||
[45.215233654661, 12.07010340037],
|
||||
],
|
||||
),
|
||||
// DIVIDE-028: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"divide",
|
||||
5.0,
|
||||
[37.5, 10.0],
|
||||
5,
|
||||
&[
|
||||
[0.0, 0.0],
|
||||
[7.332712970983, 25.664495398441],
|
||||
[25.609294779385, 28.310302237406],
|
||||
[26.691478107655, 0.0],
|
||||
[45.58190593662, 14.465330916891],
|
||||
],
|
||||
),
|
||||
// MEASURE-041: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[45.54951565955276, 17.15901034980981],
|
||||
17,
|
||||
&[
|
||||
[1.636621047999, 5.728173667998],
|
||||
[3.69702950723, 12.939603275304],
|
||||
[5.75743796646, 20.15103288261],
|
||||
[42.799868254703, 6.663750217618],
|
||||
[44.060611317761, 20.402595149531],
|
||||
[45.491536690495, 13.587129550966],
|
||||
],
|
||||
),
|
||||
// MEASURE-042: (list ename point).
|
||||
(
|
||||
(
|
||||
"pline",
|
||||
json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}),
|
||||
),
|
||||
"measure",
|
||||
7.5,
|
||||
[44.0, 20.5],
|
||||
17,
|
||||
&[
|
||||
[2.06040845923, 7.211429607306],
|
||||
[4.120816918461, 14.422859214612],
|
||||
[5.957390538273, 0.0],
|
||||
[41.854257779673, 5.445746712402],
|
||||
[45.137412221497, 19.497902817843],
|
||||
[45.215233654661, 12.07010340037],
|
||||
],
|
||||
),
|
||||
];
|
||||
for ((seed, params), what, value, pick, count, want) in cases {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute(seed, ¶ms).unwrap();
|
||||
let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string();
|
||||
// AutoLISP's (list ename point): the object and where it was picked.
|
||||
let params = if what == "divide" {
|
||||
json!({"handle": handle, "segments": value as u64, "from": pick})
|
||||
} else {
|
||||
json!({"handle": handle, "length": value, "from": pick})
|
||||
};
|
||||
s.execute(what, ¶ms).unwrap();
|
||||
let pts: Vec<Vec2> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
|
||||
assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}");
|
||||
for w in want {
|
||||
let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0);
|
||||
assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a
|
||||
/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and
|
||||
/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length.
|
||||
#[test]
|
||||
fn measure_spline_by_its_own_parameter() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap());
|
||||
s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap();
|
||||
let pts: Vec<Vec2> =
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect();
|
||||
assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
/// MEASURE counts every vertex in a polyline's parameter, coincident ones too (AutoCAD's polyline
|
||||
/// parameter is the vertex index; the chain drops zero-length spans), and picks an ellipse's end
|
||||
/// by the ellipse parameter like any other curve: AutoCAD 2024 runs both measured cases below from
|
||||
/// the end (black-box, accoreconsole, POINTs read back with `entget`).
|
||||
#[test]
|
||||
fn measure_counts_coincident_vertices_and_runs_ellipses_by_parameter() {
|
||||
let pts = |s: &Session| -> Vec<Vec2> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect()
|
||||
};
|
||||
// (0,0) (1,0) (101,0) (101,0) picked at x = 1.1: parameter 1.001 of 3, before the middle, so
|
||||
// from the start (the chain alone, 1.001 of 2, ran it from the end).
|
||||
let mut s = Session::new();
|
||||
let pl = h(&s.execute("pline", &json!({"vertices": [[0, 0], [1, 0], [101, 0], [101, 0]]})).unwrap());
|
||||
s.execute("measure", &json!({"handle": pl, "length": 30, "from": [1.1, 0]})).unwrap();
|
||||
assert!(pts(&s).iter().any(|p| near(*p, 30.0, 0.0)), "{:?}", pts(&s));
|
||||
// (0,0) (0,0) (10,0) (20,0) picked at x = 7: parameter 1.7 of 3, past the middle, so from the
|
||||
// end; the same as a 3D polyline.
|
||||
for (cmd, params) in [
|
||||
("pline", json!({"vertices": [[0, 0], [0, 0], [10, 0], [20, 0]]})),
|
||||
("3dpoly", json!({"points": [[0, 0, 0], [0, 0, 0], [10, 0, 0], [20, 0, 0]]})),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let pl = h(&s.execute(cmd, ¶ms).unwrap());
|
||||
s.execute("measure", &json!({"handle": pl, "length": 3, "from": [7, 0]})).unwrap();
|
||||
assert!(pts(&s).iter().any(|p| near(*p, 17.0, 0.0)), "{cmd}: {:?}", pts(&s));
|
||||
}
|
||||
// AutoCAD's points. CADCraft measures along a tessellation (1e-4 of the major axis), so its
|
||||
// points sit within 2e-4 of the axis of AutoCAD's; the other end is 4 to 200 units away.
|
||||
type Ellipse = ([f64; 2], [f64; 2], f64, f64, f64);
|
||||
let cases: [((Ellipse, f64, [f64; 2]), &[[f64; 2]]); 2] = [
|
||||
// MEASURE-032-TO: a full ellipse picked at t = pi.
|
||||
(
|
||||
(([-35000.0, 81000.0], [1000.0, 250.0], 0.5, 0.0, std::f64::consts::TAU), 325.0, [-36000.0, 80750.0]),
|
||||
&[
|
||||
[-36005.13895764338, 80852.59066530415],
|
||||
[-35871.99367437639, 80574.74515309861],
|
||||
[-35846.48143804769, 81129.79467385833],
|
||||
[-35585.0606965763, 81321.20806711384],
|
||||
[-35570.69066464184, 80460.16118260568],
|
||||
[-35288.01208649818, 81451.990340526],
|
||||
[-35246.80459288685, 80443.34080344986],
|
||||
[-34973.3365764063, 81531.8855660685],
|
||||
[-34925.28109250207, 80488.09750605426],
|
||||
[-34649.89308676404, 81558.94977503068],
|
||||
[-34615.08169799787, 80583.88008545575],
|
||||
[-34328.55263503237, 81517.06267451325],
|
||||
[-34326.49236487653, 80732.20222770158],
|
||||
[-34085.24055588816, 80947.67206458953],
|
||||
[-34052.78599900089, 81354.8654746607],
|
||||
],
|
||||
),
|
||||
// MEASURE-033: an elliptical arc picked past its middle.
|
||||
(
|
||||
(([0.0, 0.0], [20.0, 5.0], 0.5, 0.5, 4.0), 4.25, [-14.28970951093154, -10.073417468999274]),
|
||||
&[
|
||||
[-20.14815072114992, -3.432193431111516],
|
||||
[-18.74987859459168, -7.329883069345779],
|
||||
[-18.68559214389327, 0.5029121733397516],
|
||||
[-15.81338990685036, 3.617741906558092],
|
||||
[-15.26656288688172, -9.70206428611582],
|
||||
[-12.33992351852055, 6.057945492888583],
|
||||
[-8.547503008323163, 7.9699830580747],
|
||||
[-4.557798848614094, 9.428419587410612],
|
||||
[-0.4368018715850841, 10.45983604769949],
|
||||
[3.769519107506058, 11.05284388252232],
|
||||
[8.015496424961295, 11.15319778065308],
|
||||
[12.22912105170455, 10.6383262710487],
|
||||
[16.23125506912425, 9.247174221779819],
|
||||
],
|
||||
),
|
||||
];
|
||||
for (((center, major, ratio, t0, t1), length, from), want) in cases {
|
||||
let mut s = Session::new();
|
||||
let e = h(&s
|
||||
.execute("ellipse", &json!({"center": center, "major": major, "ratio": ratio, "start": t0.to_degrees(), "end": t1.to_degrees()}))
|
||||
.unwrap());
|
||||
s.execute("measure", &json!({"handle": e, "length": length, "from": from})).unwrap();
|
||||
let got = pts(&s);
|
||||
let tol = 2e-4 * major[0].hypot(major[1]);
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
for w in want {
|
||||
assert!(got.iter().any(|p| p.dist(Vec2::new(w[0], w[1])) < tol), "no point near {w:?} in {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_and_measure_command_line() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+220
-27
@@ -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<SaveAs> {
|
||||
|
||||
/// 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<ReadFile, String> {
|
||||
pub fn read_file(path: &std::path::Path) -> std::result::Result<ReadFile, String> {
|
||||
#[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<usiz
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
||||
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
||||
/// Put saved bytes where the user gets them: natively the file at `path`; on the web, which has
|
||||
/// no file system, a browser download named after `path` via [`deliver`].
|
||||
///
|
||||
/// Natively the file is replaced atomically ([`crate::atomic_write`]: a fresh temporary file
|
||||
/// beside it, renamed over it), so a crash leaves either the old file or the new one, never a
|
||||
/// truncated one. Saving over a drawing first keeps the previous file as `<name>.bak` (ISAVEBAK,
|
||||
/// #540).
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if is_drawing_file(path) && crate::autosave::isavebak() {
|
||||
backup(std::path::Path::new(path))
|
||||
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
|
||||
}
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
@@ -434,6 +446,180 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The backup of the drawing file `path`: `<name>.bak` beside it, as AutoCAD names it.
|
||||
pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
path.with_extension("bak")
|
||||
}
|
||||
|
||||
/// Whether `bytes` read as a DWG or a DXF (ASCII or binary) by their start: what a save keeps as
|
||||
/// the backup. A file that doesn't (truncated, overwritten by another program) never replaces a
|
||||
/// good `.bak`.
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
loop {
|
||||
match (lines.next(), lines.next()) {
|
||||
(Some("999"), Some(_)) => continue,
|
||||
(Some("0"), Some(v)) => return v.eq_ignore_ascii_case("SECTION"),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a drawing's end [`looks_complete`] reads.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TAIL_BYTES: u64 = 256;
|
||||
|
||||
/// Whether a drawing that [`looks_like_drawing`] (`head`, its first bytes, at least 256 of them
|
||||
/// for a DWG) is whole, from its end (`tail`, its last bytes) and size `len`, without reading it
|
||||
/// all: a DXF ends with `0`/`EOF` (a binary one with `EOF` and its NUL); a DWG reaches the end its
|
||||
/// own header gives ([`dwg_complete`]). `None` when that can't be told (a 2007 DWG), so a file cut
|
||||
/// off by a failed save or another program doesn't replace a good backup.
|
||||
pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
let dwg = head.len() >= 6 && head.starts_with(b"AC") && head[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()).rev();
|
||||
Some(matches!((lines.next(), lines.next()), (Some(last), Some("0")) if last.eq_ignore_ascii_case("EOF")))
|
||||
}
|
||||
|
||||
/// [`completeness`] known to be whole.
|
||||
pub fn looks_complete(head: &[u8], tail: &[u8], len: u64) -> bool {
|
||||
completeness(head, tail, len) != Some(false)
|
||||
}
|
||||
|
||||
/// Whether a DWG of `len` bytes reaches the end its header gives (Open Design Specification for
|
||||
/// .dwg files): from 2004 on, the copy of the file header that ends the file (its address is in
|
||||
/// the encrypted header at 0x80, and the copy is 0x6C bytes); up to 2000, the end of every section
|
||||
/// the locator records give. `None` for another layout (2007, or before R13).
|
||||
fn dwg_complete(head: &[u8], len: u64) -> Option<bool> {
|
||||
let version = head.get(..6)?;
|
||||
match version {
|
||||
b"AC1018" | b"AC1024" | b"AC1027" | b"AC1032" => {
|
||||
// The header at 0x80 is XORed with the generator the specification gives.
|
||||
let enc = head.get(0x80..0x80 + 0x6C)?;
|
||||
let mut seed: u32 = 1;
|
||||
let h: Vec<u8> = enc
|
||||
.iter()
|
||||
.map(|b| {
|
||||
seed = seed.wrapping_mul(0x343FD).wrapping_add(0x0026_9EC3);
|
||||
b ^ (seed >> 16) as u8
|
||||
})
|
||||
.collect();
|
||||
if h.get(..12) != Some(&b"AcFssFcAJMB\0"[..]) {
|
||||
return Some(false);
|
||||
}
|
||||
let second = u64::from_le_bytes(h.get(0x34..0x3C)?.try_into().ok()?);
|
||||
Some(second >= 0x100 && second.checked_add(0x6C).is_some_and(|end| len >= end))
|
||||
}
|
||||
b"AC1012" | b"AC1014" | b"AC1015" => {
|
||||
let n = i32::from_le_bytes(head.get(0x15..0x19)?.try_into().ok()?);
|
||||
if !(1..=16).contains(&n) {
|
||||
return Some(false);
|
||||
}
|
||||
let mut end = 0u64;
|
||||
for i in 0..n as usize {
|
||||
let r = head.get(0x19 + i * 9..0x19 + i * 9 + 9)?;
|
||||
let seek = u64::from(u32::from_le_bytes(r.get(1..5)?.try_into().ok()?));
|
||||
let size = u64::from(u32::from_le_bytes(r.get(5..9)?.try_into().ok()?));
|
||||
end = end.max(seek + size);
|
||||
}
|
||||
Some(len >= end)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file's first bytes, last bytes and length, left positioned just after the first bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ends(src: &mut std::fs::File) -> std::io::Result<(Vec<u8>, Vec<u8>, u64)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut head = Vec::with_capacity(4096);
|
||||
(&mut *src).take(4096).read_to_end(&mut head)?;
|
||||
// Its end too (a DXF's EOF), then on from where the start left off for the copy.
|
||||
let len = src.metadata()?.len();
|
||||
let mut tail = Vec::new();
|
||||
src.seek(SeekFrom::Start(len.saturating_sub(TAIL_BYTES)))?;
|
||||
(&mut *src).take(TAIL_BYTES).read_to_end(&mut tail)?;
|
||||
src.seek(SeekFrom::Start(head.len() as u64))?;
|
||||
Ok((head, tail, len))
|
||||
}
|
||||
|
||||
/// Whether saving replaces the `.bak` with this file. A file known to be cut off never replaces a
|
||||
/// backup already there. One that can't be told (a 2007 DWG) does: it is most likely fine, and it
|
||||
/// is about to be replaced. With no backup yet any file is kept, because the checks can be wrong
|
||||
/// about a real file and this save replaces the only copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup_replaced(head: &[u8], tail: &[u8], len: u64, backup_exists: bool) -> bool {
|
||||
(looks_like_drawing(head) && completeness(head, tail, len) != Some(false)) || (len > 0 && !backup_exists)
|
||||
}
|
||||
|
||||
/// Whether a save over `path` now would leave the current file in its `.bak`, for a warning that
|
||||
/// must not promise a backup the save won't write.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_keeps_current(path: &std::path::Path) -> bool {
|
||||
let Ok(mut src) = std::fs::File::open(path) else { return false };
|
||||
crate::autosave::isavebak() && ends(&mut src).is_ok_and(|(head, tail, len)| backup_replaced(&head, &tail, len, backup_path(path).exists()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn save_keeps_current(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
|
||||
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
|
||||
/// to keep when there is no file yet. A file that doesn't look like a drawing
|
||||
/// ([`looks_like_drawing`]) is not backed up, and the `.bak` already there is left alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backup(path: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut src = match std::fs::File::open(path) {
|
||||
Ok(f) if f.metadata()?.is_file() => f,
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (head, tail, len) = ends(&mut src)?;
|
||||
let bak = backup_path(path);
|
||||
if !backup_replaced(&head, &tail, len, bak.exists()) {
|
||||
log::warn!("save: {} doesn't look like a whole drawing; its backup is left as it was", path.display());
|
||||
return Ok(());
|
||||
}
|
||||
// The backup is no more readable than the drawing, nor than the .bak it replaces (a private
|
||||
// 0600 drawing or .bak stays private). The mode is set as the new file is created, so a
|
||||
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
|
||||
// written through.
|
||||
#[cfg(unix)]
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut mode = src.metadata()?.permissions().mode() & 0o777;
|
||||
if let Ok(m) = std::fs::symlink_metadata(&bak)
|
||||
&& m.is_file()
|
||||
{
|
||||
mode &= m.permissions().mode();
|
||||
}
|
||||
Some(std::fs::Permissions::from_mode(mode))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = None;
|
||||
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
|
||||
std::io::Write::write_all(out, &head)?;
|
||||
std::io::copy(&mut src, out).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
||||
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
||||
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
||||
@@ -515,7 +701,7 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String, usize>) -> Op
|
||||
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
||||
}
|
||||
|
||||
/// What saving the active drawing to `path` would leave out, before anything is written: empty
|
||||
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
|
||||
/// reports the same list afterwards to callers that never get a dialog.
|
||||
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
|
||||
if !drops_unknown(path) {
|
||||
return Default::default();
|
||||
}
|
||||
let version = drawing_version(s, path);
|
||||
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
||||
/// bridge drops types it doesn't know); DXF writes them back as read.
|
||||
pub fn drops_unknown(path: &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<std::path::Path>) -> 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<std::path::Path>) -> 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");
|
||||
|
||||
+101
-19
@@ -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<Vec<HatchLoop>>,
|
||||
asking: Option<HatchAsk>,
|
||||
/// The pattern was asked by the Pattern option: its scale and angle follow, as at AutoCAD's
|
||||
/// -HATCH Properties.
|
||||
properties: bool,
|
||||
/// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again.
|
||||
selecting: bool,
|
||||
}
|
||||
@@ -561,6 +564,7 @@ impl HatchM {
|
||||
settings: HatchSettings::from(s, &Value::Null, gradient),
|
||||
loops: Vec::new(),
|
||||
asking: None,
|
||||
properties: false,
|
||||
selecting: s.last_used.hatch_select,
|
||||
}
|
||||
}
|
||||
@@ -582,11 +586,13 @@ impl HatchM {
|
||||
h.set_i64("HPORIGINMODE", mode.index());
|
||||
Ok(())
|
||||
}
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> {
|
||||
/// Take an answer to sub-prompt `a`; false when it was refused.
|
||||
fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<bool> {
|
||||
match a {
|
||||
HatchAsk::Pattern => {
|
||||
let up = match patfile::typed_file(s, t.trim()) {
|
||||
Some(n) if n.is_empty() => return Ok(()),
|
||||
// Nothing loaded from it: the pattern is asked again.
|
||||
Some(n) if n.is_empty() => return Ok(false),
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
@@ -595,20 +601,19 @@ impl HatchM {
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
HatchAsk::Scale => {
|
||||
if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) {
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) else { return Ok(false) };
|
||||
self.settings.scale = v;
|
||||
s.doc_mut()?.header.set_f64("HPSCALE", v);
|
||||
}
|
||||
HatchAsk::Angle => {
|
||||
// Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1.
|
||||
if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) {
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) else { return Ok(false) };
|
||||
self.settings.angle = a;
|
||||
s.doc_mut()?.header.set_f64("HPANG", a);
|
||||
}
|
||||
HatchAsk::Origin => {
|
||||
let mode = match t {
|
||||
@@ -622,7 +627,22 @@ impl HatchM {
|
||||
self.set_origin(s, None, mode)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The sub-prompt that follows `a` (`taken`: its answer was taken). The Pattern option goes
|
||||
/// on, as AutoCAD 2024's -HATCH Properties does, from the pattern name to its scale and then
|
||||
/// its angle (a solid fill has neither), and asks again for a pattern or a value it refused.
|
||||
fn after(&self, a: HatchAsk, taken: bool) -> Option<HatchAsk> {
|
||||
if !self.properties {
|
||||
return None;
|
||||
}
|
||||
match a {
|
||||
_ if !taken => Some(a),
|
||||
HatchAsk::Pattern if self.settings.pattern != "SOLID" => Some(HatchAsk::Scale),
|
||||
HatchAsk::Scale => Some(HatchAsk::Angle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,9 +663,10 @@ impl Interactive for HatchM {
|
||||
// The sub-prompts offer the current value: Enter keeps it.
|
||||
match self.asking {
|
||||
Some(HatchAsk::Pattern) => {
|
||||
return Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
let p = Prompt::new("Enter a pattern name", Accept::TEXT)
|
||||
.default(self.settings.pattern.clone())
|
||||
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
|
||||
return if self.gradient { p } else { p.kw(&["Solid"]) };
|
||||
}
|
||||
Some(HatchAsk::Scale) => {
|
||||
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
|
||||
@@ -686,11 +707,19 @@ impl Interactive for HatchM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking.take() {
|
||||
match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => self.set_origin(s, Some(*p), OriginMode::Current)?,
|
||||
let taken = match &i {
|
||||
Input::Point(p) if a == HatchAsk::Origin => {
|
||||
self.set_origin(s, Some(*p), OriginMode::Current)?;
|
||||
true
|
||||
}
|
||||
Input::Keyword(k) if a == HatchAsk::Pattern && k == "Solid" => self.answer(s, a, "SOLID")?,
|
||||
Input::Text(t) | Input::Keyword(t) => self.answer(s, a, t)?,
|
||||
_ => {}
|
||||
}
|
||||
// Enter keeps the current value; a point or a pick answers none of these prompts.
|
||||
Input::Enter => true,
|
||||
_ => false,
|
||||
};
|
||||
self.asking = self.after(a, taken);
|
||||
self.properties &= self.asking.is_some();
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
@@ -723,7 +752,10 @@ impl Interactive for HatchM {
|
||||
"Select objects" => self.set_mode(s, true),
|
||||
"picK internal point" => self.set_mode(s, false),
|
||||
"Undo" => self.loops.clear(),
|
||||
"Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern),
|
||||
"Pattern" | "seTtings" => {
|
||||
self.asking = Some(HatchAsk::Pattern);
|
||||
self.properties = !self.gradient;
|
||||
}
|
||||
"sCale" => self.asking = Some(HatchAsk::Scale),
|
||||
"Angle" => self.asking = Some(HatchAsk::Angle),
|
||||
"Origin" => self.asking = Some(HatchAsk::Origin),
|
||||
@@ -738,7 +770,7 @@ impl Interactive for HatchM {
|
||||
}
|
||||
// A pattern chosen in the picker while boundaries are picked.
|
||||
Input::Text(t) if !self.gradient => {
|
||||
self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
let _ = self.answer(s, HatchAsk::Pattern, &t)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// Enter ends boundary picking in either mode and creates the hatch.
|
||||
@@ -838,6 +870,9 @@ mod tests {
|
||||
s.input(Input::Keyword("Pattern".into())).unwrap();
|
||||
assert!(s.current_prompt().unwrap().picker.is_some());
|
||||
s.input(Input::Text("BRICK".into())).unwrap();
|
||||
// Its scale and angle follow; Enter keeps them.
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
|
||||
@@ -851,6 +886,53 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
/// -HATCH's Properties as AutoCAD 2024 takes them from AutoLISP's `command` (accoreconsole, the
|
||||
/// hatch read back with `entget`): a pattern name, then its scale and angle (Enter keeps them);
|
||||
/// Solid (`S`, `_S` or SOLID) asks neither; an unknown name is asked again.
|
||||
#[test]
|
||||
fn hatch_properties_ask_pattern_scale_and_angle_as_autocad() {
|
||||
// (properties given, pattern, solid, scale, angle in degrees)
|
||||
let cases: [(&str, &str, bool, f64, f64); 7] = [
|
||||
("\"ANSI31\" 2 45 \"_S\" (entlast) \"\"", "ANSI31", false, 2.0, 45.0),
|
||||
("\"_S\" \"_S\" (entlast) \"\"", "SOLID", true, 1.0, 0.0),
|
||||
("\"ANSI37\" 0.5 30 '(5.0 5.0)", "ANSI37", false, 0.5, 30.0),
|
||||
("\"ANSI31\" \"\" \"\" '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
("\"S\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"SOLID\" '(5.0 5.0)", "SOLID", true, 1.0, 0.0),
|
||||
("\"XYZ\" \"ANSI31\" 1 0 '(5.0 5.0)", "ANSI31", false, 1.0, 0.0),
|
||||
];
|
||||
for (props, pattern, solid, scale, angle) in cases {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [20, 15], "radius": 8 })).unwrap();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
s.cmdline("(setvar \"HPNAME\" \"ANSI31\")").unwrap();
|
||||
s.cmdline("(setvar \"HPSCALE\" 1.0)").unwrap();
|
||||
s.cmdline("(setvar \"HPANG\" 0.0)").unwrap();
|
||||
s.cmdline(&format!("(command \"_.-HATCH\" \"_P\" {props} \"\")")).unwrap();
|
||||
assert!(s.running.is_none(), "{props}: {}", s.prompt_text());
|
||||
let h = only_hatch(&s).1;
|
||||
assert!(
|
||||
h.pattern == pattern && h.solid == solid && (h.scale - scale).abs() < 1e-12 && (h.angle - angle.to_radians()).abs() < 1e-12,
|
||||
"{props}: {} {} {} {}",
|
||||
h.pattern,
|
||||
h.solid,
|
||||
h.scale,
|
||||
h.angle.to_degrees()
|
||||
);
|
||||
}
|
||||
// An object (an entity name from LISP) answers none of these prompts: each is asked again.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [40, 30] })).unwrap();
|
||||
let h = s.doc().unwrap().model.handles()[0];
|
||||
s.cmdline("(command \"_.-HATCH\" \"_P\")").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Enter a pattern name"), "{}", s.prompt_text());
|
||||
s.cmdline("ANSI31").unwrap();
|
||||
s.input(crate::Input::Pick(vec![h])).unwrap();
|
||||
assert!(s.prompt_text().contains("Specify a scale for the hatch pattern"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||||
let mut s = Session::new();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user