Compare commits

..
797 changed files with 32969 additions and 62477 deletions
-3
View File
@@ -1,3 +0,0 @@
# 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"
-11
View File
@@ -14,17 +14,6 @@ updates:
- "*"
commit-message:
prefix: "chore(deps)"
# craft-ui is still on egui 0.36, and two eguis in the graph don't build (#1990). Remove this
# once craft-ui moves to egui 0.37 (storytold/craft-libs#199).
ignore:
- dependency-name: "egui"
versions: [">=0.37"]
- dependency-name: "eframe"
versions: [">=0.37"]
- dependency-name: "egui-wgpu"
versions: [">=0.37"]
- dependency-name: "egui_kittest"
versions: [">=0.37"]
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
-26
View File
@@ -1,26 +0,0 @@
# 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
-30
View File
@@ -1,30 +0,0 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev
+1 -4
View File
@@ -2,9 +2,6 @@
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [release]
@@ -29,4 +26,4 @@ jobs:
# 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/freebsd.yml@main
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
-38
View File
@@ -21,41 +21,3 @@ jobs:
nix:
# 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/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
-24
View File
@@ -21,27 +21,3 @@ 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')
+2 -45
View File
@@ -11,63 +11,20 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1 -2
View File
@@ -11,8 +11,7 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
sonarcloud:
+2 -6
View File
@@ -3,17 +3,15 @@
# binaries are ARM64, run the CLI natively, and uninstall.
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "3 5 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 3 * * 1"
workflow_dispatch:
concurrency:
@@ -30,5 +28,3 @@ jobs:
with:
app-name: cadcraft
app-dir: CADCraft
install-relative-path: Programs/ArtCraft/CADCraft
install-scope: perUser
+1 -6
View File
@@ -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 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)]`.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
@@ -66,8 +66,3 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
-5
View File
@@ -23,11 +23,6 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
+1 -6
View File
@@ -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 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)]`.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
@@ -47,8 +47,3 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+339 -1467
View File
File diff suppressed because it is too large Load Diff
+15 -55
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.14.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
rust-version = "1.90"
repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
@@ -25,17 +25,10 @@ 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 = "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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
thiserror = "2"
same-file = "1"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
@@ -44,29 +37,24 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
# rendering / formats
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.48"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
skrifa = "0.47"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.12"
unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.42"
quick-xml = "0.41"
# ui
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: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -74,47 +62,19 @@ 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."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
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 = "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" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+4 -30
View File
@@ -15,36 +15,10 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
is available from crates.io and the repository above.
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
each under the SIL Open Font License 1.1.
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc).
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
and registered through egui's default font definitions. They derive from emoji-icon-font by
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+4 -11
View File
@@ -123,7 +123,8 @@ numbers):
## Quick start
CADCraft requires Rust 1.95 or later.
Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum
remains Rust 1.90.
```sh
git clone https://github.com/storytold/cadcraft
@@ -140,7 +141,7 @@ offset 0.25 then pick the circle and a side
zoom e zoom to extents
```
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
### Fonts
@@ -196,7 +197,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/`); empty means no settings folder (no log file, autosave, workspace or plotters) |
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) |
| `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.
@@ -218,10 +219,6 @@ cadcraft-cli convert drawing.dxf drawing.svg
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
# Act on what an earlier --cmd made: {last} is the last handle it returned.
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
```
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
@@ -238,9 +235,6 @@ 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
@@ -314,7 +308,6 @@ 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
+1 -3
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
@@ -119,7 +119,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
@@ -136,7 +135,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
-17
View File
@@ -6,14 +6,6 @@ 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
@@ -22,13 +14,4 @@ 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 }
@@ -1,41 +0,0 @@
//! 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)
}
-786
View File
@@ -1,786 +0,0 @@
//! 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}");
}
}
-17
View File
@@ -1,17 +0,0 @@
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(())
}
-22
View File
@@ -1,22 +0,0 @@
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)
}
-29
View File
@@ -1,29 +0,0 @@
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>;
-14
View File
@@ -1,14 +0,0 @@
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(())
}
-315
View File
@@ -1,315 +0,0 @@
//! `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);
}
}
+884 -5
View File
@@ -1,8 +1,887 @@
//! `cadcraft-cli`: headless CADCraft.
//!
//! ```text
//! cadcraft-cli info FILE.dxf summary as JSON
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
// reported as an error, not panic before anything is printed.
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
/// `println!` that doesn't panic when stdout is closed (`cadcraft-cli commands | head`) with
/// "failed printing to stdout: Broken pipe (os error 32)". Once stdout has failed, later output is
/// dropped and the work goes on: see `stdout_failed`.
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if !$crate::stdout_gone() {
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}
}};
}
use std::process::ExitCode;
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{Session, View};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicBool, Ordering};
/// Set once a write to stdout has failed; later output is dropped.
static STDOUT_GONE: AtomicBool = AtomicBool::new(false);
/// Set when stdout failed for a reason other than a closed reader: the exit status becomes 1.
static STDOUT_ERROR: AtomicBool = AtomicBool::new(false);
fn stdout_gone() -> bool {
STDOUT_GONE.load(Ordering::Relaxed)
}
/// stdout went away. The program doesn't stop there: the rest of `run` (later `--cmd` results,
/// `--save`, `--export`) still happens, so `cadcraft-cli run … --save out.dxf | head` keeps the
/// drawing. A reader that stopped early (a closed pipe) is not an error, as in ripgrep; any other
/// write error is reported and makes the exit status 1.
fn stdout_failed(e: std::io::Error) {
if STDOUT_GONE.swap(true, Ordering::Relaxed) || e.kind() == std::io::ErrorKind::BrokenPipe {
return;
}
eprintln!("cadcraft-cli: can't write to stdout: {e}");
STDOUT_ERROR.store(true, Ordering::Relaxed);
}
const USAGE: &str = "usage:
cadcraft-cli info FILE.dxf
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
cadcraft-cli perf [N | FILE]
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
cadcraft-cli --version
image framing (png 2400x1600, svg, pdf; always model space):
default convert: fit the drawing extents.
run --export: the current view if the script/commands changed it
(zoom, pan, view.set...), otherwise fit the drawing extents.
--window x0,y0,x1,y1 show this rectangle (drawing units, x0<x1, y0<y1), centred and
scaled to fit the image without distortion.
--view extents always fit the drawing extents.
--view current the current view (as last set by zoom/pan/view.set; a freshly
opened drawing's view is its extents plus a 10% margin).";
/// What part of model space an image export shows.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Framing {
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
Auto,
Extents,
Current,
Window(Bounds2),
}
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
fn parse_window(text: &str) -> Result<Bounds2, String> {
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
let [x0, y0, x1, y1] = parts.as_slice() else {
return Err(format!("--window needs four numbers x0,y0,x1,y1, got `{text}`"));
};
let num = |name: &str, v: &str| -> Result<f64, String> {
match v.parse::<f64>() {
Ok(n) if n.is_finite() => Ok(n),
_ => Err(format!("--window: {name} must be a finite number, got `{v}`")),
}
};
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
if !(x0 < x1 && y0 < y1) {
return Err(format!("--window: need x0 < x1 and y0 < y1 (lower-left corner first), got {x0},{y0},{x1},{y1}"));
}
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
cadcraft_io::check_window(&w).map_err(|e| format!("--window: {e}"))?;
Ok(w)
}
/// Parse a framing option (`--window`, `--view`) at `a`, taking its value from `it`. `Ok(false)`
/// means `a` is not a framing option.
fn framing_option<'a>(a: &str, it: &mut impl Iterator<Item = &'a String>, framing: &mut Framing) -> Result<bool, String> {
let f = match a {
"--window" => Framing::Window(parse_window(it.next().ok_or("--window needs x0,y0,x1,y1")?)?),
"--view" => match it.next().map(|v| v.to_ascii_lowercase()).as_deref() {
Some("extents") => Framing::Extents,
Some("current") => Framing::Current,
_ => return Err("--view needs `extents` or `current`".into()),
},
_ => return Ok(false),
};
if *framing != Framing::Auto && std::mem::discriminant(framing) != std::mem::discriminant(&f) {
return Err("use only one of --window and --view".into());
}
*framing = f;
Ok(true)
}
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
fn model_views(s: &Session) -> Vec<(u64, Option<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).
fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, String> {
match framing {
Framing::Window(w) => Ok(Some(w)),
Framing::Extents => Ok(None),
Framing::Current => current_window(s).map(Some).ok_or_else(|| "--view current: the drawing has no model-space view".into()),
Framing::Auto => {
let Some(before) = before else { return Ok(None) };
let Ok(st) = s.state() else { return Ok(None) };
let now = model_views(s);
let was = before.iter().find(|(uid, _)| *uid == st.uid);
let is = now.iter().find(|(uid, _)| *uid == st.uid);
// Only a view the commands changed counts; a drawing opened by the commands keeps
// the extents framing.
match (was, is) {
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
_ => Ok(None),
}
}
}
}
fn install_io() {
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn open(s: &mut Session, path: &str) -> Result<(), String> {
s.execute("open", &json!({ "path": path })).map(|_| ()).map_err(|e| e.to_string())
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
takes_value: bool,
}
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), String> {
let mut i = 0;
while let Some(arg) = args.get(i) {
if let Some(name) = arg.strip_prefix("--") {
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
return Err(format!("{command}: unknown option --{name}"));
};
if spec.takes_value && args.get(i + 1).is_some_and(|value| !value.starts_with("--")) {
i += 2;
} else {
i += 1;
}
} else {
i += 1;
}
}
Ok(())
}
const NO_OPTIONS: &[OptionSpec] = &[];
fn info(path: &str) -> Result<(), String> {
let mut s = Session::empty();
open(&mut s, path)?;
let v = s.execute("drawing.inspect", &json!({ "entities": false })).map_err(|e| e.to_string())?;
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
fn export(s: &Session, out: &str) -> Result<(), String> {
export_framed(s, out, None)
}
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
let d = s.doc().map_err(|e| e.to_string())?;
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
eprintln!("{m}");
}
}
Ok(())
}
fn sample(args: &[String]) -> Result<(), String> {
let (Some(which), Some(out)) = (args.first(), args.get(1)) else { return Err(USAGE.into()) };
let d = match which.as_str() {
"bracket" => cadcraft_engine::sample::bracket(),
"floorplan" | "floor" => cadcraft_engine::sample::floor_plan(),
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
};
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("wrote {out} ({} bytes)", bytes.len());
Ok(())
}
/// Reject an explicit framing option without an image output to apply it to.
fn check_framed_output(framing: Framing, out: Option<&str>) -> Result<(), String> {
match out {
_ if framing == Framing::Auto => Ok(()),
Some(o) if !cadcraft_io::is_image(o) => Err(format!("--window/--view only apply to image output (png, svg, pdf), not `{o}`")),
Some(_) => Ok(()),
None => Err("--window/--view need an image output (--export OUT.png|svg|pdf)".into()),
}
}
fn convert(args: &[String]) -> Result<(), String> {
let mut framing = Framing::Auto;
let mut files = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
if !framing_option(a, &mut it, &mut framing)? {
files.push(a);
}
}
let [i, o] = files.as_slice() else { return Err(USAGE.into()) };
check_framed_output(framing, Some(o))?;
let mut s = Session::empty();
open(&mut s, i)?;
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
eprintln!("{m}");
}
if cadcraft_io::is_image(o)
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
{
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<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.
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(", ")))
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
let mut save = None;
let mut exp: Option<String> = None;
let mut framing = Framing::Auto;
let mut steps: Vec<(String, String)> = Vec::new();
while let Some(a) = it.next() {
if framing_option(a, &mut it, &mut framing)? {
continue;
}
match a.as_str() {
"--sample" => {
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
}
"--metric" => {
s.new_drawing(true);
}
"--script" => steps.push(("script".into(), it.next().ok_or("--script needs text")?.replace("\\n", "\n"))),
"--script-file" => {
let p = it.next().ok_or("--script-file needs a path")?;
steps.push(("script".into(), std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?));
}
"--cmd" => steps.push(("cmd".into(), it.next().ok_or("--cmd needs `id {json}`")?.clone())),
"--save" => save = Some(it.next().ok_or("--save needs a path")?.clone()),
"--export" => exp = Some(it.next().ok_or("--export needs a path")?.clone()),
f if !f.starts_with("--") => open(&mut s, f)?,
other => return Err(format!("unknown option {other}")),
}
}
if s.docs.is_empty() {
s.new_drawing(false);
}
check_framed_output(framing, exp.as_deref())?;
let before = model_views(&s);
for (kind, body) in steps {
match kind.as_str() {
"script" => s.script(&body).map_err(|e| e.to_string())?,
_ => {
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if !r.is_null() {
outln!("{r}");
}
}
}
}
for l in &s.log {
eprintln!("{l}");
}
if let Some(p) = save {
export(&s, &p)?;
}
if let Some(p) = exp {
let window = if cadcraft_io::is_image(&p) { export_window(&s, framing, Some(&before))? } else { None };
export_framed(&s, &p, window)?;
}
Ok(())
}
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next()
}
}
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
/// over a square whose side grows with sqrt(n), so density stays constant.
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, String> {
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
let mut dr = d::Drawing::new_imperial();
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
for i in 0..n {
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
let s = r.range(0.5, 8.0);
let kind = match i % 5 {
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
3 => EntityKind::LwPolyline(d::LwPolyline {
vertices: (0..6)
.map(|k| PolyVertex {
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
bulge: if k == 2 { 0.4 } else { 0.0 },
start_width: 0.0,
end_width: 0.0,
})
.collect(),
closed: i % 2 == 0,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
_ => EntityKind::Text(d::Text {
insert: v3(x, y),
align_pt: None,
height: s * 0.2,
value: format!("T{i}"),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: d::HAlign::Left,
valign: d::VAlign::Baseline,
}),
};
dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?;
}
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &str) -> Result<(), String> {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state().map_err(|e| e.to_string())?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err("perf: the drawing is empty".into());
}
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
Ok(())
}
fn perf(args: &[String]) -> Result<(), String> {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
return perf_file(path);
}
let n: usize = match args.first() {
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
None => 200_000,
};
let n = n.clamp(1, 20_000_000);
let side = (n as f64).sqrt() * 10.0;
let mut rows: Vec<(String, f64, String)> = Vec::new();
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let d = synthetic(n, side)?;
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
let px = side / 1600.0;
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
let t = Instant::now();
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
let ap = 0.1;
let mut r = Rng(42);
let pts: Vec<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").map_err(|e| e.to_string())?;
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
let t = Instant::now();
let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?;
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
outln!("{:<40} {:>12} notes", "operation", "ms");
outln!("{}", "-".repeat(80));
for (name, t, note) in rows {
outln!("{name:<40} {t:>12.2} {note}");
}
Ok(())
}
/// The backend `cadcraft-cli mcp` serves from.
#[derive(Debug, PartialEq, Eq)]
enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
}
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
let mut addr: Option<String> = None;
let (mut lazy, mut launch) = (false, false);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => {
let a = it
.next()
.map(|v| v.trim())
.filter(|v| !v.is_empty() && !v.starts_with('-'))
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
if addr.is_some() {
return Err("--connect given more than once".into());
}
addr = Some(a.to_string());
}
"--lazy" => lazy = true,
"--launch" => launch = true,
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
}
}
match addr {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
None => Ok(McpTarget::Headless),
}
}
fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
},
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod mcp_args_tests {
use super::*;
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
}
#[test]
fn mcp_without_connect_is_headless() {
assert_eq!(parse_mcp_args(&args(&[])), Ok(McpTarget::Headless));
}
#[test]
fn mcp_connect_with_address_selects_remote() {
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
// Without an app to reach they mean nothing: never a headless session.
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
}
#[test]
fn mcp_connect_without_address_is_an_error_not_headless() {
for bad in [&["--connect"][..], &["--connect", ""], &["--connect", " "], &["--connect", "--foo"], &["--connect", "-v"]] {
let r = parse_mcp_args(&args(bad));
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
assert!(e.contains("--connect needs HOST:PORT"), "{bad:?}: {e}");
}
}
#[test]
fn mcp_rejects_unknown_and_repeated_arguments() {
assert!(parse_mcp_args(&args(&["--conect", "127.0.0.1:7979"])).is_err());
assert!(parse_mcp_args(&args(&["127.0.0.1:7979"])).is_err());
assert!(parse_mcp_args(&args(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"])).is_err());
}
}
fn main() -> ExitCode {
let code = cli();
if STDOUT_ERROR.load(Ordering::Relaxed) { ExitCode::FAILURE } else { code }
}
fn cli() -> ExitCode {
install_io();
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
let r = match args.first().map(String::as_str) {
Some("info") => validate_options("info", &rest, NO_OPTIONS).and_then(|_| rest.first().ok_or_else(|| USAGE.to_string())).and_then(|p| info(p)),
Some("convert") => convert(&rest),
Some("run") => run(&rest),
Some("perf") => validate_options("perf", &rest, NO_OPTIONS).and_then(|_| perf(&rest)),
Some("sample") => validate_options("sample", &rest, NO_OPTIONS).and_then(|_| sample(&rest)),
Some("commands") => validate_options("commands", &rest, NO_OPTIONS).map(|_| {
let s = Session::new();
let f = rest.first().map(|x| x.to_ascii_lowercase());
let v: Vec<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();
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
}),
Some("mcp") => mcp(&rest),
Some("--version" | "-V") => {
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
Ok(())
}
_ => Err(USAGE.into()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
}
fn sample_session() -> Session {
let mut s = Session::empty();
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
s
}
fn view_box(svg: &str) -> String {
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
svg[i..].split('"').next().unwrap_or("").to_string()
}
fn assert_near(a: Bounds2, e: Bounds2) {
let d = (a.min - e.min).len().max((a.max - e.max).len());
assert!(d < 1e-9, "{a:?} != {e:?}");
}
#[test]
fn window_parses() {
assert_eq!(parse_window("0,0,30,20"), Ok(b(0.0, 0.0, 30.0, 20.0)));
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 "), Ok(b(-5.5, -1000.0, 2000.0, 4.0)));
}
#[test]
fn hostile_window_is_an_error() {
for w in [
"",
"1,2,3",
"1,2,3,4,5",
"a,0,1,1",
"NaN,0,1,1",
"0,0,inf,1",
"0,0,1e400,1",
"0,0,0,1",
"0,0,1,0",
"5,0,1,1",
"0,5,1,1",
"-1e300,-1e300,1e300,1e300",
"0,0,1e-12,1e-12",
] {
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
}
}
#[test]
fn framing_options() {
let parse = |args: &[&str]| -> Result<Framing, String> {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let mut f = Framing::Auto;
let mut it = args.iter();
while let Some(a) = it.next() {
assert!(framing_option(a, &mut it, &mut f)?, "{a} is a framing option");
}
Ok(f)
};
assert_eq!(parse(&["--view", "extents"]), Ok(Framing::Extents));
assert_eq!(parse(&["--view", "CURRENT"]), Ok(Framing::Current));
assert_eq!(parse(&["--window", "0,0,2,1"]), Ok(Framing::Window(b(0.0, 0.0, 2.0, 1.0))));
assert!(parse(&["--view"]).is_err());
assert!(parse(&["--view", "selection"]).is_err());
assert!(parse(&["--window"]).is_err());
assert!(parse(&["--window", "0,0,2,1", "--view", "extents"]).is_err());
assert!(check_framed_output(Framing::Extents, Some("a.dxf")).is_err());
assert!(check_framed_output(Framing::Extents, None).is_err());
assert!(check_framed_output(Framing::Extents, Some("a.png")).is_ok());
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
}
#[test]
fn default_framing_is_extents() {
// convert: nothing ran, always the extents.
let s = sample_session();
assert_eq!(export_window(&s, Framing::Auto, None), Ok(None));
// run: commands that leave the view alone keep the extents framing.
let mut s = sample_session();
let before = model_views(&s);
s.script("LINE 0,0 10,10\n\n").unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
// A fresh drawing too.
let mut s = Session::empty();
s.new_drawing(false);
let before = model_views(&s);
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
}
#[test]
fn zoom_window_frames_export() {
let mut s = sample_session();
let before = model_views(&s);
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
assert_eq!(export_window(&s, Framing::Current, Some(&before)), Ok(Some(w)));
// --view extents and --window override the zoom.
assert_eq!(export_window(&s, Framing::Extents, Some(&before)), Ok(None));
let explicit = b(1.0, 1.0, 2.0, 2.0);
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)), Ok(Some(explicit)));
// A taller window keeps its height; the 3:2 image adds room left and right.
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
}
#[test]
fn zoom_object_frames_the_selection() {
let mut s = sample_session();
let before = model_views(&s);
// The smallest entity with an area, selected then framed with ZOOM Object.
let d = s.doc().unwrap();
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
let e = d
.model
.iter()
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
.unwrap();
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
s.set_selection(vec![h]);
s.execute("zoom", &json!({"mode": "object"})).unwrap();
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
assert!(w.contains(eb.min) && w.contains(eb.max));
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
}
#[test]
fn run_export_honours_zoom() {
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let run_svg = |extra: &[&str], out: &str| -> String {
let mut args: Vec<String> = ["--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
args.extend(["--export".to_string(), path(out)]);
run(&args).unwrap();
std::fs::read_to_string(path(out)).unwrap()
};
let base = run_svg(&[], "base.svg");
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
assert_ne!(view_box(&zoomed), view_box(&base));
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
assert_eq!(zoomed, window);
assert_eq!(forced, base);
// Hostile --window: a clear error, nothing written.
let bad: Vec<String> = ["--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")].iter().map(|a| a.to_string()).collect();
assert!(run(&bad).unwrap_err().contains("--window"));
assert!(!dir.join("bad.png").exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.dxf"]), NO_OPTIONS).is_ok());
assert!(validate_options("perf", &args(&["100"]), NO_OPTIONS).is_ok());
assert!(validate_options("sample", &args(&["bracket", "out.dxf"]), NO_OPTIONS).is_ok());
assert!(validate_options("commands", &args(&["line"]), NO_OPTIONS).is_ok());
for (command, specs) in [("info", NO_OPTIONS), ("perf", NO_OPTIONS), ("sample", NO_OPTIONS), ("commands", NO_OPTIONS)] {
assert!(validate_options(command, &args(&["--typo"]), specs).is_err());
}
}
#[test]
fn unknown_options_are_rejected_before_side_effects() {
assert_eq!(validate_options("info", &args(&["missing.dxf", "--typo"]), NO_OPTIONS), Err("info: unknown option --typo".into()));
assert_eq!(validate_options("perf", &args(&["1", "--typo"]), NO_OPTIONS), Err("perf: unknown option --typo".into()));
assert_eq!(validate_options("sample", &args(&["bracket", "out.dxf", "--typo"]), NO_OPTIONS), Err("sample: unknown option --typo".into()));
}
}
-17
View File
@@ -1,17 +0,0 @@
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(())
}
-177
View File
@@ -1,177 +0,0 @@
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(", ")))
}
-196
View File
@@ -1,196 +0,0 @@
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(())
}
-223
View File
@@ -1,223 +0,0 @@
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, &params)?;
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(())
}
-14
View File
@@ -1,14 +0,0 @@
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(())
}
-225
View File
@@ -1,225 +0,0 @@
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
//!
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
use std::process::{Command, Output};
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("run cadcraft-cli")
}
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
/// handle the runner remembers.
#[test]
fn viewport_set_after_mview_uses_the_returned_handle() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"viewport.set {"handle":"{last}","scale":1}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
}
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
#[test]
fn last_without_an_earlier_handle_is_an_error() {
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
/// printed).
fn last_json(stdout: &str) -> serde_json::Value {
let line = stdout
.lines()
.rev()
.find(|l| {
let t = l.trim_start();
t.starts_with('{') || t.starts_with('[')
})
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
serde_json::from_str(line).expect("command result is JSON")
}
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
fn assert_needs_an_earlier_handle(out: &Output) {
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
/// object reference and must not replace the last handle the `line` command returned (`101`).
#[test]
fn a_group_name_that_looks_like_a_handle_is_not_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"group {"name":"100","handles":["101"]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
}
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
/// is not a handle of the new one.
#[test]
fn last_is_cleared_when_new_switches_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
#[test]
fn last_is_cleared_when_open_switches_drawings() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let dxf = dir.join("opened.dxf");
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
assert_needs_an_earlier_handle(&out);
let _ = std::fs::remove_dir_all(&dir);
}
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
/// drawing left behind must not be used in the one switched to.
#[test]
fn last_is_cleared_when_document_switch_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
#[test]
fn a_handle_returned_after_a_switch_is_last() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--cmd",
r#"new {"metric":true}"#,
"--cmd",
r#"document.switch {"index":0}"#,
"--cmd",
r#"line {"points":[[0,10],[10,10]]}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
"--cmd",
r#"entities {"type":"LINE"}"#,
]);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
let r = last_json(&stdout);
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
}
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
/// remembered handle is cleared after a script that changed it.
#[test]
fn last_is_cleared_by_a_script_that_changes_drawings() {
let out = run(&[
"run",
"--metric",
"--cmd",
r#"line {"points":[[0,0],[10,0]]}"#,
"--script",
r#"new {"metric":true}"#,
"--cmd",
r#"erase {"handles":["{last}"]}"#,
]);
assert_needs_an_earlier_handle(&out);
}
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
#[test]
fn ordinary_values_are_not_placeholders() {
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let out_dxf = dir.join("text.dxf");
let out = run(&[
"run",
"--metric",
"--cmd",
r#"layout.set {"name":"Layout1"}"#,
"--cmd",
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
"--cmd",
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
"--save",
out_dxf.to_str().expect("temp path"),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
assert!(saved.contains("last and {last}"), "the text is stored literally");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,23 +1,23 @@
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
//! Bare `cadcraft-cli` is a usage error: the help on stderr, exit 2.
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
use std::process::Command;
#[test]
fn help_flags_print_help_to_stdout_and_exit_zero() {
fn help_flags_print_usage_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("cadcraft-cli: headless CADCraft\n"), "{args:?}: {stdout:?}");
assert!(stdout.starts_with("usage:"), "{args:?}: {stdout:?}");
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
}
}
#[test]
fn bare_invocation_reports_on_stderr() {
fn bare_invocation_still_reports_on_stderr() {
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
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"));
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:"));
}
-7
View File
@@ -1,7 +0,0 @@
mod cli;
mod drafting_spacing;
mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
mod usage;
-950
View File
@@ -1,950 +0,0 @@
//! 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");
}
@@ -1,30 +0,0 @@
//! 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();
}
@@ -33,15 +33,12 @@ fn replies(out: &Output) -> Vec<Value> {
#[test]
fn connect_without_address_fails_before_serving() {
let input = format!("{INIT}\n{CIRCLE}\n");
// 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'")]
{
for args in [&["--connect"][..], &["--connect", ""], &["--connect", "--foo"]] {
let out = mcp(args, &input);
assert_eq!(out.status.code(), Some(2), "{args:?} is a usage error");
assert!(!out.status.success(), "{args:?} must exit non-zero");
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(named), "{args:?}: {err}");
assert!(err.contains("--connect needs HOST:PORT"), "{args:?}: {err}");
}
}
+1 -2
View File
@@ -4,7 +4,6 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
[lints]
@@ -27,7 +26,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>CADCraft</title>
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
+1 -2
View File
@@ -95,8 +95,7 @@ impl Bridge {
}
}
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
/// After each frame: tell the parent when unsaved changes appear or go away.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
+13 -146
View File
@@ -14,9 +14,6 @@ pub mod download;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod panel_layout;
#[cfg(target_arch = "wasm32")]
mod web {
use cadcraft_engine::Session;
@@ -27,72 +24,37 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
/// page embedding it (`?host=parent`), and the browser's `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change).
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>, Option<web_sys::MediaQueryList>);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
/// document title when it changed (so the tab and an embedding page name the open
/// drawing), and reports unsaved changes to that page.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.take_dropped(ctx);
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
self.0.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
match crate::panel_layout::save(&self.0.ui) {
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
Err(error) => log::warn!("panel layout was not saved: {error}"),
}
}
fn persist_egui_memory(&self) -> bool {
false
}
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
/// Writes the browser's document title, which names the tab and is what an embedding page
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
/// the title has to reach the DOM directly.
fn set_page_title(title: &str) {
match web_sys::window().and_then(|window| window.document()) {
Some(document) => document.set_title(title),
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
}
@@ -105,11 +67,9 @@ mod web {
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
/// drop a `.lsp` file rather than asking for a name it cannot read.
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
fn services() -> Services {
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
}
pub fn start() {
@@ -167,11 +127,6 @@ mod web {
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
app.load_prefs(&prefs);
}
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
{
log::warn!("panel layout was not restored: {error}");
}
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
if !crate::wants_cpu(&query())
&& let Some(rs) = &cc.wgpu_render_state
@@ -197,7 +152,7 @@ mod web {
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
Ok(Box::new(Shell(app, inbox, host, reduced_motion)))
}),
)
.await;
@@ -222,44 +177,6 @@ fn wants_cpu(query: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
}
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
#[cfg(any(target_arch = "wasm32", test))]
fn document_title(name: Option<&str>, dirty: bool) -> String {
match name.filter(|n| !n.trim().is_empty()) {
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
None => "CADCraft".to_string(),
}
}
/// The title to write this frame, or `None` while the page already shows it: the browser needs
/// the document title only when it changes, not on every frame.
#[cfg(any(target_arch = "wasm32", test))]
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
let title = document_title(name, dirty);
if sent.as_deref() == Some(title.as_str()) {
return None;
}
*sent = Some(title.clone());
Some(title)
}
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
/// computed but not written leaves the tab on `CADCraft`.
#[cfg(any(target_arch = "wasm32", test))]
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
match title_to_send(sent, name, dirty) {
Some(title) => {
write(&title);
true
}
None => false,
}
}
#[cfg(target_arch = "wasm32")]
mod upload;
@@ -275,10 +192,8 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{document_title, push_title, title_to_send, wants_cpu};
use super::wants_cpu;
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
#[test]
fn cpu_flag_is_detected() {
assert!(wants_cpu("?cpu"));
@@ -289,52 +204,4 @@ mod tests {
assert!(!wants_cpu("?webgl&sample"));
assert!(!wants_cpu("?gpucpu"));
}
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
#[test]
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
assert_eq!(document_title(None, false), "CADCraft");
assert_eq!(document_title(None, true), "CADCraft");
assert_eq!(document_title(Some(" "), true), "CADCraft");
}
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
#[test]
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
let mut sent = None;
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
// The page already has it: send nothing.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
// Unsaved changes add the bullet.
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
// A rename shows the new name.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
// Saving clears it again.
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
// Closing the last drawing leaves the app name only.
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
assert_eq!(title_to_send(&mut sent, None, false), None);
}
/// Guards the title write: every changed title reaches the writer (on the web, the browser
/// document title) exactly once, and an unchanged one is not written again. Without the write
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
#[test]
fn changed_title_reaches_the_document_title_writer_once() {
let mut sent = None;
let mut written: Vec<String> = Vec::new();
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unchanged: nothing is written again.
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
// Unsaved changes are written.
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
// Closing the drawing writes the app name.
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
}
}
-104
View File
@@ -1,104 +0,0 @@
//! Browser panel layout storage, independent of drawings and native workspace files.
use cadcraft_ui_egui::{UiState, docking};
use serde_json::{Value, json};
#[cfg(target_arch = "wasm32")]
pub const KEY: &str = "cadcraft.panel-layout";
const MAX_BYTES: usize = 64 * 1024;
/// Serialize only validated panel placement and visibility, with a bounded record size.
pub fn save(ui: &UiState) -> Result<String, String> {
docking::validate(&ui.docking)?;
let saved = json!({
"version": docking::LAYOUT_VERSION,
"docking": ui.docking,
"showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes,
})
.to_string();
if saved.len() > MAX_BYTES {
return Err("panel layout exceeds 64 KiB".into());
}
Ok(saved)
}
/// Validate the entire optional record before changing any UI field.
pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {
if saved.len() > MAX_BYTES {
return Err("panel layout exceeds 64 KiB".into());
}
let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;
if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {
return Err("unsupported panel layout version".into());
}
let workspace: docking::Workspace =
serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;
docking::validate(&workspace)?;
let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;
let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;
ui.docking = workspace;
ui.show_toolsets = tools;
ui.show_palettes = palettes;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services};
#[test]
fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {
let mut source = CadApp::new(Session::default(), Services::default());
source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();
source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
let saved = save(&source.ui).unwrap();
let mut destination = CadApp::new(Session::default(), Services::default());
destination.session.start("line").unwrap();
let before = destination.session.doc().unwrap().clone();
restore(&mut destination.ui, &saved).unwrap();
assert_eq!(destination.ui.docking, source.ui.docking);
assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);
assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);
assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
assert_eq!(destination.session.doc().unwrap(), &before);
}
#[test]
fn malformed_or_unsupported_storage_is_rejected_atomically() {
let mut ui = UiState::default();
let before = serde_json::to_value(&ui).unwrap();
let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
for (field, bad) in [
("version", json!(0)),
("version", json!(docking::LAYOUT_VERSION + 1)),
("docking", json!({})),
("showToolsets", json!("false")),
("showPalettes", Value::Null),
] {
let mut invalid = valid.clone();
invalid[field] = bad;
assert!(restore(&mut ui, &invalid.to_string()).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {
assert!(restore(&mut ui, &invalid).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
}
#[test]
fn storage_cannot_remove_the_document_view() {
let mut ui = UiState::default();
let before = serde_json::to_value(&ui).unwrap();
let mut invalid = ui.clone();
invalid.docking.layout.root = None;
assert!(save(&invalid).is_err());
let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
record["docking"] = serde_json::to_value(&invalid.docking).unwrap();
assert!(restore(&mut ui, &record.to_string()).is_err());
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
}
}
+1 -2
View File
@@ -68,9 +68,8 @@ impl Inbox {
}
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
pub fn take_dropped(&self, ctx: &egui::Context) {
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
if let Some(file) = f.web_file() {
self.read(file.clone());
}
+1 -10
View File
@@ -4,7 +4,6 @@ description = "CADCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
publish = false
default-run = "cadcraft"
@@ -17,12 +16,10 @@ cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
# For the shared per-session control-token helper (control_server.rs authentication).
cadcraft-mcp = { workspace = true }
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[build-dependencies]
@@ -36,16 +33,10 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
craft-ui = { workspace = true, features = ["native-windows"] }
craft-update = { workspace = true }
rfd = { workspace = true }
sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
# controls and under floating panels.
craft-window = { workspace = true }
muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
+33 -152
View File
@@ -7,7 +7,7 @@
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
//! socket may sit idle or blocked on a write.
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -24,9 +24,6 @@ struct Limits {
max_clients: usize,
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
max_line: usize,
/// Request-line bytes buffered at once across all connections; a request that would pass it
/// is refused with an error reply.
total_bytes: usize,
/// Requests waiting for the app at once; more are answered `busy`.
queue: usize,
/// How long a connection may go without sending a byte before it is closed.
@@ -43,8 +40,6 @@ impl Default for Limits {
max_clients: 16,
// Generous, so requests that carry whole drawings or images as base64 still fit.
max_line: 256 * 1024 * 1024,
// Room for one maximal request plus smaller ones, not `max_clients` maximal ones.
total_bytes: 512 * 1024 * 1024,
queue: 64,
idle_timeout: Duration::from_secs(600),
write_timeout: Duration::from_secs(30),
@@ -53,8 +48,6 @@ impl Default for Limits {
}
}
/// Start the loopback server with a fresh session token and a bounded request queue.
/// Binding or token-issuance failure returns a disconnected receiver.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
@@ -108,10 +101,8 @@ impl Drop for Slot {
}
}
/// Accept clients within the connection limit and give each worker the same session token.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
let budget = Budget::new(limits.total_bytes);
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
@@ -123,102 +114,39 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
let tx = tx.clone();
let ctx = ctx.clone();
let token = Arc::clone(&token);
let budget = budget.clone();
let _ = std::thread::Builder::new().spawn(move || {
let _slot = slot;
serve(stream, &tx, &ctx, limits, &token, budget);
serve(stream, &tx, &ctx, limits, &token);
});
}
}
/// A total-bytes budget for request lines buffered across all connections, so
/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925).
#[derive(Clone, Debug)]
struct Budget {
used: Arc<AtomicUsize>,
total: usize,
}
impl Budget {
fn new(total: usize) -> Self {
Self { used: Arc::new(AtomicUsize::new(0)), total }
}
/// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total.
fn reserve(&self, n: usize) -> bool {
self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok()
}
}
/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the
/// next line starts or the connection ends.
struct Held {
budget: Budget,
bytes: usize,
}
impl Held {
fn new(budget: Budget) -> Self {
Self { budget, bytes: 0 }
}
fn release(&mut self) {
self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst);
self.bytes = 0;
}
}
impl Drop for Held {
fn drop(&mut self) {
self.release();
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging
/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long,
/// the shared budget is used up, or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, held: &mut Held) -> Result<bool, String> {
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
held.release();
// A line is at most `max` bytes plus its newline; never buffer more than that.
let cap = max.saturating_add(1);
loop {
let chunk = match reader.fill_buf() {
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => {
return Err(match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
});
}
};
if chunk.is_empty() {
// End of stream: nothing read, or a last line without a newline.
return Ok(!buf.is_empty());
}
let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1);
let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len()));
buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk));
reader.consume(n);
// The chunk is already buffered (a few KiB), so it is added before it is charged: a
// refusal still leaves `buf` non-empty and the connection gets an error reply.
if !held.budget.reserve(n) {
return Err("too many large requests in flight; try again later".to_string());
}
held.bytes += n;
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// One request line, parsed.
@@ -234,7 +162,6 @@ enum Line {
Reject(String),
}
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
@@ -271,9 +198,7 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) {
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -281,9 +206,8 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
let mut held = Held::new(budget);
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) {
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
Ok(false) => break,
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
// A line nobody asked for would be read as the reply to the client's next request.
@@ -334,7 +258,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
@@ -352,13 +275,13 @@ mod tests {
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
}
#[test]
@@ -366,15 +289,11 @@ mod tests {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err());
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
fn held() -> Held {
Held::new(Budget::new(usize::MAX))
}
fn short() -> Limits {
Limits {
queue: 1,
@@ -466,7 +385,6 @@ mod tests {
assert!(t.elapsed() < Duration::from_secs(4));
}
/// Authenticated requests rejected by a full queue retain their IDs and report that they did not run.
#[test]
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
@@ -489,41 +407,6 @@ mod tests {
drop(rx);
}
/// Connections can't together buffer more request-line bytes than the budget: a second large
/// request is refused with an error reply while the first still holds its bytes (#1925).
#[test]
fn requests_past_the_total_budget_are_refused() {
let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() });
// The first peer sends 200 bytes of an unfinished line and keeps the connection open.
let mut first = TcpStream::connect(addr).unwrap();
first.write_all(&[b'a'; 200]).unwrap();
std::thread::sleep(Duration::from_millis(200));
// The second would take 200 more, past the 300 byte total.
let mut second = TcpStream::connect(addr).unwrap();
second.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
second.write_all(&[b'b'; 200]).unwrap();
let mut line = String::new();
BufReader::new(second).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ok"], json!(false));
assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}");
drop(first);
}
#[test]
fn the_budget_is_given_back_when_the_connection_ends() {
let budget = Budget::new(100);
{
let mut h = Held::new(budget.clone());
let mut buf = Vec::new();
let mut input: &[u8] = &[b'x'; 80];
assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true));
assert!(!budget.reserve(30), "80 of 100 bytes are held");
}
assert!(budget.reserve(100), "all given back on drop");
}
/// Replies on one authenticated connection preserve the ID of each request.
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
@@ -540,7 +423,6 @@ mod tests {
}
}
/// Missing and incorrect session tokens are rejected before dispatch.
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
@@ -555,7 +437,6 @@ mod tests {
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
/// A request carrying the session token reaches the control dispatcher.
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
-172
View File
@@ -1,172 +0,0 @@
//! 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");
}
}
}
-158
View File
@@ -1,158 +0,0 @@
//! 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));
}
}
+1 -1
View File
@@ -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; empty means none; see `env.rs`).
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.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.
//!
+151 -96
View File
@@ -1,6 +1,6 @@
//! CADCraft desktop app.
//!
//! Usage: `cadcraft [--control <port>] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`])
//! Usage: `cadcraft [--control <port>] [--sample] [files…]`
//!
//! `--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,16 +10,14 @@
//! 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`]. The `CADCRAFT_*` variables are read once, at startup; see [`env`].
//! levels); see [`logging`] and [`config_dir`].
#![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;
@@ -28,7 +26,6 @@ mod system_animations;
mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
@@ -42,25 +39,11 @@ 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>,
}
impl App {
/// `ctx` is `None` only for the final attempt at exit.
fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
let result = match ctx {
None => self.workspace.save_on_exit(&self.cad.ui),
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
if let Some(wait) = wait {
ctx.request_repaint_after(wait);
}
}),
};
fn persist_workspace(&mut self, final_attempt: bool) {
let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) };
if let Err(error) = result {
if self.workspace_error.as_ref() != Some(&error) {
eprintln!("CADCraft workspace: {error}");
@@ -82,49 +65,37 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
}
impl eframe::App for App {
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
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 {
m.poll(&mut self.cad, ctx);
}
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(Some(ctx));
}
self.cad.system_animations = self.system_animations.current();
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(ctx, raw);
self.cad.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
self.persist_workspace(false);
}
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(None);
self.cad.cancel_screenshots();
self.persist_workspace(true);
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -172,13 +143,6 @@ fn services() -> Services {
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
pick_lisp: Some(Box::new(|dir: &str| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
}
}
@@ -194,6 +158,30 @@ 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`).
@@ -209,49 +197,68 @@ fn app_icon() -> Option<egui::IconData> {
}
fn version_string() -> String {
format!("cadcraft {}", cadcraft_engine::build_version())
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
}
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
// The update helper relaunches this binary after a self-update; it runs and exits here.
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("CADCraft update helper failed: {e}");
std::process::exit(1);
// 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),
}
return Ok(());
}
// `--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 vars.config_dir.as_ref().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
match config_dir().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 (an empty CADCRAFT_CONFIG_DIR turns it off)");
log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)");
}
}
}
install_io();
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 surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), 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).
@@ -266,9 +273,6 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
@@ -294,7 +298,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() && vars.wayland != Switch::On {
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -305,16 +309,15 @@ 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(vars.config_dir.as_deref());
let (workspace, restored, workspace_error) = workspace::Workspace::load();
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 = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect());
app.system_languages = std::env::var("CADCRAFT_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);
@@ -326,9 +329,8 @@ fn main() -> eframe::Result {
cadcraft_ui_egui::autosave::load_prefs(&prefs);
}
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = &vars.config_dir {
if let Some(dir) = config_dir() {
app.enable_autosave(&dir.join("autosave"));
app.enable_update_check();
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in.
let support = dir.join("support");
@@ -355,9 +357,7 @@ fn main() -> eframe::Result {
app.open_path(f);
}
if app.session.docs.is_empty() {
// In the units the profile asks for (MEASUREINIT, restored above).
let metric = app.session.settings.measureinit;
app.session.new_drawing(metric);
app.session.new_drawing(false);
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
@@ -372,10 +372,6 @@ fn main() -> eframe::Result {
workspace_error,
#[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,
}))
}),
)
@@ -383,7 +379,13 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{filters_for_save, follow_desktop};
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))
}
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -401,6 +403,59 @@ 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]
+6 -53
View File
@@ -3,7 +3,6 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::mpsc;
use cadcraft_ui_egui::menus::{self, Entry};
use cadcraft_ui_egui::{CadApp, i18n};
@@ -16,14 +15,10 @@ pub struct NativeMenu {
submenus: Vec<(String, Submenu)>,
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
/// ~300 ms of menu enablement.
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
counter: usize,
events: mpsc::Receiver<MenuEvent>,
}
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -35,16 +30,7 @@ fn accel(sc: &str) -> Option<Accelerator> {
}
impl NativeMenu {
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
// wake the app for it: the handler does, and the choice runs in the next frame.
let (sender, events) = mpsc::channel();
let wake = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.send(event);
wake.request_repaint();
}));
pub fn install(app: &mut CadApp) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let language = app.language();
@@ -80,17 +66,7 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self {
_menu: menu,
items,
submenus,
predefined,
language,
last_refresh: f64::NEG_INFINITY,
recent: app.ui.recent_files.clone(),
counter,
events,
}
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -111,14 +87,13 @@ impl NativeMenu {
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
}
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
while let Ok(ev) = self.events.try_recv() {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
if cmd == "ui.discord" {
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
} else {
menus::activate_native(app, ctx, &cmd.clone());
menus::activate(app, &cmd.clone());
}
ctx.request_repaint();
}
@@ -138,25 +113,17 @@ impl NativeMenu {
}
}
let now = cadcraft_ui_egui::now_ms();
if !refresh_due(now, self.last_refresh) {
if now - self.last_refresh < 300.0 {
return;
}
self.last_refresh = now;
let typing = menus::text_field_window(ctx).is_some();
for (cmd, _, item) in self.items.values() {
let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
item.set_enabled(en);
}
}
}
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build(
parent: &Submenu,
entries: &[Entry],
@@ -183,17 +150,3 @@ fn build(
}
}
}
#[cfg(test)]
mod tests {
use super::refresh_due;
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+13 -92
View File
@@ -107,23 +107,6 @@ impl Filter {
*self = Self::default();
}
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
if !complete {
return;
}
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (b, raw) in bias.iter_mut().zip(self.raw) {
*b += (raw - *b) * 0.01;
}
self.bias = Some(*bias);
}
} else {
self.idle_since = None;
}
}
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
for (index, seen) in self.seen.iter_mut().enumerate() {
if mask & (1 << index) != 0 {
@@ -145,7 +128,19 @@ impl Filter {
}
let Some(mut bias) = self.bias else { return Motion::default() };
// Follow small resting drift only after two seconds entirely inside the deadzone.
self.update_drift(complete, now, &mut bias);
if complete {
if self.raw.iter().zip(bias).all(|(raw, bias)| (*raw - bias).abs() <= FULL_SCALE * DEADZONE) {
let since = *self.idle_since.get_or_insert(now);
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
for (bias, raw) in bias.iter_mut().zip(self.raw) {
*bias += (raw - *bias) * 0.01;
}
self.bias = Some(bias);
}
} else {
self.idle_since = None;
}
}
let mut axes = [0.0; 6];
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
@@ -161,77 +156,3 @@ impl Filter {
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_reports() {
assert_eq!(decode(&[]), None);
assert_eq!(decode(&[3]), None);
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
let values = [1_i16, -2, 3, 4, -5, 6];
let mut bytes = vec![1_u8];
for v in values {
bytes.extend_from_slice(&v.to_le_bytes());
}
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
bytes[0] = 2;
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
}
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
/// on a made-up point in time big enough that they sit in the past of later updates.
fn calibrated(t0: Instant) -> Filter {
let mut f = Filter::default();
for i in 0..17 {
let at = t0 + Duration::from_millis(50 * i);
assert_eq!(f.update([0; 6], 1, at), Motion::default());
assert_eq!(f.update([0; 6], 2, at), Motion::default());
}
assert!(f.calibrated());
f
}
#[test]
fn axes_deflect_and_expire() {
let t0 = Instant::now();
let mut f = calibrated(t0);
let at = t0 + Duration::from_secs(2);
let ms = Duration::from_millis;
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
assert!((m.pan[0] - 1.0).abs() < 1e-9);
assert!((m.pan[1] - 1.0).abs() < 1e-9);
assert_eq!(m.zoom, 0.0, "no rotation input");
// A fresh rotation report drives the zoom.
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
assert!((m.zoom - 1.0).abs() < 1e-9);
// The rotation axes went quiet for over 250 ms while translations still arrive.
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
assert_eq!(m.zoom, 0.0);
// A fresh rotation report puts the zoom back.
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
assert!((m.zoom - 1.0).abs() < 1e-9);
}
#[test]
fn drift_tracks_rest_after_two_idle_seconds() {
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
let t0 = Instant::now();
let mut b = [0.0; 6];
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
assert_eq!(b, [0.0; 6]);
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
assert_eq!(f.bias, Some(b));
// Motion outside the deadzone resets the idle window.
f.raw = [100.0; 6];
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
assert_eq!(f.idle_since, None);
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
}
}
+55 -123
View File
@@ -1,18 +1,15 @@
//! 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};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use cadcraft_ui_egui::UiState;
use serde_json::{Value, json};
const MAX_BYTES: u64 = 64 * 1024;
/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are
/// spaced at least this far apart, and the newest state is written when the interval ends.
const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500);
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub struct Workspace {
@@ -20,11 +17,22 @@ pub struct Workspace {
last_attempt: Option<Value>,
read_failed: bool,
pending_write_error: Option<String>,
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)?;
ui.docking.validate()?;
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
return Err("toolsetTab must be Drafting or Modeling".into());
}
@@ -36,7 +44,6 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
}
Ok(json!({
"docking": ui.docking,
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
"showToolsets": ui.show_toolsets,
"showPalettes": ui.show_palettes,
"showToolbar": ui.show_toolbar,
@@ -73,18 +80,12 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
// Dock schemas can evolve independently of the other saved workspace preferences.
// Recover only this optional field; runtime UiState deserialization remains strict.
// A layout from before the current shape (split frames, saved without `dockingVersion`)
// gives way to the default, its hidden panels with it.
let mut fields = workspace.clone();
let (docking, version) = match fields.as_object_mut() {
Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
None => (None, None),
};
let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking"));
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
if let Some(docking) = docking.filter(|_| current)
if let Some(docking) = docking
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
&& docking.validate().is_ok()
{
ui.docking = docking;
}
@@ -126,9 +127,8 @@ fn write(path: &Path, workspace: &Value) -> Result<(), String> {
}
impl Workspace {
/// 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")))
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")))
}
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
@@ -136,54 +136,44 @@ impl Workspace {
match result {
Ok(ui) => {
let initial = ui.as_ref().cloned().unwrap_or_default();
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None)
}
Err(error) => (
Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
Self { path, last_attempt: None, read_failed: true, pending_write_error: None },
None,
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
),
}
}
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
/// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
let Some(path) = &self.path else { return Ok(None) };
pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> {
let Some(path) = &self.path else { return Ok(()) };
if self.read_failed {
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
return Ok(None);
return Ok(());
}
let value = snapshot(ui)?;
if self.last_attempt.as_ref() == Some(&value) {
return self.pending_write_error.clone().map_or(Ok(None), Err);
}
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
&& !wait.is_zero()
{
return Ok(Some(wait));
return self.pending_write_error.clone().map_or(Ok(()), Err);
}
// Keep a failed state's warning without retrying disk I/O every rendered frame.
self.last_attempt = Some(value.clone());
self.last_write = Some(now);
let result = write(path, &value);
self.pending_write_error = result.as_ref().err().cloned();
result.map(|()| None)
result
}
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
if self.pending_write_error.is_some() {
self.last_attempt = None;
}
self.last_write = None;
self.save_if_changed(ui, Instant::now()).map(|_| ())
self.save_if_changed(ui)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn temp_dir() -> PathBuf {
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
@@ -194,18 +184,20 @@ mod tests {
#[test]
fn profile_override_isolated_or_explicitly_disabled() {
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,
}
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 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");
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);
}
#[test]
@@ -224,7 +216,7 @@ mod tests {
start_tab: true,
..Default::default()
};
first.save_if_changed(&ui, Instant::now()).unwrap();
first.save_if_changed(&ui).unwrap();
let bytes = fs::read(&path).unwrap();
let value: Value = serde_json::from_slice(&bytes).unwrap();
assert!(value["workspace"].get("dialog").is_none());
@@ -237,68 +229,34 @@ mod tests {
let mut transient_only = ui.clone();
transient_only.dialog = None;
transient_only.start_tab = false;
first.save_if_changed(&transient_only, Instant::now()).unwrap();
first.save_if_changed(&transient_only).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() {
use cadcraft_ui_egui::docking::Panel;
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState::default();
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap();
ui.docking = app.ui.docking;
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
ui.docking.set_visible(Panel::ToolSets, false).unwrap();
ui.show_toolsets = false;
store.save_if_changed(&ui, Instant::now()).unwrap();
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
store.save_if_changed(&ui).unwrap();
let (_, reloaded, error) = Workspace::at(Some(path));
assert!(error.is_none());
let mut reloaded = reloaded.unwrap();
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
assert!(!reloaded.show_toolsets);
reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
reloaded.docking.set_visible(Panel::ToolSets, true).unwrap();
assert!(reloaded.docking.layout.contains(&Panel::ToolSets));
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
use cadcraft_ui_egui::docking::default_workspace;
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
let file = r#"{"version":1,"workspace":{
"docking":{
"layout":{
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
"first":{"Tabs":{"panels":["canvas"],"active":0}},
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
let directory = temp_dir();
let path = directory.join("workspace.json");
fs::write(&path, file).unwrap();
let (_, restored, error) = Workspace::at(Some(path));
assert!(error.is_none(), "{error:?}");
let ui = restored.unwrap();
assert_eq!(ui.toolset_tab, "Modeling");
assert_eq!(ui.history_lines, 4);
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
assert_eq!(ui.docking.layout, default_workspace().layout);
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
let directory = temp_dir();
@@ -314,7 +272,7 @@ mod tests {
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
assert!(ui.is_none());
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap();
assert_eq!(fs::read(&path).unwrap(), bytes);
}
fs::remove_dir_all(directory).unwrap();
@@ -329,8 +287,7 @@ mod tests {
json!({"layout":false,"hidden":[]}),
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
] {
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking});
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
let (_, restored, error) = Workspace::at(Some(path.clone()));
@@ -346,41 +303,16 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let start = Instant::now();
let mut ui = UiState { history_lines: 1, ..Default::default() };
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
// A drag: a new state every frame, none of them written until the interval ends.
for (frame, lines) in (2..=5).enumerate() {
ui.history_lines = lines;
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
let wait = store.save_if_changed(&ui, now).unwrap();
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
}
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
ui.history_lines = 6;
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
store.save_on_exit(&ui).unwrap();
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn invalid_update_cannot_replace_last_good_workspace() {
let directory = temp_dir();
let path = directory.join("workspace.json");
let (mut store, _, _) = Workspace::at(Some(path.clone()));
let mut ui = UiState { show_palettes: false, ..Default::default() };
store.save_if_changed(&ui, Instant::now()).unwrap();
store.save_if_changed(&ui).unwrap();
let good = fs::read(&path).unwrap();
ui.collapsed_groups = vec!["x".repeat(257)];
assert!(store.save_if_changed(&ui, Instant::now()).is_err());
assert!(store.save_if_changed(&ui).is_err());
assert_eq!(fs::read(&path).unwrap(), good);
fs::remove_dir_all(directory).unwrap();
}
@@ -393,9 +325,9 @@ mod tests {
// Simulate a destination becoming unavailable after startup.
fs::create_dir(&path).unwrap();
let ui = UiState { show_palettes: false, ..Default::default() };
let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
let first_error = store.save_if_changed(&ui).unwrap_err();
fs::remove_dir(&path).unwrap();
assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error);
assert!(!path.exists(), "ordinary frames must not retry disk writes");
store.save_on_exit(&ui).unwrap();
assert!(!read(&path).unwrap().unwrap().show_palettes);
-4
View File
@@ -2,7 +2,3 @@ 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
-26
View File
@@ -73,29 +73,3 @@ sync_github_name = false
[people.gurparvez]
real_name = "Gurparvez"
[people.nicholasgower]
sync_github_name = true
[people.ffishman]
real_name = "Rodrigo Gressler"
display_name = "Rodrigo"
sync_github_name = false
[people.aussieboer61]
real_name = "Richard Sauer"
display_name = "Richard Sauer"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.2ez4gcx]
real_name = "Doan Huynh Huu Khuong"
display_name = "Doan Huynh Huu Khuong"
sync_github_name = false
[people.dylanmtaylor]
sync_github_name = true
+8 -43
View File
@@ -21,8 +21,7 @@ impl Rgb {
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -111,9 +110,9 @@ impl Color {
_ => {}
}
if let Some(rest) = t.strip_prefix("color ") {
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
}
if let Some(i) = parse_digits::<u16>(&t) {
if let Ok(i) = t.parse::<u16>() {
return match i {
0 => Some(Color::ByBlock),
256 => Some(Color::ByLayer),
@@ -124,22 +123,14 @@ impl Color {
if t.starts_with('#') {
return Rgb::parse_hex(&t).map(Color::True);
}
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
return Some(Color::True(Rgb(*r, *g, *b)));
}
None
}
}
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
pub fn aci_rgb(i: u8) -> Rgb {
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
@@ -165,7 +156,7 @@ fn build_palette() -> [Rgb; 256] {
for (slot, c) in p.iter_mut().zip(base) {
*slot = c;
}
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
for i in 10..250usize {
let hue = (i - 10) / 10; // 0..24, 15° each
let sub = i % 10;
@@ -242,39 +233,13 @@ mod tests {
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
}
#[test]
fn parse_rejects_plus_sign() {
assert_eq!(Color::parse("+5"), None);
assert_eq!(Color::parse(" +256"), None);
assert_eq!(Color::parse("color +5"), None);
assert_eq!(Color::parse("+1,+2,+3"), None);
assert_eq!(Color::parse("1,+2,3"), None);
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
}
#[test]
fn parse_hex_rejects_repeated_hash() {
assert_eq!(Rgb::parse_hex("##ff0000"), None);
assert_eq!(Rgb::parse_hex("###ff0000"), None);
assert_eq!(Color::parse("##ff0000"), None);
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
}
#[test]
fn palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
+8 -4
View File
@@ -52,11 +52,15 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {
g.curves.push((GeomRef::whole(h), ga.center, a.radius));
}
}
EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
EntityKind::Circle(c) => {
if c.center.is_finite() && c.radius.is_finite() {
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
}
}
EntityKind::Point(p) if p.p.is_finite() => {
g.points.push((GeomRef::whole(h), p.p.xy()));
EntityKind::Point(p) => {
if p.p.is_finite() {
g.points.push((GeomRef::whole(h), p.p.xy()));
}
}
EntityKind::LwPolyline(p) => {
let n = p.vertices.len();
+1 -4
View File
@@ -89,8 +89,6 @@ struct CompDof {
objects: Vec<Option<usize>>,
}
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
fn analyse(comp: &Comp) -> CompDof {
let free = comp.free(None);
let rows = comp.rows(None);
@@ -215,7 +213,6 @@ mod budget_tests {
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
use cadcraft_geom::{Spline, Vec2};
/// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation.
#[test]
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
let mut d = Drawing::default();
@@ -235,7 +232,7 @@ mod budget_tests {
aux_init: Vec::new(),
aux: 0,
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
}
}
+9 -58
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
/// Longest expression accepted (characters).
pub const MAX_LEN: usize = 1024;
/// Deepest syntax tree and combined expression/parameter evaluation path.
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
pub const MAX_DEPTH: usize = 64;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
@@ -49,7 +49,7 @@ struct Parser<'a> {
impl Parser<'_> {
fn ws(&mut self) {
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
self.i += 1;
}
}
@@ -165,20 +165,6 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
if p.peek().is_some() {
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
}
let mut pending = vec![(&e, 1)];
while let Some((node, depth)) = pending.pop() {
if depth > MAX_DEPTH {
return Err(ExprError::TooDeep);
}
match node {
Expr::Neg(a) => pending.push((a, depth + 1)),
Expr::Bin(_, a, b) => {
pending.push((a, depth + 1));
pending.push((b, depth + 1));
}
_ => {}
}
}
Ok(e)
}
@@ -238,19 +224,12 @@ pub fn rename(text: &str, old: &str, new: &str) -> String {
/// Evaluate with a variable lookup.
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
eval_at_depth(e, &mut |n, _| lookup(n), 0)
}
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let v = match e {
Expr::Num(v) => *v,
Expr::Var(n) => lookup(n, depth + 1)?,
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
Expr::Var(n) => lookup(n)?,
Expr::Neg(a) => -eval_with(a, lookup)?,
Expr::Bin(op, a, b) => {
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
match op {
'+' => x + y,
'-' => x - y,
@@ -294,12 +273,9 @@ impl Env {
/// Value of a named parameter.
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
let mut stack = Vec::new();
self.value_inner(name, &mut stack, 0)
self.value_inner(name, &mut stack)
}
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
if depth >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
if let Some(v) = self.cache.get(name) {
return Ok(*v);
}
@@ -312,7 +288,7 @@ impl Env {
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?;
stack.push(name.to_string());
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
let r = eval_with(&e, &mut |n| self.value_inner(n, stack));
stack.pop();
let v = r?;
self.cache.insert(name.to_string(), v);
@@ -322,7 +298,7 @@ impl Env {
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
let e = parse(text)?;
let mut stack = Vec::new();
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
}
}
@@ -375,31 +351,6 @@ mod tests {
assert!(chain.value("p0").is_err());
}
#[test]
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
std::thread::Builder::new()
.stack_size(1024 * 1024)
.spawn(|| {
for op in ["+", "-", "*", "/"] {
let within = vec!["1"; MAX_DEPTH].join(op);
assert!(eval_const(&within).is_ok());
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
}
let mut env = Env::new();
for i in 0..MAX_DEPTH {
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
}
env.insert(&format!("p{MAX_DEPTH}"), "1");
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
env.insert("safe", "2*3");
assert_eq!(env.eval("safe+1"), Ok(7.0));
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+44 -197
View File
@@ -81,12 +81,6 @@ pub struct SolveOptions {
pub keep: Vec<Handle>,
/// Entities that should take up the change.
pub prefer_move: Vec<Handle>,
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
/// just put down): see [`edited_points`].
pub hold: Vec<GeomRef>,
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
/// now: see [`moved_anchors`].
pub anchors: Vec<(u32, Vec2)>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
@@ -211,19 +205,12 @@ pub(crate) struct Comp {
pub(crate) model: Model,
pub(crate) built: Vec<Built>,
weights: Vec<f64>,
/// Parameters of the points held by [`SolveOptions::hold`].
held: Vec<usize>,
tol: f64,
}
impl Comp {
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
let mut locked = vec![false; self.model.x0.len()];
for &j in &self.held {
if let Some(l) = locked.get_mut(j) {
*l = true;
}
}
for (i, b) in self.built.iter().enumerate() {
if Some(i) == skip || b.form != Form::Lock {
continue;
@@ -258,30 +245,7 @@ impl Comp {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
// an axis end by index does not.
if o.converged && !self.model.storable(&o.x) {
let mut out = Vec::new();
match self.model.stored_form(&o.x) {
Some(mut xc) => {
// Contact parameters are positions along the curves: they follow the stored form.
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
for (k, r) in b.refs.iter().enumerate() {
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
*slot = self.model.stored_t(&o.x, r.handle, t);
}
}
}
self.eval(&xc, &mut out, skip);
o.converged = lm::max_abs(&out) <= self.tol;
o.x = xc;
}
None => o.converged = false,
}
}
Ok(o)
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
}
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
@@ -358,64 +322,12 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
groups.into_values().collect()
}
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
/// less, angles and ratios by size², and contact parameters move freely.
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
for (j, f) in slot_scales(model, s) {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
weights
}
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
/// points is what is minimised.
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
}
}
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
d: &Drawing,
cons: &[&Constraint],
@@ -432,24 +344,55 @@ pub(crate) fn build_budgeted(
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
match interpret(c, &mut model, target) {
Ok(mut b) => {
if let Form::Anchor(_) = b.form
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
{
b.form = Form::Anchor(a);
}
built.push(b);
}
Ok(b) => built.push(b),
Err(msg) => skipped.push((c.id, msg)),
}
}
if built.is_empty() {
continue;
}
let weights = solver_weights(&model, opts);
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
let mut weights = vec![1.0; model.x0.len()];
for s in &model.slots {
let w = if opts.keep.contains(&s.handle) {
1e4
} else if opts.prefer_move.contains(&s.handle) {
1e-2
} else {
1.0
};
for j in s.off..s.off + s.len {
if let Some(slot) = weights.get_mut(j) {
*slot = w;
}
}
// Angles and ratios: weight by size² so the movement of points is what is minimised.
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
let scaled: Vec<(usize, f64)> = match s.shape {
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
model::Shape::Ellipse { arc } => {
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
let mut v = vec![(s.off + 4, a2)];
if arc {
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
}
v
}
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
_ => Vec::new(),
};
for (j, f) in scaled {
if let Some(slot) = weights.get_mut(j) {
*slot *= f;
}
}
}
// Contact parameters move freely.
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
for w in weights.iter_mut().skip(geo) {
*w = 1e-3;
}
let tol = scale_tol(&model.x0);
comps.push(Comp { model, built, weights, held, tol });
comps.push(Comp { model, built, weights, tol });
}
Ok((comps, skipped))
}
@@ -500,9 +443,6 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
/// the point back.
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let refs: Vec<GeomRef> = d
.constraints
@@ -519,21 +459,10 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
let mut changed = false;
for r in &refs {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else { continue };
if !same_shape {
continue;
}
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
&& translated(&now, &old, r.handle)
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
{
for (v, &w) in dst.iter_mut().zip(src) {
*v = w;
}
changed = true;
}
continue;
};
for (i, j) in i.into_iter().zip(j) {
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
&& *slot != v
@@ -549,88 +478,6 @@ pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
changed
}
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
m.point_refs(h)
.iter()
.filter_map(|r| m.lock_indices(r))
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
.collect()
}
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
let tol = scale_tol(xb) * 10.0;
let pts: Vec<(usize, usize)> =
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
return false;
}
(0..xb.len())
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
}
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
/// object kept its points or moved them all.
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
return Vec::new();
}
let tol = scale_tol(&old.x0) * 10.0;
let refs = now.point_refs(h);
let moved: Vec<GeomRef> = refs
.iter()
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
.copied()
.collect();
if moved.len() == refs.len() { Vec::new() } else { moved }
}
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
///
/// A Fix on a derived point anchors it where it is when the system is built, and
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
/// would leave the Fix satisfied at the new place.
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
let fixes: Vec<(u32, GeomRef)> = d
.constraints
.iter()
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
.filter_map(|c| match c.refs.as_slice() {
[r] => Some((c.id, *r)),
_ => None,
})
.collect();
if fixes.is_empty() {
return Vec::new();
}
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
let tol = scale_tol(&old.x0);
let mut out = Vec::new();
for (id, r) in fixes {
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
if !same_shape || now.lock_indices(&r).is_some() {
continue;
}
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
&& !p.near(a, tol)
{
out.push((id, a));
}
}
out
}
/// True when every interpretable constraint holds at the current geometry.
pub fn all_satisfied(d: &Drawing) -> bool {
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
+67 -145
View File
@@ -32,8 +32,6 @@ pub const MAX_RIGID_POINTS: usize = 256;
const MAX_RIGID_MEMBERS: usize = 100_000;
/// Shared across every model built for one analysis request.
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
const RATIO_TOL: f64 = 1e-9;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
@@ -119,8 +117,6 @@ pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
rigid_points_budgeted(d, ins, &mut members_left)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
@@ -191,78 +187,6 @@ impl Model {
Self::build_budgeted(d, handles, &mut members_left)
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
return None;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
return None;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => return None,
};
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
for &h in handles {
@@ -271,7 +195,71 @@ impl Model {
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
let shape = match &e.kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
Shape::Line
}
EntityKind::Circle(c) => {
m.x0.extend([c.center.x, c.center.y, c.radius]);
Shape::Circle
}
EntityKind::Arc(a) => {
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
Shape::Arc
}
EntityKind::LwPolyline(p) => {
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
continue;
}
for v in &p.vertices {
m.x0.extend([v.p.x, v.p.y]);
}
Shape::Poly { n: p.vertices.len(), closed: p.closed }
}
EntityKind::Point(p) => {
m.x0.extend([p.p.x, p.p.y]);
Shape::Point
}
EntityKind::Ellipse(el) => {
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
let arc = !ell.is_full();
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
if arc {
m.x0.extend([el.start, el.end]);
}
Shape::Ellipse { arc }
}
EntityKind::Spline(sp) => {
let fit = fit_mode(sp);
let pts = if fit { &sp.fit } else { &sp.control };
let n = pts.len();
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
continue;
}
for p in pts {
m.x0.extend([p.x, p.y]);
}
// Fit points interpolate exactly, so the curve's ends are the first and last
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
Shape::Insert { n }
}
_ => continue,
};
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
@@ -486,18 +474,6 @@ impl Model {
}
}
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
let Some(s) = self.slot(h) else { return Vec::new() };
let at = |sub: Sub| GeomRef { handle: h, sub };
match s.shape {
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
Shape::Point => vec![at(Sub::Whole)],
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
}
}
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
/// point (fixed through a residual instead).
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
@@ -525,57 +501,6 @@ impl Model {
Some(v)
}
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
/// longer one: a solution past that is stored with its axes swapped or its minor axis
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
pub fn storable(&self, x: &[f64]) -> bool {
self.slots.iter().all(|s| match s.shape {
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
_ => true,
})
}
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
/// a negative ratio or an ellipse a zero one.
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
let mut xc = x.to_vec();
for s in &self.slots {
let Shape::Ellipse { arc } = s.shape else { continue };
let o = s.off;
let mut ratio = *x.get(o + 4)?;
if ratio < 0.0 && !arc {
ratio = -ratio;
}
if ratio <= 0.0 {
return None;
}
if ratio > 1.0 + RATIO_TOL {
let major = p2(x, o + 2)?.perp() * ratio;
*xc.get_mut(o + 2)? = major.x;
*xc.get_mut(o + 3)? = major.y;
ratio = 1.0 / ratio;
if arc {
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
}
}
*xc.get_mut(o + 4)? = ratio;
}
Some(xc)
}
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
/// the axes were swapped.
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
let Some(s) = self.slot(h) else { return t };
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
}
/// Write solved parameters back. Returns the handles that moved.
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
let mut moved = Vec::new();
@@ -644,9 +569,8 @@ impl Model {
[a, b] if arc => (*a, *b),
_ => (el.start, el.end),
};
// Keep the stored form: the major axis is the longer one. `storable` lets a
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
if ratio > 1.0 + RATIO_TOL {
// Keep the stored form: the major axis is the longer one.
if ratio > 1.0 {
major = major.perp() * ratio;
ratio = 1.0 / ratio;
if arc {
@@ -696,7 +620,6 @@ mod budget_tests {
use cadcraft_geom::Vec3;
use std::sync::Arc;
/// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact.
#[test]
fn write_back_keeps_partial_rational_weights() {
let mut drawing = Drawing::default();
@@ -712,7 +635,6 @@ mod budget_tests {
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
+185 -342
View File
@@ -3,7 +3,7 @@
use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef};
use cadcraft_geom::Vec2;
use crate::curves::{Ell, circle_at};
use crate::curves::circle_at;
use crate::model::Model;
/// Contact parameter samples per curve when guessing where two curves touch.
@@ -116,7 +116,6 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
}
impl Built {
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
@@ -205,7 +204,42 @@ impl Built {
};
b.rows = 1;
}
K::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x),
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::Symmetric => {
if n != 3 || ln(2).is_none() {
return need("two objects and a symmetry line");
@@ -312,8 +346,154 @@ impl Built {
out.resize(start + self.rows, 0.0);
}
fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec<f64>) -> Option<()> {
ResidualEval { built: self, model, values, out }.evaluate()
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(())
}
/// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`.
@@ -346,54 +526,6 @@ 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()));
@@ -463,292 +595,3 @@ 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(())
}
}
+2 -2
View File
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
let mut d = Drawing::default();
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
let (a0, a1) = ln(&d, a);
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
}
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
})
.unwrap();
assert!(!all_satisfied(&d));
let r = solve(&mut d, &SolveOptions::default()).unwrap();
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
assert_eq!(r.moved, vec![a]);
assert!(all_satisfied(&d));
}
-81
View File
@@ -364,7 +364,6 @@ fn hostile_new_geometry_never_panics() {
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
}
/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline.
#[test]
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
let mut d = Drawing::default();
@@ -382,83 +381,3 @@ fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
assert_eq!(report.status, DofStatus::Under);
assert_eq!(report.dof, Some(1000));
}
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
/// with the drawing unchanged.
fn refused_on_minor_axis_end(p: (f64, f64)) {
let mut d = Drawing::default();
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
let p = point(&mut d, p);
ok(&mut d, con(K::Fix, &[w(p)]));
let before = d.clone();
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
assert_eq!(d, before);
}
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
#[test]
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
refused_on_minor_axis_end((0.0, 6.0));
}
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
#[test]
fn minor_axis_end_pulled_through_the_centre_is_refused() {
refused_on_minor_axis_end((0.0, -1.0));
}
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
/// swapped, which constraints that name no axis end by index do not notice.
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
e
}
#[test]
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
ok(&mut d, con(K::Fix, &[w(l)]));
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let p = point(&mut d, (0.0, 4.5));
ok(&mut d, con(K::Fix, &[w(p)]));
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
#[test]
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
let mut d = Drawing::default();
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
let c = circle(&mut d, (0.0, 6.0), 1.5);
ok(&mut d, con(K::Fix, &[w(c)]));
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
assert!(ell(&d, e).ratio <= 1.0);
assert!(all_satisfied(&d));
}
+1 -1
View File
@@ -208,7 +208,7 @@ impl Default for ParametricSettings {
angle_tolerance: 1.0,
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
.iter()
.map(ToString::to_string)
.map(|s| s.to_string())
.collect(),
bars_visible: true,
bar_exceptions: Vec::new(),
+1 -1
View File
@@ -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`](crate::dynblock::dynamic_block) reads them into the model below without changing the kept data, so a drawing
//! [`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
+4 -42
View File
@@ -29,21 +29,16 @@ fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() }
}
/// The element tag (`AcDbBlockRepETag` 1071) in `x`, if it has one.
fn etag_in(x: &[RawTag]) -> Option<i64> {
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
}
/// The element tag of object `e`: its own xdata (where the importer keeps it), else any kept in the
/// foreign data.
fn etag(d: &Drawing, e: &Entity) -> Option<i64> {
etag_in(&e.common.xdata).or_else(|| etag_in(&d.foreign.entities.get(&e.handle)?.xdata))
}
/// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
}
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
@@ -547,12 +542,6 @@ fn apply(
let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone();
c.handle = nh;
// The copy's own xdata names the copy, not its original (as the kept data below does).
for t in &mut c.common.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(e.handle) {
t.value = nh.hex();
}
}
if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v;
}
@@ -975,30 +964,3 @@ fn drop_if_unused(d: &mut Drawing, name: &str) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tagged(h: u64, tag_value: Option<&str>) -> Entity {
let mut e = Entity::new(Handle(h), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::ZERO }));
if let Some(v) = tag_value {
e.common.xdata = vec![tag(1001, ETAG), tag(1071, v)];
}
e
}
#[test]
fn element_tags_are_read_from_the_entitys_own_xdata() {
let d = Drawing::new_imperial();
let mut def = Block::new("DEF");
def.entities.push(tagged(1, Some("10")));
def.entities.push(tagged(2, Some("20")));
let mut anon = Block::new("*U1");
// Reordered, and the first object left out.
anon.entities.push(tagged(5, Some("20")));
anon.entities.push(tagged(6, None));
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
}
+936 -28
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
use super::common::{Common, Lineweight};
use super::text::{MText, Text};
use super::{Color, Deserialize, EntityKind, Mat3, Serialize, Vec2, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attrib {
pub tag: String,
pub text: Text,
#[serde(default)]
pub invisible: bool,
#[serde(default)]
pub constant: bool,
#[serde(default)]
pub prompt: String,
/// An attribute's own properties; each one left unset follows the attribute definition.
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
pub props: AttribProps,
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
/// multiline text. `text.value` holds the plain text, which value edits change.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiline: Option<MultilineAttrib>,
/// Its other modes: Verify, Preset and Lock position.
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
pub modes: AttribModes,
}
/// An attribute's modes beyond invisible and constant.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttribModes {
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
#[serde(default)]
pub verify: bool,
/// Preset: the default value is used without asking (DXF flag 8).
#[serde(default)]
pub preset: bool,
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
/// 280 after the flags). A reference's attributes take it from their definitions.
#[serde(default)]
pub lock_position: bool,
}
impl AttribModes {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
/// only Lock position there.
pub fn for_reference(self) -> Self {
AttribModes { lock_position: self.lock_position, ..Self::default() }
}
}
/// The multiline text of a multiline attribute.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultilineAttrib {
pub mtext: MText,
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
/// since, and its value replaces the contents.
pub read_as: String,
}
impl MultilineAttrib {
/// This text placed by `m` (a block reference's transform, for its attribute made from a
/// definition).
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
let mut k = EntityKind::MText(self.mtext.clone());
k.transform(m);
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
MultilineAttrib { mtext, read_as: self.read_as.clone() }
}
}
impl Attrib {
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
pub fn transform_multiline(&mut self, m: &Mat3) {
if let Some(ml) = &mut self.multiline {
*ml = ml.transformed(m);
}
}
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
/// replaced by the value when the value was edited since it was read.
pub fn multiline_text(&self) -> Option<MText> {
let m = self.multiline.as_ref()?;
let mut t = m.mtext.clone();
if self.text.value != m.read_as {
t.contents = mtext_literal(&self.text.value);
}
Some(t)
}
}
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
/// paragraph breaks.
pub fn mtext_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
'\n' => out.push_str("\\P"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
/// lineweight), overriding those of its definition.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AttribProps {
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<Color>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linetype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineweight: Option<Lineweight>,
}
impl AttribProps {
/// Nothing set: the attribute is drawn with its definition's properties.
pub fn is_empty(&self) -> bool {
*self == AttribProps::default()
}
/// `base` (the definition's properties) with the ones set here in place.
pub fn over(&self, base: &Common) -> Common {
Common {
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
color: self.color.unwrap_or(base.color),
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
lineweight: self.lineweight.unwrap_or(base.lineweight),
..base.clone()
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Insert {
pub block: String,
pub insert: Vec3,
#[serde(default = "unit_scale")]
pub scale: Vec3,
#[serde(default)]
pub rotation: f64,
#[serde(default)]
pub attribs: Vec<Attrib>,
#[serde(default = "one_u")]
pub cols: u32,
#[serde(default = "one_u")]
pub rows: u32,
#[serde(default)]
pub col_spacing: f64,
#[serde(default)]
pub row_spacing: f64,
}
fn unit_scale() -> Vec3 {
Vec3::new(1.0, 1.0, 1.0)
}
fn one_u() -> u32 {
1
}
impl Insert {
/// Block-to-world transform (2D part).
pub fn transform(&self, base: Vec2) -> Mat3 {
Mat3::translate(self.insert.xy())
.then_before(Mat3::rotate(self.rotation))
.then_before(Mat3::scale(self.scale.x, self.scale.y))
.then_before(Mat3::translate(-base))
}
}
-132
View File
@@ -1,132 +0,0 @@
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)]
pub struct Handle(pub u64);
impl Handle {
pub fn hex(self) -> String {
format!("{:X}", self.0)
}
pub fn parse_hex(s: &str) -> Option<Handle> {
let s = s.trim();
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u64::from_str_radix(s, 16).ok().map(Handle)
}
}
/// Lineweight in hundredths of a millimetre, or a logical value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Lineweight {
#[default]
ByLayer,
ByBlock,
Default,
/// 0..=211 (hundredths of mm).
Mm100(u16),
}
impl Lineweight {
/// The standard lineweight values (mm × 100).
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
pub fn to_dxf(self) -> i16 {
match self {
Lineweight::ByLayer => -1,
Lineweight::ByBlock => -2,
Lineweight::Default => -3,
Lineweight::Mm100(v) => v.min(211) as i16,
}
}
pub fn from_dxf(v: i16) -> Lineweight {
match v {
-2 => Lineweight::ByBlock,
-3 => Lineweight::Default,
0..=211 => Lineweight::Mm100(v as u16),
_ => Lineweight::ByLayer,
}
}
pub fn name(self) -> String {
match self {
Lineweight::ByLayer => "ByLayer".into(),
Lineweight::ByBlock => "ByBlock".into(),
Lineweight::Default => "Default".into(),
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
}
}
}
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Transparency {
#[default]
ByLayer,
ByBlock,
Percent(u8),
}
impl Transparency {
/// "ByLayer", "ByBlock" or the percentage ("50").
pub fn name(&self) -> String {
match self {
Transparency::ByLayer => "ByLayer".into(),
Transparency::ByBlock => "ByBlock".into(),
Transparency::Percent(p) => p.to_string(),
}
}
}
/// Properties every entity has.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Common {
pub layer: String,
pub color: Color,
pub linetype: String,
pub lineweight: Lineweight,
pub ltscale: f64,
pub transparency: Transparency,
pub visible: bool,
pub thickness: f64,
pub extrusion: Vec3,
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
/// style table.
pub plot_style: String,
/// The annotation scales an annotative object supports (empty: not annotative).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub anno_scales: Vec<crate::AnnoScale>,
/// Other applications' extended data (xdata) as read from the drawing file: whole
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub xdata: Vec<RawTag>,
}
impl Default for Common {
fn default() -> Self {
Common {
layer: "0".into(),
color: Color::ByLayer,
linetype: "ByLayer".into(),
lineweight: Lineweight::ByLayer,
ltscale: 1.0,
transparency: Transparency::ByLayer,
visible: true,
thickness: 0.0,
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
anno_scales: Vec::new(),
xdata: Vec::new(),
}
}
}
/// A raw DXF group: kept for round-tripping entities we do not model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RawTag {
pub code: i32,
pub value: String,
}
-76
View File
@@ -1,76 +0,0 @@
use super::{Deserialize, PolyVertex, Serialize, Vec3};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Line {
pub a: Vec3,
pub b: Vec3,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Point {
pub p: Vec3,
#[serde(default)]
pub angle: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Circle {
pub center: Vec3,
pub radius: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Arc {
pub center: Vec3,
pub radius: f64,
/// Radians, CCW.
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ellipse {
pub center: Vec3,
pub major: Vec3,
pub ratio: f64,
pub start: f64,
pub end: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LwPolyline {
pub vertices: Vec<PolyVertex>,
pub closed: bool,
#[serde(default)]
pub const_width: f64,
#[serde(default)]
pub elevation: f64,
/// Polyline linetype generation (PLINEGEN).
#[serde(default)]
pub plinegen: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Polyline3d {
pub points: Vec<Vec3>,
pub closed: bool,
}
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Solid {
pub corners: [Vec3; 4],
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Face3d {
pub corners: [Vec3; 4],
#[serde(default)]
pub hidden_edges: u8,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
pub dir: Vec3,
}
-226
View File
@@ -1,226 +0,0 @@
use super::block::Insert;
use super::common::Handle;
use super::text::MText;
use super::{Deserialize, GSpline, Serialize, Vec2, Vec3, one, standard, yes};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DimKind {
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
Linear {
rotation: f64,
},
Aligned,
Angular,
Angular3P,
Diameter,
Radius,
Ordinate {
x_type: bool,
},
ArcLength,
}
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Dimension {
pub kind: DimKind,
pub defpt: Vec3,
pub text_mid: Vec3,
#[serde(default)]
pub p13: Vec3,
#[serde(default)]
pub p14: Vec3,
#[serde(default)]
pub p15: Vec3,
#[serde(default)]
pub p16: Vec3,
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
#[serde(default)]
pub text: String,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub measurement: f64,
#[serde(default)]
pub text_rotation: f64,
/// User moved the text (text_mid is authoritative).
#[serde(default)]
pub user_text_pos: bool,
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub overrides: serde_json::Map<String, serde_json::Value>,
/// Associativity: which definition points follow which objects.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assoc: Vec<DimAssoc>,
/// Oblique extension lines, jogs, breaks and inspection frames.
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
pub marks: DimMarks,
}
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
/// DIMBREAK and DIMINSPECT.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimMarks {
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
/// perpendicular to the dimension line.
#[serde(skip_serializing_if = "Option::is_none")]
pub oblique: Option<f64>,
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
#[serde(skip_serializing_if = "Option::is_none")]
pub jog_line: Option<Vec3>,
/// A jogged radius dimension (DIMJOGGED).
#[serde(skip_serializing_if = "Option::is_none")]
pub jogged: Option<JoggedRadius>,
/// Gaps in the dimension and extension lines (DIMBREAK).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub breaks: Vec<DimBreak>,
/// Inspection frame, label and rate (DIMINSPECT).
#[serde(skip_serializing_if = "Option::is_none")]
pub inspect: Option<Inspection>,
}
impl DimMarks {
pub fn is_empty(&self) -> bool {
*self == DimMarks::default()
}
/// 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 });
}
if let Some(j) = &mut self.jog_line {
t3(j);
}
if let Some(j) = &mut self.jogged {
t3(&mut j.center);
t3(&mut j.jog);
}
for b in &mut self.breaks {
t3(&mut b.at);
b.size *= s;
}
}
}
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
/// centre and jogs on its way to the arc.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct JoggedRadius {
/// Where the dimension line starts instead of the true centre.
pub center: Vec3,
/// The middle of the jog.
pub jog: Vec3,
}
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DimBreak {
pub at: Vec3,
pub size: f64,
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
pub auto: bool,
}
/// An inspection dimension (DIMINSPECT).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Inspection {
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
pub shape: u8,
/// Text left of the value ("" = none).
pub label: String,
/// Inspection rate right of the value ("" = none).
pub rate: String,
}
/// A dimension definition point that follows an object (associative dimensions).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DimAssoc {
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
pub point: String,
/// The object it is attached to.
pub handle: Handle,
pub snap: AssocSnap,
}
/// Where on the object an associative definition point sits.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AssocSnap {
/// Start point of a line/arc (or polyline vertex 0).
Start,
/// End point of a line/arc.
End,
Mid,
/// Centre of a circle/arc.
Center,
/// On a circle/arc at a fixed angle (radians) from its centre.
OnCircle {
angle: f64,
},
/// Intersection of this (line) object with another line.
Intersection {
other: Handle,
},
/// Vertex `index` of a polyline.
Vertex {
index: usize,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Leader {
pub vertices: Vec<Vec3>,
#[serde(default = "yes")]
pub arrow: bool,
#[serde(default)]
pub spline: bool,
#[serde(default = "standard")]
pub style: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MLeader {
pub leaders: Vec<Vec<Vec3>>,
pub landing: Vec3,
pub dogleg: f64,
pub text: Option<MText>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "one")]
pub arrow_size: f64,
/// Leader lines are smooth curves through their points (leader type Spline). An empty
/// `leaders` is a multileader without a leader line (leader type None).
#[serde(default)]
pub spline: bool,
/// Block content in place of the text: a block reference placed at the landing.
#[serde(default)]
pub block: Option<Insert>,
}
impl MLeader {
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
self.leaders
.iter()
.map(|l| {
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
})
.collect()
}
}
-56
View File
@@ -1,56 +0,0 @@
use super::common::Handle;
use super::{Color, Deserialize, PolyVertex, Serialize, Vec2, one};
/// A hatch boundary loop: closed polyline with bulges.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HatchLoop {
pub vertices: Vec<PolyVertex>,
#[serde(default)]
pub outer: bool,
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
/// hatch re-fills the loop from them when they change.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<Handle>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hatch {
pub pattern: String,
pub solid: bool,
pub loops: Vec<HatchLoop>,
#[serde(default = "one")]
pub scale: f64,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub associative: bool,
/// 0 normal (odd parity), 1 outer, 2 ignore.
#[serde(default)]
pub style: u8,
#[serde(default)]
pub elevation: f64,
/// Gradient fill: name + second colour.
#[serde(default)]
pub gradient: Option<Gradient>,
#[serde(default)]
pub origin: Vec2,
#[serde(default)]
pub background: Option<Color>,
/// The pattern's line families as a file defined them, for a pattern that isn't in the
/// standard library (unit scale and angle, like the library's; empty otherwise).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pattern_lines: Vec<crate::library::PatternLine>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub name: String,
pub color1: Color,
pub color2: Color,
#[serde(default)]
pub angle: f64,
#[serde(default)]
pub centered: bool,
}
-168
View File
@@ -1,168 +0,0 @@
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Viewport {
pub center: Vec3,
pub width: f64,
pub height: f64,
pub view_center: Vec2,
pub view_height: f64,
#[serde(default)]
pub id: u32,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub frozen_layers: Vec<String>,
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub layer_colors: Vec<(String, Color)>,
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<Handle>,
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
#[serde(default = "yes")]
pub on: bool,
}
impl Default for Viewport {
fn default() -> Self {
Viewport {
center: Vec3::ZERO,
width: 1.0,
height: 1.0,
view_center: Vec2::ZERO,
view_height: 1.0,
id: 0,
locked: false,
frozen_layers: Vec::new(),
layer_colors: Vec::new(),
clip: None,
on: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Image {
pub insert: Vec3,
/// One pixel's width (U) and height (V) vectors in drawing units.
pub u: Vec3,
pub v: Vec3,
/// Image size in pixels.
pub size: Vec2,
/// The image file (its IMAGEDEF object's path).
pub path: String,
/// The image definition's name (its key in the drawing's image dictionary).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
#[serde(default)]
pub clipping: bool,
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
/// clip boundary, 8 transparency on.
#[serde(default = "image_display")]
pub display: u16,
/// Brightness, contrast and fade, 0..=100.
#[serde(default = "image_fifty")]
pub brightness: u8,
#[serde(default = "image_fifty")]
pub contrast: u8,
#[serde(default)]
pub fade: u8,
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
#[serde(default = "image_pixel")]
pub pixel_size: Vec2,
#[serde(default)]
pub resolution_units: u8,
}
fn image_display() -> u16 {
7
}
fn image_fifty() -> u8 {
50
}
fn image_pixel() -> Vec2 {
Vec2::new(1.0, 1.0)
}
impl Default for Image {
fn default() -> Self {
Image {
insert: Vec3::ZERO,
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(1.0, 1.0),
path: String::new(),
name: String::new(),
clip: Vec::new(),
clipping: false,
display: image_display(),
brightness: image_fifty(),
contrast: image_fifty(),
fade: 0,
pixel_size: image_pixel(),
resolution_units: 0,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableCell {
pub text: String,
#[serde(default)]
pub merged: Option<(u32, u32)>,
/// The cell's own cell style; empty: its row's.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub style: String,
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formula: Option<String>,
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
/// `None`: its cell style's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_height: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Table {
pub insert: Vec3,
pub col_widths: Vec<f64>,
pub row_heights: Vec<f64>,
pub cells: Vec<Vec<TableCell>>,
#[serde(default = "standard")]
pub style: String,
#[serde(default = "default_text_h")]
pub text_height: f64,
/// First row is a title (merged across, larger text).
#[serde(default)]
pub title: bool,
/// The row after the title holds column headers.
#[serde(default)]
pub header: bool,
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub row_styles: Vec<String>,
/// Table breaking (TABLEBREAK): the table drawn in several parts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breaks: Option<crate::TableBreak>,
/// The CSV file the cells come from (DATALINK).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_link: Option<crate::DataLink>,
}
fn default_text_h() -> f64 {
0.18
}
-141
View File
@@ -1,141 +0,0 @@
use super::{Deserialize, Serialize, Vec2, Vec3, one, standard};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HAlign {
#[default]
Left,
Center,
Right,
Aligned,
Middle,
Fit,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VAlign {
#[default]
Baseline,
Bottom,
Middle,
Top,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Text {
pub insert: Vec3,
#[serde(default)]
pub align_pt: Option<Vec3>,
pub height: f64,
pub value: String,
#[serde(default)]
pub rotation: f64,
#[serde(default = "one")]
pub width_factor: f64,
#[serde(default)]
pub oblique: f64,
#[serde(default = "standard")]
pub style: String,
#[serde(default)]
pub halign: HAlign,
#[serde(default)]
pub valign: VAlign,
}
impl Text {
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
/// aligned and fit text.
pub fn justify_point(&self) -> Vec2 {
match (self.halign, self.valign) {
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
_ => self.align_pt.unwrap_or(self.insert).xy(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MText {
pub insert: Vec3,
pub height: f64,
/// Reference rectangle width (0 = no wrapping).
#[serde(default)]
pub width: f64,
/// 1..=9: TL TC TR ML MC MR BL BC BR.
#[serde(default = "attach_tl")]
pub attach: u8,
#[serde(default)]
pub rotation: f64,
#[serde(default = "standard")]
pub style: String,
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
pub contents: String,
#[serde(default = "one")]
pub line_spacing: f64,
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
/// heights; At least (`false`, the default) lets taller characters push lines apart.
#[serde(default)]
pub line_spacing_exact: bool,
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
/// one column.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<MTextColumns>,
}
fn attach_tl() -> u8 {
1
}
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
/// as the text needs, of one height (auto height) or each its own (manual heights).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MTextColumnType {
#[default]
Static,
Dynamic,
}
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MTextColumns {
#[serde(rename = "type")]
pub kind: MTextColumnType,
/// Number of columns (static columns: the columns there are).
pub count: u16,
/// Space between two columns.
pub gutter: f64,
/// Height of every column (static, and dynamic with auto height).
pub height: f64,
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
pub auto_height: bool,
/// Dynamic columns with manual heights: the height of each column.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub heights: Vec<f64>,
/// Columns run right to left.
pub flow_reversed: bool,
}
impl MTextColumns {
/// The width of `count` columns of `column_width` and their gutters.
pub fn total_width(&self, column_width: f64) -> f64 {
let n = f64::from(self.count.max(1));
column_width * n + self.gutter.max(0.0) * (n - 1.0)
}
/// The tallest column.
pub fn max_height(&self) -> f64 {
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
}
/// Scale every length (SCALE, block insertion).
pub fn scale(&mut self, s: f64) {
self.gutter *= s;
self.height *= s;
self.heights.iter_mut().for_each(|h| *h *= s);
}
}
-162
View File
@@ -1,162 +0,0 @@
//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
//! One exact terminal copy of the first point is a closure marker, not an editable corner.
//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
use std::borrow::Cow;
use cadcraft_geom::Vec2;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Wipeout {
/// Ordered boundary, implicitly closed or ending in a repeated first point.
pub boundary: Vec<Vec2>,
}
impl Wipeout {
/// Editable vertices, excluding one exactly repeated closing point, without changing storage.
pub fn vertices(&self) -> &[Vec2] {
match self.boundary.split_last() {
Some((last, vertices)) if vertices.first() == Some(last) => vertices,
_ => &self.boundary,
}
}
/// An explicitly closed outline for geometry, rendering and export. Already closed or
/// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
Cow::Borrowed(&self.boundary)
} else {
let mut points = self.boundary.clone();
points.extend(self.boundary.first());
Cow::Owned(points)
}
}
/// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
/// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
/// Invalid indices/non-finite points leave the boundary unchanged.
pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
let count = self.vertices().len();
if index >= count || !to.is_finite() {
return None;
}
let explicit = count < self.boundary.len();
*self.boundary.get_mut(index)? = to;
if explicit
&& index == 0
&& let Some(last) = self.boundary.last_mut()
{
*last = to;
}
if !explicit && self.vertices().len() < count {
// The moved endpoint is a logical corner, not the newly implied closing marker.
if let Some(first) = self.boundary.first().copied() {
self.boundary.push(first);
}
}
Some(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EntityKind;
#[test]
fn vertices_and_grips_exclude_only_the_exact_closing_point() {
let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
let near_a = Vec2::new(1e-13, 0.0);
for (boundary, expected) in [
(vec![], vec![]),
(vec![a], vec![a]),
(vec![a, a], vec![a]),
(vec![a, b, c], vec![a, b, c]),
(vec![a, b, c, a], vec![a, b, c]),
(vec![a, b, a, c, a], vec![a, b, a, c]),
(vec![a, b, c, a, a], vec![a, b, c, a]),
(vec![a, b, c, near_a], vec![a, b, c, near_a]),
] {
let wipeout = Wipeout { boundary: boundary.clone() };
assert_eq!(wipeout.vertices(), expected);
assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
assert_eq!(wipeout.boundary, boundary);
}
}
#[test]
fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
let implicit = Wipeout { boundary: points.clone() };
let mut expected = points.clone();
expected.push(points[0]);
assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
assert_eq!(implicit.closed_boundary().as_ref(), expected);
assert_eq!(implicit.boundary, points);
let explicit = Wipeout { boundary: expected.clone() };
assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
assert_eq!(explicit.closed_boundary().as_ref(), expected);
let a = EntityKind::Wipeout(implicit).prims();
let b = EntityKind::Wipeout(explicit).prims();
assert_eq!(a, b);
assert_eq!(a.len(), 3);
for boundary in [vec![], vec![Vec2::ZERO]] {
let wipeout = Wipeout { boundary: boundary.clone() };
assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
}
}
#[test]
fn moving_endpoints_together_keeps_both_corners_editable() {
let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
for explicit in [false, true] {
for index in [0, 3] {
let other = 3 - index;
let mut boundary = points.to_vec();
if explicit {
boundary.push(points[0]);
}
let mut wipeout = Wipeout { boundary };
let mut expected = points;
expected[index] = points[other];
for _ in 0..2 {
assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
assert_eq!(wipeout.vertices(), expected);
assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
}
expected[other] = Vec2::new(-2.0, 4.0);
assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
assert_eq!(wipeout.vertices(), expected);
assert_eq!(wipeout.boundary.len(), 5);
assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
}
}
}
#[test]
fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
for explicit in [false, true] {
let mut boundary = vec![a, b, a, c];
if explicit {
boundary.push(a);
}
let mut wipeout = Wipeout { boundary };
assert_eq!(wipeout.set_vertex(0, to), Some(()));
let mut expected = vec![to, b, a, c];
if explicit {
expected.push(to);
}
assert_eq!(wipeout.boundary, expected);
for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
assert_eq!(wipeout.set_vertex(index, point), None);
assert_eq!(wipeout.boundary, expected);
}
}
}
}
-5
View File
@@ -66,10 +66,6 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>,
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
/// here, and saving renumbers it to the written CLASSES (#1812).
pub class_order: Vec<String>,
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
@@ -85,7 +81,6 @@ impl Foreign {
&& self.records.is_empty()
&& self.classes.is_empty()
&& self.targets.is_empty()
&& self.class_order.is_empty()
&& self.data_store.is_empty()
}
-100
View File
@@ -1,100 +0,0 @@
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");
}
}
-18
View File
@@ -10,7 +10,6 @@ mod array;
mod constraint;
mod entity;
mod extents;
mod format;
mod gdt;
mod header;
pub mod library;
@@ -28,7 +27,6 @@ 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::*;
@@ -100,9 +98,6 @@ pub struct Drawing {
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
pub model: EntityStore,
pub layouts: Vec<Layout>,
/// The layout a file marks active (its block is `*Paper_Space`, the others `*Paper_SpaceN`),
/// written active again. `None`, or a layout that no longer exists: the first by tab order.
pub active_layout: Option<String>,
pub views: Vec<NamedView>,
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
pub vports: Vec<VportConfig>,
@@ -176,7 +171,6 @@ impl Drawing {
blocks: BTreeMap::new(),
model: EntityStore::new(),
layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)],
active_layout: None,
views: Vec::new(),
vports: Vec::new(),
ucss: Vec::new(),
@@ -304,18 +298,6 @@ impl Drawing {
true
}
/// The layouts in the order their paper space blocks are named in a file: the active one
/// (`*Paper_Space`) first, then the others by tab order (`*Paper_Space0`, `*Paper_Space1`, …).
pub fn paper_space_layouts(&self) -> Vec<&Layout> {
let mut ls: Vec<&Layout> = self.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
if let Some(i) = self.active_layout.as_ref().and_then(|a| ls.iter().position(|l| l.name.eq_ignore_ascii_case(a))) {
let active = ls.remove(i);
ls.insert(0, active);
}
ls
}
pub fn space(&self, space: &Space) -> Option<&EntityStore> {
match space {
Space::Model => Some(&self.model),
+36 -66
View File
@@ -177,11 +177,6 @@ pub struct DimStyle {
pub dim_line_color: Color,
pub ext_line_color: Color,
pub text_color: Color,
/// DIMTFILL, the background of the text: 0 none, 1 the drawing's background color,
/// 2 `text_fill_color`.
pub text_fill: u8,
/// DIMTFILLCLR: the text background color when `text_fill` is 2.
pub text_fill_color: Color,
pub text_style: String,
/// DIMPOST prefix/suffix "<>" template.
pub post: String,
@@ -301,8 +296,6 @@ pub const DIMVARS: &[(&str, &str)] = &[
("DIMCLRD", "dimLineColor"),
("DIMCLRE", "extLineColor"),
("DIMCLRT", "textColor"),
("DIMTFILL", "textFill"),
("DIMTFILLCLR", "textFillColor"),
("DIMTXSTY", "textStyle"),
("DIMPOST", "post"),
("DIMCEN", "centerMark"),
@@ -374,8 +367,6 @@ impl Default for DimStyle {
dim_line_color: Color::ByBlock,
ext_line_color: Color::ByBlock,
text_color: Color::ByBlock,
text_fill: 0,
text_fill_color: Color::ByBlock,
text_style: "Standard".into(),
post: String::new(),
center_mark: 0.09,
@@ -441,49 +432,6 @@ impl DimStyle {
.or_else(|| ["name", "annotative"].into_iter().find(|f| f.eq_ignore_ascii_case(name)))
}
/// The value `v` takes for a field whose current value is `old`: coerced to the field's type where
/// the text or number can be read as it.
fn coerce_value(old: &serde_json::Value, v: &serde_json::Value, field: &str) -> serde_json::Value {
use serde_json::Value;
match (old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
}
}
/// Whether `coerced` is outside the range its `field` can take.
fn out_of_range(field: &str, coerced: &serde_json::Value) -> bool {
let x = coerced.as_f64();
match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" | "textFill" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
}
}
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
/// had unusable values.
@@ -501,29 +449,51 @@ impl DimStyle {
rejected.push(k.clone());
continue;
};
let coerced = Self::coerce_value(&old, v, field);
let coerced = match (&old, v) {
(Value::Bool(_), Value::Number(n)) => Value::Bool(n.as_f64().is_some_and(|x| x != 0.0)),
(Value::Bool(_), Value::String(t)) => Value::Bool(matches!(t.to_ascii_lowercase().as_str(), "1" | "on" | "true" | "yes")),
(Value::Number(_), Value::String(t)) => match t.trim().parse::<f64>() {
Ok(x) => serde_json::json!(x),
// Lineweight names.
Err(_) if matches!(field, "dimLineweight" | "extLineweight") => match t.trim().to_ascii_lowercase().as_str() {
"bylayer" => serde_json::json!(-1),
"byblock" => serde_json::json!(-2),
"default" => serde_json::json!(-3),
_ => v.clone(),
},
Err(_) => v.clone(),
},
(Value::Number(_), Value::Bool(b)) => serde_json::json!(u8::from(*b)),
(Value::String(_), Value::Number(n)) => Value::String(n.to_string()),
(Value::Object(_), Value::Number(n)) => {
let c = n.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci);
c.and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone())
}
(Value::Object(_), Value::String(t)) => Color::parse(t).and_then(|c| serde_json::to_value(c).ok()).unwrap_or_else(|| v.clone()),
_ => v.clone(),
};
// Integers stored in u8 fields: round floats.
let coerced = match (&old, &coerced) {
// Lineweights (i16): whole hundredths of a mm or -1/-2/-3.
(Value::Number(_), Value::Number(n)) if matches!(field, "dimLineweight" | "extLineweight") && !n.is_i64() => {
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round() as i64)
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(-3.0, 211.0) as i64)
}
(Value::Number(_), Value::Number(_)) if matches!(field, "dimLineweight" | "extLineweight") => coerced,
// A value below 0 or one that rounds above 255 is refused; DIMADEC -1 reads as 0.
(Value::Number(o), Value::Number(n)) if o.is_u64() && !n.is_u64() => {
let min = if field == "angularDecimals" { -1.0 } else { 0.0 };
match n.as_f64().filter(|x| x.is_finite() && *x >= min).map(f64::round).filter(|x| *x <= 255.0) {
Some(x) => serde_json::json!(x.max(0.0) as u64),
None => {
rejected.push(k.clone());
continue;
}
}
serde_json::json!(n.as_f64().filter(|x| x.is_finite()).unwrap_or(0.0).round().clamp(0.0, 255.0) as u64)
}
_ => coerced,
};
// Sizes that can't be negative (text height and tolerance scale: not zero either).
let out_of_range = Self::out_of_range(field, &coerced);
let x = coerced.as_f64();
let out_of_range = match field {
"textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0),
"scale" | "arrowSize" | "tickSize" | "fixedExtLength" => x.is_some_and(|x| x < 0.0),
"fit" | "angularZeroSuppression" => x.is_some_and(|x| x > 3.0),
"textMove" | "tolJust" | "arcSymbol" => x.is_some_and(|x| x > 2.0),
"dimLineweight" | "extLineweight" => x.is_some_and(|x| !(-3.0..=211.0).contains(&x)),
"jogAngle" => x.is_some_and(|x| !(5.0..=90.0).contains(&x)),
_ => false,
};
if out_of_range {
rejected.push(k.clone());
continue;
@@ -887,7 +857,7 @@ pub struct VportConfig {
impl VportConfig {
/// The name of the configuration shown.
pub const ACTIVE: &str = "*Active";
pub const ACTIVE: &'static str = "*Active";
pub fn is_active(&self) -> bool {
self.name.eq_ignore_ascii_case(Self::ACTIVE)
}
+4 -4
View File
@@ -109,9 +109,9 @@ impl Default for CellStyle {
}
impl CellStyle {
pub const TITLE: &str = "Title";
pub const HEADER: &str = "Header";
pub const DATA: &str = "Data";
pub const TITLE: &'static str = "Title";
pub const HEADER: &'static str = "Header";
pub const DATA: &'static str = "Data";
/// Whether `name` is one of the three styles every table style has.
pub fn is_builtin(name: &str) -> bool {
@@ -190,7 +190,7 @@ impl TableStyle {
/// The names of its cell styles: Title, Header, Data, then the custom ones.
pub fn cell_style_names(&self) -> Vec<String> {
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(|s| s.to_string()).collect();
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
out
}
-2
View File
@@ -1,2 +0,0 @@
mod lin_pat_parse;
mod sub_segment_alias;
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.5"
acadrust = "0.6.4"
+27 -284
View File
@@ -4,25 +4,6 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -104,7 +85,11 @@ mod native {
}
let mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
current_style_names(&mut doc);
table_merges(&mut doc);
table_style_rows(&mut doc, false);
dimension_flags(&mut doc, false);
let orphans = drop_orphaned_dgn_components(&mut doc);
if orphans > 0 {
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
@@ -234,33 +219,6 @@ mod native {
/// cells already carry merges are left alone. Each range's first cell gets the span; the
/// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does.
fn table_merges(doc: &mut acadrust::CadDocument) {
table_merges_within(doc, MAX_MERGE_CELLS);
}
/// The most table cells [`table_merges`] visits in one document: each range is capped, but
/// can cover rows × cols cells, so the total is bounded too.
const MAX_MERGE_CELLS: usize = 1 << 24;
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
/// components were dropped. The document comes from a third-party reader and is hostile.
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
super::fail_in_test("post_process");
current_style_names(doc);
table_merges(doc);
table_style_rows(doc, false);
keep_sort_handles(doc);
drop_orphaned_dgn_components(doc)
}
/// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way).
fn guarded<R>(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result<R, ()> {
std::panic::catch_unwind(f).map_err(|_| ())
}
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
/// left unmerged. Returns the cells visited.
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
let mut left = budget;
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) {
@@ -276,11 +234,6 @@ mod native {
if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) {
continue;
}
let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1);
if area > left {
continue;
}
left -= area;
let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX);
for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) {
for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) {
@@ -292,28 +245,6 @@ mod native {
}
}
}
budget - left
}
/// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF
/// writer renumbers them past every entity handle, but an entity left out of a table sorts by
/// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving
/// the table its codes verbatim makes the writer emit them unchanged.
fn keep_sort_handles(doc: &mut acadrust::CadDocument) {
use acadrust::objects::ObjectType;
let version = doc.version;
for o in doc.objects.values_mut() {
let ObjectType::SortEntitiesTable(t) = o else { continue };
let hex = |h: acadrust::types::Handle| format!("{:X}", h.value());
let mut codes =
vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))];
for e in t.entries() {
codes.push((331, hex(e.entity_handle)));
codes.push((5, hex(e.sort_handle)));
}
t.raw_dxf_codes = Some(codes);
t.raw_dxf_version = Some(version);
}
}
/// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps
@@ -341,14 +272,24 @@ mod native {
}
}
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// acadrust 0.6.5 reads and writes itself.
fn dimension_flags(doc: &mut acadrust::CadDocument) {
/// The DWG dimension flags byte. Its bit 0 is the opposite of DXF group 70's bit 128 (set: the
/// text is at its default position) and its bit 1 is group 70's bit 32 (ODA DWG
/// specification, common dimension data), while acadrust 0.6.4 reads and writes bit 0 as
/// `text_user_positioned` itself and keeps no bit 32 from DXF. Without this, every dimension
/// of a DWG CADCraft writes is user-positioned for AutoCAD (#1094), and every dimension of an
/// AutoCAD DWG is read as user-positioned. `to_dwg` sets the fields up for the writer:
/// bit 32 as CADCraft's DXF writer always sets it, and bit 0 through the inverted field;
/// otherwise the field is read from bit 0 after a DWG is read.
fn dimension_flags(doc: &mut acadrust::CadDocument, to_dwg: bool) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
d.base_mut().dwg_flags_byte |= 0x02;
let b = d.base_mut();
if to_dwg {
b.dwg_flags_byte |= 0x02;
b.text_user_positioned = !b.text_user_positioned;
} else {
b.text_user_positioned = b.dwg_flags_byte & 0x01 == 0;
}
}
}
@@ -563,29 +504,7 @@ mod native {
}
}
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
/// the document, so the drawing reopened with layer "0" current (#2252).
fn current_layer(doc: &mut acadrust::CadDocument) {
let name = doc.header.current_layer_name.clone();
// The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä").
if let Some(layer) = doc.layers.get(&name) {
doc.header.current_layer_handle = layer.handle;
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
// runs inside `catch_unwind` so a decode failure is reported rather than crashing.
if dxf.len() > Limits::DEFAULT.max_dxf_bytes {
return Err(format!(
"DXF: the input is {}, larger than the {} CADCraft converts to DWG",
mb(dxf.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
@@ -598,91 +517,17 @@ mod native {
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
None => {}
}
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
current_layer(&mut doc);
}))
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc, true);
drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
current_text_style(&mut doc);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
/// ACDBVARIABLEDICTIONARY; dictionary keys are case-insensitive).
const VARIABLE_DICTIONARY: &str = "AcDbVariableDictionary";
/// A style or scale name, or `default` when the drawing has none.
fn name_or_default(name: &str, default: &str) -> String {
let name = name.trim();
if name.is_empty() { default.to_string() } else { name.to_string() }
}
/// A drawing variable's value as text, or `default` when the header holds a value the
/// variable doesn't accept (a hostile file can put any integer in it).
fn variable_value(value: i16, valid: &[i16], default: i16) -> String {
if valid.contains(&value) { value.to_string() } else { default.to_string() }
}
/// Add the drawing variables dictionary AutoCAD always writes into the named object
/// dictionary, with the variables it keeps there, when the drawing has none (#1813).
/// Vectric Aspire 10.5 reads a 2004 (AC1018) DWG without it as empty ("No data was imported
/// from the DXF / DWG file"), though it reads the same drawing as DXF — which has no such
/// dictionary either, as CADCraft's and ezdxf's writers don't make one. AutoCAD's AC1032 DWGs
/// read there without it, so newer output is left as it was, and a dictionary the drawing
/// does have keeps its own entries and values.
fn add_variable_dictionary(doc: &mut acadrust::CadDocument) {
use acadrust::objects::{Dictionary, DictionaryVariable, ObjectType};
if doc.version >= acadrust::DxfVersion::AC1032 {
return;
}
let root_handle = doc.header.named_objects_dict_handle;
match doc.objects.get(&root_handle) {
Some(ObjectType::Dictionary(root)) => {
if root.get(VARIABLE_DICTIONARY).is_some() {
return;
}
}
_ => return,
}
// The variables AutoCAD writes for a drawing with default styles and a 1:1 annotation
// scale. DIMASSOC and HIDETEXT keep the drawing's own header values; LAYEREVAL and
// LAYERNOTIFY have no DXF header variable.
let variables = [
("CMLEADERSTYLE", name_or_default(&doc.header.current_mleader_style_name, "Standard")),
("CTABLESTYLE", name_or_default(&doc.header.current_table_style_name, "Standard")),
("CANNOSCALE", name_or_default(&doc.header.current_annotation_scale, "1:1")),
("DIMASSOC", variable_value(doc.header.dimension_associativity, &[0, 1, 2], 2)),
("HIDETEXT", variable_value(doc.header.hide_text, &[0, 1], 1)),
("LAYEREVAL", "0".to_string()),
("LAYERNOTIFY", "0".to_string()),
];
let dictionary_handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = dictionary_handle;
dictionary.owner = root_handle;
for (name, value) in variables {
let handle = doc.allocate_handle();
let mut variable = DictionaryVariable::new(name, &value);
variable.handle = handle;
variable.owner_handle = dictionary_handle;
dictionary.add_entry(name, handle);
doc.objects.insert(handle, ObjectType::DictionaryVariable(variable));
}
doc.objects.insert(dictionary_handle, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_handle) {
root.add_entry(VARIABLE_DICTIONARY, dictionary_handle);
}
}
/// Entities of a layout other than the one in `*Paper_Space` are written owned by their block
/// (entity mode 0, with its handle). acadrust writes every paper-space entity with mode 1,
/// "paper space", which AutoCAD reads as the `*Paper_Space` block's: everything drawn on a
@@ -707,60 +552,11 @@ mod native {
}
}
}
#[cfg(test)]
mod post_tests {
use super::*;
use acadrust::entities::{CellRange, EntityType, Table};
fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument {
let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols);
t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect();
let mut doc = acadrust::CadDocument::new();
assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok());
doc
}
/// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934).
#[test]
fn merge_work_is_bounded_by_the_cell_budget() {
let mut doc = doc_with_table(10, 10, 50);
assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped");
let mut doc = doc_with_table(10, 10, 1);
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
}
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
#[test]
fn a_panic_in_post_processing_is_an_error() {
#[allow(clippy::panic)]
let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") }));
assert!(r.is_err());
assert_eq!(guarded(|| 3), Ok(3));
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
// stage then panics on entry, so a test through the public API shows whether it is guarded.
#[cfg(all(test, not(target_arch = "wasm32")))]
thread_local! {
static PANIC_IN: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
}
/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise.
#[cfg(not(target_arch = "wasm32"))]
#[allow(clippy::panic)]
fn fail_in_test(_stage: &str) {
#[cfg(test)]
if PANIC_IN.with(|p| p.get()) == Some(_stage) {
panic!("test-forced panic in {_stage}");
}
}
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn unsupported(version: &str) -> String {
@@ -793,50 +589,6 @@ pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
mod tests {
use super::*;
/// The DXF of an empty drawing, enough for the bridge to make a small DWG.
#[cfg(not(target_arch = "wasm32"))]
const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n";
/// Makes `stage` panic on the current thread until dropped.
#[cfg(not(target_arch = "wasm32"))]
struct PanicIn;
#[cfg(not(target_arch = "wasm32"))]
impl PanicIn {
fn at(stage: &'static str) -> Self {
PANIC_IN.with(|p| p.set(Some(stage)));
Self
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for PanicIn {
fn drop(&mut self) {
PANIC_IN.with(|p| p.set(None));
}
}
/// The public read reports a panic in the post-read fix-ups as an error: this fails if the
/// `guarded` call around `post_process` is taken out (#2402).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn reading_a_dwg_survives_a_panic_in_post_processing() {
let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap();
assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic");
let _panic = PanicIn::at("post_process");
let err = dwg_to_dxf(&dwg).unwrap_err();
assert!(err.contains("the DWG post-processing failed on this file"), "{err}");
}
/// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn converting_to_dwg_survives_a_panic_in_the_fix_ups() {
let _panic = PanicIn::at("convert");
let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err();
assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}");
}
#[test]
fn detects_magic() {
assert!(is_dwg(b"AC1032\0\0\0\0"));
@@ -866,15 +618,6 @@ mod tests {
assert!(dwg_to_dxf(b"").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
// rather than crashing (and rejects oversized input before decoding).
assert!(dxf_to_dwg(b"not a dxf file").is_err());
assert!(dxf_to_dwg(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
+14 -44
View File
@@ -181,20 +181,8 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
return parse_binary_with_limit(body, max_tags);
}
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
let mut lines = bytes.split(|&b| b == b'\n');
let mut tags = 0;
while let Some(code) = lines.next() {
let code = code.trim_ascii();
if code.is_empty() {
continue;
}
let Some(value) = lines.next() else { break };
tags += 1;
if code == b"0" && value.trim_ascii() == b"EOF" {
break;
}
}
// Every group code takes two lines (code, value).
let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2);
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
@@ -276,7 +264,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
#[allow(clippy::while_let_loop)]
fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
let mut lines = text.lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
let ct = code_line.trim();
@@ -425,7 +413,8 @@ pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) => escape_unicode(s, utf8, &mut out),
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
Value::Str(s) => escape_unicode(s, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()),
Value::Bool(b) => out.push_str(&i64::from(*b).to_string()),
@@ -457,7 +446,11 @@ pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
v => v.as_str(),
};
let mut text = String::with_capacity(s.len());
escape_unicode(&s, utf8, &mut text);
if utf8 {
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
} else {
escape_unicode(&s, &mut text);
}
out.extend(text.bytes().filter(|&b| b != 0));
out.push(0);
}
@@ -514,16 +507,12 @@ pub fn is_binary(bytes: &[u8]) -> bool {
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page; with `utf8` they stay as they are.
/// A backslash that would start a `\U+` or `\M+` sequence is written as `\U+005C` so the reader
/// gives back the literal text. Line breaks become spaces.
fn escape_unicode(s: &str, utf8: bool, out: &mut String) {
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
fn escape_unicode(s: &str, out: &mut String) {
for c in s.chars() {
match c {
'\r' | '\n' => out.push(' '),
'\\' if matches!(chars.get(i + 1), Some('U' | 'M')) && chars.get(i + 2) == Some(&'+') => out.push_str("\\U+005C"),
c if utf8 || c.is_ascii() => out.push(c),
c if c.is_ascii() => out.push(c),
c => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
@@ -580,25 +569,6 @@ pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
mod tests {
use super::*;
#[test]
fn literal_escape_text_roundtrips() {
for utf8 in [false, true] {
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
let tags = vec![Tag::s(8, text)];
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
assert_eq!(back, tags, "utf8={utf8} {text}");
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
}
}
}
#[test]
fn blank_lines_do_not_consume_group_code_limit() {
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
}
#[test]
fn ascii_roundtrip() {
let tags = vec![
-19
View File
@@ -1,19 +0,0 @@
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
#[test]
fn utf8_bom_bytes_are_skipped() {
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(BODY.as_bytes());
let with_bom = cadcraft_dxf::parse(&bytes);
assert!(with_bom.is_ok(), "{with_bom:?}");
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
}
#[test]
fn bom_text_is_skipped() {
let text = format!("\u{feff}{BODY}");
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
}
+1 -1
View File
@@ -22,7 +22,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
rstar = { version = "0.13", default-features = false }
rstar = { version = "0.12", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10 -6
View File
@@ -460,13 +460,17 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
m.style = "Standard".into();
}
}
EntityKind::Table(t) if !has_name(&d.table_styles, &t.style, |s| &s.name) => {
note("table style", &t.style);
t.style = "Standard".into();
EntityKind::Table(t) => {
if !has_name(&d.table_styles, &t.style, |s| &s.name) {
note("table style", &t.style);
t.style = "Standard".into();
}
}
EntityKind::Leader(l) if !ok_dim(d, &l.style) => {
note("dimension style", &l.style);
l.style = "Standard".into();
EntityKind::Leader(l) => {
if !ok_dim(d, &l.style) {
note("dimension style", &l.style);
l.style = "Standard".into();
}
}
EntityKind::Dimension(dim) => {
if !ok_dim(d, &dim.style) {
+8 -57
View File
@@ -59,22 +59,10 @@ pub fn set_savefilepath(path: &str) {
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
// File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary.
let suffix = format!("_{instance}_{uid}.{EXT}");
let budget = 255usize.saturating_sub(suffix.len());
let mut used = 0;
let stem: String = title
.chars()
.map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' })
.take(80)
.take_while(|c| {
used += c.len_utf8();
used <= budget
})
.collect();
let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect();
let stem = stem.trim().trim_matches('.');
let stem = if stem.is_empty() { "Drawing" } else { stem };
format!("{stem}{suffix}")
format!("{stem}_{instance}_{uid}.{EXT}")
}
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
@@ -220,12 +208,6 @@ impl Autosaver {
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
continue;
}
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
// they can't be put there, nothing is written and nothing is marked as covered, so the
// next pass tries again instead of keeping the host without them.
let Ok(doc) = st.recovery_drawing() else {
continue;
};
let path = t
.file
.clone()
@@ -234,7 +216,8 @@ impl Autosaver {
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
t.since = None;
jobs.push(Job::Write { uid: st.uid, path, doc });
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
}
jobs
}
@@ -268,10 +251,7 @@ mod native {
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
};
use super::*;
/// A new instance id: the process id and the start time.
pub fn instance_id() -> String {
@@ -322,7 +302,7 @@ mod native {
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
pub fn discard(path: &str) {
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
let ours = Path::new(path).file_name().and_then(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
if !ours {
log::warn!("autosave: not deleting {path}: not an autosave file");
return;
@@ -354,22 +334,10 @@ mod native {
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
/// SAVEFILEPATH can be any folder the user picked.
fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
let bytes = write(doc, "autosave.dxf")?;
if let Some(dir) = Path::new(path).parent() {
create_folder(dir).map_err(|e| e.to_string())?;
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
@@ -466,7 +434,7 @@ mod native {
}
self.last_beat = Some(now);
let dir = Path::new(dir);
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
if let Err(e) = r {
log::warn!("autosave: lock file in {}: {e}", dir.display());
}
@@ -490,20 +458,3 @@ mod native {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_fits_255_bytes_for_multibyte_titles() {
let instance = "12345-abcdef0123456789";
for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] {
let name = file_name(&title, instance, u64::MAX);
assert!(name.len() <= 255, "{} bytes", name.len());
let (t, i, u) = parse_file_name(&name).expect("round trips");
assert_eq!((i, u), (instance, u64::MAX));
assert!(!t.is_empty());
}
}
}
+5 -44
View File
@@ -144,11 +144,6 @@ 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();
@@ -176,21 +171,6 @@ 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> {
@@ -215,13 +195,12 @@ impl DocState {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
}
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them. An error when the editor's changes can't be put in the
/// block (it would contain itself): the host drawing alone would lose them.
pub fn recovery_drawing(&self) -> Result<Arc<Drawing>> {
/// block, so a crash doesn't lose them.
pub fn recovery_drawing(&self) -> Arc<Drawing> {
match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new),
Some(be) => Ok(be.host.doc.clone()),
None => Ok(self.doc.clone()),
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
Some(be) => be.host.doc.clone(),
None => self.doc.clone(),
}
}
@@ -292,24 +271,6 @@ 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();
-3
View File
@@ -44,7 +44,6 @@ pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
mod tests {
use super::*;
/// Regular-file reads accept the byte boundary and reject any larger payload.
#[test]
fn reads_within_limit_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
@@ -58,7 +57,6 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// An endless device is rejected before its contents can be buffered.
#[cfg(unix)]
#[test]
fn rejects_non_regular_file() {
@@ -68,7 +66,6 @@ mod tests {
assert!(read_capped(p, 8 << 20).is_err());
}
}
/// Opening a FIFO without a writer returns an error without waiting for a peer.
#[cfg(unix)]
#[test]
fn rejects_fifo_without_a_writer() {
+1 -2
View File
@@ -7,8 +7,7 @@ use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annota
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::{CommandSpec, bad, f64_req, targets};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
File diff suppressed because it is too large Load Diff
-333
View File
@@ -1,333 +0,0 @@
use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc};
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> {
let d = s.doc().ok()?;
let line = |h: Handle| match &d.entity(h)?.kind {
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
_ => None,
};
let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
// Use the leg ends on the side of the picked arc location (the quadrant the user meant).
let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| {
let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) };
let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) };
let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized());
if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir }
};
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
if let EntityKind::Dimension(d) = &mut k {
d.p16 = v3(at);
}
let mut assoc = Vec::new();
if assoc_enabled(s) {
assoc = vec![
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
];
}
Some(with_assoc(k, assoc))
}
pub(super) fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
let hs: Vec<Handle> = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) };
let at = point_req("dimangular", p, "at")?;
let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?;
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
if let Some(h) = hex_param(p, "arc") {
let at = point_req("dimangular", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimangular", "object is not an arc")),
};
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
}
let v = point_req("dimangular", p, "vertex")?;
let a = point_req("dimangular", p, "p1")?;
let b = point_req("dimangular", p, "p2")?;
let at = point_req("dimangular", p, "at")?;
if v.near(a, 1e-12) || v.near(b, 1e-12) {
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
}
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
pub(super) fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?;
let at = point_req("dimarc", p, "at")?;
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
Some(EntityKind::Arc(a)) => a,
_ => return Err(bad("dimarc", "object is not an arc")),
};
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
#[derive(Default)]
pub(super) struct AngularM {
handles: Vec<Handle>,
first: Option<(Vec2, Vec2)>,
second: Option<(Vec2, Vec2)>,
vertex_mode: Vec<Vec2>,
vertex: bool,
arc: Option<(Vec2, Vec2, Vec2)>,
text: DimText,
/// Quadrant: a point inside the angle to measure; the arc location then only sets the radius.
quadrant: Option<Vec2>,
asking_quadrant: bool,
}
impl AngularM {
/// (vertex, p1, p2) of the angle being dimensioned.
fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> {
if let Some(a) = self.arc {
return Some(a);
}
if let [v, a, b] = self.vertex_mode.as_slice() {
return Some((*v, *a, *b));
}
let ((a1, a2), (b1, b2)) = (self.first?, self.second?);
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q };
Some((v, far(a1, a2), far(b1, b2)))
}
/// The arc location `at`, turned into the locked quadrant (same radius) when one is set.
fn located(&self, v: Vec2, at: Vec2) -> Vec2 {
match self.quadrant {
Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v),
_ => at,
}
}
}
impl Interactive for AngularM {
fn name(&self) -> &'static str {
"DIMANGULAR"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if self.asking_quadrant {
return Prompt::new("Specify quadrant", Accept::POINT);
}
if self.geometry().is_some() {
return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]);
}
if self.vertex {
return Prompt::new(
["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"]
.get(self.vertex_mode.len())
.copied()
.unwrap_or(""),
Accept::POINT,
);
}
match self.first {
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
Some(_) => Prompt::new("Select second line", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if self.asking_quadrant {
if let Input::Point(q) = i {
self.quadrant = Some(q);
}
self.asking_quadrant = false;
return Ok(Step::Continue);
}
if let Some((v, a, b)) = self.geometry() {
return match i {
Input::Point(at) => {
let at = self.located(v, at);
let k = match (self.handles.as_slice(), self.arc) {
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
_ => None,
};
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
add_dim(s, self.text.apply(k))?;
Ok(Step::Done)
}
Input::Keyword(k) => {
if k == "Quadrant" {
self.asking_quadrant = true;
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
}
if self.vertex {
match i {
Input::Point(p) => self.vertex_mode.push(p),
Input::Enter => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
}
match i {
Input::Enter if self.first.is_none() => {
self.vertex = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let Some(h) = pick_at(s, p) else {
s.echo("*Invalid selection*");
return Ok(Step::Continue);
};
let circle = s
.doc()?
.entity(h)
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
if let Some(g) = circle.filter(|_| self.first.is_none()) {
// The center is the vertex and the pick the first endpoint; ask for the second.
self.vertex = true;
self.vertex_mode = vec![g.center, g.closest(p)];
return Ok(Step::Continue);
}
match segment_at(s, h, p) {
Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => {
self.arc = Some((g.center, g.start_point(), g.end_point()));
}
Some(Segment::Line(l)) => {
self.handles.push(h);
let seg = (l.a, l.b);
if self.first.is_none() {
self.first = Some(seg);
} else {
self.second = Some(seg);
}
}
_ => s.echo("Select an arc, circle or line."),
}
Ok(Step::Continue)
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
}
}
#[derive(Default)]
pub(super) struct ArcLenM {
arc: Option<cadcraft_geom::Arc>,
text: DimText,
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
partial: Option<Vec<Vec2>>,
}
/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction).
fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option<cadcraft_geom::Arc> {
let sweep = arc.sweep();
// Offset along the arc from its start, clamped to the nearer end when the point is off the arc.
let along = |x: Vec2| {
let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x));
if t <= sweep {
t
} else if t - sweep < std::f64::consts::TAU - t {
sweep
} else {
0.0
}
};
let (t1, t2) = (along(p), along(q));
let (lo, hi) = (t1.min(t2), t1.max(t2));
(hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi))
}
impl Interactive for ArcLenM {
fn name(&self) -> &'static str {
"DIMARC"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
if let Some(picked) = &self.partial {
return match picked.first() {
None => Prompt::new("Specify first point for arc length dimension", Accept::POINT),
Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f),
};
}
match self.arc {
None => Prompt::new("Select arc or polyline arc segment", Accept::POINT),
Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
if let Some(picked) = self.partial.take() {
match (i, picked.first().copied(), self.arc) {
(Input::Point(p), None, _) => self.partial = Some(vec![p]),
(Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) {
Some(part) => self.arc = Some(part),
None => s.echo("The two points must be different."),
},
(Input::Enter, ..) => {}
_ => self.partial = Some(picked),
}
return Ok(Step::Continue);
}
match (self.arc, i) {
(None, Input::Point(p)) => {
if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end));
} else {
s.echo("Select an arc.");
}
Ok(Step::Continue)
}
(Some(g), Input::Point(at)) => {
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
Ok(Step::Done)
}
(Some(_), Input::Keyword(k)) => {
if k == "Partial" {
self.partial = Some(Vec::new());
} else {
self.text.keyword(&k);
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if self.partial.is_some() {
return Vec::new();
}
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
}
}

Some files were not shown because too many files have changed in this diff Show More