mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Compare commits
235
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f50dce03bd | ||
|
|
b9f96d3dd3 | ||
|
|
63394e872d | ||
|
|
e59195edb8 | ||
|
|
5e39af933f | ||
|
|
c66f6f45b8 | ||
|
|
b062030a03 | ||
|
|
7aa952b8da | ||
|
|
5cfe1a4149 | ||
|
|
9f2060d674 | ||
|
|
9fc626a33b | ||
|
|
fef32761af | ||
|
|
9562d951df | ||
|
|
f05fa9cec3 | ||
|
|
73e7486dc9 | ||
|
|
fde62a39ab | ||
|
|
7db656fdc7 | ||
|
|
9d9e4c3b0d | ||
|
|
c92bb5cdad | ||
|
|
1c35d7f8c1 | ||
|
|
a94fc0898b | ||
|
|
a0dced6d45 | ||
|
|
532c4ccc1c | ||
|
|
88a73b7618 | ||
|
|
0fc8e5241c | ||
|
|
cff2eb7262 | ||
|
|
ab6bff5e4e | ||
|
|
1a76e10c20 | ||
|
|
941ea341bc | ||
|
|
71882cf5e6 | ||
|
|
64c35cadff | ||
|
|
63dec85749 | ||
|
|
1c3c25035c | ||
|
|
2bc3b02ea1 | ||
|
|
6842b8774b | ||
|
|
78c1653f50 | ||
|
|
fda82da3df | ||
|
|
2c59eb19c5 | ||
|
|
0299a3eaa0 | ||
|
|
1c7d3f0fae | ||
|
|
4f282b30cc | ||
|
|
b9d59c64ab | ||
|
|
4c327cccd0 | ||
|
|
587d1dcd69 | ||
|
|
e04056f714 | ||
|
|
25faf34d28 | ||
|
|
dadd1f227f | ||
|
|
1428174159 | ||
|
|
f686a595e6 | ||
|
|
7d2fbd959a | ||
|
|
829909274a | ||
|
|
f8f13c3f4e | ||
|
|
f119610287 | ||
|
|
620e6a664c | ||
|
|
8dd8635e10 | ||
|
|
4251f1e253 | ||
|
|
9a32aa5613 | ||
|
|
6ee6e652fd | ||
|
|
76f7cea83f | ||
|
|
47e978b58e | ||
|
|
a68d0a458b | ||
|
|
60265a1eee | ||
|
|
20b0c9c11a | ||
|
|
b517e0749d | ||
|
|
5c911ad824 | ||
|
|
15147260c1 | ||
|
|
bbbf4465c4 | ||
|
|
7bf5905ac9 | ||
|
|
c1dd60ffd6 | ||
|
|
bec28d6b87 | ||
|
|
d3e6111cf1 | ||
|
|
dd1f0531ac | ||
|
|
37150774c6 | ||
|
|
9cf115e808 | ||
|
|
9d8f16c825 | ||
|
|
f043540d13 | ||
|
|
78decf80f7 | ||
|
|
8ad9a3ce15 | ||
|
|
251fddb658 | ||
|
|
51db2d52e4 | ||
|
|
9621dcbd94 | ||
|
|
5648a06ac4 | ||
|
|
f344cf4460 | ||
|
|
e61e328edb | ||
|
|
611eb9f5a9 | ||
|
|
1977a493c7 | ||
|
|
a08fd7c1d1 | ||
|
|
fa6bb0351b | ||
|
|
bad1073fdf | ||
|
|
48b4165591 | ||
|
|
01bbdb24bc | ||
|
|
15ba3dc5cc | ||
|
|
07bb604214 | ||
|
|
dc4bbe86f6 | ||
|
|
953a6b3b01 | ||
|
|
8085745a22 | ||
|
|
ca578a8fb7 | ||
|
|
916be26ed3 | ||
|
|
2dbc7699a0 | ||
|
|
7c72ec45a9 | ||
|
|
0158712bfd | ||
|
|
683eb5e6e7 | ||
|
|
345fb27a69 | ||
|
|
8f988f630d | ||
|
|
acd109d97c | ||
|
|
f4e6873130 | ||
|
|
e4214f7180 | ||
|
|
35934718ea | ||
|
|
916d440bed | ||
|
|
632967aa78 | ||
|
|
f15be2305c | ||
|
|
f2dadec23a | ||
|
|
00acec952e | ||
|
|
78254b4d3f | ||
|
|
8094d7746f | ||
|
|
4ad4f4f598 | ||
|
|
477eec5026 | ||
|
|
38750c6eb8 | ||
|
|
75d9d72703 | ||
|
|
fdcd9610f2 | ||
|
|
54d96834d5 | ||
|
|
47a8fb02e7 | ||
|
|
896a390b42 | ||
|
|
345eb60ef3 | ||
|
|
0cf3bdea83 | ||
|
|
82424e47c9 | ||
|
|
7fef8cbe2f | ||
|
|
f2b473dc51 | ||
|
|
4d826ed91f | ||
|
|
e86eacf094 | ||
|
|
a7bb28d722 | ||
|
|
27bff9cc30 | ||
|
|
d600a80354 | ||
|
|
81f0aa53ff | ||
|
|
27861ad0ed | ||
|
|
b72680cfb7 | ||
|
|
d9adce038c | ||
|
|
0b32c2e6a1 | ||
|
|
323f4629eb | ||
|
|
c02027abc8 | ||
|
|
8c249aa722 | ||
|
|
a0aa0863bf | ||
|
|
b4509d2368 | ||
|
|
13a3ebc0d1 | ||
|
|
41e0860725 | ||
|
|
0d23cc3cb3 | ||
|
|
81acc9b8e4 | ||
|
|
397b8e156a | ||
|
|
12567f14f2 | ||
|
|
95e86caea5 | ||
|
|
7adf8d89cd | ||
|
|
006f71f650 | ||
|
|
b0ed57f1c7 | ||
|
|
30db7296c4 | ||
|
|
a76775bf2b | ||
|
|
6576cac1a3 | ||
|
|
4125b351cd | ||
|
|
6d15a40f43 | ||
|
|
5f390e6e6a | ||
|
|
00f521858a | ||
|
|
bcd0b3745e | ||
|
|
ba27f36b58 | ||
|
|
9f7a2422ea | ||
|
|
148091aa93 | ||
|
|
c82d6228ff | ||
|
|
786c8c46e4 | ||
|
|
62329310d7 | ||
|
|
6bd09ea468 | ||
|
|
0440fa77ff | ||
|
|
471478868d | ||
|
|
0f833358b9 | ||
|
|
64e55f903c | ||
|
|
94aad92f1d | ||
|
|
1738d3805e | ||
|
|
bf6d9fd46a | ||
|
|
f17be3d7ad | ||
|
|
582d9535b6 | ||
|
|
2a3b0c2626 | ||
|
|
abce3a718b | ||
|
|
acb28313c6 | ||
|
|
2be9d00796 | ||
|
|
286d3de0aa | ||
|
|
cd3e8478d6 | ||
|
|
12d0235b2c | ||
|
|
1ac2ebaad2 | ||
|
|
a3b3e1281f | ||
|
|
ca5a34be12 | ||
|
|
c400d6d48c | ||
|
|
e635248b1e | ||
|
|
739e083f4b | ||
|
|
3fa7275e4f | ||
|
|
aba903dbb3 | ||
|
|
493addc2dd | ||
|
|
a2bee8b49e | ||
|
|
c68420221c | ||
|
|
a5fa5ca064 | ||
|
|
ef599e58e0 | ||
|
|
27fd074f54 | ||
|
|
2db6ab069a | ||
|
|
d33a6e634a | ||
|
|
0bfe609567 | ||
|
|
7e830044ab | ||
|
|
6546f0eecd | ||
|
|
15b5395c06 | ||
|
|
cdc61f32b5 | ||
|
|
d4bacce885 | ||
|
|
9aea9b16c4 | ||
|
|
9e3f584a68 | ||
|
|
3bd7510f2b | ||
|
|
b464a947ec | ||
|
|
f76ca751cc | ||
|
|
e0ad0ef33e | ||
|
|
56d73ceb21 | ||
|
|
691bdbe0b8 | ||
|
|
162d5c6820 | ||
|
|
632c2d4f1f | ||
|
|
f0e4fef003 | ||
|
|
7164b6b5b1 | ||
|
|
38fdfa4ac4 | ||
|
|
baa9c00fb7 | ||
|
|
0cc66ad306 | ||
|
|
3370a91fe9 | ||
|
|
b23d362f6a | ||
|
|
714111d528 | ||
|
|
ff6cc8b3fc | ||
|
|
98d22f0c68 | ||
|
|
4c75b0c458 | ||
|
|
ba02572b31 | ||
|
|
8fe7c421ea | ||
|
|
972ae7888c | ||
|
|
ea6d7beca2 | ||
|
|
9b2308b138 | ||
|
|
69fc442ea6 | ||
|
|
be3ce73895 | ||
|
|
8a2b8f4bdf |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,41 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
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"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,30 @@
|
||||
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: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
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
|
||||
@@ -0,0 +1,32 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (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]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "27 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# 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: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
@@ -0,0 +1,61 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
@@ -11,14 +11,57 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,32 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# 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/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-dir: CADCraft
|
||||
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
|
||||
|
||||
|
||||
@@ -68,3 +68,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
|
||||
Generated
+106
-149
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94862189cab6e282ed0433a801a216cd1a712953813284a48fa81c67223e38f6"
|
||||
checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.2",
|
||||
@@ -209,9 +209,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
@@ -223,7 +223,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -547,11 +547,13 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-mcp",
|
||||
"cadcraft-ui-egui",
|
||||
"chrono",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
@@ -569,7 +571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -580,14 +582,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -597,7 +599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -608,14 +610,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -624,7 +626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -634,6 +636,8 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"libc",
|
||||
"log",
|
||||
"rstar",
|
||||
"serde",
|
||||
@@ -643,25 +647,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
"skrifa 0.47.0",
|
||||
"harfrust 0.14.0",
|
||||
"skrifa 0.48.0",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
@@ -669,8 +674,8 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"miniz_oxide 0.8.9",
|
||||
"quick-xml",
|
||||
"miniz_oxide 0.9.1",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -679,18 +684,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -704,7 +710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -712,6 +718,7 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -726,7 +733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -823,6 +830,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -927,6 +943,11 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1291,7 +1312,7 @@ dependencies = [
|
||||
"emath",
|
||||
"epaint_default_fonts",
|
||||
"font-types",
|
||||
"harfrust",
|
||||
"harfrust 0.12.0",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"parking_lot",
|
||||
@@ -1420,9 +1441,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.6"
|
||||
version = "0.12.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
|
||||
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
@@ -1713,6 +1734,18 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86aa776d6d32b072119e1f1650f4db843ff4382a4aaa6a2c9c4e35703cd377cb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts 0.45.1",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
@@ -1893,7 +1926,7 @@ dependencies = [
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
@@ -2079,9 +2112,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -2250,9 +2283,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.0"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -2261,8 +2294,7 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"png",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -2924,19 +2956,6 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
@@ -3060,6 +3079,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3138,13 +3166,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.44.0"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
|
||||
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3223,9 +3253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rstar"
|
||||
version = "0.12.2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
|
||||
checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"num-traits",
|
||||
@@ -3356,9 +3386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -3443,12 +3473,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
|
||||
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.44.0",
|
||||
"read-fonts 0.45.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3694,24 +3724,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.17.16",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
@@ -4132,7 +4162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4489,7 +4519,7 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4498,16 +4528,7 @@ version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4525,31 +4546,14 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4567,96 +4571,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
@@ -4719,9 +4675,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
|
||||
dependencies = [
|
||||
"toml",
|
||||
"version_check",
|
||||
@@ -4804,8 +4760,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
+13
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -24,26 +24,30 @@ cadcraft-io = { path = "crates/io" }
|
||||
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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
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"
|
||||
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"
|
||||
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.41"
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
@@ -65,9 +69,11 @@ 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
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CADCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
|
||||
<b>Computer-aided design and drafting; an open-source, modern 2D and 3D CAD application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
|
||||
A fast, open-source CAD workflow: the command line, object snaps, layers,
|
||||
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
@@ -26,6 +30,9 @@
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-0b6f88?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-14a3c7?style=flat-square">
|
||||
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_cadcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/cadcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -111,7 +118,7 @@ numbers):
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska) and Arabic (العربية, with an installed Arabic font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
@@ -134,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://trunkrs.dev)).
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
|
||||
|
||||
### Fonts
|
||||
|
||||
@@ -212,6 +219,10 @@ 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.
|
||||
@@ -225,6 +236,9 @@ MCP tools include `command_line` (type at the prompt), `execute` (any command wi
|
||||
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
|
||||
[docs/control-protocol.md](docs/control-protocol.md).
|
||||
|
||||
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
|
||||
[docs/customization.md](docs/customization.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
@@ -279,6 +293,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
|
||||
|
||||
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
> [!WARNING]
|
||||
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
|
||||
>
|
||||
> ```sh
|
||||
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
|
||||
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
|
||||
> ```
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
@@ -394,8 +416,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+6
-1
@@ -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-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
|
||||
> **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
|
||||
|
||||
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
|
||||
@@ -94,6 +94,7 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
|
||||
| Portuguese | none | 0% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
@@ -118,6 +119,8 @@ 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) |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
@@ -133,6 +136,8 @@ 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) |
|
||||
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
|
||||
|
||||
@@ -28,7 +28,7 @@ macro_rules! outln {
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{Session, View};
|
||||
use serde_json::{Value, json};
|
||||
@@ -73,7 +73,15 @@ image framing (png 2400x1600, svg, pdf; always model space):
|
||||
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).";
|
||||
opened drawing's view is its extents plus a 10% margin).
|
||||
|
||||
--cmd chaining (run):
|
||||
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.";
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -275,10 +283,34 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
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));
|
||||
@@ -290,6 +322,133 @@ fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<Stri
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
|
||||
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
|
||||
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
|
||||
/// through unchanged, and `--script` text is never rewritten.
|
||||
const LAST_HANDLE: &str = "{last}";
|
||||
|
||||
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
|
||||
const RESULT_DEPTH: usize = 16;
|
||||
|
||||
/// Result keys whose values are object references: the engine's result convention for handles.
|
||||
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
|
||||
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
|
||||
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
|
||||
fn handle_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"handle"
|
||||
| "handles"
|
||||
| "viewport"
|
||||
| "viewports"
|
||||
| "boundary"
|
||||
| "clip"
|
||||
| "insert"
|
||||
| "members"
|
||||
| "source"
|
||||
| "objects"
|
||||
| "entities"
|
||||
| "selection"
|
||||
| "parts"
|
||||
| "refs"
|
||||
| "matches"
|
||||
| "line"
|
||||
| "arc"
|
||||
| "blocks"
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
|
||||
/// or objects that carry handle fields of their own (`entities`, `parts`).
|
||||
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<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.
|
||||
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.
|
||||
fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, String> {
|
||||
match p {
|
||||
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
|
||||
Some(h) => Ok(Value::String(h.hex())),
|
||||
None => Err(format!("`{LAST_HANDLE}` needs an earlier --cmd in this run to have returned a handle")),
|
||||
},
|
||||
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
@@ -325,13 +484,31 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
check_framed_output(framing, exp.as_deref())?;
|
||||
let before = model_views(&s);
|
||||
let mut last_handle: Option<Handle> = None;
|
||||
for (kind, body) in steps {
|
||||
// A handle belongs to the drawing that returned it: if this step makes another drawing
|
||||
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
|
||||
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
|
||||
// so it is a handle of the drawing it made current.
|
||||
let drawing = active_drawing(&s);
|
||||
match kind.as_str() {
|
||||
"script" => s.script(&body).map_err(|e| e.to_string())?,
|
||||
"script" => {
|
||||
s.script(&body).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
|
||||
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let params = fill_last(¶ms, last_handle).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
if let Some(h) = returned_handle(&s, &r) {
|
||||
last_handle = Some(h);
|
||||
}
|
||||
if !r.is_null() {
|
||||
outln!("{r}");
|
||||
}
|
||||
@@ -666,6 +843,10 @@ fn cli() -> ExitCode {
|
||||
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some("--help" | "-h" | "help") => {
|
||||
outln!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
};
|
||||
match r {
|
||||
@@ -752,6 +933,40 @@ mod tests {
|
||||
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_placeholder_takes_the_previous_handle() {
|
||||
let mut s = sample_session();
|
||||
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("LINE returns its handle");
|
||||
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)), Ok(json!({"handle": h.hex()})));
|
||||
// Nested, as in a selection list.
|
||||
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)), Ok(json!({"handles": [h.hex(), "1"]})));
|
||||
// Only the exact token: a name or a sentence that merely contains it stays as written.
|
||||
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
|
||||
assert_eq!(fill_last(&p, Some(h)), Ok(p.clone()));
|
||||
}
|
||||
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
|
||||
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().contains("{last}"));
|
||||
}
|
||||
|
||||
/// Handles are read from a result by looking the strings up in the drawing: counts and names
|
||||
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
|
||||
#[test]
|
||||
fn returned_handle_reads_only_real_handles() {
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
|
||||
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
|
||||
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
|
||||
// The most recent of several.
|
||||
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
|
||||
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
|
||||
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_framing_is_extents() {
|
||||
// convert: nothing ran, always the extents.
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
|
||||
//! Bare `cadcraft-cli` keeps reporting on stderr with exit 1.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
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("usage:"), "{args:?}: {stdout:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(1));
|
||||
assert!(out.stdout.is_empty(), "bare invocation keeps its usage on stderr");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage:"));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod cli;
|
||||
mod drafting_spacing;
|
||||
mod help;
|
||||
mod mcp_connect;
|
||||
mod skipped_records;
|
||||
mod undrawn_entities;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `convert` to an image says how many model-space entities it can't draw, by type, instead of
|
||||
//! leaving them out silently (#1208). The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
|
||||
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
|
||||
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\nabc\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\ndef\n\
|
||||
0\nPDFUNDERLAY\n8\n0\n10\n0\n20\n0\n41\n1\n42\n1\n43\n1\n\
|
||||
0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("proxies.dxf");
|
||||
std::fs::write(&input, PROXIES).unwrap();
|
||||
let input = input.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", input]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["counts"]["Proxy"], 3, "{v}");
|
||||
|
||||
for ext in ["svg", "png", "pdf"] {
|
||||
let image = dir.join(format!("out.{ext}"));
|
||||
let out = cli(&["convert", input, image.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("not drawn")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{ext}: {err}");
|
||||
assert_eq!(warnings[0], format!("{input}: 3 entities not drawn (PDFUNDERLAY: 1, REGION: 2)"), "{ext}");
|
||||
}
|
||||
|
||||
// A DXF copy keeps them, so there is nothing to warn about.
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", input, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// A drawing without them: no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CADCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
|
||||
@@ -24,16 +24,29 @@ 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`), 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>);
|
||||
/// 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>,
|
||||
);
|
||||
|
||||
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) {
|
||||
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);
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
@@ -58,6 +71,16 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
@@ -67,9 +90,11 @@ 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.
|
||||
/// 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.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
@@ -152,7 +177,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)))
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -177,6 +202,44 @@ 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;
|
||||
|
||||
@@ -192,8 +255,10 @@ fn main() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::wants_cpu;
|
||||
use super::{document_title, push_title, title_to_send, 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"));
|
||||
@@ -204,4 +269,52 @@ 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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,13 @@ workspace = true
|
||||
cadcraft-engine = { workspace = true }
|
||||
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 = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -1,41 +1,100 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
|
||||
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`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, Read, Write};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Generous, so requests that carry
|
||||
/// whole drawings or images as base64 still fit.
|
||||
const MAX_LINE: usize = 256 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Resource bounds for the control server.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Limits {
|
||||
/// Connections served at once; more get an error line and are closed.
|
||||
max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
max_line: 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.
|
||||
idle_timeout: Duration,
|
||||
/// How long a reply write may block before the connection is closed.
|
||||
write_timeout: Duration,
|
||||
/// How long a request waits for the app's answer.
|
||||
reply_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_clients: 16,
|
||||
// Generous, so requests that carry whole drawings or images as base64 still fit.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
reply_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => {
|
||||
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
|
||||
// token in an owner-only file and require it on every request (CWE-306). A client
|
||||
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
|
||||
// access; another local account cannot.
|
||||
let token = match cadcraft_mcp::control_token::issue(port) {
|
||||
Ok(t) => Arc::new(t),
|
||||
Err(e) => {
|
||||
log::error!("control server not started: could not write the control token: {e}");
|
||||
return sync_channel::<ControlRequest>(0).1;
|
||||
}
|
||||
};
|
||||
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
|
||||
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
}
|
||||
serve_listener(l, Limits::default(), ctx, token)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
sync_channel::<ControlRequest>(0).1
|
||||
}
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
|
||||
rx
|
||||
}
|
||||
|
||||
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
|
||||
/// its thread can't be started, or panics).
|
||||
/// A connection's place among [`Limits::max_clients`], given back when the connection ends (also
|
||||
/// when its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
@@ -44,10 +103,12 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// 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));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
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 {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
@@ -55,9 +116,10 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, tx, ctx);
|
||||
serve(stream, &tx, &ctx, limits, &token);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,7 +130,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
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);
|
||||
}
|
||||
@@ -86,48 +152,112 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// One request line, parsed.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
token: Option<String>,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
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());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| 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) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(true) => {}
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
|
||||
Ok(false) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
|
||||
let Ok(line) = std::str::from_utf8(&buf) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
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.
|
||||
Err(_) if buf.is_empty() => break,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params, token: given } => {
|
||||
// Authenticate the peer before dispatching: loopback TCP does not identify the
|
||||
// OS user, so only a client holding the owner-only session token may drive the
|
||||
// desktop user's commands (CWE-306).
|
||||
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
|
||||
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
let _ = writeln!(out, "{r}");
|
||||
break;
|
||||
}
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
// On timeout the request is cancelled if the app hasn't started it, and
|
||||
// the reply's `state` says whether it can still have run.
|
||||
pending.wait(limits.reply_timeout)
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests", "state": "not-run"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
// On timeout the request is cancelled if the app hasn't started it, and the reply's
|
||||
// `state` says whether it can still have run.
|
||||
let mut r = pending.wait(Duration::from_secs(60));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +265,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
@@ -158,12 +300,29 @@ mod tests {
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
fn short() -> Limits {
|
||||
Limits {
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
..Limits::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
|
||||
const TEST_TOKEN: &str = "test-control-token";
|
||||
|
||||
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
|
||||
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let (addr, rx) = serve_with(Limits::default());
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
@@ -172,12 +331,97 @@ mod tests {
|
||||
addr
|
||||
}
|
||||
|
||||
/// A request line carrying the test token, so it authenticates.
|
||||
fn req_line(id: i64, method: &str) -> String {
|
||||
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
|
||||
}
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
let mut replies = Vec::new();
|
||||
for line in BufReader::new(c).lines() {
|
||||
// A read timeout here means the server kept the connection open.
|
||||
let line = line.unwrap();
|
||||
replies.push(serde_json::from_str(&line).unwrap());
|
||||
}
|
||||
replies
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"cmdline.input\",\"params\":{\"text\":\"circle 0,0 5\"}}\n";
|
||||
let replies = exchange(addr, http);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let addr = serve_echo();
|
||||
let mut input = junk.to_vec();
|
||||
input.extend_from_slice(b"{\"id\":2,\"method\":\"ping\"}\n");
|
||||
let replies = exchange(addr, &input);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_connection_is_closed() {
|
||||
let (addr, _rx) = serve_with(short());
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let mut got = Vec::new();
|
||||
c.read_to_end(&mut got).unwrap();
|
||||
assert!(got.is_empty(), "{}", String::from_utf8_lossy(&got));
|
||||
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());
|
||||
// No UI drains the one-place queue: the first request fills it, the second finds it full.
|
||||
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(4);
|
||||
let busy = loop {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(req_line(2, "m").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v["error"].as_str().is_some_and(|e| e.starts_with("busy")) || std::time::Instant::now() > deadline {
|
||||
break v;
|
||||
}
|
||||
};
|
||||
assert_eq!((busy["id"].as_i64(), busy["state"].as_str()), (Some(2), Some("not-run")), "{busy}");
|
||||
assert_eq!(first.join().unwrap()[0]["state"], "not-run");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// Replies on one authenticated connection preserve the ID of each request.
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
|
||||
c.write_all(input.as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
@@ -187,11 +431,39 @@ 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).
|
||||
let addr = serve_echo();
|
||||
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
|
||||
// A wrong token is also refused.
|
||||
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
|
||||
assert_eq!(wrong[0]["ok"], false);
|
||||
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();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let held: Vec<TcpStream> = (0..Limits::default().max_clients).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
|
||||
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -385,8 +393,20 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -143,6 +143,13 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +364,9 @@ fn main() -> eframe::Result {
|
||||
app.open_path(f);
|
||||
}
|
||||
if app.session.docs.is_empty() {
|
||||
app.session.new_drawing(false);
|
||||
// In the units the profile asks for (MEASUREINIT, restored above).
|
||||
let metric = app.session.settings.measureinit;
|
||||
app.session.new_drawing(metric);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
|
||||
@@ -107,6 +107,23 @@ 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 {
|
||||
@@ -128,19 +145,7 @@ impl Filter {
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
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;
|
||||
}
|
||||
}
|
||||
self.update_drift(complete, now, &mut bias);
|
||||
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);
|
||||
@@ -156,3 +161,77 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,29 @@ sync_github_name = false
|
||||
real_name = "Chester Fung"
|
||||
display_name = "Chester Fung"
|
||||
sync_github_name = true
|
||||
|
||||
[people.C00kieMonsta]
|
||||
real_name = "Antoine Boxho"
|
||||
display_name = "Antoine Boxho"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
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
|
||||
|
||||
|
||||
+52
-9
@@ -21,8 +21,9 @@ impl Rgb {
|
||||
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
|
||||
@@ -110,9 +111,9 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix("color ") {
|
||||
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
|
||||
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
|
||||
}
|
||||
if let Ok(i) = t.parse::<u16>() {
|
||||
if let Some(i) = parse_digits::<u16>(&t) {
|
||||
return match i {
|
||||
0 => Some(Color::ByBlock),
|
||||
256 => Some(Color::ByLayer),
|
||||
@@ -123,14 +124,22 @@ impl Color {
|
||||
if t.starts_with('#') {
|
||||
return Rgb::parse_hex(&t).map(Color::True);
|
||||
}
|
||||
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
|
||||
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
|
||||
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
|
||||
if let [Some(r), Some(g), Some(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))
|
||||
@@ -156,7 +165,7 @@ fn build_palette() -> [Rgb; 256] {
|
||||
for (slot, c) in p.iter_mut().zip(base) {
|
||||
*slot = c;
|
||||
}
|
||||
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
|
||||
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
|
||||
for i in 10..250usize {
|
||||
let hue = (i - 10) / 10; // 0..24, 15° each
|
||||
let sub = i % 10;
|
||||
@@ -225,13 +234,47 @@ pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_non_hex_digits() {
|
||||
assert_eq!(Rgb::parse_hex("+12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("-12345"), None);
|
||||
assert_eq!(Color::parse("#+12345"), None);
|
||||
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(165, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
|
||||
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(20), Rgb(255, 63, 0));
|
||||
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
|
||||
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
|
||||
|
||||
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
|
||||
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
|
||||
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
|
||||
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
|
||||
// Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
|
||||
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
|
||||
// (CWE-407). Empty `o.types` means "all types".
|
||||
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
|
||||
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
|
||||
let g = collect(d, handles);
|
||||
let mut out: Vec<Constraint> = Vec::new();
|
||||
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
|
||||
|
||||
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
|
||||
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
|
||||
const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
@@ -86,6 +89,8 @@ 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);
|
||||
@@ -94,6 +99,14 @@ fn analyse(comp: &Comp) -> CompDof {
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
|
||||
return unknown;
|
||||
}
|
||||
// Numerical differentiation evaluates every residual about `rows × free` times, and each
|
||||
// spline-end residual reconstructs its spline through a dense solve. Account for that
|
||||
// reconstruction cost before building the Jacobian so a component with few equations but large
|
||||
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
|
||||
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
|
||||
if eval_work > MAX_EVAL_WORK {
|
||||
return unknown;
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
|
||||
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
|
||||
if nf > MAX_NULL_COLS {
|
||||
@@ -126,7 +139,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let (comps, _) = build(d, &cons, &SolveOptions::default())?;
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -165,7 +179,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build(d, &[h]);
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -193,3 +207,35 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
};
|
||||
Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use crate::residual::{Built, Form};
|
||||
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();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut spline = Spline::from_control(pts.clone(), 3);
|
||||
spline.fit = pts;
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
let model = Model::build(&d, &[h]);
|
||||
let endpoint = Built {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h)],
|
||||
form: Form::EndOn { spline: 0, at_end: false },
|
||||
target: 0.0,
|
||||
sign: 1.0,
|
||||
rows: 2,
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// Longest expression accepted (characters).
|
||||
pub const MAX_LEN: usize = 1024;
|
||||
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
|
||||
/// Deepest syntax tree and combined expression/parameter evaluation path.
|
||||
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(|c| c.is_ascii_whitespace()) {
|
||||
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,20 @@ 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)
|
||||
}
|
||||
|
||||
@@ -185,14 +199,58 @@ pub fn names(e: &Expr, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with every whole-name use of `old` replaced by `new`; numbers (`1e3`), other names
|
||||
/// (`d10` for `d1`) and spacing are left as written.
|
||||
pub fn rename(text: &str, old: &str, new: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let start = i;
|
||||
if c.is_ascii_digit() || c == b'.' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
if b.get(i).is_some_and(|c| *c == b'e' || *c == b'E') {
|
||||
let mut j = i + 1;
|
||||
if b.get(j).is_some_and(|c| *c == b'+' || *c == b'-') {
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
i = j;
|
||||
while b.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if c.is_ascii_alphabetic() || c == b'_' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += text.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||||
}
|
||||
let token = text.get(start..i).unwrap_or_default();
|
||||
out.push_str(if token == old { new } else { token });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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)?,
|
||||
Expr::Neg(a) => -eval_with(a, lookup)?,
|
||||
Expr::Var(n) => lookup(n, depth + 1)?,
|
||||
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
|
||||
Expr::Bin(op, a, b) => {
|
||||
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
|
||||
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
|
||||
match op {
|
||||
'+' => x + y,
|
||||
'-' => x - y,
|
||||
@@ -236,9 +294,12 @@ 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)
|
||||
self.value_inner(name, &mut stack, 0)
|
||||
}
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
if let Some(v) = self.cache.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
@@ -251,7 +312,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_with(&e, &mut |n| self.value_inner(n, stack));
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
stack.pop();
|
||||
let v = r?;
|
||||
self.cache.insert(name.to_string(), v);
|
||||
@@ -261,7 +322,7 @@ impl Env {
|
||||
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
|
||||
let e = parse(text)?;
|
||||
let mut stack = Vec::new();
|
||||
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
|
||||
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +375,38 @@ 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)");
|
||||
assert_eq!(rename("1e2+e", "e", "x"), "1e2+x");
|
||||
assert_eq!(rename("é+a", "a", "b"), "é+b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_valid() {
|
||||
assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi"));
|
||||
|
||||
@@ -324,12 +324,24 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
|
||||
|
||||
/// 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],
|
||||
opts: &SolveOptions,
|
||||
members_left: &mut usize,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build(d, &handles);
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
@@ -428,6 +440,46 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put back what each Fix locks where `before` had it, and report whether anything moved.
|
||||
///
|
||||
/// 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.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.flat_map(|c| c.refs.iter().copied())
|
||||
.collect();
|
||||
if refs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let mut x = now.x0.clone();
|
||||
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;
|
||||
}
|
||||
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
|
||||
{
|
||||
*slot = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
now.write_back(d, &x);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -659,6 +711,33 @@ pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveO
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Rename a parameter (dimensional constraint name or user parameter) and every expression that
|
||||
/// uses it. Geometry doesn't change. On error the drawing is unchanged.
|
||||
pub fn rename_parameter(d: &mut Drawing, old: &str, new: &str) -> Result<(), SolveError> {
|
||||
if !name_in_use(d, old) {
|
||||
return Err(SolveError::Other(format!("no parameter `{old}`")));
|
||||
}
|
||||
if !expr::valid_name(new) {
|
||||
return Err(SolveError::Other(format!("invalid parameter name `{new}`")));
|
||||
}
|
||||
if new != old && name_in_use(d, new) {
|
||||
return Err(SolveError::Other(format!("`{new}` is already in use")));
|
||||
}
|
||||
for c in d.constraints.iter_mut().filter(|c| c.kind.is_dimensional()) {
|
||||
if c.name == old {
|
||||
c.name = new.to_string();
|
||||
}
|
||||
c.expr = expr::rename(&c.expr, old, new);
|
||||
}
|
||||
for p in &mut d.parametric.parameters {
|
||||
if p.name == old {
|
||||
p.name = new.to_string();
|
||||
}
|
||||
p.expr = expr::rename(&p.expr, old, new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where to draw a constraint's glyphs (one anchor per reference).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -27,6 +27,11 @@ pub const MAX_PARAMS: usize = 4000;
|
||||
pub const MAX_SPLINE_POINTS: usize = 500;
|
||||
/// Most characteristic points of a block reference used as rigid constraint points.
|
||||
pub const MAX_RIGID_POINTS: usize = 256;
|
||||
/// Block members examined when sampling rigid points, counting those that contribute none, so a
|
||||
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
|
||||
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;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
@@ -74,6 +79,21 @@ pub(crate) struct Model {
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(s) = self.splines.get(&handle) else { return 0.0 };
|
||||
let n = s.fit.len() as f64;
|
||||
if fit_mode(s) {
|
||||
n * n * n
|
||||
} else {
|
||||
let n = s.control.len() as f64;
|
||||
n * n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this entity take part in constraints?
|
||||
pub fn supported(kind: &EntityKind) -> bool {
|
||||
matches!(
|
||||
@@ -93,13 +113,26 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
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());
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
|
||||
// every Insert — would otherwise be unbounded (CWE-407).
|
||||
let mut examined = 0usize;
|
||||
for e in block.entities.iter() {
|
||||
if out.len() >= MAX_RIGID_POINTS {
|
||||
examined += 1;
|
||||
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
let pts: Vec<Vec2> = match &e.kind {
|
||||
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
|
||||
EntityKind::Arc(a) => {
|
||||
@@ -152,6 +185,12 @@ fn fit_mode(s: &GSpline) -> bool {
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if m.index.contains_key(&h) {
|
||||
@@ -208,12 +247,15 @@ impl Model {
|
||||
// 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)));
|
||||
m.splines.insert(h, sp.clone());
|
||||
// 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(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
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);
|
||||
@@ -573,3 +615,60 @@ impl Model {
|
||||
moved
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::{Block, Common, Entity, Line, Space};
|
||||
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();
|
||||
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
|
||||
spline.weights = vec![2.0, 0.5];
|
||||
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
|
||||
let model = Model::build(&drawing, &[h]);
|
||||
model.write_back(&mut drawing, &model.x0);
|
||||
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
|
||||
assert_eq!(after.weights, spline.weights);
|
||||
for i in 0..=10 {
|
||||
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let mut block = Block::new("BAR");
|
||||
for _ in 0..3 {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
let mut budget = 2;
|
||||
let first = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert_eq!(first.rigid[&h].len(), 4);
|
||||
assert_eq!(budget, 0);
|
||||
let next = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert!(next.rigid[&h].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ pub(crate) enum Form {
|
||||
C1,
|
||||
/// Arc sweep (angular on an arc).
|
||||
ArcSweep,
|
||||
/// Two lines measured along the rays from their intersection to each line's far end, the
|
||||
/// angle the dimension draws; `rev` marks a line whose far end is its start point.
|
||||
Rays {
|
||||
rev: [bool; 2],
|
||||
},
|
||||
/// Fix of a derived point (residual to an anchor).
|
||||
Anchor(Vec2),
|
||||
/// Fix handled by locking parameters (no rows).
|
||||
@@ -92,6 +97,10 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
|
||||
if l > 1e-12 { d * (1.0 / l) } else { d }
|
||||
}
|
||||
|
||||
fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 {
|
||||
if rev { dir_n(b, a) } else { dir_n(a, b) }
|
||||
}
|
||||
|
||||
fn sgn(v: f64) -> f64 {
|
||||
if v < 0.0 { -1.0 } else { 1.0 }
|
||||
}
|
||||
@@ -107,6 +116,16 @@ 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 {
|
||||
Form::EndOn { spline, .. } => cost(spline),
|
||||
Form::Join { .. } => cost(0) + cost(1),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for
|
||||
/// dimensional constraints (degrees for angular). Errors explain what references are needed.
|
||||
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
|
||||
@@ -284,10 +303,15 @@ impl Built {
|
||||
}
|
||||
K::Angular => {
|
||||
b.rows = 1;
|
||||
if n == 2 && ln(0).is_some() && ln(1).is_some() {
|
||||
b.form = Form::LL;
|
||||
let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") };
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) {
|
||||
// Start-to-end directions would measure the supplement when a line is drawn
|
||||
// towards the vertex, so use the rays the dimension draws.
|
||||
let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) {
|
||||
Some((_, t, u)) => [t > 0.5, u > 0.5],
|
||||
None => [false, false],
|
||||
};
|
||||
b.form = Form::Rays { rev };
|
||||
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
|
||||
b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2)));
|
||||
} else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() {
|
||||
b.form = Form::ArcSweep;
|
||||
@@ -297,7 +321,7 @@ impl Built {
|
||||
b.target = target.to_radians();
|
||||
// Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger
|
||||
// target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn.
|
||||
let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU };
|
||||
let max = if matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU };
|
||||
if !b.target.is_finite() || b.target.abs() > max {
|
||||
return Err("angle out of range".into());
|
||||
}
|
||||
@@ -461,9 +485,9 @@ impl Built {
|
||||
(K::Angular, Form::ArcSweep) => {
|
||||
out.push(m.arc_sweep(x, r(0)?)? - self.target);
|
||||
}
|
||||
(K::Angular, _) => {
|
||||
(K::Angular, Form::Rays { rev }) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
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),
|
||||
|
||||
@@ -363,3 +363,22 @@ fn hostile_new_geometry_never_panics() {
|
||||
let _ = dof(&d, &hs);
|
||||
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();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut sp = GSpline::from_control(pts.clone(), 3);
|
||||
sp.fit = pts;
|
||||
let h = put(&mut d, EntityKind::Spline(sp));
|
||||
for i in 0..5 {
|
||||
let p = point(&mut d, (i as f64, (i % 3) as f64));
|
||||
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
|
||||
c.id = i + 1;
|
||||
d.constraints.push(c);
|
||||
}
|
||||
let report = dof(&d, &[]).unwrap();
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
|
||||
@@ -248,10 +248,17 @@ impl ArrayParams {
|
||||
return false;
|
||||
}
|
||||
let Some(items) = self.items() else { return false };
|
||||
// Index the replacement entries by placement so each item is matched in O(1) instead of
|
||||
// scanning the whole `replaced` vector per item, which is quadratic when both the items
|
||||
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
|
||||
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
|
||||
for (idx, r) in self.replaced.iter().enumerate() {
|
||||
last_replaced.insert((r.i, r.j, r.level), idx);
|
||||
}
|
||||
let mut source: Option<&str> = None;
|
||||
for (it, e) in items.iter().zip(&ents) {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return false };
|
||||
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) {
|
||||
match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
|
||||
Some(r) => r.block.clone_from(&ins.block),
|
||||
None => match source {
|
||||
None => source = Some(&ins.block),
|
||||
|
||||
@@ -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(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
bars_visible: true,
|
||||
bar_exceptions: Vec::new(),
|
||||
|
||||
@@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
// Deduplicate member handles before resolving them: a graph can list the same member many
|
||||
// times, and parsing each occurrence into its own owned parameter multiplies a single large
|
||||
// record into gigabytes of retained allocations (CWE-400).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
|
||||
@@ -464,7 +464,15 @@ fn apply(
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Index this action's vertex lists by handle once, and skip handles already handled in
|
||||
// this action's selection, so a selection that repeats a handle does not re-scan the
|
||||
// vertex list and re-clone its index vector per occurrence (CWE-400).
|
||||
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
|
||||
let mut handled: HashSet<Handle> = HashSet::new();
|
||||
for h in &a.selection {
|
||||
if !handled.insert(*h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = param_of.get(h) {
|
||||
// A parameter in the selection is carried along: its points in the frame. Which
|
||||
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
|
||||
@@ -489,7 +497,7 @@ fn apply(
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
|
||||
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most grips listed for one reference.
|
||||
@@ -79,6 +79,29 @@ pub struct DynGrip {
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which ends of two-point parameter `node` the block's actions follow, as (base, end): a
|
||||
/// connection to one point's change (BaseXDelta, EndYDelta, UpdatedEnd…) follows that end, one to
|
||||
/// the value itself (a distance, an angle) follows both. None when no action is attached to the
|
||||
/// parameter: its grips are all kept, as the block has them.
|
||||
fn ends_used(m: &DynamicBlock, node: i32) -> Option<(bool, bool)> {
|
||||
let mut used: Option<(bool, bool)> = None;
|
||||
for (n, name) in m.actions.iter().flat_map(|a| a.connections.iter()) {
|
||||
if *n != node {
|
||||
continue;
|
||||
}
|
||||
let c = name.to_ascii_lowercase();
|
||||
let (base, end) = used.unwrap_or((false, false));
|
||||
used = Some(if c.starts_with("base") || c.starts_with("updatedbase") {
|
||||
(true, end)
|
||||
} else if c.starts_with("end") || c.starts_with("updatedend") {
|
||||
(base, true)
|
||||
} else {
|
||||
(true, true)
|
||||
});
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
|
||||
[p.x, p.y].serialize(s)
|
||||
}
|
||||
@@ -112,8 +135,15 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A grip shows in the current visibility state when the state lists it, or lists its
|
||||
// parameter (AutoCAD shows a listed parameter's grips though the grips aren't listed).
|
||||
let param_handle: HashMap<i32, Handle> = m.parameters.iter().map(|p| (p.node, p.handle)).collect();
|
||||
let shown = |node: i32, id: i32| {
|
||||
id != 0 && (Some(node) == visibility_node || shown_in_state.as_ref().is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h))))
|
||||
id != 0
|
||||
&& (Some(node) == visibility_node
|
||||
|| shown_in_state
|
||||
.as_ref()
|
||||
.is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h)) || param_handle.get(&node).is_some_and(|h| a.contains(h))))
|
||||
};
|
||||
let choices: HashMap<String, Vec<String>> =
|
||||
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
|
||||
@@ -121,15 +151,21 @@ pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
for p in &m.parameters {
|
||||
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base, end, label, .. } => {
|
||||
ParameterKind::Linear { base, end, label, from_midpoint, .. } => {
|
||||
let (b, e) = match r.values.get(&p.node) {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
let u = e - b;
|
||||
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
// An end no action follows has no grip (dragging it would change nothing); a
|
||||
// distance from its midpoint moves both ends.
|
||||
let used = match ends_used(&m, p.node) {
|
||||
Some((base, end)) if !*from_midpoint => [base, end],
|
||||
_ => [true, true],
|
||||
};
|
||||
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
|
||||
if shown(p.node, grip(i)) {
|
||||
if used[i] && shown(p.node, grip(i)) {
|
||||
let (at, dir) = (world(at), toward(at, way));
|
||||
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
|
||||
}
|
||||
|
||||
@@ -1408,9 +1408,12 @@ impl EntityKind {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let mut g: Vec<Vec2> = Vec::new();
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
// Compute the segments once, not once per vertex: rebuilding the whole segment
|
||||
// vector inside the loop is quadratic in the vertex count (CWE-407).
|
||||
let segs = pl.segments();
|
||||
for (i, v) in p.vertices.iter().enumerate() {
|
||||
g.push(v.p);
|
||||
if let Some(s) = pl.segments().get(i) {
|
||||
if let Some(s) = segs.get(i) {
|
||||
g.push(s.mid());
|
||||
}
|
||||
}
|
||||
@@ -1468,3 +1471,21 @@ fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) {
|
||||
// MIRRTEXT = 0 (the default): mirrored text keeps reading left to right.
|
||||
t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grip_tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn polyline_grips_are_vertices_and_midpoints() {
|
||||
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
|
||||
// once rather than rebuilt per vertex (CWE-407, row 35).
|
||||
let verts: Vec<PolyVertex> = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect();
|
||||
let e = EntityKind::LwPolyline(LwPolyline { vertices: verts, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false });
|
||||
let g = e.grips();
|
||||
// 3 vertices + 2 segment midpoints.
|
||||
assert_eq!(g.len(), 5, "{g:?}");
|
||||
assert!(g.contains(&Vec2::new(0.0, 0.0)) && g.contains(&Vec2::new(5.0, 0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
|
||||
&& depth < MAX_BLOCK_DEPTH
|
||||
{
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
b = b.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
// Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
|
||||
// block with branching or self-referencing children costs one pass per definition
|
||||
// per depth, not one per reference (which grows exponentially) (CWE-400).
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(cached) => *cached,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
}
|
||||
};
|
||||
b = b.union(&inner);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ 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`
|
||||
@@ -81,6 +85,7 @@ impl Foreign {
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.class_order.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -98,6 +98,9 @@ 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>,
|
||||
@@ -142,12 +145,14 @@ impl Drawing {
|
||||
d.header = Header::imperial();
|
||||
d
|
||||
}
|
||||
/// An empty metric drawing (millimetres, ISO-25 dimensions).
|
||||
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
|
||||
pub fn new_metric() -> Self {
|
||||
let mut d = Drawing::bare();
|
||||
d.header = Header::metric();
|
||||
d.scales = metric_scales();
|
||||
d.dim_styles.push(DimStyle::iso25());
|
||||
d.mleader_styles = vec![MLeaderStyle::metric()];
|
||||
d.table_styles = vec![TableStyle::metric()];
|
||||
d.header.set_str("DIMSTYLE", "ISO-25");
|
||||
d
|
||||
}
|
||||
@@ -169,6 +174,7 @@ 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(),
|
||||
@@ -296,6 +302,18 @@ 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),
|
||||
|
||||
@@ -616,6 +616,13 @@ impl Default for MLeaderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
impl MLeaderStyle {
|
||||
/// The Standard style for metric drawings: text and arrows as high as ISO-25's.
|
||||
pub fn metric() -> Self {
|
||||
MLeaderStyle { arrow_size: 2.5, text_height: 2.5, landing_gap: 0.625, dogleg: 5.0, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TableStyle {
|
||||
@@ -636,6 +643,13 @@ impl Default for TableStyle {
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyle {
|
||||
/// The Standard style for metric drawings: text as high as ISO-25's.
|
||||
pub fn metric() -> Self {
|
||||
TableStyle { text_height: 2.5, margin: 1.0, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
mod cellstyle;
|
||||
mod tablebreak;
|
||||
pub use cellstyle::*;
|
||||
@@ -738,6 +752,8 @@ pub struct PageSetup {
|
||||
pub landscape: bool,
|
||||
/// "layout" | "extents" | "display" | "window" | "limits".
|
||||
pub plot_area: String,
|
||||
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
|
||||
pub plot_window: [f64; 4],
|
||||
pub scale_to_fit: bool,
|
||||
/// Paper units per drawing unit.
|
||||
pub scale: f64,
|
||||
@@ -750,6 +766,9 @@ pub struct PageSetup {
|
||||
/// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can
|
||||
/// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF.
|
||||
pub plot_transparency: bool,
|
||||
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
|
||||
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
|
||||
pub paper_mm: bool,
|
||||
}
|
||||
|
||||
impl Default for PageSetup {
|
||||
@@ -762,6 +781,7 @@ impl Default for PageSetup {
|
||||
margins_mm: [6.35, 6.35, 6.35, 6.35],
|
||||
landscape: true,
|
||||
plot_area: "layout".into(),
|
||||
plot_window: [0.0; 4],
|
||||
scale_to_fit: false,
|
||||
scale: 1.0,
|
||||
plot_style_table: String::new(),
|
||||
@@ -769,6 +789,7 @@ impl Default for PageSetup {
|
||||
lineweights: true,
|
||||
show_plot_styles: false,
|
||||
plot_transparency: false,
|
||||
paper_mm: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -836,7 +857,7 @@ pub struct VportConfig {
|
||||
|
||||
impl VportConfig {
|
||||
/// The name of the configuration shown.
|
||||
pub const ACTIVE: &'static str = "*Active";
|
||||
pub const ACTIVE: &str = "*Active";
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.name.eq_ignore_ascii_case(Self::ACTIVE)
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ impl Default for CellStyle {
|
||||
}
|
||||
|
||||
impl CellStyle {
|
||||
pub const TITLE: &'static str = "Title";
|
||||
pub const HEADER: &'static str = "Header";
|
||||
pub const DATA: &'static str = "Data";
|
||||
pub const TITLE: &str = "Title";
|
||||
pub const HEADER: &str = "Header";
|
||||
pub const DATA: &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(|s| s.to_string()).collect();
|
||||
let mut out: Vec<String> = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].iter().map(ToString::to_string).collect();
|
||||
out.extend(self.cell_styles.iter().filter(|c| !CellStyle::is_builtin(&c.name)).map(|c| c.name.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -16,6 +16,23 @@ fn new_drawing_has_standard_tables() {
|
||||
assert!(m.dim_style("ISO-25").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_metric_drawing_has_metric_multileader_and_table_styles() {
|
||||
let m = Drawing::new_metric();
|
||||
let iso = m.dim_style("ISO-25").unwrap();
|
||||
let ml = m.mleader_styles.iter().find(|s| s.name == "Standard").unwrap();
|
||||
assert_eq!((ml.text_height, ml.arrow_size), (iso.text_height, iso.arrow_size));
|
||||
assert_eq!(ml.dogleg, 5.0);
|
||||
let ts = m.table_styles.iter().find(|s| s.name == "Standard").unwrap();
|
||||
assert_eq!(ts.text_height, iso.text_height);
|
||||
assert_eq!(iso.text_height, 2.5);
|
||||
|
||||
let i = Drawing::new_imperial();
|
||||
assert_eq!(i.mleader_styles, vec![MLeaderStyle::default()]);
|
||||
assert_eq!(i.table_styles, vec![TableStyle::default()]);
|
||||
assert_eq!((i.mleader_styles[0].text_height, i.table_styles[0].text_height), (0.18, 0.18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_extents() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mod lin_pat_parse;
|
||||
mod sub_segment_alias;
|
||||
@@ -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.4"
|
||||
acadrust = "0.6.5"
|
||||
|
||||
+460
-2
@@ -4,6 +4,25 @@
|
||||
//! 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)]
|
||||
|
||||
@@ -83,10 +102,16 @@ mod native {
|
||||
let why = outcome.stats.diagnostics.first().map(|d| d.message.trim_start_matches("Failsafe: ").to_string());
|
||||
return Err(format!("DWG: {}", why.unwrap_or_else(|| "the file's objects couldn't be read".to_string())));
|
||||
}
|
||||
let notes = read_notes(&outcome.stats);
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
current_style_names(&mut doc);
|
||||
table_merges(&mut doc);
|
||||
table_style_rows(&mut doc, false);
|
||||
let orphans = drop_orphaned_dgn_components(&mut doc);
|
||||
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"));
|
||||
}
|
||||
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
|
||||
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
|
||||
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
|
||||
@@ -265,6 +290,73 @@ 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) {
|
||||
for e in doc.entities_mut() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
d.base_mut().dwg_flags_byte |= 0x02;
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave out DGN line style components (`LS*COMPONENT`) that no line style definition uses
|
||||
/// (#1482). A drawing that once came from MicroStation can carry hundreds of thousands of
|
||||
/// them, orphaned copies piled up by earlier DGN imports and copy-pastes (AutoCAD's PURGE
|
||||
/// removes them as orphaned data): in a real 83 MB site plan they were 888,483 of 889,053
|
||||
/// components and made its DXF rendition 1.2 GB, over [`Limits::DEFAULT`], so the file
|
||||
/// didn't open at all. No definition that was read refers to them (a definition the reader
|
||||
/// skipped can't use them either) and CADCraft doesn't draw DGN line styles, so nothing
|
||||
/// visible is lost.
|
||||
/// Every component a definition reaches (directly, or through compound and point
|
||||
/// components) is kept, as are the definitions; dictionary entries that named a dropped
|
||||
/// component go with it. Returns how many were left out.
|
||||
fn drop_orphaned_dgn_components(doc: &mut acadrust::CadDocument) -> usize {
|
||||
use acadrust::objects::{DgnLineStyleData, ObjectType};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
// Each component's references, and the definitions' root components to start from.
|
||||
let mut refs: HashMap<acadrust::Handle, Vec<acadrust::Handle>> = HashMap::new();
|
||||
let mut stack: Vec<acadrust::Handle> = Vec::new();
|
||||
for (h, o) in &doc.objects {
|
||||
let ObjectType::DgnLineStyle(ls) = o else { continue };
|
||||
match &ls.data {
|
||||
DgnLineStyleData::Definition { root_component, .. } => stack.push(*root_component),
|
||||
DgnLineStyleData::Component { component, .. } => {
|
||||
refs.insert(*h, component.references());
|
||||
}
|
||||
DgnLineStyleData::Registered { .. } => {}
|
||||
}
|
||||
}
|
||||
if refs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Each component is visited once, so cycles and shared subtrees end.
|
||||
let mut used: HashSet<acadrust::Handle> = HashSet::new();
|
||||
while let Some(h) = stack.pop() {
|
||||
if let Some(r) = refs.get(&h)
|
||||
&& used.insert(h)
|
||||
{
|
||||
stack.extend(r.iter().copied());
|
||||
}
|
||||
}
|
||||
let orphans: HashSet<acadrust::Handle> = refs.into_keys().filter(|h| !used.contains(h)).collect();
|
||||
if orphans.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
doc.objects.retain(|h, _| !orphans.contains(h));
|
||||
doc.dgn_ls_components.retain(|h, _| !orphans.contains(h));
|
||||
for o in doc.objects.values_mut() {
|
||||
let ObjectType::Dictionary(d) = o else { continue };
|
||||
if d.entries.iter().any(|(_, e)| orphans.contains(e)) {
|
||||
d.entries.retain(|(_, e)| !orphans.contains(e));
|
||||
let keys: HashSet<&str> = d.entries.iter().map(|(k, _)| k.as_str()).collect();
|
||||
d.hard_owner_entries.retain(|k| keys.contains(k.as_str()));
|
||||
}
|
||||
}
|
||||
orphans.len()
|
||||
}
|
||||
|
||||
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
|
||||
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
|
||||
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
|
||||
@@ -307,7 +399,8 @@ mod native {
|
||||
/// the HEADER section: (name, group code, value).
|
||||
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
|
||||
[
|
||||
("$ANGBASE", 50, h.angle_base.to_string()),
|
||||
// DWG holds radians; `$ANGBASE` is in degrees.
|
||||
("$ANGBASE", 50, h.angle_base.to_degrees().to_string()),
|
||||
("$ANGDIR", 70, h.angle_direction.to_string()),
|
||||
("$FILLETRAD", 40, h.fillet_radius.to_string()),
|
||||
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
|
||||
@@ -385,7 +478,52 @@ mod native {
|
||||
convert(dxf, Some(v))
|
||||
}
|
||||
|
||||
/// The current dimension and text styles' names from their handles: a DWG stores the
|
||||
/// handles, and the reader leaves the names at "Standard", so a drawing reopened from DWG
|
||||
/// lost its current dimension style (#1051).
|
||||
fn current_style_names(doc: &mut acadrust::CadDocument) {
|
||||
let h = doc.header.current_dimstyle_handle;
|
||||
if let Some(n) = doc.dim_styles.iter().find(|ds| ds.handle == h && !h.is_null()).map(|ds| ds.name.clone()) {
|
||||
doc.header.current_dimstyle_name = n;
|
||||
}
|
||||
let h = doc.header.current_text_style_handle;
|
||||
if let Some(n) = doc.text_styles.iter().find(|ts| ts.handle == h && !h.is_null()).map(|ts| ts.name.clone()) {
|
||||
doc.header.current_text_style_name = n;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current dimension style (`$DIMSTYLE`) by its name: the DXF gives the name, and the
|
||||
/// handle read with it needn't be the style's in the document, in which case the DWG writer
|
||||
/// fell back to Standard and AutoCAD opened the drawing with Standard current (#1051).
|
||||
fn current_dimstyle(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_dimstyle_name.clone();
|
||||
if let Some(h) = doc.dim_styles.iter().find(|ds| ds.name.eq_ignore_ascii_case(&name)).map(|ds| ds.handle) {
|
||||
doc.header.current_dimstyle_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
|
||||
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
|
||||
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
|
||||
fn current_text_style(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_text_style_name.clone();
|
||||
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
|
||||
doc.header.current_text_style_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
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())?
|
||||
@@ -398,12 +536,109 @@ mod native {
|
||||
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
|
||||
None => {}
|
||||
}
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
add_variable_dictionary(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
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
|
||||
/// second layout landed on the first one, and the second stayed empty (#1057).
|
||||
fn other_layout_owners(doc: &mut acadrust::CadDocument) {
|
||||
let others: std::collections::HashSet<acadrust::Handle> = doc
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| {
|
||||
let n = br.name.to_ascii_uppercase();
|
||||
n.len() > 12 && n.starts_with("*PAPER_SPACE") && n[12..].bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
.map(|br| br.handle)
|
||||
.collect();
|
||||
if others.is_empty() {
|
||||
return;
|
||||
}
|
||||
for e in doc.entities_mut() {
|
||||
let c = e.common_mut();
|
||||
if others.contains(&c.owner_handle) {
|
||||
c.entity_mode = Some(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -448,9 +683,232 @@ mod tests {
|
||||
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
|
||||
}
|
||||
|
||||
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
|
||||
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn current_text_style_reaches_the_dwg() {
|
||||
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
|
||||
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
|
||||
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
|
||||
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
|
||||
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
|
||||
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
|
||||
assert!(romans.is_some(), "ROMANS is missing from the DWG");
|
||||
assert_eq!(Some(doc.header.current_text_style_handle), romans);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error_not_a_crash() {
|
||||
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
|
||||
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 {
|
||||
let mut s = String::from(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n");
|
||||
for (text, flags, sub, pts) in dims {
|
||||
s += " 0\nDIMENSION\n 8\n0\n100\nAcDbEntity\n100\nAcDbDimension\n 10\n0\n 20\n0\n 30\n0\n";
|
||||
s += &format!(" 11\n50\n 21\n-20\n 31\n0\n 70\n{flags}\n 1\n{text}\n 3\nStandard\n100\n{sub}\n");
|
||||
for (code, [x, y]) in pts.iter() {
|
||||
s += &format!("{code:>3}\n{x}\n{:>3}\n{y}\n{:>3}\n0\n", code + 10, code + 20);
|
||||
}
|
||||
}
|
||||
s + " 0\nENDSEC\n 0\nEOF\n"
|
||||
}
|
||||
|
||||
/// DWG stores DXF group 70's bit 128 inverted, in bit 0 of the dimension flags byte (set:
|
||||
/// the text is at its default position), and bit 32 in bit 1 (ODA DWG specification, common
|
||||
/// dimension data). The bytes are read back raw, so a reader that inverts bit 0 the same way
|
||||
/// as the writer can't hide a wrong byte.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn dimension_text_position_flags_reach_the_dwg_as_autocad_reads_them() {
|
||||
let linear: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [100.0, 0.0])];
|
||||
let radius: &[(i32, [f64; 2])] = &[(15, [10.0, 0.0])];
|
||||
let angular: &[(i32, [f64; 2])] = &[(13, [0.0, 0.0]), (14, [10.0, 0.0]), (15, [0.0, 0.0]), (16, [0.0, 10.0])];
|
||||
let kinds = [
|
||||
(0, "AcDbAlignedDimension", linear),
|
||||
(1, "AcDbAlignedDimension", linear),
|
||||
(4, "AcDbRadialDimension", radius),
|
||||
(2, "AcDb2LineAngularDimension", angular),
|
||||
];
|
||||
let mut dims = Vec::new();
|
||||
for (ty, sub, pts) in kinds {
|
||||
dims.push(("HOME", ty | 32, sub, pts));
|
||||
dims.push(("MOVED", ty | 32 | 128, sub, pts));
|
||||
}
|
||||
let dwg = dxf_to_dwg(dims_dxf(&dims).as_bytes()).unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for e in doc.entities() {
|
||||
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
|
||||
let (text, byte) = (d.base().text_override().unwrap_or_default(), d.base().dwg_flags_byte);
|
||||
seen.push((text.to_string(), byte & 0x03));
|
||||
assert_eq!(byte & 0x02, 0x02, "{text} {:?}: bit 32 of group 70 is lost (flags byte {byte:#04x})", d.base().dimension_type);
|
||||
let default_pos = text == "HOME";
|
||||
assert_eq!(byte & 0x01 != 0, default_pos, "{text} {:?}: flags byte {byte:#04x}", d.base().dimension_type);
|
||||
}
|
||||
for text in ["HOME", "MOVED"] {
|
||||
assert_eq!(seen.iter().filter(|(t, _)| t == text).count(), 4, "{seen:?}");
|
||||
}
|
||||
|
||||
// And CADCraft reads its own DWG back as it wrote it: 128 only on the moved texts.
|
||||
let dxf = String::from_utf8(dwg_to_dxf(&dwg).unwrap()).unwrap();
|
||||
let lines: Vec<&str> = dxf.lines().map(str::trim).collect();
|
||||
let mut flags = Vec::new();
|
||||
for rec in lines.chunks(2).collect::<Vec<_>>().split(|p| p.first() == Some(&"0")) {
|
||||
let value = |code: &str| rec.iter().find(|p| p.first() == Some(&code)).and_then(|p| p.get(1).copied());
|
||||
if let (Some(text @ ("HOME" | "MOVED")), Some(v)) = (value("1"), value("70")) {
|
||||
flags.push((text, v.parse::<i64>().unwrap_or(-1) & 128));
|
||||
}
|
||||
}
|
||||
assert_eq!(flags.len(), 8, "{flags:?}");
|
||||
assert!(flags.iter().all(|(l, f)| (*f == 128) == (*l == "MOVED")), "{flags:?}");
|
||||
}
|
||||
|
||||
/// The handles a DXF text refers to or gives its records (groups 5, 105 and 330–369).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dxf_handles(dxf: &str) -> std::collections::HashSet<u64> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
lines
|
||||
.chunks(2)
|
||||
.filter_map(|p| match p {
|
||||
[c, v] if matches!(c.trim().parse::<i32>(), Ok(5 | 105 | 330..=369)) => u64::from_str_radix(v.trim(), 16).ok(),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Issue #1482: a DWG that once came from MicroStation can carry hundreds of thousands of DGN
|
||||
/// line style components no definition uses (a real 83 MB site plan: 888,483 of 889,053, a
|
||||
/// 1.2 GB DXF rendition over the limit, so it didn't open). They're left out with a note, and
|
||||
/// their dictionary entries with them; the definition and every component it reaches —
|
||||
/// through compound and point components, around a cycle — are kept.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn orphaned_dgn_line_style_components_are_left_out() {
|
||||
use acadrust::objects::*;
|
||||
let mut doc = acadrust::CadDocument::new();
|
||||
let container = doc.allocate_handle();
|
||||
let nod = doc.header.named_objects_dict_handle;
|
||||
let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&nod) else { panic!("no named object dictionary") };
|
||||
root.entries.push(("ACAD_DGNLINESTYLECOMP".into(), container));
|
||||
let pattern = || DgnLsStrokePattern {
|
||||
has_iteration_limit: false,
|
||||
is_single_segment: false,
|
||||
iteration_limit: 0,
|
||||
auto_phase: 0.0,
|
||||
phase: 0.0,
|
||||
phase_mode: DgnLsPhaseMode::Distance,
|
||||
strokes: vec![DgnLsStroke {
|
||||
is_dash: true,
|
||||
bypass_corner: false,
|
||||
can_be_scaled: true,
|
||||
invert_at_origin: false,
|
||||
invert_at_end: false,
|
||||
length: 2.5,
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
width_mode: 0,
|
||||
cap_mode: 0,
|
||||
}],
|
||||
};
|
||||
let kind = |c: &DgnLsComponentData| match c {
|
||||
DgnLsComponentData::Symbol(_) => DgnLsComponentType::Symbol,
|
||||
DgnLsComponentData::Compound(_) => DgnLsComponentType::Compound,
|
||||
DgnLsComponentData::Stroke(_) => DgnLsComponentType::Stroke,
|
||||
DgnLsComponentData::Point(_) => DgnLsComponentType::Point,
|
||||
DgnLsComponentData::Internal(_) => DgnLsComponentType::Internal,
|
||||
};
|
||||
let compound = |of: &[acadrust::Handle]| {
|
||||
DgnLsComponentData::Compound(DgnLsCompoundComponent {
|
||||
entries: of.iter().map(|&component| DgnLsCompoundEntry { component, offset: 0.0 }).collect(),
|
||||
})
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut put = |doc: &mut acadrust::CadDocument, h: acadrust::Handle, data: DgnLineStyleData| {
|
||||
entries.push((format!("{:X}", h.value()), h));
|
||||
let reactors = vec![container];
|
||||
doc.objects
|
||||
.insert(h, ObjectType::DgnLineStyle(DgnLineStyleObject { handle: h, owner: container, reactors, xdictionary_handle: None, data }));
|
||||
};
|
||||
let component = |c: DgnLsComponentData| DgnLineStyleData::Component {
|
||||
kind: kind(&c),
|
||||
description: "dash".into(),
|
||||
version: 1,
|
||||
component_uid: [7; 16],
|
||||
scale: 1.0,
|
||||
property_flags: 0,
|
||||
component: c,
|
||||
properties: vec![],
|
||||
};
|
||||
let h: Vec<acadrust::Handle> = (0..9).map(|_| doc.allocate_handle()).collect();
|
||||
let [def, root, stroke, point, back, orphan, orphan_cycle, internal, shared] = h[..] else { panic!("nine handles") };
|
||||
put(
|
||||
&mut doc,
|
||||
def,
|
||||
DgnLineStyleData::Definition {
|
||||
description: "{ dash }".into(),
|
||||
version: 1,
|
||||
style_number: 1,
|
||||
component_uid: [1; 16],
|
||||
is_continuous: false,
|
||||
unit_definition: 1.0,
|
||||
unit_scale: 1.0,
|
||||
units_type: 0,
|
||||
is_element: false,
|
||||
is_physical: false,
|
||||
is_scale_independent: false,
|
||||
is_snappable: false,
|
||||
root_component: root,
|
||||
properties: vec![],
|
||||
},
|
||||
);
|
||||
// Used: def → root → {stroke, point → shared, back → root (a cycle)}.
|
||||
put(&mut doc, root, component(compound(&[stroke, point, back])));
|
||||
put(&mut doc, stroke, component(DgnLsComponentData::Stroke(pattern())));
|
||||
put(&mut doc, point, component(DgnLsComponentData::Point(DgnLsPointComponent { stroke_component: shared, symbols: vec![] })));
|
||||
put(&mut doc, back, component(compound(&[root])));
|
||||
put(&mut doc, shared, component(DgnLsComponentData::Stroke(pattern())));
|
||||
// Orphans: an internal component, and two compounds that refer to each other and to a
|
||||
// used component (which stays).
|
||||
let pattern_internal =
|
||||
DgnLsInternalComponent { pattern: pattern(), internal_version: 0, hardware_style: 0, is_hardware_style: false, line_code: 0 };
|
||||
put(&mut doc, internal, component(DgnLsComponentData::Internal(pattern_internal)));
|
||||
put(&mut doc, orphan, component(compound(&[orphan_cycle, shared])));
|
||||
put(&mut doc, orphan_cycle, component(compound(&[orphan])));
|
||||
let mut dict = Dictionary::new();
|
||||
dict.handle = container;
|
||||
dict.owner = nod;
|
||||
dict.entries = entries;
|
||||
doc.objects.insert(container, ObjectType::Dictionary(dict));
|
||||
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
|
||||
|
||||
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
|
||||
let dxf = String::from_utf8(dxf).unwrap();
|
||||
let handles = dxf_handles(&dxf);
|
||||
for kept in [def, root, stroke, point, back, shared] {
|
||||
assert!(handles.contains(&kept.value()), "{:X} was left out", kept.value());
|
||||
}
|
||||
for gone in [internal, orphan, orphan_cycle] {
|
||||
assert!(!handles.contains(&gone.value()), "{:X} was kept or is still referred to", gone.value());
|
||||
}
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let ls: Vec<&str> = lines.chunks(2).filter_map(|p| p.get(1).filter(|_| p[0].trim() == "0" && p[1].starts_with("LS")).copied()).collect();
|
||||
assert_eq!(ls.iter().filter(|t| t.ends_with("COMPONENT")).count(), 5, "{ls:?}");
|
||||
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
|
||||
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@ cadcraft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
craft-docpath = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
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.12", default-features = false }
|
||||
rstar = { version = "0.13", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# Real DXF files for the external reference tests (io is a lower layer).
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Safe, atomic file replacement.
|
||||
//!
|
||||
//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in
|
||||
//! a sibling temporary file and then renaming it over the destination, so a failed or partial
|
||||
//! write never truncates the real file. The temporary file must be created *exclusively*: opening
|
||||
//! a predictable, pre-existing temporary path for a truncating write lets another local user plant
|
||||
//! a symlink there and redirect the write onto a file the victim can write but the attacker chose
|
||||
//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`),
|
||||
//! so an existing entry at that path — symlink or not — makes the create fail rather than follow
|
||||
//! it. The handle is retained for the whole write, and only its own fresh file is renamed into
|
||||
//! place.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::fs::{File, OpenOptions, Permissions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// How many fresh temporary names to try before giving up (each collision is another local race).
|
||||
const MAX_TRIES: u32 = 32;
|
||||
|
||||
/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing
|
||||
/// temporary symlink.
|
||||
///
|
||||
/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes
|
||||
/// are written through the retained handle and flushed, and the temporary is then renamed over
|
||||
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let permissions = match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
file.write_all(bytes)?;
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
})();
|
||||
if let Err(e) = write {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
drop(file);
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
|
||||
///
|
||||
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
|
||||
/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to
|
||||
/// avoid collisions in practice, and genuine collisions are retried with a new name.
|
||||
fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> {
|
||||
let pid = std::process::id();
|
||||
let mut last_err = None;
|
||||
for _ in 0..MAX_TRIES {
|
||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
|
||||
let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}"));
|
||||
match create_exclusive(&tmp, permissions) {
|
||||
Ok(file) => return Ok((file, tmp)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file")))
|
||||
}
|
||||
|
||||
fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if let Some(permissions) = permissions {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
// Restrict access in the create syscall: chmod afterwards leaves a window in which a
|
||||
// different user can open the inode and retain read access to bytes written later.
|
||||
options.mode(permissions.mode() & 0o777);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = permissions;
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replaces_contents_atomically() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("out.bin");
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
write_atomic(&dest, b"new bytes").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes");
|
||||
// No temporary files left behind.
|
||||
let leftovers: Vec<_> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect();
|
||||
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim");
|
||||
std::fs::write(&victim, b"precious").unwrap();
|
||||
let planted = dir.join(".cadcraft-tmp-plant");
|
||||
symlink(&victim, &planted).unwrap();
|
||||
assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
let dest = dir.join("drawing.dxf");
|
||||
write_atomic(&dest, b"drawing bytes").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replacement_preserves_private_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("private.adt");
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_atomic(&dest, b"private report").unwrap();
|
||||
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_temp_is_restricted_from_creation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("private-temp");
|
||||
let permissions = Permissions::from_mode(0o600);
|
||||
let file = create_exclusive(&path, Some(&permissions)).unwrap();
|
||||
assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0);
|
||||
drop(file);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,12 @@ impl Report {
|
||||
"message": self.message(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Format this report for an AUDITCTL `.adt` log file.
|
||||
pub fn format_adt(&self, title: &str, path: Option<&str>) -> String {
|
||||
let name = path.unwrap_or(title);
|
||||
format!("Audit of drawing: {name}\n\n{}\n", self.message())
|
||||
}
|
||||
}
|
||||
|
||||
/// Audit `d` without changing it.
|
||||
|
||||
@@ -302,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(|n| n.to_str()).is_some_and(|n| parse_file_name(n).is_some());
|
||||
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::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;
|
||||
@@ -334,14 +334,26 @@ 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() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
create_folder(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, path).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).
|
||||
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
@@ -434,7 +446,7 @@ mod native {
|
||||
}
|
||||
self.last_beat = Some(now);
|
||||
let dir = Path::new(dir);
|
||||
let r = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
|
||||
let r = create_folder(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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Bounded reads of local files.
|
||||
//!
|
||||
//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable
|
||||
//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check
|
||||
//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular
|
||||
//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected
|
||||
//! before it is fully buffered.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Read at most `max` bytes from the regular file at `path`.
|
||||
///
|
||||
/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max`
|
||||
/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory.
|
||||
pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
|
||||
}
|
||||
// Read one byte past the limit so we can tell "exactly max" from "too big" without trusting
|
||||
// the metadata length (which a device may misreport).
|
||||
let mut buf = Vec::new();
|
||||
let cap = (max as u64).saturating_add(1);
|
||||
let read = file.take(cap).read_to_end(&mut buf)?;
|
||||
if read as u64 > max as u64 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit")));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("f.bin");
|
||||
std::fs::write(&p, vec![7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert_eq!(read_capped(&p, 200).unwrap().len(), 100);
|
||||
assert!(read_capped(&p, 99).is_err());
|
||||
assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100);
|
||||
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() {
|
||||
// /dev/zero is an endless readable device; it must be rejected, not buffered.
|
||||
let p = Path::new("/dev/zero");
|
||||
if p.exists() {
|
||||
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() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Angular dimension between two lines, attached to them (vertex = their intersection).
|
||||
/// 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 {
|
||||
@@ -200,13 +200,18 @@ fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<E
|
||||
};
|
||||
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
|
||||
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
|
||||
let k = dim(s, DimKind::Angular3P, at, p1, p2, v, Vec2::ZERO, "");
|
||||
// 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 k = dim(s, DimKind::Angular, o2, o1, p1, p2, at, "");
|
||||
let mut assoc = Vec::new();
|
||||
if assoc_enabled(s) {
|
||||
assoc = vec![
|
||||
DimAssoc { point: "p13".into(), handle: h1, snap: s1 },
|
||||
DimAssoc { point: "p14".into(), handle: h2, snap: s2 },
|
||||
DimAssoc { point: "p15".into(), handle: h1, snap: AssocSnap::Intersection { other: h2 } },
|
||||
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))
|
||||
@@ -260,18 +265,16 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
};
|
||||
let k = match at {
|
||||
Some(at) => text_inside(k, c, c.dist(pt), at),
|
||||
Some(at) => text_at(k, at),
|
||||
None => k,
|
||||
};
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// A radius/diameter dimension line location picked inside the circle puts the text there, on
|
||||
/// the dimension line (outside, the text keeps its default place beyond the arrowhead).
|
||||
fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k
|
||||
&& at.dist(c) < r
|
||||
{
|
||||
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
|
||||
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
|
||||
fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.text_mid = v3(at);
|
||||
d.user_text_pos = true;
|
||||
}
|
||||
@@ -429,13 +432,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
let d = s.doc().ok()?;
|
||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||||
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
|
||||
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
|
||||
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
|
||||
let th = st.text_height * k;
|
||||
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
|
||||
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
|
||||
let th = st.text_height;
|
||||
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
let dogleg = if landing { st.dogleg * k } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
|
||||
let dogleg = if landing { st.dogleg } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
|
||||
Some(EntityKind::MLeader(MLeader {
|
||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||
landing: v3(*landing_pt),
|
||||
@@ -453,7 +456,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
columns: None,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
arrow_size: st.arrow_size,
|
||||
spline: false,
|
||||
block: None,
|
||||
}))
|
||||
@@ -1106,7 +1109,7 @@ impl RadialM {
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
|
||||
});
|
||||
text_inside(k, c, r, at)
|
||||
text_at(k, at)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use cadcraft_doc::{AssocSnap, DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_doc::{DimKind, Dimension, EntityKind, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -136,7 +136,9 @@ fn angular_between_lines_follows_rotation() {
|
||||
let b = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
let d = h(&s.execute("dimangular", &json!({ "lines": [a.hex(), b.hex()], "at": [3, 3] })).unwrap());
|
||||
let dm = dim(&s, d);
|
||||
assert!(dm.assoc.iter().any(|x| matches!(x.snap, AssocSnap::Intersection { .. })));
|
||||
// Two picked lines make a two-line angular dimension (type 2), tied to both lines at all four points.
|
||||
assert_eq!(dm.kind, DimKind::Angular);
|
||||
assert_eq!(dm.assoc.len(), 4);
|
||||
assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°");
|
||||
// Swing the second line to 45°.
|
||||
s.execute("properties.set", &json!({ "handles": [b.hex()], "end": [10, 10] })).unwrap();
|
||||
|
||||
@@ -626,7 +626,7 @@ impl Interactive for RectM {
|
||||
SpaceRows => dist("Specify the distance between rows", self.rs),
|
||||
UnitCell => match self.corner {
|
||||
None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base),
|
||||
Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c),
|
||||
Some(c) => Prompt::new("Specify other corner", Accept::POINT).corner(c),
|
||||
},
|
||||
ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]),
|
||||
ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64),
|
||||
|
||||
@@ -84,7 +84,11 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if fix && !issues.is_empty() {
|
||||
s.state_mut()?.doc = Arc::new(copy);
|
||||
}
|
||||
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
|
||||
let report = crate::audit::Report { issues, fixed: fix };
|
||||
if s.settings.auditctl {
|
||||
write_audit_log(s, &report);
|
||||
}
|
||||
Ok(report.to_json())
|
||||
}
|
||||
|
||||
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
|
||||
@@ -119,12 +123,36 @@ fn run_recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.saved = Arc::new((*st.doc).clone());
|
||||
}
|
||||
let uid = st.uid;
|
||||
if s.settings.auditctl {
|
||||
write_audit_log(s, &report);
|
||||
}
|
||||
let mut r = report.to_json();
|
||||
r["index"] = json!(i);
|
||||
r["uid"] = json!(uid);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_audit_log(s: &Session, report: &crate::audit::Report) {
|
||||
let (path_str, title) = match s.state() {
|
||||
Ok(st) => (st.path.clone(), st.title.clone()),
|
||||
Err(_) => (None, "Drawing".to_string()),
|
||||
};
|
||||
// An unsaved drawing has no folder of its own to hold the log.
|
||||
let Some(path) = &path_str else { return };
|
||||
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
|
||||
let content = report.format_adt(&title, path_str.as_deref());
|
||||
// Write through an exclusively-created temporary and rename over the companion path, so a
|
||||
// planted `.adt` symlink beside the drawing cannot redirect the report onto another file
|
||||
// writable by the victim (CWE-59).
|
||||
if let Err(e) = crate::atomic_write::write_atomic(&adt_path, content.as_bytes()) {
|
||||
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Block Editor.
|
||||
|
||||
pub(crate) mod bedit;
|
||||
mod purge;
|
||||
pub(in crate::cmd) mod purge;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod attedit;
|
||||
mod burst;
|
||||
mod dyngrip;
|
||||
mod extract;
|
||||
pub(crate) mod refedit;
|
||||
@@ -72,6 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
battman::battman_spec(),
|
||||
attribs::attdisp_spec(),
|
||||
attribs::attsync_spec(),
|
||||
burst::burst_spec(),
|
||||
extract::dataextraction_spec(),
|
||||
extract::eattext_spec(),
|
||||
]
|
||||
@@ -82,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn valid_block_name(n: &str) -> bool {
|
||||
pub(crate) fn valid_block_name(n: &str) -> bool {
|
||||
!n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
@@ -92,7 +94,7 @@ fn referenced_block(e: &Entity) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Whether any of `ents` refers to block `name`, directly or through nested block references.
|
||||
fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool {
|
||||
let mut todo: Vec<String> = ents.iter().filter_map(referenced_block).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(b) = todo.pop() {
|
||||
@@ -108,8 +110,41 @@ fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> b
|
||||
false
|
||||
}
|
||||
|
||||
/// What BLOCK does with the selected objects once the block is defined (the dialog's Convert to
|
||||
/// block, Retain and Delete).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Keep {
|
||||
/// Replace the objects by a reference to the new block.
|
||||
Convert,
|
||||
/// Leave the objects in the drawing.
|
||||
Retain,
|
||||
/// Erase the objects.
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl Keep {
|
||||
/// The `keep` parameter's documented values, in any case; `None` for anything else.
|
||||
pub(crate) fn parse(value: &str) -> Option<Keep> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"convert" => Some(Keep::Convert),
|
||||
"retain" => Some(Keep::Retain),
|
||||
"delete" => Some(Keep::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `keep` parameter of `cmd`, `default` when absent. A value that is not documented is an
|
||||
/// error (issue #935: a misspelt value used to erase the objects without converting them).
|
||||
fn keep_param(cmd: &str, p: &Value, default: Keep) -> Result<Keep> {
|
||||
match str_param(p, "keep") {
|
||||
None => Ok(default),
|
||||
Some(v) => Keep::parse(v).ok_or_else(|| bad(cmd, format!("`keep` must be \"convert\", \"retain\" or \"delete\", not {v:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a block from entities. Returns the insert handle when converting.
|
||||
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
|
||||
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: Keep, description: &str) -> Result<Option<Handle>> {
|
||||
if !valid_block_name(name) {
|
||||
return Err(bad("block", "invalid block name"));
|
||||
}
|
||||
@@ -136,13 +171,13 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
|
||||
b.entities.push(e);
|
||||
}
|
||||
doc.blocks.insert(name.to_string(), Arc::new(b));
|
||||
if keep != "retain" {
|
||||
if keep != Keep::Retain {
|
||||
for h in hs {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
if keep == "convert" {
|
||||
if keep == Keep::Convert {
|
||||
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
|
||||
return Ok(Some(h));
|
||||
}
|
||||
@@ -219,8 +254,8 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("block", "`name` is required"))?.to_string();
|
||||
let base = point_param(p, "base").unwrap_or(Vec2::ZERO);
|
||||
let hs = targets(s, p)?;
|
||||
let keep = str_param(p, "keep").unwrap_or("convert").to_string();
|
||||
let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?;
|
||||
let keep = keep_param("block", p, Keep::Convert)?;
|
||||
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
|
||||
// An annotative block: its references (the converted selection too) are annotative.
|
||||
if bool_or(p, "annotative", false) {
|
||||
let d = s.doc_mut()?;
|
||||
@@ -495,7 +530,7 @@ impl BlockM {
|
||||
fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) };
|
||||
// A definition that can't be made (e.g. one that would reference itself) ends the command.
|
||||
let keep = if self.dash { "delete" } else { "convert" };
|
||||
let keep = if self.dash { Keep::Delete } else { Keep::Convert };
|
||||
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
|
||||
s.echo(e.to_string());
|
||||
return Ok(Step::Done);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! BURST (Express Tools): explode block references as EXPLODE does, but their visible attributes
|
||||
//! become text with the values they show instead of the block's attribute definitions.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Insert};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::machines::SelectRun;
|
||||
use crate::cmd::modify::{explode_kind, explode_with};
|
||||
use crate::cmd::{CommandSpec, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(super) fn burst_spec() -> CommandSpec {
|
||||
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
|
||||
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
|
||||
.params("{handles?} (block references; other objects are left alone) → {handles}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
|
||||
}
|
||||
|
||||
fn run_burst(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let r = explode_with(s, &hs, burst_pieces)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// What a block reference bursts into: EXPLODE's pieces without the attribute definitions, then
|
||||
/// a text (multiline text for a multiline attribute) for each visible attribute.
|
||||
fn burst_pieces(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let mut out: Vec<Entity> = explode_kind(d, e)?.into_iter().filter(|p| !matches!(p.kind, EntityKind::AttDef(_))).collect();
|
||||
for a in ins.attribs.iter().filter(|a| !a.invisible) {
|
||||
let kind = match a.multiline_text() {
|
||||
Some(m) => EntityKind::MText(m),
|
||||
None => EntityKind::Text(a.text.clone()),
|
||||
};
|
||||
out.push(Entity { handle: Handle(0), common: attrib_common(d, e, ins, &a.tag, &a.props), kind });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The properties an attribute is drawn with: its own, else its definition's; layer 0 is the
|
||||
/// reference's layer and ByBlock colour the reference's colour, as EXPLODE gives the block's
|
||||
/// objects.
|
||||
fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, tag: &str, props: &cadcraft_doc::AttribProps) -> Common {
|
||||
let def =
|
||||
d.block(&ins.block).and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(tag))));
|
||||
// An attribute without a definition follows the reference.
|
||||
let base = def.map(|de| de.common.clone()).unwrap_or_else(|| Common { color: Color::ByBlock, ..Common::default() });
|
||||
let mut c = props.over(&base);
|
||||
if c.layer == "0" {
|
||||
c.layer.clone_from(&e.common.layer);
|
||||
}
|
||||
if c.color == Color::ByBlock {
|
||||
c.color = e.common.color;
|
||||
}
|
||||
c
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! grip begins, with the reference re-evaluated under the cursor as its preview. Typing a number
|
||||
//! at a distance's grip sets the distance.
|
||||
|
||||
use cadcraft_doc::color::Color;
|
||||
use cadcraft_doc::dynblock::{self, GripPart, GripShape};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Handle};
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -50,26 +51,59 @@ fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, and its
|
||||
/// attributes (what a preview draws, in the preview colour: solid fills are left out so they
|
||||
/// don't cover the outline and text).
|
||||
fn drawn(d: &Drawing, h: Handle) -> Vec<EntityKind> {
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };
|
||||
/// The properties an object of a block draws with in reference `r`: on layer 0 it is on the
|
||||
/// reference's layer, and its ByBlock colour, linetype, lineweight and transparency are the
|
||||
/// reference's (as the renderer draws a block's contents).
|
||||
fn under(c: &Common, r: &Common) -> Common {
|
||||
let mut c = c.clone();
|
||||
if c.layer == "0" {
|
||||
c.layer.clone_from(&r.layer);
|
||||
}
|
||||
if c.color == Color::ByBlock {
|
||||
c.color = r.color;
|
||||
}
|
||||
if c.linetype.eq_ignore_ascii_case("ByBlock") {
|
||||
c.linetype.clone_from(&r.linetype);
|
||||
}
|
||||
if c.lineweight == Lineweight::ByBlock {
|
||||
c.lineweight = r.lineweight;
|
||||
}
|
||||
if c.transparency == Transparency::ByBlock {
|
||||
c.transparency = r.transparency;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// The reference `h` as drawn in drawing `d`: its block's shown objects placed by it, in the
|
||||
/// block's order (fills under the outline and text), and its attributes, each with the
|
||||
/// properties it draws with.
|
||||
fn drawn(d: &Drawing, h: Handle) -> Vec<Entity> {
|
||||
let Some(e) = d.entity(h) else { return Vec::new() };
|
||||
let EntityKind::Insert(ins) = &e.kind else { return Vec::new() };
|
||||
let Some(b) = d.block(&ins.block) else { return Vec::new() };
|
||||
let r = &e.common;
|
||||
let m = ins.transform(b.base.xy());
|
||||
let mut out: Vec<EntityKind> = b
|
||||
let mut out: Vec<Entity> = b
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
|
||||
.filter(|e| !matches!(&e.kind, EntityKind::Hatch(h) if h.solid))
|
||||
.take(MAX_PREVIEW)
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
let mut kind = e.kind.clone();
|
||||
kind.transform(&m);
|
||||
Entity { handle: Handle(0), common: under(&e.common, r), kind }
|
||||
})
|
||||
.collect();
|
||||
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| EntityKind::Text(a.text.clone())));
|
||||
// An attribute draws with its definition's properties, except those set on it.
|
||||
let by_block = Common { layer: "0".into(), color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..r.clone() };
|
||||
out.extend(ins.attribs.iter().filter(|a| !a.invisible).map(|a| {
|
||||
let def = b.entities.iter().find_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag) => Some(&e.common),
|
||||
_ => None,
|
||||
});
|
||||
let common = under(&a.props.over(def.unwrap_or(&by_block)), r);
|
||||
Entity { handle: Handle(0), common, kind: EntityKind::Text(a.text.clone()) }
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -121,15 +155,20 @@ impl Interactive for DynGripEdit {
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
/// The rubber band from the grip.
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
vec![crate::cmd::helpers::line(self.from, c)]
|
||||
}
|
||||
|
||||
/// The reference re-evaluated at the cursor, drawn as it will look (its own colours, so
|
||||
/// text stays readable on a filled block).
|
||||
fn preview_objects(&self, s: &Session, c: Vec2) -> Vec<Entity> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut d = d.clone();
|
||||
let mut out = match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
|
||||
Ok(()) => drawn(&d, self.handle),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
out.push(crate::cmd::helpers::line(self.from, c));
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
|
||||
merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name);
|
||||
merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name);
|
||||
merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
|
||||
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
|
||||
}
|
||||
|
||||
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
||||
@@ -255,4 +256,17 @@ mod tests {
|
||||
assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into()));
|
||||
assert!(file_ref(&s, "BOLT").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_file_brings_its_mline_styles() {
|
||||
let mut src = Drawing::default();
|
||||
src.mline_styles.push(cadcraft_doc::MLineStyle { name: "WALL".into(), ..Default::default() });
|
||||
let mut d = Drawing::default();
|
||||
merge_tables(&mut d, &src);
|
||||
assert!(d.mline_styles.iter().any(|m| m.name == "WALL"));
|
||||
// A style this drawing already has keeps its own definition.
|
||||
let before = d.mline_styles.len();
|
||||
merge_tables(&mut d, &src);
|
||||
assert_eq!(d.mline_styles.len(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ use super::super::qselect::wildcard;
|
||||
use super::super::{bad, find_command, str_param};
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The named objects PURGE can remove. `USAGE` runs the same scan.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
pub(in crate::cmd) enum Kind {
|
||||
Blocks,
|
||||
Dimstyles,
|
||||
Groups,
|
||||
@@ -117,14 +118,35 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
/// The arrowhead block names a dimension's overrides set.
|
||||
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
|
||||
dm.overrides
|
||||
.iter()
|
||||
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
|
||||
.filter_map(|(_, v)| v.as_str().map(|n| n.trim().to_string()))
|
||||
.filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the
|
||||
/// arrowhead blocks dimension styles and dimension overrides name.
|
||||
pub(in crate::cmd) fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
let mut used = HashSet::new();
|
||||
let mut frontier: Vec<String> = Vec::new();
|
||||
for st in &d.dim_styles {
|
||||
frontier.extend(
|
||||
[&st.arrow_block, &st.arrow_block1, &st.arrow_block2, &st.leader_arrow_block]
|
||||
.into_iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty()),
|
||||
);
|
||||
}
|
||||
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -139,7 +161,10 @@ fn used_blocks(d: &Drawing) -> HashSet<String> {
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => frontier.push(i.block.clone()),
|
||||
EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()),
|
||||
EntityKind::Dimension(dm) => {
|
||||
frontier.extend(dm.block.clone());
|
||||
frontier.extend(override_arrows(dm));
|
||||
}
|
||||
EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())),
|
||||
_ => {}
|
||||
}
|
||||
@@ -153,8 +178,9 @@ fn is(name: &str, list: &[&str]) -> bool {
|
||||
list.iter().any(|n| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Names of the unused objects of a named kind, in table order.
|
||||
fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
/// Names of the unused objects of a named kind, in table order. This is PURGE's scan; `USAGE`
|
||||
/// reads it too, without purging anything.
|
||||
pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
|
||||
let lower = |it: &mut dyn Iterator<Item = String>| it.map(|n| n.to_ascii_lowercase()).collect::<HashSet<String>>();
|
||||
match kind {
|
||||
Kind::Blocks => {
|
||||
|
||||
@@ -104,8 +104,8 @@ fn reference_count(d: &Drawing, key: &str) -> usize {
|
||||
fn describe(d: &Drawing, b: &Block, host: Option<&str>) -> Value {
|
||||
let refs = reference_count(d, &b.name);
|
||||
let status = if refs == 0 && b.xref_status == XrefStatus::Loaded { "Unreferenced" } else { b.xref_status.label() };
|
||||
let dir = host.and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
|
||||
let found = b.xref_path.as_deref().and_then(|p| load::find(p, dir.as_deref())).map(|f| f.to_string_lossy().into_owned());
|
||||
let found =
|
||||
b.xref_path.as_deref().and_then(|p| load::find(p, host.map(std::path::Path::new), false).ok()).map(|f| f.to_string_lossy().into_owned());
|
||||
json!({
|
||||
"name": b.name,
|
||||
"path": b.xref_path,
|
||||
@@ -146,7 +146,7 @@ fn attach(s: &mut Session, name: &str, path: &str, overlay: bool) -> Result<Stri
|
||||
b.xref_path = Some(path.to_string());
|
||||
b.xref_overlay = overlay;
|
||||
d.blocks.insert(name.to_string(), Arc::new(b));
|
||||
let out = load::load(&mut d, name, host.as_deref());
|
||||
let out = load::load(&mut d, name, host.as_deref(), true);
|
||||
match out.first() {
|
||||
Some(o) if o.status == XrefStatus::Loaded => {}
|
||||
Some(o) => return Err(bad("xattach", format!("{path} can't be attached: {}", o.detail))),
|
||||
@@ -227,11 +227,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
"reload" | "r" => {
|
||||
let names = named(s, "-xref", p)?;
|
||||
let mut out = Vec::new();
|
||||
for n in &names {
|
||||
let d = s.doc_mut()?;
|
||||
out.extend(load::load(d, n, host.as_deref()));
|
||||
}
|
||||
let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false);
|
||||
echo_all(s, &out);
|
||||
Ok(
|
||||
json!({ "xrefs": out.iter().filter(|o| !o.name.is_empty()).map(|o| json!({"name": o.name, "status": o.status.label(), "detail": o.detail})).collect::<Vec<_>>() }),
|
||||
@@ -271,14 +267,13 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"path" | "p" => {
|
||||
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
|
||||
let names = named(s, "-xref", p)?;
|
||||
let mut out = Vec::new();
|
||||
let d = s.doc_mut()?;
|
||||
for n in &names {
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(b) = d.blocks.get_mut(n) {
|
||||
Arc::make_mut(b).xref_path = Some(path.clone());
|
||||
}
|
||||
out.extend(load::load(d, n, host.as_deref()));
|
||||
}
|
||||
let out = load::load_each(d, &names, host.as_deref(), true);
|
||||
echo_all(s, &out);
|
||||
Ok(json!({ "xrefs": names, "path": path }))
|
||||
}
|
||||
@@ -367,8 +362,10 @@ fn run_xopen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = d.block(&name).ok_or_else(|| bad("xopen", format!("no xref \"{name}\"")))?;
|
||||
let path = b.xref_path.clone().ok_or_else(|| bad("xopen", format!("\"{name}\" is not an xref")))?;
|
||||
let host = host(s);
|
||||
let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent().map(std::path::Path::to_path_buf));
|
||||
let file = load::find(&path, dir.as_deref()).ok_or_else(|| bad("xopen", format!("{path}: file not found")))?;
|
||||
let file = load::find(&path, host.as_deref().map(std::path::Path::new), false).map_err(|m| match m {
|
||||
load::Missing::NotFound => bad("xopen", format!("{path}: file not found")),
|
||||
load::Missing::Refused(r) => bad("xopen", format!("{path}: not opened, {r}")),
|
||||
})?;
|
||||
s.execute("open", &json!({ "path": file.to_string_lossy() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! `xref|name`.
|
||||
//!
|
||||
//! A path is looked for as given (relative paths against the host drawing's folder), then by its
|
||||
//! file name in the host drawing's folder. A referenced drawing's own attached xrefs load with it
|
||||
//! (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
|
||||
//! file name in the host drawing's folder. A path stored in a drawing is followed only to this
|
||||
//! computer or the host drawing's network share ([`crate::doc_path`]); one that isn't followed and
|
||||
//! has no file of its name beside the host leaves the xref unresolved. A referenced drawing's own
|
||||
//! attached xrefs load with it (overlays don't), at most [`MAX_BLOCK_DEPTH`] levels deep; a drawing that is already being
|
||||
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
|
||||
//! block empty with a status saying why; nothing here stops the host drawing from opening.
|
||||
|
||||
@@ -13,6 +15,8 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
|
||||
use crate::doc_path::{self, Refused};
|
||||
|
||||
/// Most objects the xrefs of one drawing load in one go (nested xrefs included).
|
||||
pub(crate) const MAX_XREF_OBJECTS: usize = 4_000_000;
|
||||
/// Most referenced files read in one go (nested xrefs included).
|
||||
@@ -20,6 +24,9 @@ const MAX_XREF_FILES: usize = 1000;
|
||||
/// Largest referenced file read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_XREF_BYTES: u64 = 512 << 20;
|
||||
/// Most bytes read from referenced files in one go (nested xrefs included).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_XREF_TOTAL_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// The kinds of named symbols an object can refer to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
@@ -119,16 +126,38 @@ struct Ctx {
|
||||
chain: Vec<PathBuf>,
|
||||
objects: usize,
|
||||
files: usize,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
bytes: u64,
|
||||
/// Messages about nested xrefs.
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The folder of the host drawing at `host` (a file path).
|
||||
fn folder(host: Option<&str>) -> Option<PathBuf> {
|
||||
let p = Path::new(host?).parent()?;
|
||||
fn folder(host: Option<&Path>) -> Option<PathBuf> {
|
||||
let p = host?.parent()?;
|
||||
Some(if p.as_os_str().is_empty() { PathBuf::from(".") } else { p.to_path_buf() })
|
||||
}
|
||||
|
||||
/// Why a referenced drawing wasn't found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Missing {
|
||||
NotFound,
|
||||
/// The saved path isn't followed, and there is no file of its name beside the host.
|
||||
Refused(Refused),
|
||||
}
|
||||
|
||||
impl Missing {
|
||||
fn outcome(self, path: &str) -> (XrefStatus, String) {
|
||||
match self {
|
||||
Self::NotFound => {
|
||||
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
|
||||
(XrefStatus::NotFound, format!("{path}: {why}"))
|
||||
}
|
||||
Self::Refused(r) => (XrefStatus::Unresolved, format!("{path}: not opened, {r}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
@@ -138,58 +167,94 @@ fn canonical(p: &Path) -> PathBuf {
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// Where to look for a referenced drawing saved as `path`: the path itself (a relative one against
|
||||
/// the host's folder), then its file name in the host's folder. Both separators work everywhere.
|
||||
fn candidates(path: &str, dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
|
||||
let p = PathBuf::from(norm.trim());
|
||||
/// Where to look for a referenced drawing at `path` in the drawing at `host`: the path itself (a
|
||||
/// relative one against the host's folder), then its file name in the host's folder. Both
|
||||
/// separators work everywhere. A path stored in a drawing goes through [`doc_path::resolve`]
|
||||
/// (the refusal is returned); a path the user just `chosen` is used as given.
|
||||
fn candidates(path: &str, host: Option<&Path>, chosen: bool) -> (Vec<PathBuf>, Option<Refused>) {
|
||||
let path = path.trim();
|
||||
let mut out = Vec::new();
|
||||
match dir {
|
||||
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
|
||||
_ => out.push(p.clone()),
|
||||
let mut refused = None;
|
||||
if chosen {
|
||||
let norm = if cfg!(windows) { path.to_string() } else { path.replace('\\', "/") };
|
||||
let p = PathBuf::from(norm);
|
||||
match folder(host) {
|
||||
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
|
||||
_ => out.push(p),
|
||||
}
|
||||
} else {
|
||||
match doc_path::resolve(path, host) {
|
||||
Ok(p) => out.push(p),
|
||||
Err(Refused::Invalid) => {}
|
||||
Err(r) => refused = Some(r),
|
||||
}
|
||||
}
|
||||
if let (Some(d), Some(f)) = (dir, p.file_name()) {
|
||||
out.push(d.join(f));
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
if host.is_some()
|
||||
&& !name.is_empty()
|
||||
&& let Ok(p) = doc_path::resolve(name, host)
|
||||
&& !out.contains(&p)
|
||||
{
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
(out, refused)
|
||||
}
|
||||
|
||||
/// The file a referenced drawing saved as `path` is loaded from, if there is one.
|
||||
pub(crate) fn find(path: &str, dir: Option<&Path>) -> Option<PathBuf> {
|
||||
/// The file a referenced drawing at `path` (saved in the drawing at `host`, or just `chosen` by
|
||||
/// the user) is loaded from.
|
||||
pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<PathBuf, Missing> {
|
||||
if path.trim().is_empty() {
|
||||
return None;
|
||||
return Err(Missing::NotFound);
|
||||
}
|
||||
let (files, refused) = candidates(path, host, chosen);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
candidates(path, dir).into_iter().find(|p| p.is_file())
|
||||
}
|
||||
let found = files.into_iter().find(|p| p.is_file());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = candidates(path, dir);
|
||||
let found: Option<PathBuf> = {
|
||||
let _ = files;
|
||||
None
|
||||
}
|
||||
};
|
||||
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
|
||||
}
|
||||
|
||||
/// Read a drawing file through the host's io hooks.
|
||||
fn read(file: &Path) -> Result<Drawing, String> {
|
||||
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
|
||||
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
|
||||
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let len = std::fs::metadata(file).map_err(|e| e.to_string())?.len();
|
||||
if len > MAX_XREF_BYTES {
|
||||
return Err(format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20));
|
||||
use std::io::Read;
|
||||
let left = MAX_XREF_TOTAL_BYTES.saturating_sub(cx.bytes);
|
||||
let limit = MAX_XREF_BYTES.min(left);
|
||||
let mut f = std::fs::File::open(file).map_err(|e| e.to_string())?;
|
||||
let len = f.metadata().map_err(|e| e.to_string())?.len();
|
||||
let too_big = || {
|
||||
if limit < MAX_XREF_BYTES {
|
||||
format!("the referenced files are larger than {} MB together", MAX_XREF_TOTAL_BYTES >> 20)
|
||||
} else {
|
||||
format!("the file is larger than {} MB", MAX_XREF_BYTES >> 20)
|
||||
}
|
||||
};
|
||||
if len > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
f.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
let n = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
|
||||
if n > limit {
|
||||
return Err(too_big());
|
||||
}
|
||||
cx.bytes = cx.bytes.saturating_add(n);
|
||||
(hooks.read)(&bytes, &file.to_string_lossy())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (hooks, file);
|
||||
let _ = (hooks, file, cx);
|
||||
Err("files are not available on the web".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Load every xref of `d` that isn't unloaded; `host` is the drawing's own path.
|
||||
/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's
|
||||
/// own path.
|
||||
pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
|
||||
let names: Vec<String> = d
|
||||
.blocks
|
||||
@@ -197,28 +262,44 @@ pub(crate) fn load_all(d: &mut Drawing, host: Option<&str>) -> Vec<Outcome> {
|
||||
.filter(|b| b.xref_path.is_some() && b.xref_status != XrefStatus::Unloaded && !b.name.contains('|'))
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
names.iter().flat_map(|n| load(d, n, host)).collect()
|
||||
load_each(d, &names, host, false)
|
||||
}
|
||||
|
||||
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path. Returns
|
||||
/// the outcome, then notes about its nested xrefs.
|
||||
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>) -> Vec<Outcome> {
|
||||
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
let mut cx = Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, notes: Vec::new() };
|
||||
let dir = folder(host);
|
||||
let (status, detail) = load_in(d, name, dir.as_deref(), &mut cx, 0);
|
||||
/// Load (or reload) xref `name` of `d` from its file; `host` is the drawing's own path, and
|
||||
/// `chosen` says the xref's path was just given by the user (not read from a drawing). Returns the
|
||||
/// outcome, then notes about its nested xrefs.
|
||||
pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool) -> Vec<Outcome> {
|
||||
load_each(d, std::slice::from_ref(&name.to_string()), host, chosen)
|
||||
}
|
||||
|
||||
/// [`load`] the xrefs `names` within one set of limits.
|
||||
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
|
||||
let mut cx = Ctx::new(host);
|
||||
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn new(host: Option<&str>) -> Self {
|
||||
let host_file = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
Ctx { chain: host_file.map(canonical).into_iter().collect(), objects: 0, files: 0, bytes: 0, notes: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
fn load_with(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool, cx: &mut Ctx) -> Vec<Outcome> {
|
||||
let host = host.map(Path::new).filter(|p| p.file_name().is_some());
|
||||
let (status, detail) = load_in(d, name, host, chosen, cx, 0);
|
||||
let key = d.block(name).map(|b| b.name.clone()).unwrap_or_else(|| name.to_string());
|
||||
let mut out = vec![Outcome { name: key, status, detail }];
|
||||
out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
|
||||
out.extend(cx.notes.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
|
||||
out
|
||||
}
|
||||
|
||||
fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
|
||||
fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
|
||||
let Some(b) = d.block(name) else { return (XrefStatus::NotFound, "no such xref".into()) };
|
||||
let key = b.name.clone();
|
||||
let Some(path) = b.xref_path.clone() else { return (XrefStatus::Unresolved, "not an xref".into()) };
|
||||
clear(d, &key);
|
||||
let (status, detail) = match fetch(&path, dir, cx, depth) {
|
||||
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
|
||||
Ok((x, file)) => match import(d, &key, &x, cx) {
|
||||
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
|
||||
Err(e) => (XrefStatus::Unresolved, e),
|
||||
@@ -234,15 +315,12 @@ fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, cx: &mut Ctx, depth:
|
||||
}
|
||||
|
||||
/// Read the drawing referenced as `path` and load its own attached xrefs into it.
|
||||
fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
|
||||
fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
|
||||
let unresolved = |m: &str| (XrefStatus::Unresolved, m.to_string());
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
return Err(unresolved("xrefs are nested too deeply"));
|
||||
}
|
||||
let Some(file) = find(path, dir) else {
|
||||
let why = if cfg!(target_arch = "wasm32") { "files are not available on the web" } else { "file not found" };
|
||||
return Err((XrefStatus::NotFound, format!("{path}: {why}")));
|
||||
};
|
||||
let file = find(path, host, chosen).map_err(|m| m.outcome(path))?;
|
||||
let canon = canonical(&file);
|
||||
if cx.chain.contains(&canon) {
|
||||
return Err(unresolved("circular reference"));
|
||||
@@ -251,13 +329,12 @@ fn fetch(path: &str, dir: Option<&Path>, cx: &mut Ctx, depth: usize) -> Result<(
|
||||
return Err(unresolved("too many referenced files"));
|
||||
}
|
||||
cx.files += 1;
|
||||
let mut x = read(&file).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
|
||||
let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
|
||||
cx.chain.push(canon);
|
||||
let nested: Vec<String> =
|
||||
x.blocks.values().filter(|b| b.xref_path.is_some() && !b.xref_overlay && !b.name.contains('|')).map(|b| b.name.clone()).collect();
|
||||
let xdir = file.parent().map(Path::to_path_buf);
|
||||
for n in nested {
|
||||
let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1);
|
||||
let (status, detail) = load_in(&mut x, &n, Some(&file), false, cx, depth + 1);
|
||||
if status != XrefStatus::Loaded {
|
||||
cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}"));
|
||||
}
|
||||
@@ -430,18 +507,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_the_host_folder_then_by_name() {
|
||||
let dir = Path::new("/proj/plans");
|
||||
let c = candidates("parts/a.dxf", Some(dir));
|
||||
assert_eq!(c, vec![dir.join("parts/a.dxf"), dir.join("a.dxf")]);
|
||||
let c = candidates("/abs/b.dwg", Some(dir));
|
||||
assert_eq!(c, vec![PathBuf::from("/abs/b.dwg"), dir.join("b.dwg")]);
|
||||
let dir = std::env::temp_dir().join("proj").join("plans");
|
||||
let host = dir.join("host.dxf");
|
||||
let c = |path: &str, chosen| candidates(path, Some(&host), chosen);
|
||||
assert_eq!(c("parts/a.dxf", false), (vec![dir.join("parts").join("a.dxf"), dir.join("a.dxf")], None));
|
||||
let abs = std::env::temp_dir().join("abs").join("b.dwg");
|
||||
assert_eq!(c(&abs.to_string_lossy(), false), (vec![abs.clone(), dir.join("b.dwg")], None));
|
||||
if !cfg!(windows) {
|
||||
// A Windows path saved elsewhere still finds the file next to the host.
|
||||
let c = candidates("C:\\jobs\\site.dwg", Some(dir));
|
||||
assert_eq!(c.last(), Some(&dir.join("site.dwg")));
|
||||
assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None));
|
||||
}
|
||||
assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]);
|
||||
assert!(find("", Some(dir)).is_none());
|
||||
// Paths to other computers are only looked for beside the host.
|
||||
assert_eq!(c(r"\\192.0.2.1\share\x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
|
||||
assert_eq!(c("file://192.0.2.1/s/x.dxf", false), (vec![dir.join("x.dxf")], Some(Refused::Network)));
|
||||
// Without a host drawing a relative path names no file.
|
||||
assert_eq!(candidates("a.dxf", None, false), (vec![], None));
|
||||
assert_eq!(candidates("a.dxf", None, true), (vec![PathBuf::from("a.dxf")], None));
|
||||
assert_eq!(find("", Some(&host), false), Err(Missing::NotFound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("parameters", "Parameters Manager", run_parameters)
|
||||
.menu(&["Tools", "Parametric", "Parameters Manager"])
|
||||
.alias(&["par", "parametersclose"])
|
||||
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
|
||||
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}; {name, rename: newName} renames it in every expression"),
|
||||
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
|
||||
.menu(&["Tools", "Parametric", "Constraint Settings"])
|
||||
.alias(&["csettings"])
|
||||
@@ -873,6 +873,10 @@ fn run_parameters(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other(format!("`{name}` is used by `{}`", row.name)));
|
||||
}
|
||||
*s.doc_mut()? = d;
|
||||
} else if let (Some(old), Some(to)) = (str_param(p, "name"), str_param(p, "rename")) {
|
||||
let mut d = s.doc()?.clone();
|
||||
cs::rename_parameter(&mut d, old.trim(), to.trim()).map_err(err)?;
|
||||
*s.doc_mut()? = d;
|
||||
} else if let Some(name) = str_param(p, "name") {
|
||||
let expr = match (str_param(p, "expr"), p.get("value").and_then(Value::as_f64)) {
|
||||
(Some(e), _) => e.to_string(),
|
||||
@@ -952,6 +956,13 @@ fn run_constraintsettings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set.angle_tolerance = f64_or(p, "angleTolerance", set.angle_tolerance).clamp(0.0, 45.0);
|
||||
set.bars_visible = bool_or(p, "barsVisible", set.bars_visible);
|
||||
set.dims_visible = bool_or(p, "dimsVisible", set.dims_visible);
|
||||
// The checkboxes act like Show All / Hide All: exceptions are relative to the old value and would flip.
|
||||
if set.bars_visible != cur.bars_visible {
|
||||
set.bar_exceptions.clear();
|
||||
}
|
||||
if set.dims_visible != cur.dims_visible {
|
||||
set.dim_exceptions.clear();
|
||||
}
|
||||
if let Some(t) = p.get("barTransparency").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
set.bar_transparency = t.clamp(0.0, 90.0) as u8;
|
||||
}
|
||||
@@ -1136,8 +1147,9 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
let purged = cs::purge_invalid(&mut d);
|
||||
let restored = cs::restore_fixed(&mut d, before);
|
||||
let satisfied = cs::all_satisfied(&d);
|
||||
if purged.is_empty() && satisfied {
|
||||
if purged.is_empty() && !restored && satisfied {
|
||||
return;
|
||||
}
|
||||
let mut msg = Vec::new();
|
||||
|
||||
@@ -56,6 +56,42 @@ fn json_geometric_and_dimensional_constraints() {
|
||||
assert_eq!(inspect["glyphs"][0]["kind"], "Horizontal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_parameter_rewrites_the_expressions_that_use_it() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("parameters", &json!({ "name": "user1", "expr": "4" })).unwrap();
|
||||
s.execute("dcaligned", &json!({ "h1": a, "expr": "user1*2" })).unwrap();
|
||||
s.execute("dcaligned", &json!({ "h1": b, "expr": "d1+1" })).unwrap();
|
||||
let names = |s: &Session| -> Vec<(String, String)> {
|
||||
cadcraft_constraints::parameter_table(s.doc().unwrap()).into_iter().map(|r| (r.name, r.expr)).collect()
|
||||
};
|
||||
|
||||
s.execute("parameters", &json!({ "name": "user1", "rename": "width" })).unwrap();
|
||||
assert_eq!(names(&s), vec![("d1".into(), "width*2".into()), ("d2".into(), "d1+1".into()), ("width".into(), "4".into())]);
|
||||
assert!((ends(&s, &a).0.dist(ends(&s, &a).1) - 8.0).abs() < 1e-6);
|
||||
|
||||
// A dimensional constraint's name too, and only the whole name: d1 is not part of d10.
|
||||
s.execute("parameters", &json!({ "name": "d10", "expr": "3" })).unwrap();
|
||||
s.execute("parameters", &json!({ "name": "d2", "expr": "d1+d10" })).unwrap();
|
||||
s.execute("parameters", &json!({ "name": "d1", "rename": "base" })).unwrap();
|
||||
assert_eq!(
|
||||
names(&s),
|
||||
vec![("base".into(), "width*2".into()), ("d2".into(), "base+d10".into()), ("width".into(), "4".into()), ("d10".into(), "3".into())]
|
||||
);
|
||||
|
||||
// Taken and invalid names are refused and change nothing; undo takes a rename back.
|
||||
let before = s.doc().unwrap().clone();
|
||||
for to in ["width", "d10", "1x", "pi", ""] {
|
||||
assert!(s.execute("parameters", &json!({ "name": "base", "rename": to })).is_err(), "{to}");
|
||||
}
|
||||
assert!(s.execute("parameters", &json!({ "name": "nothing", "rename": "x" })).is_err());
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(names(&s)[0], ("d1".into(), "width*2".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_are_resolved_and_erase_purges() {
|
||||
let mut s = Session::new();
|
||||
@@ -231,3 +267,83 @@ fn delconstraint_rejects_referenced_dimension() {
|
||||
s.execute("delconstraint", &json!({ "ids": ids })).unwrap();
|
||||
assert!(s.doc().unwrap().constraints.is_empty());
|
||||
}
|
||||
|
||||
/// The angle between the two rays from the vertex to the far end of each line: the angle
|
||||
/// DCANGULAR draws.
|
||||
fn drawn_angle(s: &Session, a: &str, b: &str) -> f64 {
|
||||
let ((a0, a1), (b0, b1)) = (ends(s, a), ends(s, b));
|
||||
let (o, _, _) = cadcraft_geom::line_line_infinite(a0, a1, b0, b1).unwrap();
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(o) > q.dist(o) { p } else { q };
|
||||
let (u, v) = (far(a0, a1) - o, far(b0, b1) - o);
|
||||
u.cross(v).atan2(u.dot(v)).abs().to_degrees()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcangular_constrains_the_drawn_angle_whichever_way_the_lines_run() {
|
||||
// #1134: the second line drawn towards the shared vertex got the supplement of the value.
|
||||
for b_pts in [[[2.0, 10.0], [0.0, 0.0]], [[0.0, 0.0], [2.0, 10.0]]] {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1000.0, 1000.0);
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let b = line(&mut s, b_pts[0], b_pts[1]);
|
||||
s.cmdline("dcangular").unwrap();
|
||||
s.input(Input::Point(Vec2::new(3.0, 0.0))).unwrap();
|
||||
s.input(Input::Point(Vec2::new(0.6, 3.0))).unwrap();
|
||||
assert!(s.prompt_text().contains("78.69"), "{b_pts:?}: {}", s.prompt_text());
|
||||
s.cmdline("100").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let got = drawn_angle(&s, &a, &b);
|
||||
assert!((got - 100.0).abs() < 1e-6, "{b_pts:?}: drawn angle {got}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Same rule as the overlay in `ui-egui/src/parametric.rs` that draws dynamic constraints.
|
||||
fn dim_shown(s: &Session, h: &str) -> bool {
|
||||
let d = s.doc().unwrap();
|
||||
let set = &d.parametric.settings;
|
||||
let c = d.constraints.iter().find(|c| c.refs.iter().any(|r| r.handle.hex() == h)).unwrap();
|
||||
set.dims_visible != c.refs.iter().any(|r| set.dim_exceptions.contains(&r.handle))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_dims_checkbox_clears_exceptions() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("dclinear", &json!({ "h1": a, "expr": "10" })).unwrap();
|
||||
s.execute("dcdisplay.hideall", &json!({})).unwrap();
|
||||
let b = line(&mut s, [20.0, 0.0], [25.0, 0.0]);
|
||||
s.execute("dclinear", &json!({ "h1": b, "expr": "5" })).unwrap();
|
||||
let r = s.execute("dcdisplay", &json!({ "mode": "show", "handles": [b] })).unwrap();
|
||||
assert_eq!(r["exceptions"], json!([b]));
|
||||
assert!(!dim_shown(&s, &a) && dim_shown(&s, &b));
|
||||
// Re-stating the current value keeps the exceptions.
|
||||
s.execute("constraintsettings", &json!({ "dimsVisible": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().parametric.settings.dim_exceptions.len(), 1);
|
||||
// Ticking "Show dynamic constraints" shows all of them, like Show All.
|
||||
s.execute("constraintsettings", &json!({ "dimsVisible": true })).unwrap();
|
||||
assert!(s.doc().unwrap().parametric.settings.dim_exceptions.is_empty());
|
||||
assert!(dim_shown(&s, &a) && dim_shown(&s, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_bars_checkbox_clears_exceptions() {
|
||||
let mut s = Session::new();
|
||||
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("gchorizontal", &json!({ "h1": a })).unwrap();
|
||||
s.execute("constraintbar.hideall", &json!({})).unwrap();
|
||||
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
s.execute("gchorizontal", &json!({ "h1": b })).unwrap();
|
||||
let r = s.execute("constraintbar", &json!({ "mode": "show", "handles": [b] })).unwrap();
|
||||
assert_eq!(r["exceptions"], json!([b]));
|
||||
// Same rule as the bar overlay in `ui-egui/src/parametric.rs`.
|
||||
let bar = |s: &Session, h: &str| {
|
||||
let set = &s.doc().unwrap().parametric.settings;
|
||||
set.bars_visible != set.bar_exceptions.contains(&Handle::parse_hex(h).unwrap())
|
||||
};
|
||||
assert!(!bar(&s, &a) && bar(&s, &b));
|
||||
s.execute("constraintsettings", &json!({ "barsVisible": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().parametric.settings.bar_exceptions.len(), 1);
|
||||
s.execute("constraintsettings", &json!({ "barsVisible": true })).unwrap();
|
||||
assert!(s.doc().unwrap().parametric.settings.bar_exceptions.is_empty());
|
||||
assert!(bar(&s, &a) && bar(&s, &b));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ pub fn last_curve_end(s: &Session) -> Option<(Vec2, Vec2)> {
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
// Arcs are stored CCW: one drawn clockwise ended on its start point, so continue
|
||||
// from the last picked point (LASTPOINT), leaving it in the drawing direction.
|
||||
let tol = 1e-9 * g.radius.max(1.0);
|
||||
if g.start_point().near(s.last_point, tol) && !g.end_point().near(s.last_point, tol) {
|
||||
return Some((g.start_point(), -Vec2::from_angle(g.start).perp()));
|
||||
}
|
||||
return Some((g.end_point(), Vec2::from_angle(g.end).perp()));
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed => {
|
||||
|
||||
@@ -11,6 +11,8 @@ use super::pline_opts::{Outcome, PlineAsk};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, snap};
|
||||
|
||||
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
|
||||
/// machine each command starts.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("line", "Line", run_line)
|
||||
@@ -36,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("rectang", "Rectangle", super::draw2::run_rectang2)
|
||||
.menu(&["Draw", "Rectangle"])
|
||||
.alias(&["rec", "rectangle"])
|
||||
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?}")
|
||||
.params("{p1, p2 | dimensions: [l, w] (p2 picks the quadrant) | area + length|breadth, rotation? (deg), fillet?, chamfer?: [d1, d2], width?, elevation?, thickness?, center?: bool (p1 is the centre, not a corner; the size spans it evenly)}")
|
||||
.interactive(|_| Ok(Box::new(super::draw2::RectM2::default()))),
|
||||
CommandSpec::new("polygon", "Polygon", run_polygon)
|
||||
.menu(&["Draw", "Polygon"])
|
||||
@@ -755,9 +757,12 @@ impl Interactive for CircleM {
|
||||
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
match (self.mode, n) {
|
||||
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
|
||||
(0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
|
||||
(0, _) if self.diameter => {
|
||||
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
|
||||
}
|
||||
(0, _) => {
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
|
||||
let p =
|
||||
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
|
||||
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
|
||||
}
|
||||
(1, k) => Prompt::new(
|
||||
@@ -890,7 +895,7 @@ impl Interactive for PolygonM {
|
||||
(Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT)
|
||||
.kw(&["Inscribed in circle", "Circumscribed about circle"])
|
||||
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
|
||||
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
|
||||
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::machines::number;
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub(crate) mod breakline;
|
||||
pub(crate) mod mline;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -133,6 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
|
||||
];
|
||||
v.extend(mline::specs());
|
||||
v.extend(breakline::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -217,7 +219,7 @@ impl ArcV {
|
||||
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
|
||||
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
|
||||
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
|
||||
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, Ask::Point)],
|
||||
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
|
||||
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
|
||||
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
|
||||
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
|
||||
@@ -231,7 +233,7 @@ impl ArcV {
|
||||
],
|
||||
}
|
||||
}
|
||||
fn build(self, v: &[Val]) -> Option<EntityKind> {
|
||||
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
|
||||
let p = |i: usize| v.get(i).and_then(|x| x.p());
|
||||
let n = |i: usize| v.get(i).and_then(|x| x.n());
|
||||
let a = match self {
|
||||
@@ -243,7 +245,14 @@ impl ArcV {
|
||||
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
|
||||
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)),
|
||||
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
|
||||
// other way around: swapping the start/end angles is the mirror arc through the
|
||||
// same two endpoints. (Continue below flips differently, by negating the start
|
||||
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
|
||||
ArcV::Cse => {
|
||||
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
|
||||
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
|
||||
}
|
||||
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::CircleCd => {
|
||||
@@ -338,6 +347,11 @@ impl Interactive for SeqM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some((st, dir)) = self.cont {
|
||||
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
|
||||
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
|
||||
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
|
||||
// modifiers so it remains UI-agnostic.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
return match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
@@ -380,7 +394,7 @@ impl Interactive for SeqM {
|
||||
if self.vals.len() < self.v.steps().len() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match self.v.build(&self.vals) {
|
||||
match self.v.build(&self.vals, s.ctrl_down) {
|
||||
Some(k) => {
|
||||
if let EntityKind::Circle(c) = &k {
|
||||
let r = c.radius;
|
||||
@@ -397,8 +411,9 @@ impl Interactive for SeqM {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some((st, d)) = self.cont {
|
||||
let d = if s.ctrl_down { -d } else { d };
|
||||
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
|
||||
}
|
||||
let Some((_, ask)) = self.ask() else { return Vec::new() };
|
||||
@@ -407,7 +422,7 @@ impl Interactive for SeqM {
|
||||
vals.push(v);
|
||||
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
|
||||
if vals.len() == self.v.steps().len() {
|
||||
out.extend(self.v.build(&vals));
|
||||
out.extend(self.v.build(&vals, s.ctrl_down));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -460,7 +475,10 @@ fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
|
||||
};
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
|
||||
added(s.add_entity(arc(&a))?)
|
||||
let h = s.add_entity(arc(&a))?;
|
||||
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
|
||||
s.last_point = e;
|
||||
added(h)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -485,6 +503,8 @@ impl Interactive for ArcContinueM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
|
||||
// Ctrl flips the direction, exactly as in `SeqM::input`.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
@@ -495,8 +515,14 @@ impl Interactive for ArcContinueM {
|
||||
_ => Err(other("Point required.")),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.from.and_then(|(st, d)| curves::arc_sed(st, c, d)).map(|a| vec![arc(&a)]).unwrap_or_default()
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.from
|
||||
.and_then(|(st, dir)| {
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
curves::arc_sed(st, c, dir)
|
||||
})
|
||||
.map(|a| vec![arc(&a)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +601,7 @@ impl Interactive for TanCircleM {
|
||||
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
|
||||
_ => {
|
||||
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1));
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
|
||||
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
|
||||
}
|
||||
}
|
||||
@@ -1555,8 +1581,12 @@ fn rect_kind(first: Vec2, w: f64, h: f64, rot: f64, fillet: f64, chamfer: (f64,
|
||||
k
|
||||
}
|
||||
|
||||
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
|
||||
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
|
||||
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
|
||||
let centered = bool_or(p, "center", false);
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
@@ -1592,12 +1622,14 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
let b = point_req("rectang", p, "p2")?;
|
||||
let loc = (b - a).rotate(-rot);
|
||||
(loc.x, loc.y)
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
|
||||
};
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
if th != 0.0 {
|
||||
@@ -1628,6 +1660,8 @@ enum RAsk {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RectM2 {
|
||||
first: Option<Vec2>,
|
||||
/// The first point is the rectangle's centre (the `Center` option), not a corner.
|
||||
center: bool,
|
||||
fillet: f64,
|
||||
chamfer: (f64, f64),
|
||||
width: f64,
|
||||
@@ -1640,23 +1674,37 @@ pub(crate) struct RectM2 {
|
||||
}
|
||||
|
||||
impl RectM2 {
|
||||
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
|
||||
/// the centre when centre mode is on, then applies the requested thickness.
|
||||
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
|
||||
let Some(a) = self.first else { return Ok(Step::Cancel) };
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
let hd = s.add_entity(rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
if self.thickness != 0.0 {
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
/// point itself as a corner, or the first point less half the size around the centre.
|
||||
fn origin(&self, w: f64, h: f64) -> Option<Vec2> {
|
||||
let a = self.first?;
|
||||
Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a })
|
||||
}
|
||||
/// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only
|
||||
/// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in
|
||||
/// centre mode.
|
||||
fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> {
|
||||
let a = self.first?;
|
||||
let loc = (p - a).rotate(-self.rotation);
|
||||
Some(match self.dims {
|
||||
Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }),
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
None if self.center => (2.0 * loc.x, 2.0 * loc.y),
|
||||
None => (loc.x, loc.y),
|
||||
})
|
||||
}
|
||||
@@ -1666,6 +1714,8 @@ impl Interactive for RectM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
/// Asks for the pending option's value, or for the first and second points; in centre mode
|
||||
/// the first prompt asks for the centre instead of a corner.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}"));
|
||||
if let Some(a) = self.asking {
|
||||
@@ -1691,10 +1741,15 @@ impl Interactive for RectM2 {
|
||||
};
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Chamfer", "Elevation", "Fillet", "Thickness", "Width"]),
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
|
||||
None => {
|
||||
let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" };
|
||||
Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"])
|
||||
}
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
|
||||
}
|
||||
}
|
||||
/// Consumes one input: option keywords (`Center` only switches the first point's meaning
|
||||
/// to the centre and asks nothing), option values, and the points that size the rectangle.
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking {
|
||||
let val = |i: &Input, default: f64| -> Result<f64> {
|
||||
@@ -1768,6 +1823,11 @@ impl Interactive for RectM2 {
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
// `Center` only switches the first point's meaning; it asks nothing.
|
||||
if k == "CEnter" {
|
||||
self.center = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.asking = Some(match k.as_str() {
|
||||
"Chamfer" => RAsk::Chamfer1,
|
||||
"Elevation" => RAsk::Elevation,
|
||||
@@ -1794,12 +1854,14 @@ impl Interactive for RectM2 {
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
/// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin
|
||||
/// rules as the committed entity so centre mode previews where it will land.
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.asking.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
match (self.first, self.other_corner(c)) {
|
||||
(Some(a), Some((w, h))) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
|
||||
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
vec![rect_kind(a, w, h, self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! BREAKLINE (Express Tools): one polyline from the first point to the second with a break
|
||||
//! symbol at a location along it, the line optionally extended past both points.
|
||||
//!
|
||||
//! The symbol is a run of vertices between two attachment points. The built-in one is our own
|
||||
//! zigzag; the Block option takes a block of the drawing that marks its attachment points with
|
||||
//! two POINT objects on the Defpoints layer and draws the symbol with lines and polylines.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::added;
|
||||
use super::curves::MAX_GEN;
|
||||
use super::machines::number;
|
||||
use super::*;
|
||||
use crate::cmd::modify2::as_lwpoly;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
|
||||
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
|
||||
.params(
|
||||
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
|
||||
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
|
||||
)
|
||||
.interactive(|s| Ok(Box::new(BreaklineM::new(s)))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The values BREAKLINE offers as defaults: the symbol block (empty = built-in), its size and
|
||||
/// how far the line runs past the two points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BreaklineOptions {
|
||||
pub block: String,
|
||||
pub size: f64,
|
||||
pub extension: f64,
|
||||
}
|
||||
|
||||
impl Default for BreaklineOptions {
|
||||
fn default() -> Self {
|
||||
BreaklineOptions { block: String::new(), size: 1.0, extension: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
fn other(m: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
/// A break symbol in its own coordinates: the vertices from one attachment point to the other.
|
||||
struct Symbol {
|
||||
path: Vec<PolyVertex>,
|
||||
}
|
||||
|
||||
impl Symbol {
|
||||
/// Our own zigzag: up and across to the other side of the line, one unit between the
|
||||
/// attachment points and one unit tall.
|
||||
fn built_in() -> Self {
|
||||
let pts = [(-0.5, 0.0), (-0.25, 0.5), (0.25, -0.5), (0.5, 0.0)];
|
||||
Symbol { path: pts.into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() }
|
||||
}
|
||||
|
||||
fn of_block(d: &Drawing, name: &str) -> Result<Self> {
|
||||
let b = d.block(name).ok_or_else(|| other(format!("Block \"{name}\" not found.")))?;
|
||||
let mut anchors = Vec::new();
|
||||
let mut pieces: Vec<Vec<PolyVertex>> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Point(p) if e.common.layer.eq_ignore_ascii_case("Defpoints") => anchors.push(p.p.xy()),
|
||||
k => {
|
||||
if let Some(pl) = as_lwpoly(k).filter(|pl| !pl.closed && pl.vertices.len() >= 2)
|
||||
&& pieces.len() < MAX_GEN
|
||||
{
|
||||
pieces.push(pl.vertices);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, z] = anchors[..] else {
|
||||
return Err(other(format!("Block \"{name}\" needs exactly two points on the Defpoints layer.")));
|
||||
};
|
||||
let tol = a.dist(z) * 1e-6;
|
||||
if !(tol > 0.0 && tol.is_finite()) {
|
||||
return Err(other(format!("The two Defpoints points of block \"{name}\" coincide.")));
|
||||
}
|
||||
// Chain the pieces end to end from one attachment point until the other is reached.
|
||||
let mut path = vec![PolyVertex::new(a)];
|
||||
let mut at = a;
|
||||
while !at.near(z, tol) {
|
||||
let next = pieces.iter().position(|p| p.first().is_some_and(|v| v.p.near(at, tol)) || p.last().is_some_and(|v| v.p.near(at, tol)));
|
||||
let Some(i) = next else {
|
||||
return Err(other(format!("The lines of block \"{name}\" must run from one Defpoints point to the other.")));
|
||||
};
|
||||
let mut piece = pieces.swap_remove(i);
|
||||
if !piece.first().is_some_and(|v| v.p.near(at, tol)) {
|
||||
piece = reversed(&piece);
|
||||
}
|
||||
if let Some(last) = path.last_mut() {
|
||||
last.bulge = piece.first().map_or(0.0, |v| v.bulge);
|
||||
}
|
||||
path.extend(piece.iter().skip(1).copied());
|
||||
at = path.last().map_or(z, |v| v.p);
|
||||
}
|
||||
Ok(Symbol { path })
|
||||
}
|
||||
|
||||
fn span(&self) -> f64 {
|
||||
match (self.path.first(), self.path.last()) {
|
||||
(Some(a), Some(z)) => a.p.dist(z.p),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol scaled by `k`, its attachment points on the line through `from` along `u`.
|
||||
fn placed(&self, from: Vec2, u: Vec2, k: f64) -> Vec<PolyVertex> {
|
||||
let (Some(a), Some(z)) = (self.path.first(), self.path.last()) else { return Vec::new() };
|
||||
let ax = (z.p - a.p).normalized();
|
||||
self.path
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let q = v.p - a.p;
|
||||
let (x, y) = (q.dot(ax) * k, q.dot(ax.perp()) * k);
|
||||
PolyVertex::with_bulge(from + u * x + u.perp() * y, v.bulge)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The same vertices run backwards, each arc keeping its side.
|
||||
fn reversed(vs: &[PolyVertex]) -> Vec<PolyVertex> {
|
||||
let n = vs.len();
|
||||
(0..n)
|
||||
.rev()
|
||||
.filter_map(|i| {
|
||||
let v = vs.get(i)?;
|
||||
let bulge = if i == 0 { 0.0 } else { -vs.get(i - 1).map_or(0.0, |p| p.bulge) };
|
||||
Some(PolyVertex::with_bulge(v.p, bulge))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dimscale(s: &Session) -> f64 {
|
||||
s.doc().ok().map(|d| d.header.f64("DIMSCALE", 1.0)).filter(|k| k.is_finite() && *k > 0.0).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn symbol(s: &Session, block: &str) -> Result<Symbol> {
|
||||
if block.is_empty() { Ok(Symbol::built_in()) } else { Symbol::of_block(s.doc()?, block) }
|
||||
}
|
||||
|
||||
/// The breakline's vertices: the extension, the line up to the symbol, the symbol and the rest of
|
||||
/// the line. The symbol is centred on `at` projected onto the line (the midpoint without it) and
|
||||
/// kept between the two points.
|
||||
fn breakline_vertices(sym: &Symbol, p1: Vec2, p2: Vec2, at: Option<Vec2>, k: f64, ext: f64) -> Result<Vec<PolyVertex>> {
|
||||
let len = p1.dist(p2);
|
||||
let u = (p2 - p1).normalized();
|
||||
if !(len.is_finite() && len > 0.0) || u == Vec2::ZERO {
|
||||
return Err(other("The two points of the breakline must differ."));
|
||||
}
|
||||
let span = sym.span() * k;
|
||||
if !span.is_finite() || span <= 0.0 {
|
||||
return Err(other("The break symbol has no size."));
|
||||
}
|
||||
if span > len {
|
||||
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
|
||||
}
|
||||
let t = at.map_or(len / 2.0, |c| (c - p1).dot(u)).clamp(span / 2.0, len - span / 2.0);
|
||||
let mut pts = vec![PolyVertex::new(p1 - u * ext)];
|
||||
pts.extend(sym.placed(p1 + u * (t - span / 2.0), u, k));
|
||||
pts.push(PolyVertex::new(p2 + u * ext));
|
||||
let mut out: Vec<PolyVertex> = Vec::with_capacity(pts.len());
|
||||
for v in pts {
|
||||
if !v.p.is_finite() {
|
||||
return Err(other("The breakline is too large."));
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.p.near(v.p, len * 1e-12) => l.bulge = v.bulge,
|
||||
_ => out.push(v),
|
||||
}
|
||||
}
|
||||
if let Some(l) = out.last_mut() {
|
||||
l.bulge = 0.0;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn run_breakline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p1 = point_req("breakline", p, "p1")?;
|
||||
let p2 = point_req("breakline", p, "p2")?;
|
||||
let size = size_param("breakline", p, "size", false)?.unwrap_or(1.0);
|
||||
let ext = size_param("breakline", p, "extension", true)?.unwrap_or(0.0);
|
||||
let sym = symbol(s, str_param(p, "block").unwrap_or(""))?;
|
||||
let vs = breakline_vertices(&sym, p1, p2, point_param(p, "at"), size * dimscale(s), ext)?;
|
||||
added(s.add_entity(lwpoly(vs, false))?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Ask {
|
||||
Points,
|
||||
Block,
|
||||
Size,
|
||||
Extension,
|
||||
}
|
||||
|
||||
struct BreaklineM {
|
||||
opts: BreaklineOptions,
|
||||
ask: Ask,
|
||||
p1: Option<Vec2>,
|
||||
p2: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl BreaklineM {
|
||||
fn new(s: &Session) -> Self {
|
||||
BreaklineM { opts: s.last_used.breakline.clone(), ask: Ask::Points, p1: None, p2: None }
|
||||
}
|
||||
fn block_label(&self) -> String {
|
||||
if self.opts.block.is_empty() { "(built-in)".into() } else { self.opts.block.clone() }
|
||||
}
|
||||
fn finish(&self, s: &mut Session, at: Option<Vec2>) -> Result<Step> {
|
||||
let (Some(p1), Some(p2)) = (self.p1, self.p2) else { return Ok(Step::Cancel) };
|
||||
let vs = breakline_vertices(&symbol(s, &self.opts.block)?, p1, p2, at, self.opts.size * dimscale(s), self.opts.extension)?;
|
||||
s.add_entity(lwpoly(vs, false))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for BreaklineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BREAKLINE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo(format!("Block= {}, Size= {:.4}, Extension= {:.4}", self.block_label(), self.opts.size, self.opts.extension));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.ask, self.p1, self.p2) {
|
||||
(Ask::Block, ..) => {
|
||||
Prompt::new("Enter name of breakline symbol block (. for the built-in symbol)", Accept::TEXT).default(self.block_label())
|
||||
}
|
||||
(Ask::Size, ..) => Prompt::new("Breakline symbol size", Accept::NUMBER).default(format!("{:.4}", self.opts.size)),
|
||||
(Ask::Extension, ..) => Prompt::new("Breakline extension distance", Accept::NUMBER).default(format!("{:.4}", self.opts.extension)),
|
||||
(Ask::Points, None, _) => Prompt::new("Specify first point for breakline", Accept::POINT).kw(&["Block", "Size", "Extension"]),
|
||||
(Ask::Points, Some(a), None) => Prompt::new("Specify second point for breakline", Accept::POINT).base(a),
|
||||
(Ask::Points, Some(_), Some(_)) => Prompt::new("Specify location for break symbol", Accept::POINT).default("Midpoint"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.ask, i) {
|
||||
(Ask::Block, Input::Text(t) | Input::Keyword(t)) => {
|
||||
let t = t.trim();
|
||||
let name = if t == "." { String::new() } else { t.to_string() };
|
||||
symbol(s, &name)?;
|
||||
self.opts.block = name;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Size, Input::Text(t)) => {
|
||||
self.opts.size = number(&t).filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Extension, Input::Text(t)) => {
|
||||
self.opts.extension = number(&t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| other("Requires zero or a positive value."))?;
|
||||
self.ask = Ask::Points;
|
||||
}
|
||||
(Ask::Block | Ask::Size | Ask::Extension, Input::Enter) => self.ask = Ask::Points,
|
||||
(Ask::Points, Input::Keyword(k)) if self.p1.is_none() => {
|
||||
self.ask = match k.as_str() {
|
||||
"Block" => Ask::Block,
|
||||
"Size" => Ask::Size,
|
||||
"Extension" => Ask::Extension,
|
||||
_ => Ask::Points,
|
||||
};
|
||||
}
|
||||
(Ask::Points, Input::Point(p)) => match (self.p1, self.p2) {
|
||||
(None, _) => self.p1 = Some(p),
|
||||
(Some(a), None) => {
|
||||
let span = symbol(s, &self.opts.block)?.span() * self.opts.size * dimscale(s);
|
||||
let len = a.dist(p);
|
||||
if len.is_nan() || len < span {
|
||||
return Err(other(format!("The breakline must be at least as long as the symbol ({span:.4}).")));
|
||||
}
|
||||
self.p2 = Some(p);
|
||||
}
|
||||
(Some(_), Some(_)) => return self.finish(s, Some(p)),
|
||||
},
|
||||
(Ask::Points, Input::Enter) if self.p2.is_some() => return self.finish(s, None),
|
||||
(Ask::Points, Input::Enter) => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
s.last_used.breakline = self.opts.clone();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.ask, self.p1, self.p2) {
|
||||
(Ask::Points, Some(a), None) => vec![line(a, c)],
|
||||
(Ask::Points, Some(a), Some(b)) => symbol(s, &self.opts.block)
|
||||
.and_then(|sym| breakline_vertices(&sym, a, b, Some(c), self.opts.size * dimscale(s), self.opts.extension))
|
||||
.map(|vs| vec![lwpoly(vs, false)])
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,8 +576,7 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
|
||||
let aa = a.clone();
|
||||
cut_across(&mut b, u, &outer_of(&bo), &aa, k);
|
||||
}
|
||||
Tool::MergedTee => return Err(other("Merged tee is not available yet.")),
|
||||
Tool::ClosedTee | Tool::OpenTee => {
|
||||
Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee => {
|
||||
let path = path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let bm = b.miters();
|
||||
let (top, bottom) = outer(&bo).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
@@ -593,16 +592,36 @@ fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb
|
||||
_ => (sb, bottom),
|
||||
};
|
||||
let near = if near_e == top { lt } else { lb };
|
||||
a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
|
||||
if tool == Tool::OpenTee {
|
||||
// Merged tee: the first one's inner elements run on to the second one's nearest inner element.
|
||||
let inner = if tool == Tool::MergedTee && ao.len() > 2 {
|
||||
(0..bo.len())
|
||||
.filter(|e| *e != top && *e != bottom)
|
||||
.filter_map(|e| meet(&path, &line(e)?))
|
||||
.min_by(|x, y| (x - at).abs().total_cmp(&(y - at).abs()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
a = trim_at(&a, k, inner.unwrap_or(at), keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
|
||||
if tool != Tool::ClosedTee {
|
||||
// Open the near outer element of the second multiline where the first meets it.
|
||||
let ak = if keep_before { a.vertices.len().saturating_sub(2) } else { 0 };
|
||||
let (am, aoff) = (a.miters(), a.offsets());
|
||||
if let Some((t2, b2)) = outer(&aoff) {
|
||||
let al = |e: usize| aoff.get(e).and_then(|d| element_line(&a, &am, ak, *d));
|
||||
if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).and_then(|l| meet(&near, &l))) {
|
||||
let (lt2, lb2) = (al(t2), al(b2));
|
||||
if let (Some(x), Some(y)) = (lt2.and_then(|l| meet(&near, &l)), lb2.and_then(|l| meet(&near, &l))) {
|
||||
add_cut(&mut b, u, near_e, x, y);
|
||||
}
|
||||
// ...and the first one's outer elements stop at it.
|
||||
if inner.is_some() {
|
||||
for (e, l) in [(t2, lt2), (b2, lb2)] {
|
||||
if let Some(l) = l
|
||||
&& let Some(x) = meet(&l, &near)
|
||||
{
|
||||
add_cut(&mut a, ak, e, x, if keep_before { l.len() } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,28 @@ fn arc_continue_from_last_line() {
|
||||
assert!(near(as_arc(&last(&s)).center, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_continue_from_last_picked_end_of_three_point_arc() {
|
||||
// (picked points, end of the continuation, its center): both arcs leave their last
|
||||
// picked point heading -Y, so the continuation is a quarter turn on that side.
|
||||
let cases = [
|
||||
// Clockwise: stored CCW, the last picked point (2,0) is the arc's start.
|
||||
("arc 0,0 1,1 2,0", "3,-1", (3.0, 0.0)),
|
||||
// Counter-clockwise: the last picked point (0,0) is the arc's end.
|
||||
("arc 2,0 1,1 0,0", "-1,-1", (-1.0, 0.0)),
|
||||
];
|
||||
for (draw, end, center) in cases {
|
||||
let mut s = Session::new();
|
||||
s.cmdline(draw).unwrap();
|
||||
s.cmdline("arc").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline(end).unwrap();
|
||||
let a = as_arc(&last(&s));
|
||||
assert!(near(a.center, center.0, center.1), "{draw}: {a:?}");
|
||||
assert!((a.sweep() - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{draw}: reversed tangent {a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- CIRCLE ----------------
|
||||
|
||||
#[test]
|
||||
@@ -350,6 +372,118 @@ fn revcloud_command_line() {
|
||||
assert_eq!(kinds(&s).len(), 4);
|
||||
}
|
||||
|
||||
// ---------------- BREAKLINE ----------------
|
||||
|
||||
fn breakline_points(s: &Session) -> Vec<(f64, f64)> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
assert!(!p.closed);
|
||||
p.vertices.iter().map(|v| ((v.p.x * 1e9).round() / 1e9, (v.p.y * 1e9).round() / 1e9)).collect()
|
||||
}
|
||||
k => panic!("not a polyline: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_json_symbol_size_extension_and_location() {
|
||||
let mut s = Session::new();
|
||||
// One polyline: the line to the symbol, the zigzag centred on the midpoint, the rest of the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1);
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
|
||||
// Size scales the symbol, Extension runs the line past both points, `at` is projected onto the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "extension": 1, "at": [2, 3]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
|
||||
// A location past the end keeps the symbol on the line; its end then is the line's end.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "size": 2, "at": [100, 0]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (8.0, 0.0), (8.5, 1.0), (9.5, -1.0), (10.0, 0.0)]);
|
||||
// The symbol turns with the line.
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [0, 10]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (0.0, 4.5), (-0.5, 4.75), (0.5, 5.25), (0.0, 5.5), (0.0, 10.0)]);
|
||||
// DIMSCALE scales the size.
|
||||
s.doc_mut().unwrap().header.set_f64("DIMSCALE", 4.0);
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0]})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (3.0, 0.0), (4.0, 2.0), (6.0, -2.0), (7.0, 0.0), (10.0, 0.0)]);
|
||||
|
||||
let n = s.doc().unwrap().model.len();
|
||||
for p in [
|
||||
json!({"p1": [0, 0], "p2": [3, 0]}),
|
||||
json!({"p1": [0, 0], "p2": [0, 0]}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "size": 0}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "extension": -1}),
|
||||
json!({"p1": [0, 0], "p2": [10, 0], "block": "NOPE"}),
|
||||
json!({"p1": [0, 0], "p2": [1e308, 0], "extension": 1e308}),
|
||||
] {
|
||||
assert!(s.execute("breakline", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_block_symbol_runs_between_its_defpoints() {
|
||||
let mut s = Session::new();
|
||||
s.execute("layer.current", &json!({"name": "Defpoints"})).unwrap();
|
||||
s.execute("point", &json!({"at": [0, 0]})).unwrap();
|
||||
s.execute("point", &json!({"at": [2, 0]})).unwrap();
|
||||
s.execute("layer.current", &json!({"name": "0"})).unwrap();
|
||||
// Drawn in pieces, one of them backwards: they are chained from one point to the other.
|
||||
s.execute("line", &json!({"points": [[2, 0], [1, 1]]})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 0], [1, 1]]})).unwrap();
|
||||
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
|
||||
s.execute("block", &json!({"name": "PEAK", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap();
|
||||
s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "PEAK"})).unwrap();
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.0, 0.0), (5.0, 1.0), (6.0, 0.0), (10.0, 0.0)]);
|
||||
// Without two Defpoints points the block is refused.
|
||||
s.execute("line", &json!({"points": [[0, 5], [1, 5]]})).unwrap();
|
||||
let l = s.doc().unwrap().model.last().unwrap().handle.hex();
|
||||
s.execute("block", &json!({"name": "BARE", "base": [0, 0], "handles": [l], "keep": "delete"})).unwrap();
|
||||
assert!(s.execute("breakline", &json!({"p1": [0, 0], "p2": [10, 0], "block": "BARE"})).is_err());
|
||||
|
||||
s.cmdline("breakline b PEAK 0,0 10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s).len(), 5);
|
||||
s.cmdline("breakline b nope").unwrap();
|
||||
assert!(s.prompt_text().contains("block"), "an unknown block asks again: {}", s.prompt_text());
|
||||
s.cmdline(".").unwrap();
|
||||
s.cmdline("0,0 10,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert_eq!(breakline_points(&s).len(), 6, "`.` goes back to the built-in symbol");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakline_command_line_prompts_options_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.start("breakline").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify first point for breakline"), "{}", s.prompt_text());
|
||||
assert!(s.prompt_text().contains("Block") && s.prompt_text().contains("Size") && s.prompt_text().contains("Extension"), "{}", s.prompt_text());
|
||||
s.cmdline("0,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify second point for breakline"), "{}", s.prompt_text());
|
||||
// Too short for the symbol: asked again.
|
||||
s.cmdline("0.5,0").unwrap();
|
||||
assert!(s.prompt_text().contains("second point"), "{}", s.prompt_text());
|
||||
s.cmdline("10,0").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify location for break symbol") && s.prompt_text().contains("Midpoint"), "{}", s.prompt_text());
|
||||
assert!(matches!(s.preview(Vec2::new(3.0, 1.0)).first().map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.vertices.len() == 6));
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s), vec![(0.0, 0.0), (4.5, 0.0), (4.75, 0.5), (5.25, -0.5), (5.5, 0.0), (10.0, 0.0)]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), 1, "one undo step");
|
||||
|
||||
// Size and Extension at the first prompt, a picked location; remembered for the next time.
|
||||
s.cmdline("breakline s 2 e 1 0,0 10,0 2,3").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(breakline_points(&s), vec![(-1.0, 0.0), (1.0, 0.0), (1.5, 1.0), (2.5, -1.0), (3.0, 0.0), (11.0, 0.0)]);
|
||||
assert_eq!(s.state().unwrap().undo.len(), 2);
|
||||
s.cmdline("breakline s").unwrap();
|
||||
assert!(s.prompt_text().contains("2.0000"), "{}", s.prompt_text());
|
||||
s.cmdline("0").unwrap();
|
||||
assert!(s.prompt_text().contains("size"), "zero is refused: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 1, "undo removes the whole breakline");
|
||||
}
|
||||
|
||||
// ---------------- WIPEOUT ----------------
|
||||
|
||||
#[test]
|
||||
@@ -432,6 +566,7 @@ fn xline_options_command_line() {
|
||||
|
||||
// ---------------- RECTANG ----------------
|
||||
|
||||
/// The corners of the rectangle just added, so a test can assert its bounds.
|
||||
fn rect_pts(s: &Session) -> Vec<Vec2> {
|
||||
match last(s) {
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().map(|v| v.p).collect(),
|
||||
@@ -488,6 +623,111 @@ fn rectang_options_command_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards the JSON `center: true` form for every sizing route: a picked corner mirrored about
|
||||
/// the centre, full-size `dimensions`, full `area`, rotation about the centre, that the default
|
||||
/// corner mode is untouched, and that centre mode without a size is rejected.
|
||||
#[test]
|
||||
fn rectang_center_mode_json() {
|
||||
let mut s = Session::new();
|
||||
// The first point is the centre; the second is a corner mirrored about it.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions are the rectangle's full size, still centred on the first point.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A second point only picks the quadrant.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "p2": [0, 0]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The area is the full area, centred too.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "area": 96, "length": 8})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Rotation is about the centre.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "dimensions": [8, 12], "rotation": 90})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 4.0, 6.0) && near(b.max, 16.0, 14.0), "{b:?}");
|
||||
// The default corner mode is unchanged.
|
||||
s.execute("rectang", &json!({"p1": [10, 10], "p2": [14, 16]})).unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Centre mode still needs a size.
|
||||
assert!(s.execute("rectang", &json!({"center": true, "p1": [10, 10]})).is_err());
|
||||
}
|
||||
|
||||
/// Guards that fillet, chamfer, width and thickness still apply in centre mode, and that an
|
||||
/// oversized fillet falls back to sharp corners without moving the rectangle off centre.
|
||||
#[test]
|
||||
fn rectang_center_mode_honours_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 1, "width": 0.25, "thickness": 2})).unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.25),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().model.last().unwrap().common.thickness, 2.0);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Chamfer, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "chamfer": [1, 1]})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 8);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// A fillet too large for the rectangle falls back to sharp corners, still centred.
|
||||
s.execute("rectang", &json!({"center": true, "p1": [10, 10], "p2": [14, 16], "fillet": 5})).unwrap();
|
||||
assert_eq!(rect_pts(&s).len(), 4);
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// Guards the `Center` keyword at the command line, before or after other options, with
|
||||
/// dimensions and area still centred, the prompt wording and centred rubber band, and that the
|
||||
/// default corner mode is unchanged.
|
||||
#[test]
|
||||
fn rectang_center_mode_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("rectang ce 10,10 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Options before the centre point, and the centre picked after them.
|
||||
s.cmdline("rectang center f 1 w 0.1 10,10 14,16").unwrap();
|
||||
match &s.doc().unwrap().model.last().unwrap().kind {
|
||||
EntityKind::LwPolyline(p) => assert!(p.vertices.len() == 8 && p.const_width == 0.1),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Dimensions after the centre: the pick only chooses the quadrant.
|
||||
s.cmdline("rectang ce 10,10 d 8 12 14,16").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// Area too: the rectangle of that area is centred on the first point.
|
||||
s.cmdline("rectang ce 10,10 a 96 l 8").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 6.0, 4.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
// The rubber band is centred while the second point is picked.
|
||||
s.start("rectang").unwrap();
|
||||
s.cmdline("ce").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify center point for rectangle"), "{}", s.prompt_text());
|
||||
s.cmdline("10,10").unwrap();
|
||||
let pv = s.preview(Vec2::new(14.0, 16.0));
|
||||
let pb = match pv.first().map(|e| &e.kind) {
|
||||
Some(EntityKind::LwPolyline(p)) => cadcraft_geom::Bounds2::from_points(p.vertices.iter().map(|v| v.p).collect::<Vec<_>>()),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(near(pb.min, 6.0, 4.0) && near(pb.max, 14.0, 16.0), "{pb:?}");
|
||||
s.cancel();
|
||||
// The default corner mode is unchanged.
|
||||
s.cmdline("rectang 10,10 14,16").unwrap();
|
||||
let b = cadcraft_geom::Bounds2::from_points(rect_pts(&s));
|
||||
assert!(near(b.min, 10.0, 10.0) && near(b.max, 14.0, 16.0), "{b:?}");
|
||||
}
|
||||
|
||||
// ---------------- 3DPOLY / MLINE / HELIX / SPLINE CV ----------------
|
||||
|
||||
#[test]
|
||||
@@ -622,6 +862,7 @@ fn new_draw_commands_survive_garbage_input() {
|
||||
"revcloud.polygonal",
|
||||
"revcloud.freehand",
|
||||
"wipeout",
|
||||
"breakline",
|
||||
"3dpoly",
|
||||
"mline",
|
||||
"helix",
|
||||
|
||||
@@ -79,18 +79,31 @@ fn extension(path: &str) -> String {
|
||||
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (path, bytes);
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as base64 `data`"))
|
||||
deliver_file(cmd, path, bytes, super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
/// No file system on the web: hand the bytes to the host's delivery hook (a browser download)
|
||||
/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it.
|
||||
#[cfg(any(test, target_arch = "wasm32"))]
|
||||
pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option<super::file::DeliverHook>) -> Result<()> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let mime = match extension(&name).as_str() {
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"pdf" => "application/pdf",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?;
|
||||
deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))
|
||||
}
|
||||
|
||||
// ---------- EXPORT ----------
|
||||
|
||||
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -316,7 +329,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
||||
page.landscape = l;
|
||||
}
|
||||
let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
|
||||
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
|
||||
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
||||
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
||||
@@ -546,6 +559,23 @@ mod tests {
|
||||
use super::super::file::{IoHooks, set_io};
|
||||
use super::*;
|
||||
|
||||
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
||||
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
||||
deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap();
|
||||
let got = DELIVERED.lock().unwrap().clone();
|
||||
assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]);
|
||||
let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string();
|
||||
assert!(e.contains("base64"), "{e}");
|
||||
}
|
||||
|
||||
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
// Same output as the layout tests' stand-in: whichever test installs first wins.
|
||||
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
||||
|
||||
@@ -14,9 +14,20 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
|
||||
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
@@ -152,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.menu(&["File", "New Drawing..."])
|
||||
.key("Cmd+N")
|
||||
.alias(&["qnew"])
|
||||
.params("{metric?: bool, template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
CommandSpec::new("open", "Open...", run_open)
|
||||
@@ -190,7 +201,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -240,7 +252,8 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = template::run_new(s, p)? {
|
||||
return Ok(r);
|
||||
}
|
||||
let i = s.new_drawing(bool_or(p, "metric", false));
|
||||
let metric = s.settings.measureinit;
|
||||
let i = s.new_drawing(bool_or(p, "metric", metric));
|
||||
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
|
||||
}
|
||||
|
||||
@@ -263,7 +276,8 @@ pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
read_bytes(&bytes, path, Some(path.to_string()))
|
||||
});
|
||||
match r {
|
||||
@@ -324,13 +338,15 @@ fn opened(s: &Session, i: usize, entities: bool) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a drawing from bounded regular-file bytes or base64 data and resolve its external references.
|
||||
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(_path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
return Ok(opened(s, i, true));
|
||||
@@ -401,10 +417,9 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
fn command_id(target: &str) -> Option<String> {
|
||||
pub fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
find_command(&t).map(|c| c.id.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` can be typed as an alias: one word of printable characters, not a command name.
|
||||
fn alias_name(a: &str) -> std::result::Result<String, String> {
|
||||
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
|
||||
let a = a.trim();
|
||||
if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') {
|
||||
return Err(format!("`{a}` can't be an alias"));
|
||||
@@ -193,6 +193,7 @@ fn run_reinit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit)
|
||||
.menu(&["Tools", "Customize", "Edit Command Aliases (PGP)..."])
|
||||
.params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}")
|
||||
.enabled(always)
|
||||
.noundo(),
|
||||
|
||||
@@ -21,11 +21,11 @@ pub use pick::{HatchPicks, PickMode};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\", scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) and/or handles?: [hex] (closed objects), pattern?: \"ANSI31\" (built in, or defined by patternFile | patternText), patternFile?: path to a .pat file | patternText?: its contents (loads the patterns it defines), scale?, angle? (degrees), associative? (HPASSOC), separate? (HPSEPARATE: a hatch per point/object), origin?: [x,y] (HPORIGIN) | originMode?: \"current\"|\"bottomLeft\"|\"bottomRight\"|\"topLeft\"|\"topRight\"|\"center\" (HPORIGINMODE), style?: \"normal\"|\"outer\"|\"ignore\" (HPISLANDDETECTION)} — gaps up to HPGAPTOL close").interactive(|s| Ok(Box::new(HatchM::new(s, false)))),
|
||||
CommandSpec::new("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? and/or handles?, name?: \"LINEAR\"|\"CYLINDER\"|\"CURVED\"|\"SPHERICAL\"|\"HEMISPHERICAL\" or INV… (colours swapped), color1?, color2?, angle? (degrees), centered? (default true)}").interactive(|s| Ok(Box::new(HatchM::new(s, true)))),
|
||||
CommandSpec::new("boundary", "Boundary...", run_boundary).menu(&["Draw", "Boundary..."]).alias(&["bo", "bpoly"]).params("{points: [[x,y]]}").interactive(|_| Ok(Box::new(BoundaryM { region: false }))),
|
||||
CommandSpec::new("region", "Region", run_region).menu(&["Draw", "Region"]).alias(&["reg"]).params("{handles?}"),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, gradient?: {name?, color1?, color2?, angle? (degrees), centered?} (makes or changes a gradient fill; `pattern` turns it back), associative?: bool (false unlinks; true links each loop to a closed object of the same shape, or to `boundary` objects), boundary?: [hex], addPoints?: [[x,y]], addBoundary?: [hex], removeLoops?: [zero-based index] (geometry edits require one hatch), style?, origin? | originMode?, separate?: true, recreate?: \"polyline\"|\"region\", associate?: bool (link to the recreated boundary)}").interactive(|s| Ok(Box::new(edit::HatchEditM::new(s)))),
|
||||
CommandSpec::new("hatchpick", "Hatch Pick", pick::run_hatchpick)
|
||||
.params("{points?: [[x,y]] (internal points), handles?: [hex] (closed objects)} (typed: Points, Objects, oRigin or Hatch, then the picks): the Hatch and Gradient dialog's picks, kept in the session; changes nothing")
|
||||
.enabled(has_doc)
|
||||
@@ -34,12 +34,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`.
|
||||
pub(super) fn outline_tol(ext: Bounds2) -> f64 {
|
||||
(ext.width() + ext.height()).max(1e-9) / 20000.0
|
||||
}
|
||||
|
||||
/// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each
|
||||
/// with the handle of the object it came from.
|
||||
fn outlines(d: &Drawing, space: &Space, near: Option<Bounds2>) -> (Vec<Vec<Vec2>>, Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) };
|
||||
let ext = d.extents(space);
|
||||
let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0;
|
||||
let tol = outline_tol(ext);
|
||||
let mut out = Vec::new();
|
||||
let mut owners = Vec::new();
|
||||
for e in store.iter() {
|
||||
@@ -113,7 +118,8 @@ fn closed_loop(d: &Drawing, e: &Entity) -> Option<Vec<PolyVertex>> {
|
||||
}
|
||||
|
||||
/// Open curves that can make up an island together (a square drawn as four LINEs, two ARCs
|
||||
/// forming a circle). Closed objects are islands on their own (see `closed_loop`).
|
||||
/// forming a circle, the inner lines of multiline walls around a room). Closed objects are
|
||||
/// islands on their own (see `closed_loop`).
|
||||
fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||||
matches!(
|
||||
e.kind,
|
||||
@@ -123,6 +129,7 @@ fn island_curve(d: &Drawing, e: &Entity) -> bool {
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Polyline3d(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::MLine(_)
|
||||
) && closed_loop(d, e).is_none()
|
||||
}
|
||||
|
||||
@@ -204,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result<Vec<HatchLoop>> {
|
||||
let ext = d.extents(&space);
|
||||
let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9);
|
||||
let (polys, owners) = outlines(d, &space, None);
|
||||
// A line that ends on a flattened curve (an ellipse) can stop short of its chords.
|
||||
let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol);
|
||||
let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d))
|
||||
.ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?;
|
||||
let outer = found.outer;
|
||||
@@ -458,7 +467,7 @@ fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& cadcraft_doc::library::pattern(&up).is_none()
|
||||
&& patfile::user_pattern(&up).is_none()
|
||||
{
|
||||
return Err(bad("hatch", format!("unknown pattern {up}")));
|
||||
return Err(bad("hatch", format!("unknown pattern {up}: not built in; load it with patternFile or patternText (a .pat file)")));
|
||||
}
|
||||
make(s, p, false)
|
||||
}
|
||||
@@ -840,6 +849,16 @@ mod tests {
|
||||
assert!(s.current_prompt().unwrap().picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pattern_error_and_catalog_name_the_pat_file_route() {
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
let e = s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "GRAVEL" })).unwrap_err();
|
||||
assert!(e.to_string().contains("patternFile"), "{e}");
|
||||
let spec = specs().into_iter().find(|c| c.id == "hatch").unwrap();
|
||||
assert!(spec.params.contains("patternFile") && spec.params.contains("patternText"), "{}", spec.params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_hatch_refuses_an_undefined_pattern() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill {
|
||||
if nb.is_empty() {
|
||||
return Refill::Open;
|
||||
}
|
||||
let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0;
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let tol = super::outline_tol(nb);
|
||||
let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9);
|
||||
let polys: Vec<Vec<Vec2>> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect();
|
||||
let polys = region::snap_ends(&polys, tol, snap);
|
||||
let gap = gap_tolerance(after);
|
||||
let found = if l.outer {
|
||||
// The face around a point inside the old loop, carried along with the objects.
|
||||
@@ -137,6 +138,18 @@ fn reshaped(prev: &Hatch, h: &Hatch) -> bool {
|
||||
h.loops.iter().any(|l| !prev.loops.iter().any(|q| q.vertices == l.vertices))
|
||||
}
|
||||
|
||||
/// HATCHEDIT may add or remove whole loops without moving the ones it kept. New associative
|
||||
/// loops must still match their source objects; a hatch moved on its own must not keep its link.
|
||||
fn valid_loop_edit(prev: &Hatch, h: &Hatch, after: &Drawing) -> bool {
|
||||
if prev.loops.len().saturating_mul(h.loops.len()) > 4_000_000 {
|
||||
return false;
|
||||
}
|
||||
h.loops.iter().all(|l| {
|
||||
prev.loops.iter().any(|old| old.vertices == l.vertices)
|
||||
|| (!l.sources.is_empty() && matches!(refill(after, after, l), Refill::Loop(vs) if same_shape(&vs, &l.vertices)))
|
||||
})
|
||||
}
|
||||
|
||||
/// What happens to one associative hatch after a command; `None` when nothing changes.
|
||||
fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked: &mut usize) -> Option<(Hatch, Option<String>)> {
|
||||
let prev = match before.entity(handle).map(|e| &e.kind) {
|
||||
@@ -153,7 +166,7 @@ fn follow(before: &Drawing, after: &Drawing, handle: Handle, h: &Hatch, checked:
|
||||
}
|
||||
match prev {
|
||||
// Moved or reshaped on its own: no longer tied to the boundary.
|
||||
Some(p) if reshaped(p, h) => {
|
||||
Some(p) if reshaped(p, h) && !valid_loop_edit(p, h, after) => {
|
||||
detach(&mut nh);
|
||||
return Some((nh, None));
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::assoc::{detach, same_shape};
|
||||
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, gradient_name, island_style};
|
||||
use super::{GRADIENT_NAMES, ORIGIN_KW, OriginMode, closed_loop, dedup_loops, gradient_name, island_style, loops_at, loops_from_handles};
|
||||
use crate::cmd::helpers::lwpoly;
|
||||
use crate::cmd::{bad, bool_or, point_param, size_param, str_param, targets};
|
||||
use crate::cmd::{bad, bool_or, point_param, points_param, size_param, str_param, targets};
|
||||
use crate::{Accept, Input, Interactive, Picker, Prompt, Result, Session, Step};
|
||||
|
||||
/// Upper bound on hatches edited by one call.
|
||||
@@ -259,9 +259,111 @@ fn check_numbers(p: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary changes are prepared before any hatch is edited, so a bad pick cannot leave a
|
||||
/// partially changed drawing. Loop numbers refer to the hatch before this request.
|
||||
fn boundary_changes(s: &Session, p: &Value, hs: &[Handle]) -> Result<(Vec<HatchLoop>, Vec<usize>)> {
|
||||
let given = |key: &str| p.get(key).is_some_and(|v| !v.is_null());
|
||||
if !["addPoints", "addBoundary", "removeLoops"].into_iter().any(given) {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
if hs.len() != 1 {
|
||||
return Err(bad("hatchedit", "edit the boundaries of one hatch at a time"));
|
||||
}
|
||||
let mut added = Vec::new();
|
||||
if given("addPoints") {
|
||||
if s.doc()?.space_of(hs[0]) != Some(s.space()) {
|
||||
return Err(bad("hatchedit", "open the hatch's space before adding points"));
|
||||
}
|
||||
let points = points_param(p, "addPoints").ok_or_else(|| bad("hatchedit", "`addPoints` is an array of finite [x, y] points"))?;
|
||||
if points.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} points")));
|
||||
}
|
||||
for q in points {
|
||||
if !q.is_finite() {
|
||||
return Err(bad("hatchedit", "`addPoints` must contain finite points"));
|
||||
}
|
||||
added.extend(loops_at(s, q)?);
|
||||
if added.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} added loops")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if given("addBoundary") {
|
||||
let raw =
|
||||
p.get("addBoundary").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`addBoundary` is an array of closed object handles"))?;
|
||||
if raw.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} boundary objects")));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let space = d.space_of(hs[0]);
|
||||
let mut handles = Vec::with_capacity(raw.len());
|
||||
for value in raw {
|
||||
let h = value.as_str().and_then(Handle::parse_hex).ok_or_else(|| bad("hatchedit", "invalid `addBoundary` handle"))?;
|
||||
let e = d.entity(h).ok_or_else(|| bad("hatchedit", format!("boundary object {} not found", h.hex())))?;
|
||||
if d.space_of(h) != space || closed_loop(d, e).is_none() {
|
||||
return Err(bad("hatchedit", format!("boundary object {} must be closed and in the hatch's space", h.hex())));
|
||||
}
|
||||
handles.push(h);
|
||||
}
|
||||
if !handles.is_empty() {
|
||||
added.extend(loops_from_handles(s, &handles)?);
|
||||
}
|
||||
}
|
||||
let mut removed = Vec::new();
|
||||
if given("removeLoops") {
|
||||
let raw =
|
||||
p.get("removeLoops").and_then(Value::as_array).ok_or_else(|| bad("hatchedit", "`removeLoops` is an array of zero-based loop numbers"))?;
|
||||
if raw.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("at most {MAX_LOOPS} loop numbers")));
|
||||
}
|
||||
let count = hs
|
||||
.first()
|
||||
.and_then(|h| s.doc().ok()?.entity(*h))
|
||||
.and_then(|e| match &e.kind {
|
||||
EntityKind::Hatch(h) => Some(h.loops.len()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0);
|
||||
for value in raw {
|
||||
let index = value
|
||||
.as_u64()
|
||||
.and_then(|n| usize::try_from(n).ok())
|
||||
.filter(|n| *n < count)
|
||||
.ok_or_else(|| bad("hatchedit", "`removeLoops` contains an invalid loop number"))?;
|
||||
if !removed.contains(&index) {
|
||||
removed.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((added, removed))
|
||||
}
|
||||
|
||||
fn edit_loops(h: &mut Hatch, added: &[HatchLoop], removed: &[usize]) -> Result<()> {
|
||||
if added.is_empty() && removed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
h.loops = h.loops.drain(..).enumerate().filter_map(|(i, l)| (!removed.contains(&i)).then_some(l)).collect();
|
||||
h.loops.extend_from_slice(added);
|
||||
dedup_loops(&mut h.loops);
|
||||
if h.loops.is_empty() {
|
||||
return Err(bad("hatchedit", "a hatch needs at least one boundary loop"));
|
||||
}
|
||||
if h.loops.len() > MAX_LOOPS {
|
||||
return Err(bad("hatchedit", format!("a hatch may have at most {MAX_LOOPS} loops")));
|
||||
}
|
||||
let outlines: Vec<Vec<Vec2>> = h.loops.iter().map(outline).collect();
|
||||
for (i, l) in h.loops.iter_mut().enumerate() {
|
||||
let p = outlines.get(i).and_then(|v| v.first()).copied();
|
||||
let depth = p.map_or(0, |q| outlines.iter().enumerate().filter(|(j, other)| *j != i && point_in_polygon(other, q)).count());
|
||||
l.outer = depth % 2 == 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p)?;
|
||||
let hs = hatches(s, p)?;
|
||||
let (added, removed) = boundary_changes(s, p, &hs)?;
|
||||
let style = match str_param(p, "style") {
|
||||
Some(t) => Some(island_style(t).ok_or_else(|| bad("hatchedit", "`style` is \"normal\", \"outer\" or \"ignore\""))?),
|
||||
None => None,
|
||||
@@ -308,6 +410,14 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut budget = MAX_LOOPS;
|
||||
for handle in &hs {
|
||||
let Some(EntityKind::Hatch(mut h)) = s.doc()?.entity(*handle).map(|e| e.kind.clone()) else { continue };
|
||||
edit_loops(&mut h, &added, &removed)?;
|
||||
if h.associative && h.loops.iter().any(|l| l.sources.is_empty()) && p.get("associative").and_then(Value::as_bool) != Some(false) {
|
||||
let missing = link(s.doc()?, *handle, &mut h, None, &mut budget);
|
||||
if missing != 0 {
|
||||
detach(&mut h);
|
||||
notes.push(format!("Hatch {}: {missing} new boundary loop(s) could not be linked; it is now non-associative.", handle.hex()));
|
||||
}
|
||||
}
|
||||
properties(&mut h, p);
|
||||
if let Some(g) = &grad {
|
||||
g.apply(&mut h);
|
||||
@@ -360,6 +470,9 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Ask {
|
||||
Option,
|
||||
AddPoint,
|
||||
AddObject,
|
||||
Remove,
|
||||
Style,
|
||||
Pattern,
|
||||
Scale,
|
||||
@@ -370,7 +483,18 @@ enum Ask {
|
||||
Link,
|
||||
}
|
||||
|
||||
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
|
||||
const OPTIONS: [&str; 10] = [
|
||||
"Disassociate",
|
||||
"Style",
|
||||
"Properties",
|
||||
"recreate Boundary",
|
||||
"ASsociate",
|
||||
"separate Hatches",
|
||||
"Origin",
|
||||
"add Point",
|
||||
"add Object",
|
||||
"remove Boundary",
|
||||
];
|
||||
|
||||
/// -HATCHEDIT: select hatches, then one option.
|
||||
pub(crate) struct HatchEditM {
|
||||
@@ -422,6 +546,9 @@ impl Interactive for HatchEditM {
|
||||
let h = self.first(s);
|
||||
match self.ask {
|
||||
Ask::Option => Prompt::new("Enter a hatch option", Accept::TEXT).kw(&OPTIONS),
|
||||
Ask::AddPoint => Prompt::new("Pick an internal point for the new boundary", Accept::POINT),
|
||||
Ask::AddObject => Prompt::new("Select closed boundary objects to add", Accept::SELECT),
|
||||
Ask::Remove => Prompt::new("Enter a boundary loop number to remove (starting at 1)", Accept::TEXT),
|
||||
Ask::Style => {
|
||||
let cur = match h.map_or(0, |h| h.style) {
|
||||
1 => "Outer",
|
||||
@@ -475,10 +602,24 @@ impl Interactive for HatchEditM {
|
||||
Some("recreate Boundary") => self.ask = Ask::Recreate,
|
||||
Some("ASsociate") => self.ask = Ask::Link,
|
||||
Some("Origin") => self.ask = Ask::Origin,
|
||||
Some("add Point") => self.ask = Ask::AddPoint,
|
||||
Some("add Object") => self.ask = Ask::AddObject,
|
||||
Some("remove Boundary") => self.ask = Ask::Remove,
|
||||
_ => s.echo("Choose one of the options."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Ask::AddPoint, Input::Point(p)) => self.apply(s, json!({ "addPoints": [[p.x, p.y]] })),
|
||||
(Ask::AddObject, Input::Pick(hs)) => self.apply(s, json!({ "addBoundary": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })),
|
||||
(Ask::AddPoint | Ask::AddObject, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::AddPoint | Ask::AddObject, _) => Ok(Step::Continue),
|
||||
(Ask::Remove, _) => {
|
||||
let Some(index) = text.as_deref().and_then(|t| t.parse::<usize>().ok()).and_then(|n| n.checked_sub(1)) else {
|
||||
s.echo("Enter a positive boundary loop number.");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
self.apply(s, json!({ "removeLoops": [index] }))
|
||||
}
|
||||
(Ask::Style, Input::Enter) => Ok(Step::Done),
|
||||
(Ask::Style, _) => match text.as_deref().and_then(island_style) {
|
||||
Some(st) => {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind, Handle};
|
||||
use cadcraft_geom::{Polyline, Vec2, Vec3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::units::{format_angle, format_distance};
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod loops;
|
||||
mod regions;
|
||||
mod shapes;
|
||||
mod usage;
|
||||
|
||||
/// The inquiry commands: distances, points, listing, area, time and status, the drawing and
|
||||
/// entity queries an agent reads, SHAPES, USAGE, LOOPS, COUNT and the region commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = vec![
|
||||
CommandSpec::new("dist", "Distance", run_dist)
|
||||
@@ -41,8 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(),
|
||||
CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(),
|
||||
CommandSpec::new("entities", "Query Entities", run_entities)
|
||||
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
|
||||
.noundo(),
|
||||
CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(),
|
||||
CommandSpec::new("usage", "Analyze Usage", usage::run).params(usage::PARAMS).noundo(),
|
||||
CommandSpec::new("loops", "Implied Loops", loops::run).params(loops::PARAMS).noundo(),
|
||||
CommandSpec::new("texts", "Query Texts", run_texts)
|
||||
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
|
||||
.noundo(),
|
||||
@@ -62,37 +72,232 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Format a distance with the drawing's `LUNITS` and `LUPREC` (decimal units, four places by
|
||||
/// default).
|
||||
fn fmt_d(s: &Session, v: f64) -> String {
|
||||
let h = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
|
||||
format_distance(v, h.0, h.1)
|
||||
}
|
||||
/// Format an angle with the drawing's `AUNITS` and `AUPREC` (decimal degrees, whole degrees by
|
||||
/// default).
|
||||
fn fmt_a(s: &Session, v: f64) -> String {
|
||||
let h = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||||
format_angle(v, h.0, h.1)
|
||||
}
|
||||
|
||||
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
|
||||
/// Schema of the analysis self-check block (#1440). Fields may be added within a version; a
|
||||
/// rename or a changed meaning bumps it.
|
||||
pub(super) const SELF_CHECK_SCHEMA: u64 = 1;
|
||||
|
||||
/// How far past its bound a value may be before it is implausible: a relative slack for the
|
||||
/// rounding of two ways of measuring the same thing.
|
||||
const IMPLAUSIBLE_TOLERANCE: f64 = 1e-9;
|
||||
|
||||
/// How many objects of one type an analysis skipped, and every distinct reason it gave.
|
||||
#[derive(Default)]
|
||||
struct SkipTally {
|
||||
count: u64,
|
||||
reasons: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The self-check every analysis result carries (#1440): how many objects it found, how many it
|
||||
/// analysed, what it skipped and why, the caps it hit with their values, and values so
|
||||
/// implausible that they point at a bug rather than at the drawing.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SelfCheck {
|
||||
found: u64,
|
||||
analyzed: u64,
|
||||
skipped: BTreeMap<String, SkipTally>,
|
||||
caps: BTreeMap<String, u64>,
|
||||
implausible: Vec<Value>,
|
||||
}
|
||||
|
||||
impl SelfCheck {
|
||||
/// An empty self-check: nothing found, analysed or skipped yet.
|
||||
pub(super) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Count objects the analysis found (matched its filters).
|
||||
pub(super) fn found(&mut self, n: u64) {
|
||||
self.found = self.found.saturating_add(n);
|
||||
}
|
||||
|
||||
/// Count objects the analysis actually analysed.
|
||||
pub(super) fn analyzed(&mut self, n: u64) {
|
||||
self.analyzed = self.analyzed.saturating_add(n);
|
||||
}
|
||||
|
||||
/// Count `n` objects of `ty` skipped, with the reason.
|
||||
pub(super) fn skip(&mut self, ty: &str, reason: &str, n: u64) {
|
||||
let skip = self.skipped.entry(ty.to_string()).or_default();
|
||||
skip.count = skip.count.saturating_add(n);
|
||||
skip.reasons.insert(reason.to_string());
|
||||
}
|
||||
|
||||
/// Record a cap the analysis hit, with its value (e.g. `limit`, 200).
|
||||
pub(super) fn cap(&mut self, name: &str, value: u64) {
|
||||
self.caps.insert(name.to_string(), value);
|
||||
}
|
||||
|
||||
/// Flag `value` as implausible when it is past `bound`, with the rule that makes it so. A
|
||||
/// non-finite `bound` means no bound applies (an empty drawing has no extents). Values within
|
||||
/// `IMPLAUSIBLE_TOLERANCE` of the bound are not flagged: they are the rounding of two ways
|
||||
/// of measuring the same thing (a border rectangle's shoelace area against the extents'
|
||||
/// `width * height`), not a bug.
|
||||
pub(super) fn implausible(&mut self, handle: Handle, ty: &str, field: &str, value: f64, bound: f64, rule: &str) {
|
||||
if value.is_finite() && bound.is_finite() && value > bound + bound.abs() * IMPLAUSIBLE_TOLERANCE {
|
||||
self.implausible.push(json!({
|
||||
"handle": handle.hex(),
|
||||
"type": ty,
|
||||
"field": field,
|
||||
"value": value,
|
||||
"bound": bound,
|
||||
"reason": rule,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip map in the shape analyses have always reported: type → {count, reason}.
|
||||
pub(super) fn skipped_json(&self) -> Value {
|
||||
Value::Object(
|
||||
self.skipped
|
||||
.iter()
|
||||
.map(|(ty, skip)| {
|
||||
let reason = skip.reasons.iter().map(String::as_str).collect::<Vec<_>>().join("; ");
|
||||
(ty.clone(), json!({ "count": skip.count, "reason": reason }))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The self-check as the JSON object every analysis returns: schema, counts, skip map, caps
|
||||
/// and implausible values.
|
||||
pub(super) fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"schema": SELF_CHECK_SCHEMA,
|
||||
"found": self.found,
|
||||
"analyzed": self.analyzed,
|
||||
"skipped": self.skipped_json(),
|
||||
"caps": self.caps,
|
||||
"implausible": self.implausible,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle of `d` up from the XY plane.
|
||||
fn angle_from_xy(d: Vec3) -> f64 {
|
||||
d.z.atan2(d.xy().len())
|
||||
}
|
||||
|
||||
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
|
||||
let d = b - a;
|
||||
format!(
|
||||
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
|
||||
fmt_d(s, a.dist(b)),
|
||||
fmt_a(s, d.angle()),
|
||||
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
|
||||
fmt_d(s, (b - a).len()),
|
||||
fmt_a(s, d.xy().angle()),
|
||||
fmt_a(s, angle_from_xy(d)),
|
||||
fmt_d(s, d.x),
|
||||
fmt_d(s, d.y),
|
||||
fmt_d(s, 0.0)
|
||||
fmt_d(s, d.z)
|
||||
)
|
||||
}
|
||||
|
||||
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
|
||||
/// there ([`z_at`]).
|
||||
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
|
||||
let q = point_req(cmd, p, key)?;
|
||||
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
|
||||
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
|
||||
}
|
||||
|
||||
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dist", p, "p1")?;
|
||||
let b = point_req("dist", p, "p2")?;
|
||||
Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
|
||||
let a = point3_req(s, "dist", p, "p1")?;
|
||||
let b = point3_req(s, "dist", p, "p2")?;
|
||||
let d = b - a;
|
||||
Ok(json!({
|
||||
"distance": d.len(),
|
||||
"angle": d.xy().angle().to_degrees(),
|
||||
"angleFromXY": angle_from_xy(d).to_degrees(),
|
||||
"dx": d.x,
|
||||
"dy": d.y,
|
||||
"dz": d.z,
|
||||
"message": dist_text(s, a, b),
|
||||
}))
|
||||
}
|
||||
|
||||
fn id_text(s: &Session, a: Vec3) -> String {
|
||||
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
|
||||
}
|
||||
|
||||
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("id", p, "at")?;
|
||||
s.last_point = a;
|
||||
Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
|
||||
let a = point3_req(s, "id", p, "at")?;
|
||||
s.last_point = a.xy();
|
||||
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
|
||||
}
|
||||
|
||||
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
|
||||
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
|
||||
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
|
||||
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
|
||||
let Ok(d) = s.doc() else { return 0.0 };
|
||||
let elevation = d.header.f64("ELEVATION", 0.0);
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
crate::select::pick(d, &space, p, ap)
|
||||
.and_then(|h| d.entity(h))
|
||||
.and_then(|e| entity_z(e, p))
|
||||
.filter(|z| z.is_finite())
|
||||
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
|
||||
}
|
||||
|
||||
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
|
||||
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
|
||||
let on_segment = |a: Vec3, b: Vec3| {
|
||||
let d = b.xy() - a.xy();
|
||||
let l2 = d.dot(d);
|
||||
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
|
||||
};
|
||||
let along = |pts: &[Vec3], closed: bool| {
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n.saturating_sub(1) };
|
||||
(0..segs)
|
||||
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(z, _)| z)
|
||||
.or_else(|| pts.first().map(|q| q.z))
|
||||
};
|
||||
let n = e.common.extrusion;
|
||||
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
|
||||
match &e.kind {
|
||||
EntityKind::Point(q) => Some(q.p.z),
|
||||
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
|
||||
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
|
||||
// Elevation is in the polyline's OCS: down Z for a mirrored one.
|
||||
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
|
||||
// Read in world coordinates already.
|
||||
EntityKind::Circle(c) => Some(c.center.z),
|
||||
EntityKind::Arc(a) => Some(a.center.z),
|
||||
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
|
||||
EntityKind::MText(t) => Some(t.insert.z),
|
||||
EntityKind::Insert(i) => Some(i.insert.z),
|
||||
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
|
||||
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
|
||||
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
|
||||
let n = (b - a).cross(d - a);
|
||||
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
|
||||
return None;
|
||||
}
|
||||
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
|
||||
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
|
||||
};
|
||||
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
|
||||
}
|
||||
|
||||
fn list_text(s: &Session, h: Handle) -> String {
|
||||
@@ -112,23 +317,25 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
|
||||
fmt_d(s, l.a.x),
|
||||
fmt_d(s, l.a.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, l.a.z),
|
||||
fmt_d(s, l.b.x),
|
||||
fmt_d(s, l.b.y),
|
||||
fmt_d(s, 0.0)
|
||||
fmt_d(s, l.b.z)
|
||||
));
|
||||
t.push_str(&format!(
|
||||
"\n Length = {}, Angle in XY Plane = {}",
|
||||
fmt_d(s, l.a.xy().dist(l.b.xy())),
|
||||
fmt_d(s, (l.b - l.a).len()),
|
||||
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
|
||||
));
|
||||
let d = l.b - l.a;
|
||||
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
t.push_str(&format!(
|
||||
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
|
||||
fmt_d(s, c.center.x),
|
||||
fmt_d(s, c.center.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, c.center.z),
|
||||
fmt_d(s, c.radius),
|
||||
fmt_d(s, c.radius * cadcraft_geom::TAU),
|
||||
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
|
||||
@@ -139,7 +346,7 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
|
||||
fmt_d(s, a.center.x),
|
||||
fmt_d(s, a.center.y),
|
||||
fmt_d(s, 0.0),
|
||||
fmt_d(s, a.center.z),
|
||||
fmt_d(s, a.radius),
|
||||
fmt_a(s, a.start),
|
||||
fmt_a(s, a.end)
|
||||
@@ -153,16 +360,20 @@ fn list_text(s: &Session, h: Handle) -> String {
|
||||
} else {
|
||||
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
|
||||
}
|
||||
// The elevation, in world Z (down Z for a mirrored polyline).
|
||||
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
|
||||
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
|
||||
for v in &p.vertices {
|
||||
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
|
||||
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
|
||||
}
|
||||
}
|
||||
EntityKind::Text(tx) => {
|
||||
t.push_str(&format!(
|
||||
"\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
|
||||
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
|
||||
tx.style,
|
||||
fmt_d(s, tx.insert.x),
|
||||
fmt_d(s, tx.insert.y),
|
||||
fmt_d(s, tx.insert.z),
|
||||
fmt_d(s, tx.height),
|
||||
tx.value,
|
||||
fmt_a(s, tx.rotation)
|
||||
@@ -183,7 +394,7 @@ fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": texts.join("\n\n") }))
|
||||
}
|
||||
|
||||
fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
|
||||
let a = cadcraft_geom::shoelace(pts).abs();
|
||||
let n = pts.len();
|
||||
let per: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
|
||||
@@ -205,14 +416,16 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
|
||||
}
|
||||
|
||||
/// Area and perimeter of a closed object (AREA's Object option).
|
||||
fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
let d = s.doc()?;
|
||||
let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?;
|
||||
Ok(match &e.kind {
|
||||
EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU),
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
// An open polyline is measured as if closed for the area, but its perimeter is its own length (AutoCAD's AREA help).
|
||||
let g = Polyline { vertices: pl.vertices.clone(), closed: true };
|
||||
(g.area().abs(), g.len())
|
||||
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
|
||||
(g.area().abs(), drawn.len())
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let arc = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
@@ -221,7 +434,9 @@ fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
|
||||
}
|
||||
let a = el.major.xy().len();
|
||||
let b = a * el.ratio;
|
||||
(cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt()))
|
||||
// The perimeter integrated around the ellipse, not a closed-form estimate (those
|
||||
// fall short as the ellipse flattens: 0.3% on a 100:1 one).
|
||||
(cadcraft_geom::PI * a * b, arc.arc_length_from(0.0, cadcraft_geom::TAU))
|
||||
}
|
||||
EntityKind::Hatch(hh) => {
|
||||
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
|
||||
@@ -335,14 +550,17 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.transpose()?;
|
||||
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
|
||||
let selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?;
|
||||
let limit = count("limit", 500)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) };
|
||||
let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
|
||||
let selection: std::collections::HashSet<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
|
||||
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = store
|
||||
.iter()
|
||||
.filter(|e| !selected || selection.contains(&e.handle))
|
||||
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
|
||||
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
|
||||
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol)))
|
||||
@@ -427,7 +645,7 @@ fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod count;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DistM {
|
||||
pub(super) struct DistM {
|
||||
first: Option<Vec2>,
|
||||
first_source: Option<crate::snap::ViewportPoint>,
|
||||
/// Multiple points: the points so far, the first included.
|
||||
@@ -553,7 +771,8 @@ impl Interactive for DistM {
|
||||
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
|
||||
_ => (a, b),
|
||||
};
|
||||
let t = dist_text(s, a, b);
|
||||
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
|
||||
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
|
||||
for l in t.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
@@ -600,7 +819,7 @@ impl Interactive for IdM {
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Input::Point(p) = i {
|
||||
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
|
||||
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
|
||||
s.echo(m);
|
||||
}
|
||||
Ok(Step::Done)
|
||||
@@ -662,7 +881,7 @@ enum AreaMode {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AreaM {
|
||||
pub(super) struct AreaM {
|
||||
pts: Vec<Vec2>,
|
||||
mode: AreaMode,
|
||||
total: f64,
|
||||
@@ -789,3 +1008,28 @@ impl Interactive for AreaM {
|
||||
vec![super::helpers::lwpoly(v, true)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1440 review: a value one ulp past its bound (a border rectangle's shoelace area against
|
||||
/// the extents' `width * height`) is rounding, not a bug; a value clearly past it still is.
|
||||
#[test]
|
||||
fn implausible_allows_rounding_but_not_a_real_excess() {
|
||||
let bound: f64 = 100.0;
|
||||
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Polyline", "area", bound.next_up(), bound, "past the bound");
|
||||
assert!(check.implausible.is_empty(), "one ulp past the bound is rounding: {}", check.to_json());
|
||||
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Polyline", "area", bound * (1.0 + 1e-6), bound, "past the bound");
|
||||
assert_eq!(check.implausible.len(), 1, "a real excess is still flagged: {}", check.to_json());
|
||||
|
||||
// A bound that is not finite means no bound applies: the value is not flagged.
|
||||
let mut check = SelfCheck::new();
|
||||
check.implausible(Handle::default(), "Region", "area", 1e9, f64::INFINITY, "past the bound");
|
||||
assert!(check.implausible.is_empty(), "an infinite bound applies to nothing: {}", check.to_json());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use cadcraft_geom::{Mat3, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Merges done while expanding nested references; a hostile file stops here (and says so).
|
||||
@@ -20,12 +21,15 @@ const MAX_HANDLES: usize = 100_000;
|
||||
/// COUNT's JSON parameters.
|
||||
pub(super) const PARAMS: &str = "{block?, object?: handle (count objects like this one), space?: current|model|all|<layout> (default current), \
|
||||
handles?: [...] (count in these objects), nested?: bool (default true), highlight?: bool} → \
|
||||
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message`";
|
||||
{blocks: {name: n}, nested: {name: n}, spaces: {space: {name: n}}, total} | {block, count, nested, handles}, plus `message` and \
|
||||
`selfCheck` (found/analyzed/skipped counts with reasons, and the caps hit: work, handles, blockDepth)";
|
||||
|
||||
/// COUNT's interactive command-line prompt machine.
|
||||
pub(super) fn machine() -> Box<dyn Interactive> {
|
||||
Box::new(CountM)
|
||||
}
|
||||
|
||||
/// COUNTTABLE's interactive command-line prompt machine (it asks for the insertion point).
|
||||
pub(super) fn table_machine() -> Box<dyn Interactive> {
|
||||
Box::new(CountTableM)
|
||||
}
|
||||
@@ -109,11 +113,14 @@ struct Expander<'a> {
|
||||
memo: HashMap<(String, usize), Rc<BTreeMap<String, u64>>>,
|
||||
work: u64,
|
||||
truncated: bool,
|
||||
/// A block at the end of the depth cap still held references.
|
||||
depth_capped: bool,
|
||||
}
|
||||
|
||||
impl<'a> Expander<'a> {
|
||||
/// An expander that has not traversed any block yet.
|
||||
fn new(d: &'a Drawing) -> Self {
|
||||
Expander { d, memo: HashMap::new(), work: 0, truncated: false }
|
||||
Expander { d, memo: HashMap::new(), work: 0, truncated: false, depth_capped: false }
|
||||
}
|
||||
/// The definition's own name for a referenced block (names are case-insensitive).
|
||||
fn canonical(&self, name: &str) -> String {
|
||||
@@ -125,10 +132,17 @@ impl<'a> Expander<'a> {
|
||||
if let Some(m) = self.memo.get(&key) {
|
||||
return m.clone();
|
||||
}
|
||||
if levels == 0 {
|
||||
// More references than MAX_BLOCK_DEPTH covers: say so rather than silently stop.
|
||||
if self.d.block(name).is_some_and(|b| b.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(_)))) {
|
||||
self.depth_capped = true;
|
||||
}
|
||||
let out = Rc::new(BTreeMap::new());
|
||||
self.memo.insert(key, out.clone());
|
||||
return out;
|
||||
}
|
||||
let mut out: BTreeMap<String, u64> = BTreeMap::new();
|
||||
if levels > 0
|
||||
&& let Some(b) = self.d.block(name)
|
||||
{
|
||||
if let Some(b) = self.d.block(name) {
|
||||
let b = b.clone();
|
||||
for e in b.entities.iter() {
|
||||
let EntityKind::Insert(i) = &e.kind else { continue };
|
||||
@@ -173,6 +187,14 @@ struct Tally {
|
||||
/// Top-level objects holding `want` (when one block is asked for).
|
||||
handles: Vec<Handle>,
|
||||
truncated: bool,
|
||||
/// Top-level objects scanned, and the block references among them.
|
||||
found: u64,
|
||||
analyzed: u64,
|
||||
/// Top-level objects that are not block references, by type.
|
||||
skipped: BTreeMap<&'static str, u64>,
|
||||
/// More top-level objects held the block than `MAX_HANDLES`.
|
||||
handles_capped: bool,
|
||||
depth_capped: bool,
|
||||
}
|
||||
|
||||
/// Count block references in `scope`; with `want`, also collect the top-level objects holding it.
|
||||
@@ -180,7 +202,13 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
|
||||
let mut x = Expander::new(d);
|
||||
let mut t = Tally::default();
|
||||
for (space, e) in scope.objects(d) {
|
||||
let EntityKind::Insert(i) = &e.kind else { continue };
|
||||
t.found = t.found.saturating_add(1);
|
||||
let EntityKind::Insert(i) = &e.kind else {
|
||||
let n = t.skipped.entry(e.kind.type_name()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
continue;
|
||||
};
|
||||
t.analyzed = t.analyzed.saturating_add(1);
|
||||
let name = x.canonical(&i.block);
|
||||
let m = copies(i);
|
||||
let mut holds = want.is_some_and(|w| w.eq_ignore_ascii_case(&name));
|
||||
@@ -199,15 +227,41 @@ fn tally(d: &Drawing, scope: &Scope, nested: bool, want: Option<&str>) -> Tally
|
||||
holds |= want.is_some_and(|w| w.eq_ignore_ascii_case(k));
|
||||
}
|
||||
}
|
||||
if holds && t.handles.len() < MAX_HANDLES {
|
||||
t.handles.push(e.handle);
|
||||
if holds {
|
||||
if t.handles.len() < MAX_HANDLES {
|
||||
t.handles.push(e.handle);
|
||||
} else {
|
||||
t.handles_capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
t.spaces.retain(|_, m| !m.is_empty());
|
||||
t.truncated = x.truncated;
|
||||
t.depth_capped = x.depth_capped;
|
||||
t
|
||||
}
|
||||
|
||||
/// The self-check for a COUNT-style result: what was scanned, counted and skipped, and the caps
|
||||
/// that stopped it.
|
||||
fn self_check(t: &Tally) -> Value {
|
||||
let mut check = SelfCheck::new();
|
||||
check.found(t.found);
|
||||
check.analyzed(t.analyzed);
|
||||
for (ty, n) in &t.skipped {
|
||||
check.skip(ty, "not a block reference", *n);
|
||||
}
|
||||
if t.truncated {
|
||||
check.cap("work", MAX_WORK);
|
||||
}
|
||||
if t.handles_capped {
|
||||
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
|
||||
}
|
||||
if t.depth_capped {
|
||||
check.cap("blockDepth", u64::try_from(MAX_BLOCK_DEPTH).unwrap_or(u64::MAX));
|
||||
}
|
||||
check.to_json()
|
||||
}
|
||||
|
||||
/// A translation-free signature of an object's geometry: objects with equal keys are copies of
|
||||
/// each other (moved, not rotated or scaled). Coordinates are rounded to 1e-6.
|
||||
fn shape_key(k: &EntityKind) -> Option<String> {
|
||||
@@ -262,7 +316,15 @@ fn list(t: &Tally, scope: &Scope) -> Value {
|
||||
m
|
||||
};
|
||||
let total = t.blocks.values().fold(0u64, |a, b| a.saturating_add(*b));
|
||||
json!({ "blocks": t.blocks, "nested": t.nested, "spaces": t.spaces, "total": total, "truncated": t.truncated, "message": message })
|
||||
json!({
|
||||
"blocks": t.blocks,
|
||||
"nested": t.nested,
|
||||
"spaces": t.spaces,
|
||||
"total": total,
|
||||
"truncated": t.truncated,
|
||||
"selfCheck": self_check(t),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count block `b` (its references, nested ones included) in `scope`.
|
||||
@@ -273,7 +335,15 @@ fn count_block(d: &Drawing, scope: &Scope, nested: bool, b: &str) -> Value {
|
||||
let k = t.nested.get(&name).copied().unwrap_or(0);
|
||||
let wh = scope.label();
|
||||
let message = if k > 0 { format!("Block {name}: {n} in {wh} ({k} nested).") } else { format!("Block {name}: {n} in {wh}.") };
|
||||
json!({ "block": name, "count": n, "nested": k, "handles": hex(&t.handles), "truncated": t.truncated, "message": message })
|
||||
json!({
|
||||
"block": name,
|
||||
"count": n,
|
||||
"nested": k,
|
||||
"handles": hex(&t.handles),
|
||||
"truncated": t.truncated,
|
||||
"selfCheck": self_check(&t),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count the objects in `scope` shaped like `h` (a block reference: references of its block).
|
||||
@@ -284,18 +354,35 @@ fn count_like(d: &Drawing, scope: &Scope, nested: bool, h: Handle) -> Result<Val
|
||||
}
|
||||
let ty = e.kind.type_name();
|
||||
let key = shape_key(&e.kind).ok_or_else(|| bad("count", format!("a {ty} can't be counted by shape")))?;
|
||||
let hs: Vec<Handle> = scope
|
||||
.objects(d)
|
||||
.into_iter()
|
||||
.filter(|(_, o)| o.kind.type_name() == ty && shape_key(&o.kind).as_deref() == Some(key.as_str()))
|
||||
.map(|(_, o)| o.handle)
|
||||
.take(MAX_HANDLES)
|
||||
.collect();
|
||||
let mut check = SelfCheck::new();
|
||||
let mut hs: Vec<Handle> = Vec::new();
|
||||
let mut capped = false;
|
||||
for (_, o) in scope.objects(d) {
|
||||
check.found(1);
|
||||
if o.kind.type_name() != ty {
|
||||
check.skip(o.kind.type_name(), "a different type", 1);
|
||||
continue;
|
||||
}
|
||||
check.analyzed(1);
|
||||
if shape_key(&o.kind).as_deref() == Some(key.as_str()) {
|
||||
if hs.len() < MAX_HANDLES {
|
||||
hs.push(o.handle);
|
||||
} else {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if capped {
|
||||
check.cap("handles", u64::try_from(MAX_HANDLES).unwrap_or(u64::MAX));
|
||||
}
|
||||
let n = hs.len();
|
||||
let message = format!("{ty}: {n} like the selected one in {}.", scope.label());
|
||||
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "message": message }))
|
||||
Ok(json!({ "object": ty, "count": n, "handles": hex(&hs), "selfCheck": check.to_json(), "message": message }))
|
||||
}
|
||||
|
||||
/// The body of COUNT and COUNTLIST: list the blocks in scope, count one block, or count the
|
||||
/// objects shaped like a picked one. `default_all` makes an omitted `space` mean every space
|
||||
/// (COUNTLIST) instead of the current one (COUNT).
|
||||
fn run(s: &mut Session, cmd: &str, p: &Value, default_all: bool) -> Result<Value> {
|
||||
let scope = Scope::parse(s, cmd, p, default_all)?;
|
||||
let nested = bool_or(p, "nested", true);
|
||||
@@ -336,6 +423,8 @@ fn table_style(s: &Session) -> cadcraft_doc::TableStyle {
|
||||
d.table_styles.iter().find(|t| t.name.eq_ignore_ascii_case(&cur)).or(d.table_styles.first()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// COUNTTABLE: insert a table of the block counts in scope at `at`, and report what the tally
|
||||
/// covered (and refused: no block references in scope is an error, not an empty table).
|
||||
pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "counttable";
|
||||
let at = point_req(CMD, p, "at")?;
|
||||
@@ -348,9 +437,15 @@ pub(super) fn run_counttable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let header = Some(vec!["Name".to_string(), "Count".to_string()]);
|
||||
let table = super::super::table::make_table(&table_style(s), at, cells.len(), 2, None, None, Some("Block Count"), header, &cells);
|
||||
let h = s.add_entity(EntityKind::Table(table))?;
|
||||
Ok(json!({ "handle": h.hex(), "rows": cells.len(), "message": format!("Table of {} block(s) inserted.", cells.len()) }))
|
||||
Ok(json!({
|
||||
"handle": h.hex(),
|
||||
"rows": cells.len(),
|
||||
"selfCheck": self_check(&t),
|
||||
"message": format!("Table of {} block(s) inserted.", cells.len()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Echo a result's `message` at the command line, one line at a time.
|
||||
fn echo_lines(s: &mut Session, r: &Value) {
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! LOOPS: implied closed loops from loose LINE and ARC chains — perimeter, area and winding for
|
||||
//! each closed loop, the gap and its location for each open chain, and branch junctions (three or
|
||||
//! more ends meeting) reported but not resolved. Read-only.
|
||||
//!
|
||||
//! Boundaries are often drawn as separate LINEs and ARCs rather than one polyline, so an agent
|
||||
//! cannot tell whether they form a closed loop or by how much a "closed" outline is open. The
|
||||
//! endpoint chaining is quadratic in the number of endpoints; past `maxPairs` it reports `capped`
|
||||
//! and analyzes nothing rather than guess from a partial pairing.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle};
|
||||
use cadcraft_geom::{Arc as GeoArc, EPS, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "loops";
|
||||
|
||||
/// Endpoint pairs compared before the quadratic chaining gives up (reported as `capped`).
|
||||
const DEFAULT_PAIR_LIMIT: usize = 1_000_000;
|
||||
/// The most pairs a caller may ask for: a bounded amount of work however large the drawing.
|
||||
const MAX_PAIR_LIMIT: usize = 100_000_000;
|
||||
/// Loops returned, and open chains and junctions kept, by default.
|
||||
const DEFAULT_LIMIT: usize = 200;
|
||||
/// The smallest default tolerance, for a drawing at the origin.
|
||||
const MIN_TOLERANCE: f64 = 1e-12;
|
||||
|
||||
pub const PARAMS: &str = "{tolerance?: positive number (default 1e-9 × the extents diagonal), maxPairs? (endpoint pairs compared, default 1000000, at most 100000000), limit? (default 200), offset?} → \
|
||||
{schema: 1, tolerance, pairTests, pairTestsNeeded, pairLimit, capped, count (all closed loops), returned, offset, truncated, \
|
||||
loops: [{members: [handle], perimeter, area, winding: ccw|cw}], \
|
||||
openChains: {count, truncated, chains: [{members, gap, at: [x, y], ends: [[x, y], [x, y]], length}]}, \
|
||||
junctions: {count, truncated, junctions: [{at: [x, y], ends, members}]}, skipped: {<type>: {count, reason}}}: \
|
||||
chains LINE and ARC endpoints that meet within the tolerance; junctions (three or more ends meeting) are reported, not resolved";
|
||||
|
||||
/// Why a LINE or ARC cannot take part in a chain.
|
||||
type Skipped = BTreeMap<&'static str, (usize, BTreeSet<&'static str>)>;
|
||||
|
||||
/// One chainable entity, oriented as drawn (a line a→b, an arc CCW start→end).
|
||||
struct Piece {
|
||||
handle: Handle,
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
/// The arc, when the piece is one.
|
||||
arc: Option<GeoArc>,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn at(&self, which: usize) -> Vec2 {
|
||||
if which == 0 { self.start } else { self.end }
|
||||
}
|
||||
fn length(&self) -> f64 {
|
||||
match self.arc {
|
||||
Some(a) => a.radius * a.sweep(),
|
||||
None => self.start.dist(self.end),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity endpoint in the chain graph. Each piece has one at index `2 * piece` and one at
|
||||
/// `2 * piece + 1`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct End {
|
||||
piece: usize,
|
||||
at: Vec2,
|
||||
}
|
||||
|
||||
/// A piece traversed one way round a chain.
|
||||
struct Traversed {
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
/// The arc and whether the traversal follows its CCW direction.
|
||||
arc: Option<(GeoArc, bool)>,
|
||||
}
|
||||
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let limit = count("limit", DEFAULT_LIMIT)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let pair_limit = count("maxPairs", DEFAULT_PAIR_LIMIT)?.min(MAX_PAIR_LIMIT);
|
||||
let tolerance = given("tolerance")
|
||||
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
|
||||
.transpose()?;
|
||||
let s: &Session = s;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let tol = tolerance.unwrap_or_else(|| {
|
||||
let ext = d.extents(&st.space);
|
||||
if ext.is_empty() {
|
||||
return MIN_TOLERANCE;
|
||||
}
|
||||
let diag = ext.min.dist(ext.max);
|
||||
if diag.is_finite() { (diag * 1e-9).max(MIN_TOLERANCE) } else { MIN_TOLERANCE }
|
||||
});
|
||||
let mut skipped: Skipped = BTreeMap::new();
|
||||
let Some(store) = d.space(&st.space) else {
|
||||
return Ok(empty_report(offset, tol, 0, 0, pair_limit, false, skipped));
|
||||
};
|
||||
let pieces: Vec<Piece> = store.iter().filter_map(|e| chainable(e.handle, &e.kind, &mut skipped)).collect();
|
||||
let mut ends: Vec<End> = Vec::with_capacity(pieces.len().saturating_mul(2));
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
ends.push(End { piece: i, at: piece.start });
|
||||
ends.push(End { piece: i, at: piece.end });
|
||||
}
|
||||
let n = ends.len();
|
||||
let needed = n.saturating_mul(n.saturating_sub(1)) / 2;
|
||||
if needed > pair_limit {
|
||||
return Ok(empty_report(offset, tol, 0, needed, pair_limit, true, skipped));
|
||||
}
|
||||
|
||||
// Endpoints closer than the tolerance meet. Transitive components of three or more ends are
|
||||
// branch junctions; a component of exactly two is a simple joint.
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
for i in 0..n {
|
||||
let Some(a) = ends.get(i) else { continue };
|
||||
for j in (i + 1)..n {
|
||||
let Some(b) = ends.get(j) else { continue };
|
||||
if a.at.dist(b.at) <= tol {
|
||||
let (ra, rb) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if ra != rb
|
||||
&& let Some(slot) = parent.get_mut(ra)
|
||||
{
|
||||
*slot = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
for i in 0..n {
|
||||
let root = find(&mut parent, i);
|
||||
groups.entry(root).or_default().push(i);
|
||||
}
|
||||
|
||||
// Junctions are reported and their pieces left out of the chains: not resolved.
|
||||
let mut excluded = vec![false; pieces.len()];
|
||||
let mut junctions: Vec<Value> = Vec::new();
|
||||
let mut joints: Vec<(usize, usize)> = Vec::new();
|
||||
for group in groups.values() {
|
||||
match group.as_slice() {
|
||||
&[a, b] => joints.push((a, b)),
|
||||
[_, _, ..] => {
|
||||
let scale = group.len() as f64;
|
||||
let mut at = Vec2::ZERO;
|
||||
let mut members: Vec<Handle> = Vec::new();
|
||||
for &e in group {
|
||||
let Some(end) = ends.get(e) else { continue };
|
||||
at += end.at / scale;
|
||||
let Some(piece) = pieces.get(end.piece) else { continue };
|
||||
if !members.contains(&piece.handle) {
|
||||
members.push(piece.handle);
|
||||
}
|
||||
if let Some(slot) = excluded.get_mut(end.piece) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
junctions.push(json!({
|
||||
"at": [at.x, at.y],
|
||||
"ends": group.len(),
|
||||
"members": members.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// A joint links the two ends for the walk. A joint into an excluded piece is not a link, and
|
||||
// a line whose own ends meet within a huge tolerance is a spur, not a zero-area loop.
|
||||
let mut link: Vec<Option<usize>> = vec![None; n];
|
||||
for (a, b) in joints {
|
||||
let (Some(ea), Some(eb)) = (ends.get(a), ends.get(b)) else { continue };
|
||||
if ea.piece == eb.piece && pieces.get(ea.piece).is_some_and(|p| p.arc.is_none()) {
|
||||
continue;
|
||||
}
|
||||
if excluded.get(ea.piece).copied().unwrap_or(true) || excluded.get(eb.piece).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = link.get_mut(a) {
|
||||
*slot = Some(b);
|
||||
}
|
||||
if let Some(slot) = link.get_mut(b) {
|
||||
*slot = Some(a);
|
||||
}
|
||||
}
|
||||
|
||||
let mut visited = vec![false; pieces.len()];
|
||||
let mut loops: Vec<Value> = Vec::new();
|
||||
let mut open: Vec<Value> = Vec::new();
|
||||
// Paths first: a free end marks a chain. What is left with both ends linked is a cycle.
|
||||
open_chains(&pieces, &excluded, &link, &mut visited, &mut open);
|
||||
closed_loops(&pieces, &excluded, &link, &mut visited, &mut loops);
|
||||
|
||||
let total = loops.len();
|
||||
let page: Vec<Value> = loops.into_iter().skip(offset).take(limit).collect();
|
||||
let returned = page.len();
|
||||
let truncated = returned < total.saturating_sub(offset);
|
||||
let open_total = open.len();
|
||||
let open_page: Vec<Value> = open.into_iter().take(limit).collect();
|
||||
let open_truncated = open_page.len() < open_total;
|
||||
let junction_total = junctions.len();
|
||||
let junction_page: Vec<Value> = junctions.into_iter().take(limit).collect();
|
||||
let junction_truncated = junction_page.len() < junction_total;
|
||||
Ok(json!({
|
||||
"schema": 1,
|
||||
"tolerance": tol,
|
||||
"pairTests": needed,
|
||||
"pairTestsNeeded": needed,
|
||||
"pairLimit": pair_limit,
|
||||
"capped": false,
|
||||
"count": total,
|
||||
"returned": returned,
|
||||
"offset": offset,
|
||||
"truncated": truncated,
|
||||
"loops": page,
|
||||
"openChains": { "count": open_total, "truncated": open_truncated, "chains": open_page },
|
||||
"junctions": { "count": junction_total, "truncated": junction_truncated, "junctions": junction_page },
|
||||
"skipped": skipped_json(skipped),
|
||||
}))
|
||||
}
|
||||
|
||||
/// A report with nothing analyzed: an empty drawing, or one past the pair cap.
|
||||
fn empty_report(offset: usize, tol: f64, pair_tests: usize, pair_tests_needed: usize, pair_limit: usize, capped: bool, skipped: Skipped) -> Value {
|
||||
json!({
|
||||
"schema": 1,
|
||||
"tolerance": tol,
|
||||
"pairTests": pair_tests,
|
||||
"pairTestsNeeded": pair_tests_needed,
|
||||
"pairLimit": pair_limit,
|
||||
"capped": capped,
|
||||
"count": 0,
|
||||
"returned": 0,
|
||||
"offset": offset,
|
||||
"truncated": false,
|
||||
"loops": [],
|
||||
"openChains": { "count": 0, "truncated": false, "chains": [] },
|
||||
"junctions": { "count": 0, "truncated": false, "junctions": [] },
|
||||
"skipped": skipped_json(skipped),
|
||||
})
|
||||
}
|
||||
|
||||
fn skipped_json(skipped: Skipped) -> Value {
|
||||
let m: BTreeMap<&str, Value> =
|
||||
skipped.into_iter().map(|(k, (n, why))| (k, json!({ "count": n, "reason": why.into_iter().collect::<Vec<_>>().join("; ") }))).collect();
|
||||
json!(m)
|
||||
}
|
||||
|
||||
fn skip(skipped: &mut Skipped, ty: &'static str, why: &'static str) {
|
||||
let entry = skipped.entry(ty).or_default();
|
||||
entry.0 += 1;
|
||||
entry.1.insert(why);
|
||||
}
|
||||
|
||||
/// The piece for a LINE or ARC, or `None` when the entity is not chainable.
|
||||
fn chainable(handle: Handle, kind: &EntityKind, skipped: &mut Skipped) -> Option<Piece> {
|
||||
match kind {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
if !a.is_finite() || !b.is_finite() || a.dist(b) <= EPS {
|
||||
skip(skipped, "Line", "zero length");
|
||||
return None;
|
||||
}
|
||||
Some(Piece { handle, start: a, end: b, arc: None })
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let (c, r) = (a.center.xy(), a.radius.abs());
|
||||
if !c.is_finite() || !a.radius.is_finite() || r <= EPS || !a.start.is_finite() || !a.end.is_finite() {
|
||||
skip(skipped, "Arc", "degenerate");
|
||||
return None;
|
||||
}
|
||||
let arc = GeoArc::new(c, r, a.start, a.end);
|
||||
let (start, end) = (arc.start_point(), arc.end_point());
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
skip(skipped, "Arc", "degenerate");
|
||||
return None;
|
||||
}
|
||||
Some(Piece { handle, start, end, arc: Some(arc) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
while let Some(&p) = parent.get(x) {
|
||||
if p == x {
|
||||
return x;
|
||||
}
|
||||
let gp = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parent.get_mut(x) {
|
||||
*slot = gp;
|
||||
}
|
||||
x = gp;
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Walk the cycles left after the open paths from a free end, and report their geometry.
|
||||
fn closed_loops(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
|
||||
for i in 0..pieces.len() {
|
||||
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Only a piece linked at both ends can be in a cycle.
|
||||
if link.get(i * 2).copied().flatten().is_none() || link.get(i * 2 + 1).copied().flatten().is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut members: Vec<usize> = Vec::new();
|
||||
let mut forward: Vec<bool> = Vec::new();
|
||||
let (mut cur, mut entry) = (i, 0usize);
|
||||
let mut closed = false;
|
||||
loop {
|
||||
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = visited.get_mut(cur) {
|
||||
*slot = true;
|
||||
}
|
||||
members.push(cur);
|
||||
forward.push(entry == 0);
|
||||
let exit = 1 - entry;
|
||||
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
|
||||
let (next, which) = (q / 2, q % 2);
|
||||
if next == i && which == 0 {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if visited.get(next).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
cur = next;
|
||||
entry = which;
|
||||
}
|
||||
if !closed || members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let segs = traversed(pieces, &members, &forward);
|
||||
let (perimeter, area, signed) = facts(&segs);
|
||||
out.push(json!({
|
||||
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
|
||||
"perimeter": perimeter,
|
||||
"area": area,
|
||||
"winding": if signed < 0.0 { "cw" } else { "ccw" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk paths from every free end: an open chain, with the gap between its two ends.
|
||||
fn open_chains(pieces: &[Piece], excluded: &[bool], link: &[Option<usize>], visited: &mut [bool], out: &mut Vec<Value>) {
|
||||
for i in 0..pieces.len() {
|
||||
if visited.get(i).copied().unwrap_or(true) || excluded.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(entry0) = [0usize, 1].into_iter().find(|w| link.get(i * 2 + w).copied().flatten().is_none()) else { continue };
|
||||
let mut members: Vec<usize> = Vec::new();
|
||||
let mut forward: Vec<bool> = Vec::new();
|
||||
let (mut cur, mut entry) = (i, entry0);
|
||||
let mut exit = entry0;
|
||||
loop {
|
||||
if visited.get(cur).copied().unwrap_or(true) || members.len() >= pieces.len() {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = visited.get_mut(cur) {
|
||||
*slot = true;
|
||||
}
|
||||
members.push(cur);
|
||||
forward.push(entry == 0);
|
||||
exit = 1 - entry;
|
||||
let Some(q) = link.get(cur * 2 + exit).copied().flatten() else { break };
|
||||
let (next, which) = (q / 2, q % 2);
|
||||
if visited.get(next).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
cur = next;
|
||||
entry = which;
|
||||
}
|
||||
let (Some(first_piece), Some(last_piece)) = (pieces.get(i), members.last().and_then(|&m| pieces.get(m))) else { continue };
|
||||
let (first, last) = (first_piece.at(entry0), last_piece.at(exit));
|
||||
let gap = first.dist(last);
|
||||
let at = first * 0.5 + last * 0.5;
|
||||
let length: f64 = members.iter().filter_map(|&m| pieces.get(m)).map(Piece::length).sum();
|
||||
out.push(json!({
|
||||
"members": members.iter().filter_map(|&m| pieces.get(m)).map(|p| p.handle.hex()).collect::<Vec<_>>(),
|
||||
"gap": gap,
|
||||
"at": [at.x, at.y],
|
||||
"ends": [[first.x, first.y], [last.x, last.y]],
|
||||
"length": length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn traversed(pieces: &[Piece], members: &[usize], forward: &[bool]) -> Vec<Traversed> {
|
||||
members
|
||||
.iter()
|
||||
.zip(forward)
|
||||
.filter_map(|(&m, &fwd)| {
|
||||
let p = pieces.get(m)?;
|
||||
Some(Traversed { start: if fwd { p.start } else { p.end }, end: if fwd { p.end } else { p.start }, arc: p.arc.map(|a| (a, fwd)) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Perimeter, unsigned area and signed area (CCW positive, as SHAPES reports winding) of a closed
|
||||
/// chain of lines and arcs. The closing chord between the last end and the first is the chain's gap
|
||||
/// and counts in the perimeter, as an open polyline with matching ends does in SHAPES.
|
||||
fn facts(segs: &[Traversed]) -> (f64, f64, f64) {
|
||||
let Some(first) = segs.first() else { return (0.0, 0.0, 0.0) };
|
||||
let perimeter: f64 = segs.iter().map(seg_len).sum::<f64>() + segs.last().map_or(0.0, |last| last.end.dist(first.start));
|
||||
let signed = signed_area(segs, first.start);
|
||||
(perimeter, signed.abs(), signed)
|
||||
}
|
||||
|
||||
fn seg_len(t: &Traversed) -> f64 {
|
||||
match t.arc {
|
||||
Some((a, _)) => a.radius * a.sweep(),
|
||||
None => t.start.dist(t.end),
|
||||
}
|
||||
}
|
||||
|
||||
/// Green's theorem over the closed path, measured about its first point so a small loop far from
|
||||
/// the origin keeps its area. An arc contributes its exact circular integral, not a tessellation.
|
||||
fn signed_area(segs: &[Traversed], origin: Vec2) -> f64 {
|
||||
let mut twice = 0.0;
|
||||
for t in segs {
|
||||
match t.arc {
|
||||
None => twice += (t.start - origin).cross(t.end - origin),
|
||||
Some((a, forward)) => {
|
||||
let sweep = a.sweep();
|
||||
let (from, to, d) = if forward { (a.start, a.start + sweep, sweep) } else { (a.end, a.end - sweep, -sweep) };
|
||||
let c = a.center - origin;
|
||||
twice += a.radius * a.radius * d + a.radius * (c.x * (to.sin() - from.sin()) - c.y * (to.cos() - from.cos()));
|
||||
}
|
||||
}
|
||||
}
|
||||
twice / 2.0
|
||||
}
|
||||
@@ -12,8 +12,10 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
|
||||
use super::super::*;
|
||||
use super::SelfCheck;
|
||||
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// The region commands: UNION, SUBTRACT and INTERSECT combine regions, MASSPROP measures them.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("union", "Union", run_union)
|
||||
@@ -33,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))),
|
||||
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
|
||||
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
|
||||
.params("{handles?} (regions) → properties of each")
|
||||
.params("{handles?} (regions) → properties of each, plus `selfCheck` (found/analyzed/skipped with reasons, caps, implausible values)")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))),
|
||||
]
|
||||
@@ -211,13 +213,16 @@ impl Interactive for SubtractM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SUBTRACT"
|
||||
}
|
||||
/// Tell the user what to select first: the regions to subtract from.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo("Select regions to subtract from ..");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Prompt for the current selection phase (regions to subtract from, then regions to remove).
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
self.sel.prompt()
|
||||
}
|
||||
/// The first selection is the regions to subtract from; the second is subtracted from them.
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
@@ -243,18 +248,38 @@ impl Interactive for SubtractM {
|
||||
}
|
||||
}
|
||||
|
||||
/// MASSPROP: area, perimeter, bounding box, centroid, moments of inertia and principal directions
|
||||
/// for each selected region, plus a self-check of what was measured, skipped and found
|
||||
/// implausible.
|
||||
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let rs = regions(s, &hs)?;
|
||||
if rs.is_empty() {
|
||||
return Err(other("Select one or more regions."));
|
||||
}
|
||||
let mut check = SelfCheck::new();
|
||||
let d = s.doc()?;
|
||||
let extents = d.extents(&s.space());
|
||||
// No visible extents means no bound to compare an area against (`implausible` skips an
|
||||
// infinite bound).
|
||||
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
|
||||
for h in &hs {
|
||||
check.found(1);
|
||||
match d.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Region(_)) => check.analyzed(1),
|
||||
Some(kind) => check.skip(kind.type_name(), "not a region", 1),
|
||||
None => check.skip("Missing", "no such object", 1),
|
||||
}
|
||||
}
|
||||
let f = |v: f64| super::fmt_d(s, v);
|
||||
let mut out = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for (h, r) in &rs {
|
||||
let m = r.props();
|
||||
let c = m.centroid;
|
||||
let diagonal = if m.bounds.is_empty() { 0.0 } else { m.bounds.min.dist(m.bounds.max) };
|
||||
check.implausible(*h, "Region", "perimeter", m.perimeter, diagonal * 10.0, "perimeter is more than 10 x the bounding-box diagonal");
|
||||
check.implausible(*h, "Region", "area", m.area, extents_area, "area is larger than the drawing extents");
|
||||
// Moments about the centroid, and the principal moments and directions.
|
||||
let [ixc, iyc, ixyc] = m.centroid_moments;
|
||||
let mid = (ixc + iyc) / 2.0;
|
||||
@@ -292,5 +317,5 @@ fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ang.cos(),
|
||||
));
|
||||
}
|
||||
Ok(json!({ "regions": out, "message": lines.join("\n") }))
|
||||
Ok(json!({ "regions": out, "selfCheck": check.to_json(), "message": lines.join("\n") }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! SHAPES: geometry facts for every closed shape in the current space — perimeter, area, centroid,
|
||||
//! winding, the first self-intersection and coincident vertices — for agents that read a drawing.
|
||||
//! Read-only.
|
||||
|
||||
use cadcraft_doc::{Entity, EntityKind};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, intersect_segments};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::{SelfCheck, area_of, object_area};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "shapes";
|
||||
|
||||
pub const PARAMS: &str = "{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit? (default 200), offset?, tolerance?: positive number (default 1e-9 × the extents diagonal; near-duplicate vertices and a first vertex equal to the last)} → {schema: 1, count (all closed shapes), returned, offset, truncated, shapes: [{handle, type, layer, perimeter, area (≥ 0), winding: ccw|cw, centroid: [x,y] | null (zero area), selfIntersection: [x,y] | null (the first crossing of two non-adjacent edges; null past 4000 edges), duplicateVertices: [{index, distance} (vertices index and index+1, wrapping)]}], skipped: {<type>: {count, reason}} (the other matches), selfCheck: {schema, found, analyzed, skipped: {<type>: {count, reason}}, caps: {<name>: <value>}, implausible: [{handle, type, field, value, bound, reason}]} (what the analysis covered, the caps it hit, and values that point at a bug)}: closed polylines, circles, full ellipses and closed splines, arcs and bulges exact";
|
||||
|
||||
/// The facts SHAPES reports for one closed shape, or why an entity is not one.
|
||||
enum Facts {
|
||||
Shape {
|
||||
perimeter: f64,
|
||||
area: f64,
|
||||
signed: f64,
|
||||
centroid: Option<Vec2>,
|
||||
cross: Option<Vec2>,
|
||||
/// The shape had more edges than the self-intersection cap checks.
|
||||
cross_capped: bool,
|
||||
dups: Vec<(usize, f64)>,
|
||||
bounds: Bounds2,
|
||||
},
|
||||
Skip(&'static str),
|
||||
}
|
||||
|
||||
/// SHAPES: geometry facts for every closed shape that matches the filters in the current space,
|
||||
/// paged by `offset`/`limit`, with the self-check of what was found, analysed, skipped and
|
||||
/// flagged.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
given(k).map(|v| v.as_str().map(str::to_ascii_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let ty = text("type")?;
|
||||
let layer = text("layer")?;
|
||||
let window = given("window")
|
||||
.map(|w| {
|
||||
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
|
||||
match corners.as_deref() {
|
||||
Some([a, b]) => Ok(Bounds2::new(*a, *b)),
|
||||
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
|
||||
}
|
||||
})
|
||||
.transpose()?;
|
||||
let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?;
|
||||
let limit = count("limit", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let tolerance = given("tolerance")
|
||||
.map(|v| v.as_f64().filter(|t| t.is_finite() && *t > 0.0).ok_or_else(|| bad(CMD, "`tolerance` must be a positive number")))
|
||||
.transpose()?;
|
||||
let s: &Session = s;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let empty = || {
|
||||
json!({
|
||||
"schema": 1,
|
||||
"count": 0,
|
||||
"returned": 0,
|
||||
"offset": offset,
|
||||
"truncated": false,
|
||||
"shapes": [],
|
||||
"skipped": {},
|
||||
"selfCheck": SelfCheck::new().to_json(),
|
||||
})
|
||||
};
|
||||
let Some(store) = d.space(&st.space) else { return Ok(empty()) };
|
||||
let extents = d.extents(&st.space);
|
||||
// No visible extents means no bound to compare an area against (`implausible` skips an
|
||||
// infinite bound).
|
||||
let extents_area = if extents.is_empty() { f64::INFINITY } else { extents.width() * extents.height() };
|
||||
let tol = tolerance.unwrap_or_else(|| {
|
||||
let diag = if extents.is_empty() { 0.0 } else { extents.min.dist(extents.max) };
|
||||
(diag * 1e-9).max(1e-12)
|
||||
});
|
||||
let wtol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0);
|
||||
let mut check = SelfCheck::new();
|
||||
let mut shapes: Vec<(&Entity, Facts)> = Vec::new();
|
||||
for e in store
|
||||
.iter()
|
||||
.filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t))
|
||||
.filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l))
|
||||
.filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, wtol)))
|
||||
{
|
||||
check.found(1);
|
||||
match facts(s, e, tol)? {
|
||||
Facts::Skip(why) => check.skip(e.kind.type_name(), why, 1),
|
||||
f => {
|
||||
check.analyzed(1);
|
||||
if let Facts::Shape { perimeter, area, cross_capped, bounds, .. } = &f {
|
||||
if *cross_capped {
|
||||
check.cap("crossingSegments", MAX_CROSSING_SEGMENTS as u64);
|
||||
}
|
||||
let diagonal = if bounds.is_empty() { 0.0 } else { bounds.min.dist(bounds.max) };
|
||||
check.implausible(
|
||||
e.handle,
|
||||
e.kind.type_name(),
|
||||
"perimeter",
|
||||
*perimeter,
|
||||
diagonal * 10.0,
|
||||
"perimeter is more than 10 x the bounding-box diagonal",
|
||||
);
|
||||
check.implausible(e.handle, e.kind.type_name(), "area", *area, extents_area, "area is larger than the drawing extents");
|
||||
}
|
||||
shapes.push((e, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
let page: Vec<Value> = shapes.iter().skip(offset).take(limit).map(|(e, f)| shape_json(e, f)).collect();
|
||||
let truncated = page.len() < shapes.len().saturating_sub(offset);
|
||||
if truncated {
|
||||
check.cap("limit", u64::try_from(limit).unwrap_or(u64::MAX));
|
||||
}
|
||||
let skipped = check.skipped_json();
|
||||
Ok(json!({
|
||||
"schema": 1,
|
||||
"count": shapes.len(),
|
||||
"returned": page.len(),
|
||||
"offset": offset,
|
||||
"truncated": truncated,
|
||||
"shapes": page,
|
||||
"skipped": skipped,
|
||||
"selfCheck": check.to_json(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One analysed shape as the JSON object the command reports (null if `f` is not a shape).
|
||||
fn shape_json(e: &Entity, f: &Facts) -> Value {
|
||||
let Facts::Shape { perimeter, area, signed, centroid, cross, dups, .. } = f else { return Value::Null };
|
||||
json!({
|
||||
"handle": e.handle.hex(),
|
||||
"type": e.kind.type_name(),
|
||||
"layer": e.common.layer,
|
||||
"perimeter": perimeter,
|
||||
"area": area,
|
||||
"winding": if *signed < 0.0 { "cw" } else { "ccw" },
|
||||
"centroid": centroid.map(|c| [c.x, c.y]),
|
||||
"selfIntersection": cross.map(|c| [c.x, c.y]),
|
||||
"duplicateVertices": dups.iter().map(|(i, dist)| json!({ "index": i, "distance": dist })).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The facts SHAPES reports for one entity, or the reason it is not a closed shape: circles,
|
||||
/// full ellipses, closed or self-closing polylines and splines are measured, arcs and everything
|
||||
/// else are skipped.
|
||||
fn facts(s: &Session, e: &Entity, tol: f64) -> Result<Facts> {
|
||||
Ok(match &e.kind {
|
||||
EntityKind::Circle(c) => {
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
let r = Vec2::new(c.radius, c.radius);
|
||||
Facts::Shape {
|
||||
perimeter,
|
||||
area,
|
||||
signed: area,
|
||||
centroid: Some(c.center.xy()),
|
||||
cross: None,
|
||||
cross_capped: false,
|
||||
dups: Vec::new(),
|
||||
bounds: Bounds2::new(c.center.xy() - r, c.center.xy() + r),
|
||||
}
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let geom = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
if !geom.is_full() {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
let (area, perimeter) = object_area(s, e.handle)?;
|
||||
Facts::Shape {
|
||||
perimeter,
|
||||
area,
|
||||
signed: area,
|
||||
centroid: Some(geom.center),
|
||||
cross: None,
|
||||
cross_capped: false,
|
||||
dups: Vec::new(),
|
||||
bounds: geom.bounds(),
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
let mut vertices = pl.vertices.clone();
|
||||
let ends_meet = match (vertices.first(), vertices.last()) {
|
||||
(Some(a), Some(b)) => vertices.len() > 1 && a.p.dist(b.p) <= tol,
|
||||
_ => false,
|
||||
};
|
||||
if !pl.closed {
|
||||
if !ends_meet {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
// The last vertex repeats the first: the shape closes there.
|
||||
vertices.pop();
|
||||
}
|
||||
if vertices.len() < 2 {
|
||||
return Ok(Facts::Skip("fewer than two vertices"));
|
||||
}
|
||||
let g = Polyline { vertices, closed: true };
|
||||
let signed = g.area();
|
||||
let n = g.vertices.len();
|
||||
let dups = (0..n)
|
||||
.filter_map(|i| {
|
||||
let dist = g.vertices.get(i)?.p.dist(g.vertices.get((i + 1) % n)?.p);
|
||||
(dist <= tol).then_some((i, dist))
|
||||
})
|
||||
.collect();
|
||||
let (cross, cross_capped) = first_crossing(&g);
|
||||
Facts::Shape { perimeter: g.len(), area: signed.abs(), signed, centroid: g.centroid(), cross, cross_capped, dups, bounds: g.bounds() }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let mut pts = sp.tessellate(1e-3);
|
||||
let ends_meet = match (pts.first(), pts.last()) {
|
||||
(Some(a), Some(b)) => pts.len() > 2 && a.dist(*b) <= tol.max(1e-9),
|
||||
_ => false,
|
||||
};
|
||||
if !sp.closed && !ends_meet {
|
||||
return Ok(Facts::Skip("open"));
|
||||
}
|
||||
if ends_meet {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 {
|
||||
return Ok(Facts::Skip("degenerate"));
|
||||
}
|
||||
let bounds = Bounds2::from_points(pts.iter().copied());
|
||||
let (area, perimeter) = area_of(&pts);
|
||||
let g = Polyline::from_points(&pts, true);
|
||||
let (cross, cross_capped) = first_crossing(&g);
|
||||
Facts::Shape { perimeter, area, signed: g.area(), centroid: g.centroid(), cross, cross_capped, dups: Vec::new(), bounds }
|
||||
}
|
||||
EntityKind::Arc(_) => Facts::Skip("open"),
|
||||
_ => Facts::Skip("not a closed shape"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shapes with more edges than this report no selfIntersection.
|
||||
const MAX_CROSSING_SEGMENTS: usize = 4_000;
|
||||
|
||||
/// The first crossing between two non-adjacent edges, in edge order, and whether the shape was
|
||||
/// too large to check (the cap applies even if a crossing lies among the first edges).
|
||||
fn first_crossing(g: &Polyline) -> (Option<Vec2>, bool) {
|
||||
let segs = g.segments();
|
||||
let n = segs.len();
|
||||
// The pairwise test is quadratic: past this many edges the answer is null, not a hang.
|
||||
if n > MAX_CROSSING_SEGMENTS {
|
||||
return (None, true);
|
||||
}
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for (j, b) in segs.iter().enumerate().skip(i + 2) {
|
||||
if i == 0 && j + 1 == n {
|
||||
continue;
|
||||
}
|
||||
if let Some(x) = intersect_segments(a, b).first() {
|
||||
return (Some(*x), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, false)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! USAGE: a read-only cross-reference of the named objects a drawing uses — for each layer,
|
||||
//! block, text style and linetype, how many objects use it, whether they are only inside block
|
||||
//! definitions, and which are empty or unused. The unused lists are PURGE's scan (see
|
||||
//! `cmd::blocks::purge`); nothing here changes or deletes anything.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::blocks::purge;
|
||||
use super::super::*;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "usage";
|
||||
/// Names listed per kind in `unused`; the counts are complete unless the scan was cut short
|
||||
/// (`incomplete`).
|
||||
const MAX_NAMES: usize = 5_000;
|
||||
/// The most objects one scan reads, across model space, the layouts and the block definitions. A
|
||||
/// drawing with more is reported as `incomplete` instead of with counts that look complete. This
|
||||
/// is USAGE's own limit; PURGE's scan limit is separate and unchanged.
|
||||
const MAX_SCAN: usize = 5_000_000;
|
||||
|
||||
pub const PARAMS: &str = "{type?: layers|blocks|textstyles|linetypes|all (default all), limit? (default 200), offset?} → \
|
||||
{schema: 1, count (all items of the type), returned, offset, truncated, incomplete (true when the drawing has more \
|
||||
objects than one scan reads, so every count, status and summary is partial), incompleteReason? (why), \
|
||||
items: [{type: layer|block|textStyle|linetype, name, entities (objects in model space and layouts that use it), \
|
||||
blockEntities (objects in block definitions that use it), references (named objects that use it: a layer's linetype, \
|
||||
a text style's dimension and multileader styles), used (for a block: reachable from model space, a layout or a dimension), \
|
||||
insideBlocksOnly (used, but by block definitions only), empty (nothing refers to it), unused (PURGE would offer it), \
|
||||
current? (the current layer, text style or linetype)}], \
|
||||
summary: {<kind>: {total, used, insideBlocksOnly, empty, unused}}, \
|
||||
unused: {<kind>: {count, returned, truncated, names}} (PURGE's scan, in table order)}";
|
||||
|
||||
/// The four kinds of named object USAGE reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Category {
|
||||
Layer,
|
||||
Block,
|
||||
TextStyle,
|
||||
Linetype,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
const ALL: [Category; 4] = [Category::Layer, Category::Block, Category::TextStyle, Category::Linetype];
|
||||
|
||||
/// The singular JSON `type` value of this kind (`layer`, `block`, `textStyle`, `linetype`).
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "layer",
|
||||
Category::Block => "block",
|
||||
Category::TextStyle => "textStyle",
|
||||
Category::Linetype => "linetype",
|
||||
}
|
||||
}
|
||||
/// The JSON key under which this kind's objects are grouped (`layers`, `blocks`,
|
||||
/// `textStyles`, `linetypes`).
|
||||
fn plural(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "layers",
|
||||
Category::Block => "blocks",
|
||||
Category::TextStyle => "textStyles",
|
||||
Category::Linetype => "linetypes",
|
||||
}
|
||||
}
|
||||
/// The capitalised plural noun used in the human-readable message.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Category::Layer => "Layers",
|
||||
Category::Block => "Blocks",
|
||||
Category::TextStyle => "Text styles",
|
||||
Category::Linetype => "Linetypes",
|
||||
}
|
||||
}
|
||||
/// The PURGE kind whose scan answers whether this kind's objects are unused.
|
||||
fn purge(self) -> purge::Kind {
|
||||
match self {
|
||||
Category::Layer => purge::Kind::Layers,
|
||||
Category::Block => purge::Kind::Blocks,
|
||||
Category::TextStyle => purge::Kind::TextStyles,
|
||||
Category::Linetype => purge::Kind::Linetypes,
|
||||
}
|
||||
}
|
||||
/// `None` for an unknown name, `Some(None)` for all kinds.
|
||||
fn parse(s: &str) -> Option<Option<Category>> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "all" => Some(None),
|
||||
"layer" | "layers" => Some(Some(Category::Layer)),
|
||||
"block" | "blocks" => Some(Some(Category::Block)),
|
||||
"style" | "styles" | "textstyle" | "textstyles" | "text_style" | "text_styles" => Some(Some(Category::TextStyle)),
|
||||
"linetype" | "linetypes" | "ltype" | "ltypes" => Some(Some(Category::Linetype)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects in model space and the layouts (`false`), and in block definitions (`true`), up to
|
||||
/// `limit` — the USAGE scan limit, past which later objects are not read.
|
||||
fn each_entity(d: &Drawing, limit: usize) -> impl Iterator<Item = (&Entity, bool)> {
|
||||
let top = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).map(|e| (&**e, false));
|
||||
let blocks = d.blocks.values().flat_map(|b| b.entities.iter()).map(|e| (&**e, true));
|
||||
top.chain(blocks).take(limit)
|
||||
}
|
||||
|
||||
/// Objects in model space, the layouts and the block definitions — everything one scan reads.
|
||||
fn entity_total(d: &Drawing) -> usize {
|
||||
d.blocks.values().fold(d.entity_count(), |n, b| n.saturating_add(b.entities.len()))
|
||||
}
|
||||
|
||||
/// A scan that stopped at its limit: the drawing holds `total` objects but only the first
|
||||
/// `scanned` were read, so objects after the limit are missing from the counts, verdicts and
|
||||
/// summary. Present in the report as `incomplete`/`incompleteReason`, never silently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Incomplete {
|
||||
scanned: usize,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Incomplete {
|
||||
/// Why the report is partial, naming both counts so a caller can judge it.
|
||||
fn reason(self) -> String {
|
||||
format!(
|
||||
"USAGE read the first {} of {} objects (model space, layouts and block definitions); \
|
||||
references after the limit are missing, so counts, statuses and summaries are partial",
|
||||
self.scanned, self.total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses per lower-case name: (in model space and layouts, in block definitions).
|
||||
type Uses = HashMap<String, (usize, usize)>;
|
||||
|
||||
/// Counts one use of a named object, separately for model space and the layouts (`in_block`
|
||||
/// false) and for block definitions (`in_block` true); names compare case-insensitively.
|
||||
fn bump(map: &mut Uses, name: &str, in_block: bool) {
|
||||
let slot = map.entry(name.to_ascii_lowercase()).or_default();
|
||||
if in_block {
|
||||
slot.1 = slot.1.saturating_add(1);
|
||||
} else {
|
||||
slot.0 = slot.0.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts one named table entry that refers to `name`: a layer's linetype, or a dimension or
|
||||
/// multileader style's text style. Names compare case-insensitively.
|
||||
fn bump_reference(map: &mut HashMap<String, usize>, name: &str) {
|
||||
let slot = map.entry(name.to_ascii_lowercase()).or_default();
|
||||
*slot = slot.saturating_add(1);
|
||||
}
|
||||
|
||||
/// `ByLayer` and `ByBlock` name a value, not the linetype table's entries.
|
||||
fn linetype_value(name: &str) -> bool {
|
||||
name.eq_ignore_ascii_case("ByLayer") || name.eq_ignore_ascii_case("ByBlock")
|
||||
}
|
||||
|
||||
/// Every use of the drawing's layers, blocks, text styles and linetypes, in one pass over the
|
||||
/// objects (plus the block reachability PURGE already computes).
|
||||
#[derive(Default)]
|
||||
struct Scan {
|
||||
layers: Uses,
|
||||
blocks: Uses,
|
||||
styles: Uses,
|
||||
linetypes: Uses,
|
||||
/// Dimension and multileader styles that name a text style.
|
||||
style_refs: HashMap<String, usize>,
|
||||
/// Layers that name a linetype.
|
||||
linetype_refs: HashMap<String, usize>,
|
||||
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
|
||||
used_blocks: HashSet<String>,
|
||||
/// Set when the scan stopped at its limit, so every count and verdict is partial.
|
||||
incomplete: Option<Incomplete>,
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
/// Scans the drawing once, reading at most `limit` objects: counts each entity's layer,
|
||||
/// explicit linetype, block, text style and attribute style, plus the table references, and
|
||||
/// borrows block reachability from PURGE. Records whether the limit cut the drawing short.
|
||||
fn new(d: &Drawing, limit: usize) -> Scan {
|
||||
let total = entity_total(d);
|
||||
let scanned = total.min(limit);
|
||||
let mut sc = Scan { used_blocks: purge::used_blocks(d), ..Scan::default() };
|
||||
sc.incomplete = (scanned < total).then_some(Incomplete { scanned, total });
|
||||
for (e, in_block) in each_entity(d, scanned) {
|
||||
bump(&mut sc.layers, &e.common.layer, in_block);
|
||||
if !linetype_value(&e.common.linetype) {
|
||||
bump(&mut sc.linetypes, &e.common.linetype, in_block);
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
bump(&mut sc.blocks, &i.block, in_block);
|
||||
for a in &i.attribs {
|
||||
bump(&mut sc.styles, &a.text.style, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
if let Some(b) = &dm.block {
|
||||
bump(&mut sc.blocks, b, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(b) = &m.block {
|
||||
bump(&mut sc.blocks, &b.block, in_block);
|
||||
}
|
||||
if let Some(t) = &m.text {
|
||||
bump(&mut sc.styles, &t.style, in_block);
|
||||
}
|
||||
}
|
||||
EntityKind::Text(t) => bump(&mut sc.styles, &t.style, in_block),
|
||||
EntityKind::MText(t) => bump(&mut sc.styles, &t.style, in_block),
|
||||
EntityKind::AttDef(a) => bump(&mut sc.styles, &a.text.style, in_block),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for st in &d.dim_styles {
|
||||
bump_reference(&mut sc.style_refs, &st.text_style);
|
||||
}
|
||||
for st in &d.mleader_styles {
|
||||
bump_reference(&mut sc.style_refs, &st.text_style);
|
||||
}
|
||||
for l in &d.layers {
|
||||
if !linetype_value(&l.linetype) {
|
||||
bump_reference(&mut sc.linetype_refs, &l.linetype);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
}
|
||||
|
||||
/// PURGE's unused lists, kept once so each item can say whether PURGE would offer it.
|
||||
struct Unused {
|
||||
lists: Vec<(Category, Vec<String>, HashSet<String>)>,
|
||||
}
|
||||
|
||||
impl Unused {
|
||||
/// Runs PURGE's unused scan for all four kinds, keeping each list in table order plus a
|
||||
/// lower-case set for fast membership checks.
|
||||
fn new(d: &Drawing) -> Unused {
|
||||
let lists = Category::ALL
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let names = purge::candidates(d, c.purge());
|
||||
let lower = names.iter().map(|n| n.to_ascii_lowercase()).collect();
|
||||
(c, names, lower)
|
||||
})
|
||||
.collect();
|
||||
Unused { lists }
|
||||
}
|
||||
/// Whether PURGE would offer `name` of kind `c` as unused (case-insensitive).
|
||||
fn has(&self, c: Category, name: &str) -> bool {
|
||||
self.lists.iter().find(|(k, ..)| *k == c).is_some_and(|(_, _, lower)| lower.contains(&name.to_ascii_lowercase()))
|
||||
}
|
||||
/// How many objects of kind `c` PURGE would offer — the full count, even when the name list
|
||||
/// in the JSON output is truncated.
|
||||
fn count(&self, c: Category) -> usize {
|
||||
self.lists.iter().find(|(k, ..)| *k == c).map_or(0, |(_, names, _)| names.len())
|
||||
}
|
||||
/// The `unused` member: per kind, the total count, the returned page of names capped at
|
||||
/// `MAX_NAMES`, and whether that page is truncated.
|
||||
fn json(&self) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (c, names, _) in &self.lists {
|
||||
let page: Vec<&String> = names.iter().take(MAX_NAMES).collect();
|
||||
out.insert(
|
||||
c.plural().into(),
|
||||
json!({
|
||||
"count": names.len(),
|
||||
"returned": page.len(),
|
||||
"truncated": page.len() < names.len(),
|
||||
"names": page,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One report item: the use counts, the caller's `used`/`empty` verdicts, `insideBlocksOnly`
|
||||
/// derived from those counts and PURGE's `unused` answer. `current` is emitted only for the kinds
|
||||
/// that have a current entry (layers, text styles and linetypes).
|
||||
fn item_json(
|
||||
c: Category,
|
||||
name: &str,
|
||||
entities: usize,
|
||||
block_entities: usize,
|
||||
references: usize,
|
||||
used: bool,
|
||||
empty: bool,
|
||||
current: Option<bool>,
|
||||
unused: &Unused,
|
||||
) -> Value {
|
||||
let mut v = json!({
|
||||
"type": c.key(),
|
||||
"name": name,
|
||||
"entities": entities,
|
||||
"blockEntities": block_entities,
|
||||
"references": references,
|
||||
"used": used,
|
||||
"insideBlocksOnly": used && entities == 0 && block_entities > 0 && references == 0,
|
||||
"empty": empty,
|
||||
"unused": unused.has(c, name),
|
||||
});
|
||||
if let (Some(cur), Some(o)) = (current, v.as_object_mut()) {
|
||||
o.insert("current".into(), json!(cur));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Builds one item per named object in table order — layers, blocks, text styles, linetypes —
|
||||
/// reading the scanned use counts and adding the current-object flag where the drawing has one.
|
||||
fn items(scan: &Scan, unused: &Unused, d: &Drawing) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let current_layer = d.header.str("CLAYER", "0");
|
||||
for l in &d.layers {
|
||||
let (e, b) = scan.layers.get(&l.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let uses = e.saturating_add(b);
|
||||
let cur = l.name.eq_ignore_ascii_case(¤t_layer);
|
||||
out.push(item_json(Category::Layer, &l.name, e, b, 0, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
for b in d.blocks.values() {
|
||||
let (e, n) = scan.blocks.get(&b.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
// A definition is used when a reference to it is reachable, as PURGE counts it.
|
||||
let used = scan.used_blocks.contains(&b.name.to_ascii_uppercase());
|
||||
out.push(item_json(Category::Block, &b.name, e, n, 0, used, e.saturating_add(n) == 0, None, unused));
|
||||
}
|
||||
let current_style = d.header.str("TEXTSTYLE", "Standard");
|
||||
for t in &d.text_styles {
|
||||
let (e, b) = scan.styles.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let r = scan.style_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_style);
|
||||
out.push(item_json(Category::TextStyle, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
let current_linetype = d.header.str("CELTYPE", "ByLayer");
|
||||
for t in &d.linetypes {
|
||||
let (e, b) = scan.linetypes.get(&t.name.to_ascii_lowercase()).copied().unwrap_or_default();
|
||||
let r = scan.linetype_refs.get(&t.name.to_ascii_lowercase()).copied().unwrap_or(0);
|
||||
let uses = e.saturating_add(b).saturating_add(r);
|
||||
let cur = t.name.eq_ignore_ascii_case(¤t_linetype);
|
||||
out.push(item_json(Category::Linetype, &t.name, e, b, r, uses > 0, uses == 0, Some(cur), unused));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Totals per kind over every item — total, used, inside blocks only, empty — plus how many
|
||||
/// objects of that kind PURGE would offer.
|
||||
fn summary(items: &[Value], unused: &Unused) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for c in Category::ALL {
|
||||
let mut total = 0usize;
|
||||
let mut used = 0usize;
|
||||
let mut inside = 0usize;
|
||||
let mut empty = 0usize;
|
||||
for it in items.iter().filter(|i| i["type"] == c.key()) {
|
||||
total = total.saturating_add(1);
|
||||
used = used.saturating_add(usize::from(it["used"].as_bool() == Some(true)));
|
||||
inside = inside.saturating_add(usize::from(it["insideBlocksOnly"].as_bool() == Some(true)));
|
||||
empty = empty.saturating_add(usize::from(it["empty"].as_bool() == Some(true)));
|
||||
}
|
||||
out.insert(c.plural().into(), json!({ "total": total, "used": used, "insideBlocksOnly": inside, "empty": empty, "unused": unused.count(c) }));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The human-readable summary, one line per kind, plus a note when the filter matched no named
|
||||
/// objects at all and a note when the scan was cut short.
|
||||
fn message(items: &[&Value], summary: &Value, incomplete: Option<Incomplete>) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for c in Category::ALL {
|
||||
let s = &summary[c.plural()];
|
||||
lines.push(format!(
|
||||
"{}: {} total, {} used, {} inside blocks only, {} empty, {} unused.",
|
||||
c.label(),
|
||||
s["total"].as_u64().unwrap_or(0),
|
||||
s["used"].as_u64().unwrap_or(0),
|
||||
s["insideBlocksOnly"].as_u64().unwrap_or(0),
|
||||
s["empty"].as_u64().unwrap_or(0),
|
||||
s["unused"].as_u64().unwrap_or(0),
|
||||
));
|
||||
}
|
||||
if items.is_empty() {
|
||||
lines.push("There are no named objects of this type.".into());
|
||||
}
|
||||
if let Some(inc) = incomplete {
|
||||
lines.push(format!("Scan incomplete: read the first {} of {} objects; the counts above are partial.", inc.scanned, inc.total));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Runs `USAGE`: validates `type`, `limit` and `offset` (rejecting bad input without touching the
|
||||
/// drawing), scans read-only, and returns the paged items with the per-kind summary, PURGE's
|
||||
/// unused lists and the message.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let count = |k: &str, default: usize| -> Result<usize> {
|
||||
given(k).map_or(Ok(default), |v| {
|
||||
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
|
||||
})
|
||||
};
|
||||
let ty = match given("type") {
|
||||
None => None,
|
||||
Some(Value::String(t)) => {
|
||||
Category::parse(t).ok_or_else(|| bad(CMD, format!("`type` must be layers, blocks, textstyles, linetypes or all, got \"{t}\"")))?
|
||||
}
|
||||
Some(v) => return Err(bad(CMD, format!("`type` must be a string, got {v}"))),
|
||||
};
|
||||
let limit = count("limit", 200)?;
|
||||
let offset = count("offset", 0)?;
|
||||
let st = s.state()?;
|
||||
let d = &st.doc;
|
||||
let scan = Scan::new(d, MAX_SCAN);
|
||||
let unused = Unused::new(d);
|
||||
let all = items(&scan, &unused, d);
|
||||
let matched: Vec<&Value> = all.iter().filter(|i| ty.is_none_or(|c| i["type"] == c.key())).collect();
|
||||
let total = matched.len();
|
||||
let page: Vec<&Value> = matched.iter().skip(offset).take(limit).copied().collect();
|
||||
let summary = summary(&all, &unused);
|
||||
let mut out = json!({
|
||||
"schema": 1,
|
||||
"count": total,
|
||||
"returned": page.len(),
|
||||
"offset": offset,
|
||||
"truncated": page.len() < total.saturating_sub(offset),
|
||||
"incomplete": scan.incomplete.is_some(),
|
||||
"items": page,
|
||||
"summary": summary,
|
||||
"unused": unused.json(),
|
||||
"message": message(&matched, &summary, scan.incomplete),
|
||||
});
|
||||
if let (Some(inc), Some(o)) = (scan.incomplete, out.as_object_mut()) {
|
||||
o.insert("incompleteReason".into(), json!(inc.reason()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "usage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Unit tests for USAGE's scan limit (#1439): a scan cut at its limit must say so. A drawing with
|
||||
//! more than 5,000,000 objects is impractical to build in a test, so the limit is a parameter and
|
||||
//! these tests cut a tiny drawing instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Incomplete, Scan, entity_total};
|
||||
|
||||
/// A LINE on `layer`, one unit long.
|
||||
fn line(handle: u64, layer: &str) -> Entity {
|
||||
let mut e = Entity::new(Handle(handle), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }));
|
||||
e.common = Common { layer: layer.into(), ..Common::default() };
|
||||
e
|
||||
}
|
||||
|
||||
/// A drawing with `top` lines on layer Used in model space and `held` lines on layer Held in the
|
||||
/// block HELD, so the block's objects come last in the scan, exactly as they do in a real drawing.
|
||||
fn drawing(top: usize, held: usize) -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
for i in 0..top {
|
||||
d.model.push(line(0x100 + i as u64, "Used"));
|
||||
}
|
||||
let mut b = Block::new("HELD");
|
||||
for i in 0..held {
|
||||
b.entities.push(line(0x200 + i as u64, "Held"));
|
||||
}
|
||||
d.blocks.insert("HELD".into(), Arc::new(b));
|
||||
d
|
||||
}
|
||||
|
||||
/// Guards the detector's boundary: a drawing holding exactly the limit is read whole and is not
|
||||
/// reported as cut short, so `incomplete` never cries wolf on the last readable object.
|
||||
#[test]
|
||||
fn a_drawing_at_the_limit_is_complete() {
|
||||
let d = drawing(2, 1);
|
||||
assert_eq!(entity_total(&d), 3);
|
||||
let sc = Scan::new(&d, 3);
|
||||
assert_eq!(sc.incomplete, None);
|
||||
assert_eq!(sc.layers.get("used").copied(), Some((2, 0)));
|
||||
assert_eq!(sc.layers.get("held").copied(), Some((0, 1)));
|
||||
}
|
||||
|
||||
/// Guards that a scan stopped one object early reports what it omitted and why: HELD's line is
|
||||
/// past the limit, so its layer looks unused — the miscount the `incomplete` signal exists to
|
||||
/// prevent a caller from trusting.
|
||||
#[test]
|
||||
fn a_cut_scan_reports_the_objects_it_omitted() {
|
||||
let d = drawing(2, 1);
|
||||
let sc = Scan::new(&d, 2);
|
||||
let inc = sc.incomplete.expect("a cut scan must report it");
|
||||
assert_eq!(inc, Incomplete { scanned: 2, total: 3 });
|
||||
assert_eq!(sc.layers.get("held").copied(), None);
|
||||
let reason = inc.reason();
|
||||
assert!(reason.contains("first 2 of 3 objects"), "{reason}");
|
||||
assert!(reason.contains("partial"), "{reason}");
|
||||
}
|
||||
@@ -40,7 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layoff", "Layer Off", |s, p| set_obj_layers(s, p, |l| l.on = false))
|
||||
.menu(&["Format", "Layer Tools", "Layer Off"])
|
||||
.params("{handles?}")
|
||||
.interactive(|_| Ok(Box::new(SelectRun::single("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
|
||||
// Layer Off stays active until Enter or Esc, as in AutoCAD (#1418): one object per
|
||||
// pick and the prompt comes straight back.
|
||||
.interactive(|_| Ok(Box::new(SelectRun::repeat("layoff", "LAYOFF", "Select an object on the layer to be turned off")))),
|
||||
CommandSpec::new("laylck", "Lock Layer", |s, p| set_obj_layers(s, p, |l| l.locked = true))
|
||||
.menu(&["Format", "Layer Tools", "Lock Layer"])
|
||||
.params("{handles?}")
|
||||
|
||||
@@ -270,9 +270,13 @@ fn run_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = layout_name("layout.rename", s.doc()?, &from)?;
|
||||
let to = str_param(p, "to").ok_or_else(|| bad("layout.rename", "`to` is required"))?;
|
||||
let to = check_name("layout.rename", s.doc()?, to, Some(&from))?;
|
||||
if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == from) {
|
||||
let d = s.doc_mut()?;
|
||||
if let Some(l) = d.layouts.iter_mut().find(|l| l.name == from) {
|
||||
l.name = to.clone();
|
||||
}
|
||||
if d.active_layout.as_ref().is_some_and(|a| a.eq_ignore_ascii_case(&from)) {
|
||||
d.active_layout = Some(to.clone());
|
||||
}
|
||||
let (old, new) = (Space::Paper(from.clone()), Space::Paper(to.clone()));
|
||||
let st = s.state_mut()?;
|
||||
for (sp, _) in st.views.iter_mut() {
|
||||
@@ -826,13 +830,16 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = _path;
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the PDF as base64 `data`"))
|
||||
{
|
||||
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
|
||||
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
}
|
||||
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
|
||||
}
|
||||
@@ -925,6 +932,14 @@ mod tests {
|
||||
assert!(s.doc().unwrap().layout("Layout3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_the_active_layout_whatever_its_case() {
|
||||
let mut s = session_with_model();
|
||||
s.doc_mut().unwrap().active_layout = Some("layout1".into());
|
||||
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_copy_delete() {
|
||||
let mut s = session_with_model();
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("appload", "Load Application...", run_appload)
|
||||
.menu(&["Tools", "Load Application..."])
|
||||
.alias(&["ap"])
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}")
|
||||
.params("{path: .lsp file} | {name, data: base64 | text} → {value?, commands: [commands the file defined], error?}")
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(ApploadM))),
|
||||
CommandSpec::new("lisp", "Evaluate LISP", run_lisp)
|
||||
@@ -23,6 +23,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether `path` names an AutoLISP routine: APPLOAD loads `.lsp` files only, in any case.
|
||||
fn is_lisp_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp"))
|
||||
}
|
||||
|
||||
/// The text of a LISP file from `{path}` or `{name, data | text}`, and its name.
|
||||
fn source(p: &Value) -> Result<(String, String)> {
|
||||
if let Some(text) = str_param(p, "text") {
|
||||
@@ -32,7 +37,14 @@ fn source(p: &Value) -> Result<(String, String)> {
|
||||
let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned()));
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
// The path is read as it is given (a JSON caller may name a file or folder with spaces);
|
||||
// only a path of blanks alone is refused.
|
||||
let path = str_param(p, "path")
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?;
|
||||
if !is_lisp_path(path) {
|
||||
return Err(bad("appload", format!("{path}: not an AutoLISP file (APPLOAD loads .lsp files)")));
|
||||
}
|
||||
let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?;
|
||||
Ok((path.to_string(), text))
|
||||
}
|
||||
@@ -49,9 +61,14 @@ fn report(s: &Session, from_log: usize, r: Option<std::result::Result<Lv, String
|
||||
|
||||
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, text) = source(p)?;
|
||||
let before = s.lisp_commands();
|
||||
let from = s.log.len();
|
||||
let r = s.lisp_load(&name, &text, true)?;
|
||||
Ok(report(s, from, r))
|
||||
let mut out = report(s, from, r);
|
||||
// The commands the file defined, so a caller can run them next (a failed file may still
|
||||
// have defined some before the error).
|
||||
out["commands"] = json!(s.lisp_commands().into_iter().filter(|c| !before.contains(c)).collect::<Vec<_>>());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
|
||||
|
||||
@@ -121,31 +121,50 @@ impl SelectPhase {
|
||||
}
|
||||
|
||||
/// "Select objects:", then run a JSON command on them as `{handles}`. With a pickfirst selection it
|
||||
/// runs at once, exactly like the JSON form does on the current selection.
|
||||
/// runs at once, exactly like the JSON form does on the current selection. A repeating run acts on
|
||||
/// every pick and asks for the next object, the way AutoCAD's LAYOFF does.
|
||||
pub struct SelectRun {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
/// Single-object prompt (e.g. LAYFRZ's "Select an object on the layer to be frozen").
|
||||
single: Option<&'static str>,
|
||||
/// Stay active after each pick, until Enter or Esc (LAYOFF).
|
||||
repeat: bool,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl SelectRun {
|
||||
pub fn new(id: &'static str, name: &'static str) -> Self {
|
||||
SelectRun { id, name, single: None, sel: SelectPhase::default() }
|
||||
SelectRun { id, name, single: None, repeat: false, sel: SelectPhase::default() }
|
||||
}
|
||||
/// Pick one object, with a command-specific prompt.
|
||||
pub fn single(id: &'static str, name: &'static str, msg: &'static str) -> Self {
|
||||
SelectRun { single: Some(msg), ..SelectRun::new(id, name) }
|
||||
}
|
||||
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
/// Get an object, act on it and ask again, until Enter or Esc: AutoCAD's Layer Off (LAYOFF)
|
||||
/// turns off the layer of every object picked, one after another, in one command.
|
||||
pub fn repeat(id: &'static str, name: &'static str, msg: &'static str) -> Self {
|
||||
SelectRun { single: Some(msg), repeat: true, ..SelectRun::new(id, name) }
|
||||
}
|
||||
fn run(&self, s: &mut Session, hs: &[Handle]) -> Result<()> {
|
||||
let spec = super::find_command(self.id).ok_or_else(|| EngineError::UnknownCommand(self.id.into()))?;
|
||||
// Call the command body directly: the running command records the single undo step.
|
||||
let r = (spec.run)(s, &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))?;
|
||||
if let Some(m) = r.get("message").and_then(Value::as_str) {
|
||||
s.echo(m.to_string());
|
||||
}
|
||||
Ok(Step::Done)
|
||||
Ok(())
|
||||
}
|
||||
/// One pick of a repeating run: act on it at once and keep the prompt up.
|
||||
fn pick(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
if hs.is_empty() {
|
||||
s.echo("0 found");
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.run(s, hs)?;
|
||||
// Objects picked at the prompt don't stay selected afterwards.
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,26 +176,37 @@ impl Interactive for SelectRun {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.run(s, &hs);
|
||||
self.run(s, &hs)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
self.sel.single = self.single.is_some();
|
||||
// A repeating run acts on every pick, so no single pick may finish it.
|
||||
self.sel.single = self.single.is_some() && !self.repeat;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.single {
|
||||
Some(msg) if !self.sel.removing => Prompt::new(msg, Accept::SELECT),
|
||||
// A repeating run keeps asking for the next object, whatever selection keywords were typed.
|
||||
Some(msg) if self.repeat || !self.sel.removing => Prompt::new(msg, Accept::SELECT),
|
||||
_ => self.sel.prompt(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
// A repeating run acts on each pick and prompts again; Enter (or Esc) ends it.
|
||||
if self.repeat {
|
||||
return match i {
|
||||
Input::Pick(hs) => self.pick(s, &hs),
|
||||
Input::Enter => Ok(Step::Done),
|
||||
other => self.sel.feed(s, &other).map(|_| Step::Continue),
|
||||
};
|
||||
}
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
// Objects picked at the prompt don't stay selected afterwards.
|
||||
let step = self.run(s, &hs)?;
|
||||
self.run(s, &hs)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(step)
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::helpers::v3;
|
||||
use super::machines::{SelOutcome, SelectPhase};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, point_req, points_param, str_param, targets};
|
||||
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Most arrowheads added by one call, and most multileaders aligned / collected by one call.
|
||||
@@ -310,6 +311,67 @@ fn segment_dir(m: &MLeader) -> Option<Vec2> {
|
||||
(v.len() > 1e-12).then(|| v.normalized())
|
||||
}
|
||||
|
||||
fn align_parallel(items: &[(Handle, MLeader)], reference: Option<&MLeader>) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let rdir = reference.and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
let mut moves = Vec::new();
|
||||
for (h, m) in items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align_distribute(mut items: Vec<(Handle, MLeader)>, line: Option<(Vec2, Vec2)>) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
let mut moves = Vec::new();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align_directional(
|
||||
items: &[(Handle, MLeader)],
|
||||
reference: Option<&MLeader>,
|
||||
line: Option<(Vec2, Vec2)>,
|
||||
spacing: Spacing,
|
||||
) -> Result<Vec<(Handle, Vec2)>> {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
let mut moves = Vec::new();
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Ok(moves)
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, line: Option<(Vec2, Vec2)>) -> Result<usize> {
|
||||
let mut items: Vec<(Handle, MLeader)> = Vec::new();
|
||||
for h in hs {
|
||||
@@ -331,56 +393,11 @@ fn align(s: &mut Session, hs: &[Handle], to: Option<Handle>, spacing: Spacing, l
|
||||
(_, Some(t)) => Some(get_mleader(s, t).ok_or_else(|| bad("mleaderalign", "`to` is not a multileader"))?),
|
||||
(_, None) => return Err(bad("mleaderalign", "`to` (the multileader to align to) is required")),
|
||||
};
|
||||
let mut moves: Vec<(Handle, Vec2)> = Vec::new();
|
||||
match spacing {
|
||||
Spacing::Parallel => {
|
||||
let rdir = reference.as_ref().and_then(segment_dir).ok_or_else(|| bad("mleaderalign", "the reference multileader has no leader"))?;
|
||||
for (h, m) in &items {
|
||||
let Some(arrow) = m.leaders.first().and_then(|l| l.last()) else { continue };
|
||||
let len = (m.landing.xy() - arrow.xy()).len();
|
||||
moves.push((*h, arrow.xy() + rdir * len - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Distribute => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`points` (first and last position) are required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the two points coincide"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
items.sort_by(|x, y| (x.1.landing.xy() - a).dot(u).total_cmp(&(y.1.landing.xy() - a).dot(u)));
|
||||
let n = items.len();
|
||||
for (i, (h, m)) in items.iter().enumerate() {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
moves.push((*h, a + d * f - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
Spacing::Current | Spacing::Value(_) => {
|
||||
let (a, b) = line.ok_or_else(|| bad("mleaderalign", "`direction` (two points or an angle) is required"))?;
|
||||
let d = b - a;
|
||||
if d.len() < 1e-12 {
|
||||
return Err(bad("mleaderalign", "the direction is zero"));
|
||||
}
|
||||
let u = d.normalized();
|
||||
let origin = reference.as_ref().map(|r| r.landing.xy()).unwrap_or(a);
|
||||
let t_of = |m: &MLeader| (m.landing.xy() - origin).dot(u);
|
||||
if let Spacing::Value(sp) = spacing {
|
||||
let (mut pos, mut neg): (Vec<_>, Vec<_>) = items.iter().partition(|(_, m)| t_of(m) >= 0.0);
|
||||
pos.sort_by(|x, y| t_of(&x.1).total_cmp(&t_of(&y.1)));
|
||||
neg.sort_by(|x, y| t_of(&y.1).total_cmp(&t_of(&x.1)));
|
||||
for (k, (h, m)) in pos.iter().enumerate() {
|
||||
moves.push((*h, origin + u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
for (k, (h, m)) in neg.iter().enumerate() {
|
||||
moves.push((*h, origin - u * (sp * (k + 1) as f64) - m.landing.xy()));
|
||||
}
|
||||
} else {
|
||||
for (h, m) in &items {
|
||||
moves.push((*h, origin + u * t_of(m) - m.landing.xy()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let moves = match spacing {
|
||||
Spacing::Parallel => align_parallel(&items, reference.as_ref())?,
|
||||
Spacing::Distribute => align_distribute(items, line)?,
|
||||
Spacing::Current | Spacing::Value(_) => align_directional(&items, reference.as_ref(), line, spacing)?,
|
||||
};
|
||||
move_content(s, &moves)?;
|
||||
Ok(moves.len())
|
||||
}
|
||||
|
||||
@@ -120,3 +120,88 @@ fn hostile_mleader_edit_params_never_panic() {
|
||||
}
|
||||
let _ = cadcraft_render::build(s.doc().unwrap(), &cadcraft_doc::Space::Model, &cadcraft_render::Options::default());
|
||||
}
|
||||
|
||||
mod units {
|
||||
use super::super::{Spacing, align_directional, align_distribute, align_parallel};
|
||||
use cadcraft_doc::{Handle, MLeader};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
fn unit_ml(arrow: [f64; 2], land: [f64; 2]) -> MLeader {
|
||||
MLeader {
|
||||
leaders: vec![vec![Vec3::new(arrow[0], arrow[1], 0.0)]],
|
||||
landing: Vec3::new(land[0], land[1], 0.0),
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: String::new(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn no_leader(land: [f64; 2]) -> MLeader {
|
||||
MLeader {
|
||||
leaders: vec![],
|
||||
landing: Vec3::new(land[0], land[1], 0.0),
|
||||
dogleg: 0.0,
|
||||
text: None,
|
||||
style: String::new(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A five-unit-long leader whose direction differs from the reference's `+y`: moving the
|
||||
/// arrow onto `y` plus `5` (a five-unit parallel push) and back along the landing gives
|
||||
/// `(-3, 1)`.
|
||||
#[test]
|
||||
fn parallel_requires_a_reference_and_skips_leaders() {
|
||||
assert!(align_parallel(&[(Handle(1), unit_ml([0.0, 0.0], [3.0, 4.0]))], None).is_err());
|
||||
let d = align_parallel(
|
||||
&[(Handle(2), no_leader([1.0, 1.0])), (Handle(3), unit_ml([0.0, 0.0], [3.0, 4.0]))],
|
||||
Some(&unit_ml([0.0, 0.0], [0.0, 4.0])),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.len(), 1, "a multileader without a leader line is skipped");
|
||||
assert_eq!(d[0].0, Handle(3));
|
||||
assert!((d[0].1.x + 3.0).abs() < 1e-9);
|
||||
assert!((d[0].1.y - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_orders_landings_along_the_line() {
|
||||
let mk = |h: u64, land: [f64; 2]| (Handle(h), unit_ml([0.0, 0.0], land));
|
||||
assert!(align_distribute(vec![mk(1, [2.0, 0.0])], None).is_err(), "needs two points");
|
||||
assert!(align_distribute(Vec::new(), Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0)))).is_err(), "coinciding points give no direction");
|
||||
let items = vec![mk(1, [2.0, 0.0]), mk(2, [0.0, 0.0])];
|
||||
let d = align_distribute(items, Some((Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)))).unwrap();
|
||||
assert_eq!(d[0].0, Handle(2), "sorted by projection onto the line");
|
||||
assert!((d[0].1.x - 0.0).abs() < 1e-9 && (d[0].1.y - 0.0).abs() < 1e-9, "first fraction is 0");
|
||||
assert_eq!(d[1].0, Handle(1));
|
||||
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 0.0).abs() < 1e-9, "last fraction is 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_projects_landings_and_supports_spacing() {
|
||||
let line = Some((Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0)));
|
||||
assert!(align_directional(&[], None, None, Spacing::Current).is_err(), "needs a direction");
|
||||
assert!(
|
||||
align_directional(&[], None, Some((Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0))), Spacing::Current).is_err(),
|
||||
"a zero direction is an error"
|
||||
);
|
||||
// Without a reference the origin is the first point: the landing is projected onto it.
|
||||
let d = align_directional(&[(Handle(7), unit_ml([0.0, 0.0], [1.0, 5.0]))], None, line, Spacing::Current).unwrap();
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!((d[0].1.x - 0.0).abs() < 1e-9);
|
||||
assert!((d[0].1.y + 5.0).abs() < 1e-9);
|
||||
// With a reference as origin, given spacing counts outward on both sides of it.
|
||||
let items = vec![(Handle(1), unit_ml([0.0, 0.0], [4.0, 0.0])), (Handle(2), unit_ml([0.0, 0.0], [-4.0, 0.0]))];
|
||||
let d = align_directional(&items, Some(&unit_ml([0.0, 0.0], [0.0, 4.0])), line, Spacing::Value(2.0)).unwrap();
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!(d[0].0, Handle(1));
|
||||
assert!((d[0].1.x + 2.0).abs() < 1e-9 && (d[0].1.y - 4.0).abs() < 1e-9);
|
||||
assert_eq!(d[1].0, Handle(2));
|
||||
assert!((d[1].1.x - 2.0).abs() < 1e-9 && (d[1].1.y - 4.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ mod overkill;
|
||||
mod pline_opts;
|
||||
pub mod plotters;
|
||||
mod props;
|
||||
pub use draw2::breakline::BreaklineOptions;
|
||||
pub use props::MatchProps;
|
||||
pub(crate) use props::set_current_color_value;
|
||||
mod qselect;
|
||||
mod selecttools;
|
||||
mod settings;
|
||||
@@ -39,6 +41,8 @@ mod spline_opts;
|
||||
mod table;
|
||||
mod tolerance;
|
||||
mod trimextend;
|
||||
mod txt2mtxt;
|
||||
pub use txt2mtxt::Txt2MtxtOptions;
|
||||
mod utility;
|
||||
mod view;
|
||||
|
||||
@@ -200,6 +204,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(utility::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(tolerance::specs());
|
||||
v.extend(txt2mtxt::specs());
|
||||
v.extend(constraints::specs());
|
||||
v.extend(lisp::specs());
|
||||
v
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user