mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-28 04:46:50 -06:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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')
|
||||
|
||||
@@ -25,8 +25,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+382
-24
@@ -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"
|
||||
@@ -571,6 +621,7 @@ dependencies = [
|
||||
"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",
|
||||
@@ -597,7 +649,12 @@ dependencies = [
|
||||
"cadcraft-io",
|
||||
"cadcraft-mcp",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"same-file",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,6 +767,7 @@ dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -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,20 +1170,28 @@ 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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1048,7 +1216,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",
|
||||
@@ -1555,6 +1723,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 +2174,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 +2188,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 +2208,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 +2259,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 +2272,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 +2388,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 +2410,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 +2516,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 +2656,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 +2754,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 +3376,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"
|
||||
@@ -3400,12 +3694,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 +3954,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"
|
||||
@@ -4163,6 +4475,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 +4538,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]]
|
||||
@@ -4372,6 +4693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4614,6 +4936,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 +4954,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"
|
||||
@@ -5222,6 +5556,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 +5807,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"
|
||||
@@ -5728,8 +6082,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5738,6 +6095,7 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+15
-9
@@ -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,15 @@ 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 = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -69,9 +73,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 +97,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();
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
+34
-137
@@ -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.
|
||||
@@ -268,37 +225,33 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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::*;
|
||||
|
||||
@@ -256,7 +256,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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -85,7 +85,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,
|
||||
@@ -129,7 +129,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() {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Ellipse, EntityKind, Line, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3};
|
||||
|
||||
// ---------------- ellipse entries ----------------
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,64 @@ 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.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 +891,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 +997,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 +1103,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 +1150,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 +1163,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 +1184,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 +1227,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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Prompt, Result, Session, Step, Value, Vec2, arc, bad,
|
||||
bool_or, ccw_sweep, f64_or, json, line, line_line_infinite, number, point_param,
|
||||
};
|
||||
|
||||
// ---------------- fillet / chamfer ----------------
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Arc, EngineError, Entity, EntityKind, Handle, Input, Interactive, Line, PolyVertex, Polyline, Prompt, Result, Segment, Session, Step,
|
||||
Value, Vec2, arc, bad, bool_or, ccw_sweep, circle, f64_req, intersect_ext, json, line, lwpoly, number, point_req, str_param, targets, v3,
|
||||
};
|
||||
|
||||
/// Whether OFFSET can work on this kind of object at all.
|
||||
fn offsettable(k: &EntityKind) -> bool {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::break_join::join;
|
||||
use super::explode::explode;
|
||||
use super::transform::{erase, transform_entities};
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number,
|
||||
};
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::transform::{delta_of, transform_entities};
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Entity, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelectKind, SelectMode, Session, Step, Value, Vec2, bad, json,
|
||||
line, points_param,
|
||||
};
|
||||
|
||||
/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first
|
||||
/// point.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets};
|
||||
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, EngineError, Entity, EntityKind, Handle, Line, PolyVertex, Polyline, Prim, Result, Segment, Session, TAU, Vec2, angle_in_sweep, arc,
|
||||
ccw_sweep, intersect_ext, line, lwpoly, norm_angle,
|
||||
};
|
||||
|
||||
// ---------------- trim / extend ----------------
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, Vec2, bool_or, curves, json,
|
||||
line, point_param, point_req, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// ALIGN
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Chain, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Spline, Step, Value, Vec2, bad, curves, json, other, point_req,
|
||||
str_param,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// BLEND
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, EntityKind, Handle, LwPolyline, PolyVertex, Result, Session, TanObj, Vec2, arc, ccw_sweep, curve_edits, curves, line, line_circle,
|
||||
line_line_infinite, other, push_entity, set_kind,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// FILLET extensions (arcs, circles, polylines)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::trimext::ellipse_of;
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Arc, Chain, EntityKind, Handle, Input, Interactive, LwPolyline, Polyline, Prompt, Result, Session, Step, TAU, Value, Vec2, bad,
|
||||
ccw_sweep, curve_edits, curves, json, number, other, point_param, set_kind, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// LENGTHEN
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Common, EntityKind, Handle, Input, Interactive, MAX_BLOCK_DEPTH, MAX_GEN, Mat3, Prompt, Result, Session, Step, Value, Vec2, bad, curves,
|
||||
json, other, point_param, push_entity, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// NCOPY
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Chain, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Value, bad, bool_or, curves, json,
|
||||
number, other, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// ARRAYPATH (non-associative copies along a curve)
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, Chain, EntityKind, Handle, Input, Interactive, LwPolyline, MAX_GEN, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Spline,
|
||||
Step, Value, Vec2, as_lwpoly, bad, bool_or, curves, f64_or, f64_req, json, line, number, other, pedit_vertex, point_req, set_kind, str_param,
|
||||
targets,
|
||||
};
|
||||
|
||||
/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex,
|
||||
/// following the tangent directions given per vertex (Edit vertex Tangent).
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
EntityKind, Handle, Input, Interactive, LwPolyline, Mat3, PolyVertex, Prompt, Result, SelOutcome, SelectPhase, Session, Space, Step, Value,
|
||||
curves, json, lwpoly, other, set_kind, str_param, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// REVERSE / CHSPACE / FLATTEN (select, then act)
|
||||
|
||||
@@ -499,6 +499,94 @@ impl SplineditM {
|
||||
}
|
||||
}
|
||||
|
||||
impl SplineditM {
|
||||
fn input_main(&mut self, s: &mut Session, h: Handle, sp: &Spline, enter: bool, kw: &str) -> Result<Option<Step>> {
|
||||
match (enter, kw) {
|
||||
(true, _) | (_, "exit" | "x") => {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Some(Step::Done));
|
||||
}
|
||||
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
|
||||
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
|
||||
(_, "reverse" | "r") => self.apply(s, json!({ "option": "reverse" }))?,
|
||||
(_, "convert to polyline" | "p") => self.sub = Sub::Precision,
|
||||
(_, "fit data" | "f") if sp.fit.is_empty() => s.echo("The spline has no fit data."),
|
||||
(_, "fit data" | "f") => self.sub = Sub::Fit,
|
||||
(_, "edit vertex" | "e") => {
|
||||
self.mark = 0;
|
||||
self.sub = Sub::Vertex;
|
||||
}
|
||||
(_, "join" | "j") if sp.closed => s.echo("Cannot join to a closed spline."),
|
||||
(_, "join" | "j") => {
|
||||
self.join = SelectPhase::default();
|
||||
self.sub = Sub::Join;
|
||||
}
|
||||
(_, "undo" | "u") => {
|
||||
if let Some((at, ents)) = self.joined.last()
|
||||
&& *at == self.history.len()
|
||||
{
|
||||
let d = s.doc_mut()?;
|
||||
for (space, e) in ents {
|
||||
if let Some(st) = d.space_mut(space) {
|
||||
st.push(e.clone());
|
||||
}
|
||||
}
|
||||
self.joined.pop();
|
||||
}
|
||||
match self.history.pop() {
|
||||
Some(k) => set_kind(s, h, k)?,
|
||||
None => s.echo("All edits have been undone."),
|
||||
}
|
||||
}
|
||||
(_, "") => {}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn input_fit(&mut self, s: &mut Session, sp: &Spline, enter: bool, kw: &str) -> Result<()> {
|
||||
match (enter, kw) {
|
||||
(true, _) | (_, "exit" | "x") => self.sub = Sub::Main,
|
||||
(_, "add" | "a") => self.sub = Sub::FitAddPick,
|
||||
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
|
||||
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
|
||||
(_, "delete" | "d") => self.sub = Sub::FitDelete,
|
||||
(_, "kink" | "k") => self.sub = Sub::FitKink,
|
||||
(_, "move" | "m") => {
|
||||
self.mark = 0;
|
||||
self.sub = Sub::FitMove;
|
||||
}
|
||||
(_, "purge" | "p") => {
|
||||
self.apply(s, json!({ "option": "purge" }))?;
|
||||
self.sub = Sub::Main;
|
||||
}
|
||||
(_, "tangents" | "t") if sp.closed => s.echo("A closed spline has no end tangents."),
|
||||
(_, "tangents" | "t") => self.sub = Sub::FitStartTangent,
|
||||
(_, "tolerance" | "l") => self.sub = Sub::FitTolerance,
|
||||
(_, "") => {}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn input_join(&mut self, s: &mut Session, h: Handle, sp: &Spline, input: &Input) -> Result<()> {
|
||||
match self.join.feed(s, input)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.sub = Sub::Main,
|
||||
SelOutcome::Done(handles) => {
|
||||
self.sub = Sub::Main;
|
||||
let before = curves::entity(s, h)?.kind;
|
||||
let removed = join(s, h, sp, &handles)?;
|
||||
self.history.push(before);
|
||||
s.echo(format!("{} object(s) joined into the spline", removed.len()));
|
||||
self.joined.push((self.history.len(), removed));
|
||||
s.set_selection(vec![h]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for SplineditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINEDIT"
|
||||
@@ -578,60 +666,12 @@ impl Interactive for SplineditM {
|
||||
let enter = i == Input::Enter;
|
||||
let sp = self.spline(s).ok_or_else(|| other("The spline was erased."))?;
|
||||
match self.sub {
|
||||
Sub::Main => match (enter, kw) {
|
||||
(true, _) | (_, "exit" | "x") => {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
Sub::Main => {
|
||||
if let Some(step) = self.input_main(s, h, &sp, enter, kw)? {
|
||||
return Ok(step);
|
||||
}
|
||||
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
|
||||
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
|
||||
(_, "reverse" | "r") => self.apply(s, json!({ "option": "reverse" }))?,
|
||||
(_, "convert to polyline" | "p") => self.sub = Sub::Precision,
|
||||
(_, "fit data" | "f") if sp.fit.is_empty() => s.echo("The spline has no fit data."),
|
||||
(_, "fit data" | "f") => self.sub = Sub::Fit,
|
||||
(_, "edit vertex" | "e") => {
|
||||
self.mark = 0;
|
||||
self.sub = Sub::Vertex;
|
||||
}
|
||||
(_, "join" | "j") if sp.closed => s.echo("Cannot join to a closed spline."),
|
||||
(_, "join" | "j") => {
|
||||
self.join = SelectPhase::default();
|
||||
self.sub = Sub::Join;
|
||||
}
|
||||
(_, "undo" | "u") => {
|
||||
// Undoing a join brings the joined objects back.
|
||||
if let Some((at, ents)) = self.joined.last()
|
||||
&& *at == self.history.len()
|
||||
{
|
||||
let d = s.doc_mut()?;
|
||||
for (space, e) in ents {
|
||||
if let Some(st) = d.space_mut(space) {
|
||||
st.push(e.clone());
|
||||
}
|
||||
}
|
||||
self.joined.pop();
|
||||
}
|
||||
match self.history.pop() {
|
||||
Some(k) => set_kind(s, h, k)?,
|
||||
None => s.echo("All edits have been undone."),
|
||||
}
|
||||
}
|
||||
(_, "") => {}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
},
|
||||
Sub::Join => match self.join.feed(s, &i)? {
|
||||
SelOutcome::More => {}
|
||||
SelOutcome::Empty => self.sub = Sub::Main,
|
||||
SelOutcome::Done(hs) => {
|
||||
self.sub = Sub::Main;
|
||||
let before = curves::entity(s, h)?.kind;
|
||||
let removed = join(s, h, &sp, &hs)?;
|
||||
self.history.push(before);
|
||||
s.echo(format!("{} object(s) joined into the spline", removed.len()));
|
||||
self.joined.push((self.history.len(), removed));
|
||||
s.set_selection(vec![h]);
|
||||
}
|
||||
},
|
||||
}
|
||||
Sub::Join => self.input_join(s, h, &sp, &i)?,
|
||||
Sub::Precision => {
|
||||
let precision = match &i {
|
||||
Input::Text(t) => number(t).filter(|n| (0.0..=99.0).contains(n)).ok_or_else(|| other("Requires an integer between 0 and 99."))?,
|
||||
@@ -641,27 +681,7 @@ impl Interactive for SplineditM {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Sub::Fit => match (enter, kw) {
|
||||
(true, _) | (_, "exit" | "x") => self.sub = Sub::Main,
|
||||
(_, "add" | "a") => self.sub = Sub::FitAddPick,
|
||||
(_, "close" | "c") => self.apply(s, json!({ "option": "close" }))?,
|
||||
(_, "open" | "o") => self.apply(s, json!({ "option": "open" }))?,
|
||||
(_, "delete" | "d") => self.sub = Sub::FitDelete,
|
||||
(_, "kink" | "k") => self.sub = Sub::FitKink,
|
||||
(_, "move" | "m") => {
|
||||
self.mark = 0;
|
||||
self.sub = Sub::FitMove;
|
||||
}
|
||||
(_, "purge" | "p") => {
|
||||
self.apply(s, json!({ "option": "purge" }))?;
|
||||
self.sub = Sub::Main;
|
||||
}
|
||||
(_, "tangents" | "t") if sp.closed => s.echo("A closed spline has no end tangents."),
|
||||
(_, "tangents" | "t") => self.sub = Sub::FitStartTangent,
|
||||
(_, "tolerance" | "l") => self.sub = Sub::FitTolerance,
|
||||
(_, "") => {}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
},
|
||||
Sub::Fit => self.input_fit(s, &sp, enter, kw)?,
|
||||
Sub::FitAddPick => match point {
|
||||
Some(p) => {
|
||||
self.mark = nearest(&sp.fit, p).ok_or_else(|| other("The spline has no fit data."))?;
|
||||
@@ -806,6 +826,7 @@ impl Interactive for SplineditM {
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let marked = matches!(self.sub, Sub::FitMove | Sub::VertexMove | Sub::VertexWeight | Sub::FitAddNew);
|
||||
let Some(v) = marked.then(|| self.marks(s).get(self.mark).copied()).flatten() else { return Vec::new() };
|
||||
|
||||
@@ -148,6 +148,11 @@ fn pedit_command_line() {
|
||||
|
||||
#[test]
|
||||
fn splinedit_json_and_command_line() {
|
||||
splinedit_json_edits();
|
||||
splinedit_command_line_edits();
|
||||
}
|
||||
|
||||
fn splinedit_json_edits() {
|
||||
let mut s = Session::new();
|
||||
let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap());
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap();
|
||||
@@ -167,7 +172,9 @@ fn splinedit_json_and_command_line() {
|
||||
s.execute("splinedit", &json!({"handle": sp, "option": "polyline", "precision": 4})).unwrap();
|
||||
assert!(matches!(kind(&s, &sp), EntityKind::LwPolyline(p) if p.vertices.len() > 4));
|
||||
assert!(s.execute("splinedit", &json!({"handle": sp, "option": "close"})).is_err());
|
||||
}
|
||||
|
||||
fn splinedit_command_line_edits() {
|
||||
let mut s = Session::new();
|
||||
s.execute("spline", &json!({"control": [[0, 0], [2, 3], [4, 0], [6, 3]]})).unwrap();
|
||||
s.cmdline("splinedit 0,0 c").unwrap();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Accept, EntityKind, Handle, Input, Interactive, Prompt, Result, Session, Step, Value, bad, curves, json, other, set_kind, str_param, targets,
|
||||
};
|
||||
|
||||
// =====================================================================================
|
||||
// TEXTEDIT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user