mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-30 05:46:50 -06:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f536bf406f | ||
|
|
d6de2db6db | ||
|
|
f8b09e92e5 | ||
|
|
b3158b56a8 | ||
|
|
7546277128 | ||
|
|
2d2be155dd | ||
|
|
f883454d7d | ||
|
|
9b4c1fb840 | ||
|
|
912558635e | ||
|
|
42667d1332 | ||
|
|
d15de75406 | ||
|
|
dcbcb6ab32 | ||
|
|
0d9e62bc9a | ||
|
|
3243271d4e | ||
|
|
303bc97f9a | ||
|
|
b9b7b3d439 | ||
|
|
1d987904f9 | ||
|
|
1d6c72db11 | ||
|
|
61fefc3ed9 | ||
|
|
74e77f8a92 | ||
|
|
f817ea9a3a | ||
|
|
31dba6e7a7 | ||
|
|
58687dda2d | ||
|
|
3ccfad60dc | ||
|
|
35b4332ee2 | ||
|
|
9a1f200795 | ||
|
|
bcf0dbc5e8 | ||
|
|
c2695f6ab0 | ||
|
|
7efb32b455 | ||
|
|
ab8953067a | ||
|
|
afc12e4dce | ||
|
|
336243e0af | ||
|
|
9de2b8bbdc | ||
|
|
fbd97d432c | ||
|
|
d9265c9245 | ||
|
|
5654240729 | ||
|
|
3929d94d89 | ||
|
|
729f27de74 | ||
|
|
2721aa8982 | ||
|
|
360bee4dcc | ||
|
|
26da98e320 | ||
|
|
b4a8773c3c | ||
|
|
180c1885bb | ||
|
|
9d32ead9e6 | ||
|
|
2c73e5f063 | ||
|
|
ec762af206 | ||
|
|
786367e9ba | ||
|
|
d1847cf9a0 | ||
|
|
850dd94f3c | ||
|
|
0ff1b03537 | ||
|
|
7b947c3383 | ||
|
|
29e2a139db | ||
|
|
6795f0bd03 | ||
|
|
8581d30a7f | ||
|
|
d864a08b01 | ||
|
|
c3b8e7b7fe | ||
|
|
b06fd039fb | ||
|
|
98a305514b | ||
|
|
605dc43898 | ||
|
|
c22ad3229f | ||
|
|
7d0d475aee | ||
|
|
48c4a5e9d5 | ||
|
|
57f828d04c |
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **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)]`.
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -19,7 +19,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **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)]`.
|
||||
- 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,3 +47,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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
+409
-50
@@ -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"
|
||||
@@ -872,6 +989,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -898,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"
|
||||
@@ -995,12 +1127,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
@@ -1010,26 +1170,35 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1048,7 +1217,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1474,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]]
|
||||
@@ -1555,6 +1724,17 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -1995,7 +2175,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
]
|
||||
@@ -2009,7 +2189,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
@@ -2029,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"
|
||||
@@ -2074,9 +2260,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -2087,43 +2273,110 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.4.0"
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.4.0"
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
@@ -2136,18 +2389,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.4.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2155,6 +2411,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2237,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"
|
||||
@@ -2371,7 +2657,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2469,6 +2755,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3088,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"
|
||||
@@ -3140,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]]
|
||||
@@ -3400,12 +3695,24 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3648,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"
|
||||
@@ -3717,7 +4030,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3774,7 +4087,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4163,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"
|
||||
@@ -4220,7 +4539,10 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4240,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]]
|
||||
@@ -4372,6 +4694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4614,6 +4937,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -4626,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"
|
||||
@@ -5136,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]]
|
||||
@@ -5222,6 +5557,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -5462,11 +5808,20 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -5539,7 +5894,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -5728,8 +6083,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5738,6 +6096,7 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
@@ -5756,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",
|
||||
|
||||
+17
-10
@@ -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"
|
||||
@@ -25,13 +25,17 @@ cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# 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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false }
|
||||
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"
|
||||
@@ -53,15 +57,16 @@ encoding_rs = "0.8.35"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = "0.37"
|
||||
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 = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", 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."*"]
|
||||
@@ -91,9 +98,9 @@ debug = 0
|
||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||
# (Cargo ignores dependency-local patches).
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" }
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod array;
|
||||
mod constraint;
|
||||
mod entity;
|
||||
mod extents;
|
||||
mod format;
|
||||
mod gdt;
|
||||
mod header;
|
||||
pub mod library;
|
||||
@@ -27,6 +28,7 @@ pub use cadcraft_geom as geom;
|
||||
pub use constraint::*;
|
||||
pub use entity::*;
|
||||
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds};
|
||||
pub use format::Format;
|
||||
pub use gdt::*;
|
||||
pub use header::{HVal, Header};
|
||||
pub use plotstyle::*;
|
||||
|
||||
+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
@@ -181,8 +181,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
// Every group code takes two lines (code, value).
|
||||
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
|
||||
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
|
||||
let mut lines = bytes.split(|&b| b == b'\n');
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
@@ -264,7 +276,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();
|
||||
@@ -581,6 +593,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());
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -59,10 +59,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 +268,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 +490,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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
@@ -266,7 +268,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()?;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,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 +519,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,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.
|
||||
|
||||
@@ -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,40 @@ 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,
|
||||
// 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());
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
@@ -515,7 +518,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)
|
||||
@@ -563,15 +566,13 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
|
||||
|
||||
/// 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 +652,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 +679,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 +689,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");
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::{CommandSpec, bad, bool_or, f64_req, point_req, point_value, points_p
|
||||
use crate::units::{format_angle, format_distance};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod arcs;
|
||||
mod loops;
|
||||
mod regions;
|
||||
mod shapes;
|
||||
@@ -418,6 +419,9 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
|
||||
object_area(s, h)?
|
||||
};
|
||||
if let Some(what) = too_large(a, per) {
|
||||
return Err(bad("area", format!("the {what} is too large")));
|
||||
}
|
||||
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
|
||||
}
|
||||
|
||||
@@ -456,6 +460,17 @@ fn fmt_volume(s: &Session, v: f64) -> String {
|
||||
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
|
||||
}
|
||||
|
||||
/// "area" or "perimeter" when that value is past f64's range, `None` when both can be reported.
|
||||
fn too_large(a: f64, per: f64) -> Option<&'static str> {
|
||||
if !a.is_finite() {
|
||||
Some("area")
|
||||
} else if !per.is_finite() {
|
||||
Some("perimeter")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
@@ -694,10 +709,16 @@ pub(super) struct DistM {
|
||||
sources: Vec<Option<crate::snap::ViewportPoint>>,
|
||||
/// Asking for the length of a segment (the Length option).
|
||||
length: bool,
|
||||
/// The bulge of the segment that starts at each point (the Arc option).
|
||||
bulges: Vec<f64>,
|
||||
arcs: arcs::Arcs,
|
||||
}
|
||||
|
||||
/// Total length of the segments through `pts`.
|
||||
fn path_len(pts: &[Vec2]) -> f64 {
|
||||
/// Total length of the segments through `pts`, each with its bulge in `bulges`.
|
||||
fn path_len(pts: &[Vec2], bulges: &[f64]) -> f64 {
|
||||
if arcs::has_arcs(bulges) {
|
||||
return arcs::polyline(pts, bulges, false).len();
|
||||
}
|
||||
pts.windows(2)
|
||||
.map(|w| match w {
|
||||
[a, b] => a.dist(*b),
|
||||
@@ -707,15 +728,11 @@ fn path_len(pts: &[Vec2]) -> f64 {
|
||||
}
|
||||
|
||||
/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single
|
||||
/// point): the Length option of AREA and DIST's point sequences.
|
||||
fn extend_last(pts: &[Vec2], len: f64) -> Option<Vec2> {
|
||||
/// point, along the tangent after an arc segment): the Length option of AREA and DIST's point
|
||||
/// sequences.
|
||||
fn extend_last(pts: &[Vec2], bulges: &[f64], len: f64) -> Option<Vec2> {
|
||||
let last = *pts.last()?;
|
||||
let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) {
|
||||
Some(prev) => (last - *prev).normalized(),
|
||||
None => Vec2::X,
|
||||
};
|
||||
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
|
||||
let p = last + dir * len;
|
||||
let p = last + arcs::tangent(pts, bulges) * len;
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
|
||||
@@ -724,13 +741,21 @@ fn length_prompt() -> Prompt {
|
||||
}
|
||||
|
||||
/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter).
|
||||
fn length_input(pts: &[Vec2], i: Input) -> Result<Option<Vec2>> {
|
||||
fn length_input(pts: &[Vec2], bulges: &[f64], i: Input) -> Result<Option<Vec2>> {
|
||||
let t = match i {
|
||||
Input::Text(t) => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
Ok(extend_last(pts, len))
|
||||
Ok(extend_last(pts, bulges, len))
|
||||
}
|
||||
|
||||
/// Where the paper point `p`, computed near `last` (an arc option's end point), lies in the model
|
||||
/// space of the viewport `last` was picked through (the last of `sources`).
|
||||
fn follow_source(sources: &[Option<crate::snap::ViewportPoint>], last: Vec2, p: Vec2) -> Option<crate::snap::ViewportPoint> {
|
||||
let source = sources.last().copied().flatten()?;
|
||||
let point = source.point + (p - last) / source.scale;
|
||||
(source.scale.abs() > 1e-12 && point.is_finite()).then_some(crate::snap::ViewportPoint { point, ..source })
|
||||
}
|
||||
|
||||
/// Model units apply only when every point was snapped through one and the same viewport.
|
||||
@@ -743,10 +768,13 @@ impl Interactive for DistM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIST"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.length {
|
||||
return length_prompt();
|
||||
}
|
||||
if let Some(p) = self.multi.as_deref().and_then(|pts| self.arcs.prompt(s, pts, &self.bulges, "")) {
|
||||
return p;
|
||||
}
|
||||
match (&self.multi, self.first) {
|
||||
(Some(pts), _) if pts.len() < 2 => {
|
||||
Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied())
|
||||
@@ -763,7 +791,7 @@ impl Interactive for DistM {
|
||||
if let Some(pts) = &mut self.multi {
|
||||
if self.length {
|
||||
let model = model_points(&self.sources);
|
||||
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), i)? {
|
||||
if let Some(p) = length_input(model.as_deref().unwrap_or(pts), &self.bulges, i)? {
|
||||
let source = model.as_ref().and_then(|_| self.sources.last().copied().flatten());
|
||||
let paper = match (source, pts.last()) {
|
||||
(Some(source), Some(last)) => *last + (p - source.point) * source.scale,
|
||||
@@ -772,31 +800,60 @@ impl Interactive for DistM {
|
||||
if !paper.is_finite() {
|
||||
return Err(bad("dist", "the next point is too large"));
|
||||
}
|
||||
pts.push(paper);
|
||||
arcs::push(pts, &mut self.bulges, paper, 0.0);
|
||||
self.sources.push(source.map(|source| crate::snap::ViewportPoint { point: p, ..source }));
|
||||
let model = model_points(&self.sources);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||
}
|
||||
self.length = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.arcs.asking() {
|
||||
let last = pts.last().copied();
|
||||
// A picked end point keeps its own viewport; an end computed from the answers follows the
|
||||
// last point's. In model units, a typed radius or chord length is in model units too.
|
||||
let picked_end = match i {
|
||||
Input::Point(p) if self.arcs.asks_end_point() => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
let i = match model_points(&self.sources).and(self.sources.last().copied().flatten()) {
|
||||
Some(source) => self.arcs.scale_typed_size(i, source.scale),
|
||||
None => i,
|
||||
};
|
||||
if self.arcs.answer(s, i, pts, &mut self.bulges)? {
|
||||
// The arc can end off the picked point (CEnter projects it onto the circle).
|
||||
let source = match (picked_end, last, pts.last()) {
|
||||
(Some(p), _, Some(end)) => follow_source(&[s.picked_viewport], p, *end),
|
||||
(None, Some(last), Some(end)) => follow_source(&self.sources, last, *end),
|
||||
_ => None,
|
||||
};
|
||||
self.sources.push(source);
|
||||
let model = model_points(&self.sources);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
pts.push(p);
|
||||
self.arcs.point(pts, &mut self.bulges, p)?;
|
||||
self.sources.push(s.picked_viewport);
|
||||
let model = model_points(&self.sources);
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts)))));
|
||||
s.echo(format!("Distance = {}", fmt_d(s, path_len(model.as_deref().unwrap_or(pts), &self.bulges))));
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if pts.len() > 1 {
|
||||
pts.pop();
|
||||
self.sources.pop();
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" && pts.len() > 1 => {
|
||||
self.arcs.undo(pts, &mut self.bulges);
|
||||
self.sources.pop();
|
||||
}
|
||||
Input::Keyword(k) if k == "Length" => self.length = true,
|
||||
Input::Keyword(k) if k == "Total" => return self.total(s),
|
||||
Input::Keyword(k) => s.echo(format!("{k}: not available yet")),
|
||||
Input::Enter => return self.total(s),
|
||||
Input::Keyword(k) if k == "Total" => return self.total(s, false),
|
||||
Input::Keyword(k) if k == "CLose" && self.arcs.on => {
|
||||
self.arcs.close(pts, &mut self.bulges)?;
|
||||
return self.total(s, true);
|
||||
}
|
||||
Input::Keyword(k) if !self.arcs.keyword(&k, pts) => {
|
||||
s.echo(format!("{k}: not available yet"));
|
||||
}
|
||||
Input::Enter => return self.total(s, false),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
@@ -830,20 +887,20 @@ impl Interactive for DistM {
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match &self.multi {
|
||||
Some(pts) => {
|
||||
let mut v: Vec<cadcraft_geom::PolyVertex> = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
||||
v.push(cadcraft_geom::PolyVertex::new(c));
|
||||
vec![super::helpers::lwpoly(v, false)]
|
||||
}
|
||||
Some(pts) => self.arcs.preview(pts, &self.bulges, c, false),
|
||||
None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DistM {
|
||||
fn total(&self, s: &mut Session) -> Result<Step> {
|
||||
/// Report the total distance and end; `closed` adds the closing segment (CLose).
|
||||
fn total(&self, s: &mut Session, closed: bool) -> Result<Step> {
|
||||
let model = model_points(&self.sources);
|
||||
let d = model.as_deref().or(self.multi.as_deref()).map_or(0.0, path_len);
|
||||
let d = model
|
||||
.as_deref()
|
||||
.or(self.multi.as_deref())
|
||||
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
|
||||
s.echo(format!("Distance = {}", fmt_d(s, d)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
@@ -936,6 +993,9 @@ pub(super) struct AreaM {
|
||||
pending: Vec<f64>,
|
||||
/// The first point of a height given by two points.
|
||||
height_from: Option<Vec2>,
|
||||
/// The bulge of the segment that starts at each point (the Arc option).
|
||||
bulges: Vec<f64>,
|
||||
arcs: arcs::Arcs,
|
||||
}
|
||||
|
||||
impl AreaM {
|
||||
@@ -1039,27 +1099,45 @@ impl AreaM {
|
||||
}
|
||||
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
|
||||
/// In volume mode the area waits for its height instead.
|
||||
/// An area, perimeter or total past f64's range is not reported or added to the total.
|
||||
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
|
||||
if self.volume {
|
||||
if !a.is_finite() {
|
||||
s.echo("The area is too large.");
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
self.pending.push(a);
|
||||
return Step::Continue;
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
match self.mode {
|
||||
AreaMode::Single => return Step::Done,
|
||||
AreaMode::Add => self.total += a,
|
||||
AreaMode::Subtract => self.total -= a,
|
||||
let total = self.total_with(a);
|
||||
if let Some(what) = too_large(a, per).or_else(|| (!total.is_finite()).then_some("total area")) {
|
||||
s.echo(format!("The {what} is too large."));
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
s.echo(area_text(s, a, per));
|
||||
if self.mode == AreaMode::Single {
|
||||
return Step::Done;
|
||||
}
|
||||
self.total = total;
|
||||
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
|
||||
Step::Continue
|
||||
}
|
||||
/// Close the boundary picked so far (Enter or Total at the next-point prompt).
|
||||
/// An arc segment (the Arc option) can close an area of two points.
|
||||
fn close(&mut self, s: &mut Session) -> Step {
|
||||
let pts = std::mem::take(&mut self.pts);
|
||||
if pts.len() < 3 {
|
||||
let bulges = std::mem::take(&mut self.bulges);
|
||||
self.arcs = arcs::Arcs::default();
|
||||
let curved = arcs::has_arcs(&bulges);
|
||||
if pts.len() < if curved { 2 } else { 3 } {
|
||||
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
|
||||
}
|
||||
let (a, per) = area_of(&pts);
|
||||
let (a, per) = if curved {
|
||||
let g = arcs::polyline(&pts, &bulges, true);
|
||||
(g.area().abs(), g.len())
|
||||
} else {
|
||||
area_of(&pts)
|
||||
};
|
||||
self.measured(s, a, per)
|
||||
}
|
||||
}
|
||||
@@ -1068,7 +1146,7 @@ impl Interactive for AreaM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.volume { "MEASUREGEOM" } else { "AREA" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if !self.pending.is_empty() {
|
||||
return match self.height_from {
|
||||
Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
@@ -1081,6 +1159,9 @@ impl Interactive for AreaM {
|
||||
if self.length {
|
||||
return length_prompt();
|
||||
}
|
||||
if let Some(p) = self.arcs.prompt(s, &self.pts, &self.bulges, self.mode_prefix()) {
|
||||
return p;
|
||||
}
|
||||
let next = format!("{}Specify next point", self.mode_prefix());
|
||||
let (add, subtract) = self.mode_keywords();
|
||||
match (self.pts.len(), self.mode) {
|
||||
@@ -1099,24 +1180,34 @@ impl Interactive for AreaM {
|
||||
return Ok(self.object_input(s, i));
|
||||
}
|
||||
if self.length {
|
||||
if let Some(p) = length_input(&self.pts, i)? {
|
||||
self.pts.push(p);
|
||||
if let Some(p) = length_input(&self.pts, &self.bulges, i)? {
|
||||
arcs::push(&mut self.pts, &mut self.bulges, p, 0.0);
|
||||
}
|
||||
self.length = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.arcs.asking() {
|
||||
self.arcs.answer(s, i, &mut self.pts, &mut self.bulges)?;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => self.pts.push(p),
|
||||
Input::Point(p) => self.arcs.point(&mut self.pts, &mut self.bulges, p)?,
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Object" => self.object = true,
|
||||
"Add area" | "Add volume" => self.mode = AreaMode::Add,
|
||||
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
"Undo" => self.arcs.undo(&mut self.pts, &mut self.bulges),
|
||||
"Length" => self.length = true,
|
||||
"Total" => return Ok(self.close(s)),
|
||||
_ => s.echo(format!("{k}: not available yet")),
|
||||
"CLose" if self.arcs.on => {
|
||||
self.arcs.close(&self.pts, &mut self.bulges)?;
|
||||
return Ok(self.close(s));
|
||||
}
|
||||
k => {
|
||||
if !self.arcs.keyword(k, &self.pts) {
|
||||
s.echo(format!("{k}: not available yet"));
|
||||
}
|
||||
}
|
||||
},
|
||||
Input::Enter if self.pts.is_empty() => match self.mode {
|
||||
// Enter takes the default, Object; in add/subtract mode it ends the command.
|
||||
@@ -1132,9 +1223,7 @@ impl Interactive for AreaM {
|
||||
if self.pts.is_empty() || self.object || self.length {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut v: Vec<cadcraft_geom::PolyVertex> = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect();
|
||||
v.push(cadcraft_geom::PolyVertex::new(c));
|
||||
vec![super::helpers::lwpoly(v, true)]
|
||||
self.arcs.preview(&self.pts, &self.bulges, c, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! The Arc option of AREA's and DIST's point sequences (and of MEASUREGEOM's Distance, Area and
|
||||
//! Volume): arc segments, asked for as PLINE's arc mode asks for them. Each point carries the
|
||||
//! bulge of the segment that starts at it; the last point's bulge is the closing segment's,
|
||||
//! which only CLose sets.
|
||||
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::{PolyVertex, Polyline, Vec2, arc_to_bulge, bulge_to_arc};
|
||||
|
||||
use super::super::helpers::lwpoly;
|
||||
use super::super::machines::number;
|
||||
use super::super::pline_opts::{Outcome, PlineAsk};
|
||||
|
||||
use crate::{Accept, EngineError, Input, Prompt, Result, Session};
|
||||
|
||||
/// Arc mode's keywords: PLINE's arc mode without Halfwidth and Width.
|
||||
const KEYWORDS: [&str; 8] = ["Angle", "CEnter", "CLose", "Direction", "Line", "Radius", "Second pt", "Undo"];
|
||||
|
||||
/// Arc mode of a point sequence.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Arcs {
|
||||
/// The Arc option turned arc mode on; Line turns it off.
|
||||
pub(super) on: bool,
|
||||
/// An arc option waiting for its answers (Angle, CEnter, Direction, Radius, Second pt).
|
||||
ask: Option<PlineAsk>,
|
||||
}
|
||||
|
||||
/// The polyline through `pts`, each segment with its bulge in `bulges` (straight when missing).
|
||||
pub(super) fn polyline(pts: &[Vec2], bulges: &[f64], closed: bool) -> Polyline {
|
||||
let vertices = pts.iter().enumerate().map(|(i, p)| PolyVertex::with_bulge(*p, bulges.get(i).copied().unwrap_or(0.0))).collect();
|
||||
Polyline { vertices, closed }
|
||||
}
|
||||
|
||||
/// Whether any segment of the sequence is an arc.
|
||||
pub(super) fn has_arcs(bulges: &[f64]) -> bool {
|
||||
bulges.iter().any(|b| *b != 0.0)
|
||||
}
|
||||
|
||||
/// The direction the sequence runs in at its last point (+X before the second point): along the
|
||||
/// last line segment, or along the tangent at the end of the last arc segment.
|
||||
pub(super) fn tangent(pts: &[Vec2], bulges: &[f64]) -> Vec2 {
|
||||
let Some(i) = pts.len().checked_sub(2) else { return Vec2::X };
|
||||
let (Some(prev), Some(last)) = (pts.get(i).copied(), pts.last().copied()) else { return Vec2::X };
|
||||
let d = match bulge_to_arc(prev, last, bulges.get(i).copied().unwrap_or(0.0)) {
|
||||
Some((a, ccw)) => {
|
||||
let r = (last - a.center).perp().normalized();
|
||||
if ccw { r } else { -r }
|
||||
}
|
||||
None => (last - prev).normalized(),
|
||||
};
|
||||
if d == Vec2::ZERO { Vec2::X } else { d }
|
||||
}
|
||||
|
||||
/// The bulge of an arc from the last point to `p` that continues the sequence's direction; an
|
||||
/// error when `p` lies straight behind the last point, where no such arc exists.
|
||||
fn tangent_bulge(pts: &[Vec2], bulges: &[f64], p: Vec2) -> Result<f64> {
|
||||
let Some(last) = pts.last().copied() else { return Ok(0.0) };
|
||||
let dir = tangent(pts, bulges);
|
||||
let chord = p - last;
|
||||
// The included angle is twice the angle between the tangent and the chord.
|
||||
let theta = dir.cross(chord).atan2(dir.dot(chord));
|
||||
let b = arc_to_bulge(2.0 * theta);
|
||||
if theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite() { Ok(b) } else { Err(EngineError::Other("Invalid arc endpoint.".into())) }
|
||||
}
|
||||
|
||||
/// Add the segment from the last point to `end` with bulge `bulge` (the first point when there is none).
|
||||
pub(super) fn push(pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, end: Vec2, bulge: f64) {
|
||||
bulges.resize(pts.len(), 0.0);
|
||||
if let Some(b) = bulges.last_mut() {
|
||||
*b = bulge;
|
||||
}
|
||||
pts.push(end);
|
||||
bulges.push(0.0);
|
||||
}
|
||||
|
||||
impl Arcs {
|
||||
/// An arc option is waiting for its answers.
|
||||
pub(super) fn asking(&self) -> bool {
|
||||
self.ask.is_some()
|
||||
}
|
||||
|
||||
/// The question being answered takes the arc's end point, so a picked point is the end of the segment.
|
||||
pub(super) fn asks_end_point(&self) -> bool {
|
||||
matches!(
|
||||
self.ask,
|
||||
Some(PlineAsk::AngleEnd(_) | PlineAsk::CenterEnd(_) | PlineAsk::DirectionEnd(_) | PlineAsk::RadiusEnd(_) | PlineAsk::SecondEnd(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// A typed radius or chord length multiplied by `scale`, for a sequence measured in another
|
||||
/// space's units than the one its points are in; any other input as it is.
|
||||
pub(super) fn scale_typed_size(&self, i: Input, scale: f64) -> Input {
|
||||
let size = matches!(self.ask, Some(PlineAsk::Radius | PlineAsk::AngleRadius(_) | PlineAsk::CenterLength(_)));
|
||||
match i {
|
||||
Input::Text(t) if size => match number(&t).map(|v| v * scale).filter(|v| v.is_finite()) {
|
||||
Some(v) => Input::Text(v.to_string()),
|
||||
None => Input::Text(t),
|
||||
},
|
||||
i => i,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arc-mode prompt, or the question of the arc option being answered; `None` in line mode
|
||||
/// and before the first point.
|
||||
pub(super) fn prompt(&self, s: &Session, pts: &[Vec2], bulges: &[f64], prefix: &str) -> Option<Prompt> {
|
||||
let st = pts.last().copied()?;
|
||||
if let Some(a) = self.ask {
|
||||
return Some(a.prompt(st, tangent(pts, bulges), s));
|
||||
}
|
||||
self.on.then(|| Prompt::new(format!("{prefix}Specify endpoint of arc"), Accept::POINT).kw(&KEYWORDS).base(st))
|
||||
}
|
||||
|
||||
/// Feed an answer to the arc option being answered; true when it added a segment.
|
||||
pub(super) fn answer(&mut self, s: &Session, i: Input, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) -> Result<bool> {
|
||||
let (Some(a), Some(st)) = (self.ask, pts.last().copied()) else {
|
||||
self.ask = None;
|
||||
return Ok(false);
|
||||
};
|
||||
match a.input(s, i, st, tangent(pts, bulges))? {
|
||||
Outcome::Ask(next) => self.ask = Some(next),
|
||||
Outcome::Back => self.ask = None,
|
||||
Outcome::Segment(end, bulge) => {
|
||||
self.ask = None;
|
||||
push(pts, bulges, end, bulge);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Arc, Line, and arc mode's Angle, CEnter, Direction, Radius and Second pt; false for any
|
||||
/// other keyword.
|
||||
pub(super) fn keyword(&mut self, k: &str, pts: &[Vec2]) -> bool {
|
||||
match k {
|
||||
"Arc" if !pts.is_empty() => self.on = true,
|
||||
"Line" if self.on => self.on = false,
|
||||
_ => match PlineAsk::from_keyword(k, true) {
|
||||
Some(a) if self.on && !pts.is_empty() => self.ask = Some(a),
|
||||
_ => return false,
|
||||
},
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A point at the next-point prompt: in arc mode, the end of an arc that continues the
|
||||
/// sequence's direction; otherwise the end of a line segment.
|
||||
pub(super) fn point(&self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>, p: Vec2) -> Result<()> {
|
||||
let bulge = if self.on && !pts.is_empty() { tangent_bulge(pts, bulges, p)? } else { 0.0 };
|
||||
push(pts, bulges, p, bulge);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CLose: the closing segment becomes an arc from the last point to the first that continues
|
||||
/// the sequence's direction.
|
||||
pub(super) fn close(&self, pts: &[Vec2], bulges: &mut Vec<f64>) -> Result<()> {
|
||||
let Some(first) = pts.first().copied().filter(|_| pts.len() >= 2) else {
|
||||
return Err(EngineError::Other("CLose needs at least two points.".into()));
|
||||
};
|
||||
let bulge = tangent_bulge(pts, bulges, first)?;
|
||||
bulges.resize(pts.len(), 0.0);
|
||||
if let Some(b) = bulges.last_mut() {
|
||||
*b = bulge;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undo: remove the last point and the segment to it. Removing the first point leaves arc mode.
|
||||
pub(super) fn undo(&mut self, pts: &mut Vec<Vec2>, bulges: &mut Vec<f64>) {
|
||||
pts.pop();
|
||||
bulges.truncate(pts.len());
|
||||
if let Some(b) = bulges.last_mut() {
|
||||
*b = 0.0;
|
||||
}
|
||||
if pts.is_empty() {
|
||||
*self = Arcs::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// The sequence with the segment to the cursor `c` that a click would add.
|
||||
pub(super) fn preview(&self, pts: &[Vec2], bulges: &[f64], c: Vec2, closed: bool) -> Vec<EntityKind> {
|
||||
let Some(st) = pts.last().copied() else { return Vec::new() };
|
||||
let next = match self.ask {
|
||||
Some(a) => a.preview(st, tangent(pts, bulges), c),
|
||||
None if self.on => tangent_bulge(pts, bulges, c).ok().map(|b| (c, b)),
|
||||
None => Some((c, 0.0)),
|
||||
};
|
||||
let mut g = polyline(pts, bulges, closed);
|
||||
if let Some((end, bulge)) = next {
|
||||
if let Some(v) = g.vertices.last_mut() {
|
||||
v.bulge = bulge;
|
||||
}
|
||||
g.vertices.push(PolyVertex::new(end));
|
||||
}
|
||||
vec![lwpoly(g.vertices, closed)]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy),
|
||||
//! Layouts, paper-space viewports, page setup and plotting: LAYOUT (new/delete/rename/copy/saveas),
|
||||
//! MVIEW / VPORTS, viewport scale/lock/layer settings, PAGESETUP, PLOT and EXPORTPDF.
|
||||
//!
|
||||
//! Plotting goes through the host's [`super::file::IoHooks::plot`] hook so the engine stays
|
||||
@@ -9,7 +9,7 @@ use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::file::{base64_encode, io};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, find_command, point_param, point_value, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use viewports::MviewM;
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("layout", "Layout", run_layout)
|
||||
.alias(&["lo"])
|
||||
.params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)")
|
||||
.params("{option: new|copy|delete|rename|saveas|set|list, name?, to?, path? (saveas)} (typed LAYOUT / -LAYOUT: option prompts)")
|
||||
.interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),
|
||||
CommandSpec::new("layout.new", "New Layout", run_new)
|
||||
.menu(&["Insert", "Layout", "New Layout"])
|
||||
@@ -32,6 +32,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),
|
||||
CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),
|
||||
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
|
||||
CommandSpec::new("layout.saveas", "Save Layout as Template", saveas::run_saveas)
|
||||
.params("{name?: layout (default: the current layout, else the first tab), path: .dwt (or .dwg, .dxf; no extension adds .dwt)} → {name, path, bytes, notSaved?}")
|
||||
.noundo(),
|
||||
CommandSpec::new("mview", "Viewports", run_mview)
|
||||
.alias(&["mv"])
|
||||
.params(
|
||||
@@ -334,6 +337,9 @@ fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
))
|
||||
}
|
||||
|
||||
/// LAYOUT SAveas: a layout written as a drawing template.
|
||||
mod saveas;
|
||||
|
||||
/// `-LAYOUT`-style dispatcher.
|
||||
fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opt = str_param(p, "option").unwrap_or("list").to_ascii_lowercase();
|
||||
@@ -362,8 +368,9 @@ fn run_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set = find_command("layout.set").ok_or_else(|| bad("layout", "layout.set is missing"))?;
|
||||
(set.run)(s, p)
|
||||
}
|
||||
"saveas" | "sa" => saveas::run_saveas(s, p),
|
||||
"list" | "?" => run_list(s, p),
|
||||
_ => Err(bad("layout", "option must be new, copy, delete, rename, set or list")),
|
||||
_ => Err(bad("layout", "option must be new, copy, delete, rename, saveas, set or list")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ enum Stage {
|
||||
RenameFrom,
|
||||
RenameTo(String),
|
||||
Set,
|
||||
SaveasLayout,
|
||||
SaveasFile(String),
|
||||
List,
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ pub struct LayoutM {
|
||||
}
|
||||
|
||||
/// The layout the name prompts default to: the current one, else the first tab.
|
||||
fn current_layout(s: &Session) -> Option<String> {
|
||||
pub(super) fn current_layout(s: &Session) -> Option<String> {
|
||||
if let Space::Paper(n) = s.layout_space() {
|
||||
return Some(n);
|
||||
}
|
||||
@@ -70,9 +72,10 @@ impl LayoutM {
|
||||
"New" => Stage::New,
|
||||
"Rename" => Stage::RenameFrom,
|
||||
"Set" => Stage::Set,
|
||||
"SAveas" => Stage::SaveasLayout,
|
||||
"?" => Stage::List,
|
||||
other => {
|
||||
// Template, SAveas: report and end, leaving the next input to the command line.
|
||||
// Template: report and end, leaving the next input to the command line.
|
||||
s.echo(format!("LAYOUT {other} is not available yet."));
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
@@ -131,6 +134,8 @@ impl Interactive for LayoutM {
|
||||
let tab = s.layout_space().tab_name().to_string();
|
||||
Prompt::new("Enter layout to make current", Accept::TEXT).default(tab)
|
||||
}
|
||||
Stage::SaveasLayout => with_cur(Prompt::new("Enter layout to save to template", Accept::TEXT)),
|
||||
Stage::SaveasFile(layout) => Prompt::new("Enter template file name", Accept::TEXT).default(super::saveas::default_path(s, layout)),
|
||||
Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"),
|
||||
}
|
||||
}
|
||||
@@ -199,6 +204,19 @@ impl Interactive for LayoutM {
|
||||
(set.run)(s, &json!({ "name": n })).map_err(retry)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::SaveasLayout => {
|
||||
let n = need(name.or_else(|| current_layout(s)))?;
|
||||
let n = super::layout_name("layout.saveas", s.doc()?, &n).map_err(retry)?;
|
||||
self.stage = Stage::SaveasFile(n);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::SaveasFile(layout) => {
|
||||
let path = name.unwrap_or_else(|| super::saveas::default_path(s, &layout));
|
||||
let r = super::saveas::run_saveas(s, &json!({ "name": layout, "path": path })).map_err(retry)?;
|
||||
let path = r.get("path").and_then(Value::as_str).unwrap_or_default();
|
||||
s.echo(format!("Layout \"{layout}\" saved to template \"{path}\"."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::List => {
|
||||
Self::list(s, name.as_deref().unwrap_or("*"))?;
|
||||
Ok(Step::Done)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`).
|
||||
//!
|
||||
//! The template holds the layout's page setup and paper space objects, viewports included, and
|
||||
//! no model space objects and no other layout. Named objects the layout does not use (blocks,
|
||||
//! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups)
|
||||
//! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay.
|
||||
//! The open drawing is not changed.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::file::{drops_unknown, io, not_saved, not_saved_message};
|
||||
use super::super::{bad, str_param};
|
||||
use super::layout_name;
|
||||
use super::prompts::current_layout;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// File types a layout template is written as; a path without an extension gets `.dwt`.
|
||||
const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"];
|
||||
|
||||
/// The most PURGE All passes run on a template; they stop at the first pass that purges nothing.
|
||||
const MAX_PURGE_PASSES: usize = 64;
|
||||
|
||||
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||
let file = format!("{layout}.dwt");
|
||||
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||
None => file,
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||
fn template_path(path: &str) -> Result<String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return Err(bad("layout.saveas", "`path` is empty"));
|
||||
}
|
||||
match std::path::Path::new(path).extension() {
|
||||
None => Ok(format!("{path}.dwt")),
|
||||
Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()),
|
||||
Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space,
|
||||
/// and without the named objects nothing left in it uses.
|
||||
fn template_of(d: &Drawing, layout: &str) -> Result<Drawing> {
|
||||
let mut t = d.clone();
|
||||
t.layouts.retain(|l| l.name == layout);
|
||||
t.active_layout = Some(layout.to_string());
|
||||
t.model = EntityStore::new();
|
||||
t.hidden_objects.clear();
|
||||
let gone: Vec<u32> = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect();
|
||||
t.constraints.retain(|c| !gone.contains(&c.id));
|
||||
// Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts
|
||||
// only objects, so each such layer gets a point in model space while PURGE runs.
|
||||
let named: BTreeSet<String> = t
|
||||
.layouts
|
||||
.iter()
|
||||
.flat_map(|l| l.entities.iter())
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
for layer in named {
|
||||
let handle = t.new_handle();
|
||||
t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) });
|
||||
}
|
||||
// PURGE runs on a session of its own, so the open drawing keeps everything.
|
||||
let mut scratch = Session::empty();
|
||||
scratch.open_drawing(t, layout, None);
|
||||
for _ in 0..MAX_PURGE_PASSES {
|
||||
let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?;
|
||||
let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty())));
|
||||
if !purged {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?;
|
||||
let mut t = std::sync::Arc::unwrap_or_clone(st.doc);
|
||||
t.model = EntityStore::new();
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// `layout.saveas {name?, path}`: write a layout as a drawing template.
|
||||
pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?;
|
||||
if s.doc()?.refedit.is_some() {
|
||||
return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
|
||||
}
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?,
|
||||
};
|
||||
// A file is the drawing outside the Block Editor.
|
||||
let d = s.state()?.drawing();
|
||||
let name = layout_name("layout.saveas", d, &name)?;
|
||||
let t = template_of(d, &name)?;
|
||||
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
|
||||
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
|
||||
store(&path, &bytes)?;
|
||||
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
|
||||
let lost = not_saved(&t);
|
||||
if drops_unknown(&path)
|
||||
&& let Some(msg) = not_saved_message(&lost)
|
||||
{
|
||||
s.echo(msg);
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("notSaved".into(), json!(lost));
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Write the template: natively to `path` through a temporary file; on the web as a download.
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::trim::{param_on, sub_polyline};
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step,
|
||||
Value, Vec2, arc, bad, json, lwpoly, norm_angle, point_param, point_req, targets,
|
||||
};
|
||||
|
||||
/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation.
|
||||
fn with_z(mut k: EntityKind, z: f64) -> EntityKind {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user