Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 13950bc1bc Release workflow: pass the Azure signing secrets so Windows builds are signed
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:02:45 -04:00
571 changed files with 2952 additions and 63327 deletions
-6
View File
@@ -1,6 +0,0 @@
# 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]"
-41
View File
@@ -1,41 +0,0 @@
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)"
-30
View File
@@ -1,30 +0,0 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: 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
-32
View File
@@ -1,32 +0,0 @@
# 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
-61
View File
@@ -1,61 +0,0 @@
# 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
+1 -45
View File
@@ -11,63 +11,19 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
-21
View File
@@ -1,21 +0,0 @@
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
-32
View File
@@ -1,32 +0,0 @@
# 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
-1
View File
@@ -30,7 +30,6 @@ 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`.
-11
View File
@@ -60,14 +60,3 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
| `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
+152 -217
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "acadrust"
version = "0.6.5"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eaa72520f70eb6f7f09b06f44e1ed0d8fad5fa743364f2c3715c30df962ec7c"
checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -18,7 +18,6 @@ dependencies = [
"itoa",
"memmap2",
"nom",
"oem_cp",
"once_cell",
"rayon",
"ryu",
@@ -46,7 +45,7 @@ dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"atspi-common",
"phf 0.13.1",
"phf",
"serde",
"zvariant",
]
@@ -209,9 +208,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.7.0"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
@@ -223,7 +222,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
"x11rb",
]
@@ -434,12 +433,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "basic-udev"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a45f9771ced8a774de5e5ebffbe520f52e3943bf5a9a6baa3a5d14a5de1afe6"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -547,31 +540,25 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-mcp",
"cadcraft-ui-egui",
"chrono",
"eframe",
"egui",
"hidapi",
"log",
"muda",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"sys-locale",
"windows",
"winit",
"winresource",
"zbus",
]
[[package]]
name = "cadcraft-cli"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -582,14 +569,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -599,7 +586,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -610,14 +597,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"encoding_rs",
"log",
@@ -626,7 +613,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -636,8 +623,6 @@ dependencies = [
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"libc",
"log",
"rstar",
"serde",
@@ -647,26 +632,25 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-geom",
"harfrust 0.14.0",
"skrifa 0.48.0",
"harfrust",
"skrifa 0.47.0",
"unicode-bidi",
]
[[package]]
name = "cadcraft-geom"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
@@ -674,51 +658,44 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"miniz_oxide 0.8.9",
"serde",
"serde_json",
"thiserror 2.0.21",
"tiny-skia",
]
[[package]]
name = "cadcraft-mcp"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"getrandom 0.4.3",
"log",
"serde_json",
]
[[package]]
name = "cadcraft-render"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-fonts",
"cadcraft-geom",
"lyon_tessellation",
"rayon",
"serde_json",
"tiny-skia",
]
[[package]]
name = "cadcraft-ui-egui"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"chrono",
"craft-ui",
"egui",
"egui-wgpu",
@@ -733,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -830,15 +807,6 @@ 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"
@@ -943,11 +911,6 @@ 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"
@@ -1312,7 +1275,7 @@ dependencies = [
"emath",
"epaint_default_fonts",
"font-types",
"harfrust 0.12.0",
"harfrust",
"log",
"nohash-hasher",
"parking_lot",
@@ -1427,12 +1390,6 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1441,9 +1398,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.7"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
dependencies = [
"bytemuck",
"serde",
@@ -1734,18 +1691,6 @@ 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"
@@ -1797,21 +1742,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hidapi"
version = "2.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
dependencies = [
"basic-udev",
"cc",
"cfg-if",
"libc",
"nix",
"pkg-config",
"windows-sys 0.61.2",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -1926,7 +1856,7 @@ dependencies = [
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
@@ -2112,9 +2042,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libc"
version = "0.2.191"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libloading"
@@ -2189,38 +2119,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lyon_geom"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
dependencies = [
"arrayvec",
"euclid",
"num-traits",
]
[[package]]
name = "lyon_path"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
dependencies = [
"lyon_geom",
"num-traits",
]
[[package]]
name = "lyon_tessellation"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
dependencies = [
"float_next_after",
"lyon_path",
"num-traits",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2283,9 +2181,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.21.2"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2294,7 +2192,8 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png",
"once_cell",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
@@ -2373,18 +2272,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -2744,15 +2631,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "oem_cp"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c7959d3349f484aec36462f98226983578bf7d33f9b034c32967489c7000e1f"
dependencies = [
"phf 0.11.3",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2846,15 +2724,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -2862,7 +2731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
"phf_shared 0.13.1",
"phf_shared",
"serde",
]
@@ -2873,7 +2742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
"phf_shared",
]
[[package]]
@@ -2883,21 +2752,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
"phf_shared 0.13.1",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -2956,6 +2816,19 @@ 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"
@@ -3079,15 +2952,6 @@ 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"
@@ -3166,15 +3030,13 @@ dependencies = [
[[package]]
name = "read-fonts"
version = "0.45.1"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
"foldhash",
"font-types",
"once_cell",
"yoke",
]
[[package]]
@@ -3253,9 +3115,9 @@ dependencies = [
[[package]]
name = "rstar"
version = "0.13.0"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe"
checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
dependencies = [
"heapless",
"num-traits",
@@ -3386,9 +3248,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.154"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -3473,12 +3335,12 @@ dependencies = [
[[package]]
name = "skrifa"
version = "0.48.0"
version = "0.47.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f"
dependencies = [
"bytemuck",
"read-fonts 0.45.1",
"read-fonts 0.44.0",
]
[[package]]
@@ -3724,24 +3586,24 @@ dependencies = [
[[package]]
name = "tiny-skia"
version = "0.12.0"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"png 0.17.16",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.12.0"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
@@ -4162,7 +4024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml 0.41.0",
"quick-xml",
"quote",
]
@@ -4519,7 +4381,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -4528,7 +4390,16 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
"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",
]
[[package]]
@@ -4546,14 +4417,31 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"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",
"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",
]
[[package]]
@@ -4571,48 +4459,96 @@ 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"
@@ -4675,9 +4611,9 @@ dependencies = [
[[package]]
name = "winresource"
version = "0.1.34"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml",
"version_check",
@@ -4760,9 +4696,8 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.5.0"
dependencies = [
"chrono",
"serde_json",
]
+6 -15
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.12.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -24,30 +24,23 @@ 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.12", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.48"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
skrifa = "0.47"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
# and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.12"
unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
@@ -69,11 +62,9 @@ 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
+5 -38
View File
@@ -7,18 +7,14 @@
</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, modern 2D and 3D CAD application, built in pure Rust.</b>
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
</p>
<p align="center">
A fast, open-source CAD workflow: the command line, object snaps, layers,
A fast, open-source take on the AutoCAD 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>
@@ -30,9 +26,6 @@
<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">
@@ -118,7 +111,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), 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) |
| Interface languages | English, Ukrainian (Українська) and German (Deutsch), 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
@@ -232,9 +225,6 @@ 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
@@ -262,25 +252,8 @@ have accessible labels and visible keyboard focus. Property controls remain appl
## Accessibility
### System cursor
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
### Animation effects
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
| System | Where the setting is |
|---|---|
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
| Web | the browser's `prefers-reduced-motion` |
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
## Roadmap
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
@@ -289,14 +262,6 @@ 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 |
@@ -412,6 +377,8 @@ 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>
+3 -10
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **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
@@ -10,7 +10,7 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
CADCraft drops everything it doesn't model and has never been checked in
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
is ≈ 600–930 h away.
@@ -94,12 +94,11 @@ 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% |
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
Other languages shipped: Ukrainian and German, both partial (989 catalog rows each; Ukrainian landed with #36; engine messages stay English).
## Upcoming
@@ -119,9 +118,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Entry |
|---|---|
| 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. |
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
@@ -135,9 +131,6 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
+13 -121
View File
@@ -5,9 +5,8 @@
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
//! cadcraft-cli commands [FILTER] command catalog (JSON)
//! cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
//! cadcraft-cli perf FILE canvas display-list build of a drawing, zoomed in step by step
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -60,8 +59,8 @@ const USAGE: &str = "usage:
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
cadcraft-cli commands [FILTER]
cadcraft-cli mcp [--connect HOST:PORT [--lazy] [--launch]]
cadcraft-cli perf [N | FILE]
cadcraft-cli mcp [--connect HOST:PORT]
cadcraft-cli perf [N]
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
cadcraft-cli --version
@@ -172,11 +171,6 @@ fn install_io() {
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn open(s: &mut Session, path: &str) -> Result<(), String> {
@@ -272,48 +266,10 @@ fn convert(args: &[String]) -> Result<(), String> {
check_framed_output(framing, Some(o))?;
let mut s = Session::empty();
open(&mut s, i)?;
if let Some(m) = skipped_message(s.doc().map_err(|e| e.to_string())?, i) {
eprintln!("{m}");
}
if cadcraft_io::is_image(o)
&& let Some(m) = undrawn_message(s.doc().map_err(|e| e.to_string())?, i)
{
eprintln!("{m}");
}
let window = export_window(&s, framing, None)?;
export_framed(&s, o, window)
}
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for e in d.model.iter() {
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
}
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
fn run(args: &[String]) -> Result<(), String> {
let mut s = Session::empty();
let mut it = args.iter();
@@ -437,55 +393,11 @@ fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, Strin
Ok(dr)
}
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
fn perf_file(path: &str) -> Result<(), String> {
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::render::{Options, build};
use std::time::Instant;
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
let mut s = Session::empty();
let t = Instant::now();
open(&mut s, path)?;
let open_ms = ms(t);
let st = s.state().map_err(|e| e.to_string())?;
let (d, space) = (&st.doc, &st.space);
let ext = d.extents(space);
let size = ext.width().max(ext.height());
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
return Err("perf: the drawing is empty".into());
}
outln!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height());
outln!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims");
let mut finest = Options::default();
for k in 0..5 {
let zoom = 4f64.powi(k);
let h = size / zoom;
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
let half = Vec2::new(h * 0.8, h * 0.5);
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
let t = Instant::now();
let n = build(d, space, &screen).prims.len();
outln!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t));
finest = screen;
}
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
let t = Instant::now();
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
outln!("{:>8} {:>10.1} {n:>10}", "full", ms(t));
Ok(())
}
fn perf(args: &[String]) -> Result<(), String> {
use cadcraft_engine::doc::Space;
use cadcraft_engine::geom::{Bounds2, Vec2};
use cadcraft_engine::{select, snap};
use std::time::Instant;
if let Some(path) = args.first().filter(|a| a.parse::<usize>().is_err()) {
return perf_file(path);
}
let n: usize = match args.first() {
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
None => 200_000,
@@ -567,47 +479,37 @@ fn perf(args: &[String]) -> Result<(), String> {
enum McpTarget {
/// An in-process drawing session (no `--connect`).
Headless,
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
/// `launch`: opened then if it isn't running.
Connect { addr: String, lazy: bool, launch: bool },
/// The running app's control channel at `HOST:PORT`.
Connect(String),
}
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
let mut addr: Option<String> = None;
let (mut lazy, mut launch) = (false, false);
let mut target = McpTarget::Headless;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--connect" => {
let a = it
let addr = it
.next()
.map(|v| v.trim())
.filter(|v| !v.is_empty() && !v.starts_with('-'))
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
if addr.is_some() {
if target != McpTarget::Headless {
return Err("--connect given more than once".into());
}
addr = Some(a.to_string());
target = McpTarget::Connect(addr.to_string());
}
"--lazy" => lazy = true,
"--launch" => launch = true,
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
}
}
match addr {
// `--launch` connects at the first call too (the app opens then).
Some(addr) => Ok(McpTarget::Connect { addr, lazy: lazy || launch, launch }),
None if lazy || launch => Err("--lazy and --launch need --connect HOST:PORT".into()),
None => Ok(McpTarget::Headless),
}
Ok(target)
}
fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
McpTarget::Connect(addr) => match cadcraft_mcp::Remote::connect(&addr) {
Ok(r) => Box::new(r),
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
},
@@ -633,14 +535,8 @@ mod mcp_args_tests {
#[test]
fn mcp_connect_with_address_selects_remote() {
let to = |addr: &str, lazy, launch| Ok(McpTarget::Connect { addr: addr.into(), lazy, launch });
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), to("127.0.0.1:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), to("localhost:7979", false, false));
assert_eq!(parse_mcp_args(&args(&["--lazy", "--connect", "localhost:7979"])), to("localhost:7979", true, false));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979", "--launch"])), to("localhost:7979", true, true));
// Without an app to reach they mean nothing: never a headless session.
assert!(parse_mcp_args(&args(&["--lazy"])).is_err());
assert!(parse_mcp_args(&args(&["--launch"])).is_err());
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), Ok(McpTarget::Connect("127.0.0.1:7979".into())));
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), Ok(McpTarget::Connect("localhost:7979".into())));
}
#[test]
@@ -690,10 +586,6 @@ fn cli() -> ExitCode {
outln!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") => {
outln!("{USAGE}");
Ok(())
}
_ => Err(USAGE.into()),
};
match r {
-23
View File
@@ -1,23 +0,0 @@
//! `--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:"));
}
-6
View File
@@ -1,6 +0,0 @@
mod cli;
mod drafting_spacing;
mod help;
mod mcp_connect;
mod skipped_records;
mod undrawn_entities;
@@ -1,44 +0,0 @@
//! `info` reports the records a DXF file lost on reading, by section, and `convert` warns about
//! them before writing the copy (#865). 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 after the last ENDSEC and a VERTEX with no POLYLINE before it.
const DAMAGED: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n0\nVERTEX\n8\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nLINE\n8\n0\n10\n9\n20\n9\n11\n8\n21\n8\n0\nEOF\n";
#[test]
fn info_counts_and_convert_warns_about_skipped_records() {
let dir = std::env::temp_dir().join(format!("cc-cli-skipped-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let damaged = dir.join("damaged.dxf");
std::fs::write(&damaged, DAMAGED).unwrap();
let damaged = damaged.to_str().unwrap();
let out = cli(&["info", damaged]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["skipped"], serde_json::json!({ "count": 2, "sections": { "(no section)": 1, "ENTITIES": 1 } }));
let copy = dir.join("copy.dxf");
let out = cli(&["convert", damaged, copy.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("could not be read")).collect();
assert_eq!(warnings.len(), 1, "{err}");
assert!(warnings[0].contains("2 records") && warnings[0].contains("ENTITIES: 1") && warnings[0].contains("(no section): 1"), "{err}");
// An intact file: a zero count, and no warning.
let sample = dir.join("sample.dxf");
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
let out = cli(&["info", sample.to_str().unwrap()]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["skipped"], serde_json::json!({ "count": 0, "sections": {} }));
let out = cli(&["convert", sample.to_str().unwrap(), copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("could not be read"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1,53 +0,0 @@
//! `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);
}
+1 -1
View File
@@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams"] }
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>CADCraft</title>
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
+5 -15
View File
@@ -23,16 +23,14 @@ mod web {
const CANVAS_ID: &str = "cadcraft_canvas";
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>);
/// The app, the files File > Open… and drag and drop read for it in the background, and the
/// page embedding it (`?host=parent`).
struct Shell(CadApp, crate::upload::Inbox, Option<(crate::host::Bridge, crate::host::DirtyReport)>);
impl eframe::App for Shell {
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);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
@@ -82,11 +80,6 @@ mod web {
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
@@ -131,9 +124,7 @@ mod web {
if !crate::wants_cpu(&query())
&& let Some(rs) = &cc.wgpu_render_state
{
// eframe's web runner has no stencil buffer: clipped layout
// viewports stay in the layout's display list.
app.set_wgpu(rs, None);
app.set_wgpu(rs);
}
if query().contains("sample") {
let _ = app.run("ui.sample", serde_json::json!({}));
@@ -151,8 +142,7 @@ mod web {
bridge.post_ready();
(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)))
}),
)
.await;
-20
View File
@@ -14,23 +14,16 @@ 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"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal
# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing
# is built twice.
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -38,16 +31,3 @@ sys-locale = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
windows = { version = "0.62", features = ["UI_ViewManagement"] }
[target.'cfg(target_os = "linux")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
+41 -451
View File
@@ -1,474 +1,64 @@
//! 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.
//!
//! 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, ErrorKind, Read, Write};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use cadcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// 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> {
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)
}
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
sync_channel::<ControlRequest>(0).1
return rx;
}
}
}
/// 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));
};
log::info!("control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx
}
/// 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 {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
/// 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 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;
}
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, limits, &token);
});
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// One request line, parsed.
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;
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) {
Ok(false) => break,
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
// A line nobody asked for would be read as the reply to the client's next request.
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;
for line in BufReader::new(read).lines() {
let Ok(line) = line 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;
}
},
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}")}),
};
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
break;
}
}
#[cfg(test)]
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();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
fn 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()
if writeln!(out, "{reply}").is_err() {
break;
}
}
/// 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();
(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}));
}
});
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();
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();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
}
}
/// 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..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();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}
-174
View File
@@ -1,174 +0,0 @@
//! The desktop's own light/dark choice, for View ▸ Theme ▸ System.
//!
//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`),
//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself:
//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over
//! X11 and Wayland alike. The answer is handed to egui as the system theme
//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and
//! Windows.
//!
//! Ported from PdfCraft (storytold/pdfcraft#881).
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::Duration;
/// How often the watcher re-reads the desktop's answer. The portal also publishes a change
/// signal, but subscribing needs a connection kept alive for the life of the window; one round
/// trip every few seconds is cheap and reacts soon enough after a theme switch.
const POLL: Duration = Duration::from_secs(4);
/// Watches the desktop and reports its choice when it changes.
#[derive(Default)]
pub struct DesktopTheme {
/// `None` when no watcher thread could be started (or on a platform where winit answers).
changes: Option<Receiver<Option<egui::Theme>>>,
/// The desktop's last answer.
current: Option<egui::Theme>,
}
impl DesktopTheme {
/// Start watching. The first answer is sent straight away; `wake` is called after each
/// answer, so an idle window repaints and picks it up.
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
if !ASKS_THE_DESKTOP {
return Self::default();
}
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
wake();
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
wake();
}
}
});
Self { changes: started.ok().map(|_| rx), current: None }
}
/// The desktop's choice, `None` when it states none or there is nobody to ask (no session
/// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer.
pub fn current(&mut self) -> Option<egui::Theme> {
if let Some(changes) = &self.changes {
loop {
match changes.try_recv() {
Ok(Some(theme)) => self.current = Some(theme),
Ok(None) => {}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
}
self.current
}
}
/// Where winit has no answer and the portal does.
const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos")));
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(all(unix, not(target_os = "macos")))]
fn read() -> Option<egui::Theme> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
// The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then
// GNOME's own schema, which XFCE's portal proxies without the standard key.
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")];
let connection = zbus::blocking::Connection::session().ok()?;
for (namespace, key) in KEYS {
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
// wraps it in one more variant. `parse` unwraps either.
let reply = connection
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
let Ok(reply) = reply else { continue };
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Some(scheme) = parse(&answer, 0) {
return scheme;
}
}
None
}
/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)`
/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep.
#[cfg(all(unix, not(target_os = "macos")))]
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<Option<egui::Theme>> {
use zbus::zvariant::Value;
match value {
Value::U32(1) => Some(Some(egui::Theme::Dark)),
Value::U32(2) => Some(Some(egui::Theme::Light)),
Value::U32(_) => Some(None),
Value::Str(s) => Some(match s.as_str() {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
_ => None,
}),
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
_ => None,
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn read() -> Option<egui::Theme> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The standard key answers a number, GNOME's schema a string; either may come wrapped in
/// variants (the older `Read`). Only "dark" and "light" are a choice.
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn the_portal_answer_reads_as_a_choice_or_as_none() {
use zbus::zvariant::Value;
assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light)));
assert_eq!(parse(&Value::U32(0), 0), Some(None));
assert_eq!(parse(&Value::U32(7), 0), Some(None));
assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light)));
assert_eq!(parse(&Value::from("default"), 0), Some(None));
let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1)))));
assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark)));
assert_eq!(parse(&Value::Bool(true), 0), None);
let mut deep = Value::U32(1);
for _ in 0..10 {
deep = Value::Value(Box::new(deep));
}
assert_eq!(parse(&deep, 0), None, "nesting is bounded");
}
/// A watcher that was never started is inert rather than a panic.
#[test]
fn a_watcher_without_a_thread_is_inert() {
assert_eq!(DesktopTheme::default().current(), None);
}
/// Answers arrive as changes; "no preference" or silence keeps the last real answer.
#[test]
fn the_last_answer_stands_until_a_new_one() {
let (tx, rx) = channel();
let mut watcher = DesktopTheme { changes: Some(rx), current: None };
assert_eq!(watcher.current(), None);
tx.send(Some(egui::Theme::Light)).unwrap();
assert_eq!(watcher.current(), Some(egui::Theme::Light));
tx.send(Some(egui::Theme::Dark)).unwrap();
tx.send(None).unwrap();
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
drop(tx);
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
}
}
+16 -36
View File
@@ -93,10 +93,7 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `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.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -105,25 +102,20 @@ 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 → 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
}
/// 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)
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -393,20 +385,8 @@ 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 to the epoch instead of panicking.
// Before the epoch (a clock set wrong) clamps 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]
+3 -96
View File
@@ -15,14 +15,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod desktop_theme;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod spacemouse;
mod system_animations;
mod workspace;
use cadcraft_engine::Session;
@@ -30,11 +27,6 @@ use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
cad: CadApp,
spacemouse: spacemouse::SpaceMouse,
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
desktop_theme: desktop_theme::DesktopTheme,
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
system_animations: system_animations::SystemAnimations,
workspace: workspace::Workspace,
workspace_error: Option<String>,
#[cfg(target_os = "macos")]
@@ -56,14 +48,6 @@ impl App {
}
}
/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux
/// and the BSDs: the desktop's answer, where it gives one, stands in for it.
fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
if desktop.is_some() {
raw.system_theme = desktop;
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
@@ -75,16 +59,13 @@ impl eframe::App for App {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
@@ -119,11 +100,6 @@ pub fn install_io() {
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
}
fn services() -> Services {
@@ -131,7 +107,6 @@ fn services() -> Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
.add_filter(i18n::t("All files"), &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -184,9 +159,6 @@ fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<s
const APP_ID: &str = "ai.storyteller.cadcraft";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
const STENCIL_BITS: u8 = 8;
#[cfg(target_os = "macos")]
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
#[cfg(not(target_os = "macos"))]
@@ -212,19 +184,6 @@ fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
port
}
/// `--control` followed by `value`: a bare `--control` (last argument) keeps the port already
/// read from `CADCRAFT_CONTROL_PORT` (`current`) instead of turning the control channel off.
fn control_flag(current: Option<u16>, value: Option<String>) -> Option<u16> {
if value.is_some() {
return control_port_from("--control", value);
}
match current {
Some(port) => log::warn!("--control: no port given; using CADCRAFT_CONTROL_PORT ({port})"),
None => log::warn!("--control: no port given; the control channel is off"),
}
current
}
fn main() -> eframe::Result {
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
@@ -236,7 +195,7 @@ fn main() -> eframe::Result {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = control_flag(control_port, args.next()),
"--control" => control_port = control_port_from("--control", args.next()),
"--sample" => sample = true,
"--version" | "-V" => {
println!("{}", version_string());
@@ -273,8 +232,6 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
};
if let Some(icon) = app_icon() {
@@ -283,17 +240,6 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
// Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index
// buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the
// adapter actually supports.
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
let base = std::sync::Arc::clone(&create.device_descriptor);
create.device_descriptor = std::sync::Arc::new(move |adapter| {
let mut d = base(adapter);
d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size);
d
});
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
// keeps the native Wayland backend.
@@ -331,20 +277,13 @@ fn main() -> eframe::Result {
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
if let Some(dir) = config_dir() {
app.enable_autosave(&dir.join("autosave"));
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
// folder is made so it is there to put them in.
let support = dir.join("support");
if let Err(e) = std::fs::create_dir_all(&support) {
log::warn!("no LISP support folder {}: {e}", support.display());
}
app.session.set_lisp_support_dir(Some(support));
}
app.integrated_titlebar = cfg!(target_os = "macos");
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS));
app.set_wgpu(rs);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -359,15 +298,8 @@ fn main() -> eframe::Result {
if app.session.docs.is_empty() {
app.session.new_drawing(false);
}
let ctx = cc.egui_ctx.clone();
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
let ctx = cc.egui_ctx.clone();
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
Ok(Box::new(App {
desktop_theme,
system_animations,
cad: app,
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
workspace,
workspace_error,
#[cfg(target_os = "macos")]
@@ -379,7 +311,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{config_dir_from, control_flag, control_port_from, filters_for_save, follow_desktop};
use super::{config_dir_from, control_port_from, filters_for_save};
use std::ffi::OsString;
use std::path::PathBuf;
@@ -413,16 +345,6 @@ mod tests {
}
}
#[test]
fn control_without_a_port_keeps_the_environment_port() {
// `CADCRAFT_CONTROL_PORT=47321 cadcraft --control` (#844)
assert_eq!(control_flag(Some(47321), None), Some(47321));
assert_eq!(control_flag(None, None), None);
// A port given on the command line still wins over the variable.
assert_eq!(control_flag(Some(47321), Some("7979".into())), Some(7979));
assert_eq!(control_flag(Some(47321), Some("abc".into())), None);
}
#[test]
fn the_override_variable_wins_and_empty_values_are_unset() {
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
@@ -455,19 +377,4 @@ mod tests {
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
);
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
#[test]
fn the_desktop_answers_for_the_system_theme() {
let mut raw = egui::RawInput::default();
follow_desktop(&mut raw, Some(egui::Theme::Light));
assert_eq!(raw.system_theme, Some(egui::Theme::Light));
let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
follow_desktop(&mut raw, None);
assert_eq!(raw.system_theme, Some(egui::Theme::Dark));
let mut raw = egui::RawInput::default();
follow_desktop(&mut raw, None);
assert_eq!(raw.system_theme, None);
}
}
+4 -26
View File
@@ -16,9 +16,6 @@ pub struct NativeMenu {
predefined: Vec<(&'static str, PredefinedMenuItem)>,
language: &'static str,
last_refresh: f64,
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
recent: Vec<String>,
counter: usize,
}
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
@@ -60,31 +57,13 @@ impl NativeMenu {
items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings));
let _ = menu.append(&app_menu);
for (title, entries) in menus::tree(app) {
let sub = Submenu::new(i18n::tr_menu(language, &title), true);
let sub = Submenu::new(i18n::tr(language, &title), true);
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
let _ = menu.append(&sub);
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
/// menu is built once, and its items name the files by position.
fn refresh_recent(&mut self, app: &CadApp) {
if self.recent == app.ui.recent_files {
return;
}
self.recent = app.ui.recent_files.clone();
let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return };
for _ in sub.items() {
sub.remove_at(0);
}
let ids: Vec<String> = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect();
for id in ids {
self.items.remove(&id);
}
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 }
}
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
@@ -98,7 +77,6 @@ impl NativeMenu {
ctx.request_repaint();
}
}
self.refresh_recent(app);
let language = app.language();
if self.language != language {
self.language = language;
@@ -109,7 +87,7 @@ impl NativeMenu {
item.set_text(i18n::tr(language, label));
}
for (label, submenu) in &self.submenus {
submenu.set_text(i18n::tr_menu(language, label));
submenu.set_text(i18n::tr(language, label));
}
}
let now = cadcraft_ui_egui::now_ms();
@@ -135,7 +113,7 @@ fn build(
for e in entries {
match e {
Entry::Sub { label, children } => {
let sub = Submenu::new(i18n::tr_menu(language, label), true);
let sub = Submenu::new(i18n::tr(language, label), true);
build(&sub, children, items, submenus, counter, language);
let _ = parent.append(&sub);
submenus.push((label.clone(), sub));
-242
View File
@@ -1,242 +0,0 @@
//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue;
//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz.
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod motion;
use cadcraft_ui_egui::{CadApp, spacemouse::Status};
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::time::Duration;
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Instant,
};
#[derive(Default)]
struct Shared {
status: Status,
device: Option<String>,
detail: Option<String>,
#[cfg(any(target_os = "windows", target_os = "linux"))]
motion: motion::Motion,
#[cfg(any(target_os = "windows", target_os = "linux"))]
last_report: Option<Instant>,
buttons: u8,
recalibrate: bool,
}
pub struct SpaceMouse {
shared: Arc<Mutex<Shared>>,
stop: Arc<AtomicBool>,
last_frame: Instant,
#[cfg(any(target_os = "windows", target_os = "linux"))]
moving: bool,
}
impl SpaceMouse {
pub fn start(ctx: egui::Context) -> Self {
let shared = Arc::new(Mutex::new(Shared::default()));
let stop = Arc::new(AtomicBool::new(false));
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone());
if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = ctx;
Self {
shared,
stop,
last_frame: Instant::now(),
#[cfg(any(target_os = "windows", target_os = "linux"))]
moving: false,
}
}
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
let Ok(mut shared) = self.shared.lock() else { return };
if std::mem::take(&mut app.spacemouse.calibrate_requested) {
shared.recalibrate = true;
}
let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail;
app.spacemouse.status = shared.status;
app.spacemouse.device.clone_from(&shared.device);
app.spacemouse.detail.clone_from(&shared.detail);
if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) {
app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message()));
}
let buttons = std::mem::take(&mut shared.buttons);
#[cfg(any(target_os = "windows", target_os = "linux"))]
let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) {
shared.motion
} else {
motion::Motion::default()
};
drop(shared);
let allowed = app.spacemouse.settings.enabled
&& ctx.input(|i| i.focused)
&& !app.ui.start_tab
&& app.ui.dialog.is_none()
&& app.closing.is_none()
&& app.canvas.rect.is_some()
&& !egui::Popup::is_any_open(ctx);
if allowed && buttons & 1 != 0 {
let _ = app.run("zoom", serde_json::json!({"mode": "extents"}));
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
let moving = allowed && motion.active();
if moving {
if self.moving {
let settings = app.spacemouse.settings;
let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed };
if let Err(error) = app.run(
"ui.spacemouse.navigate",
serde_json::json!({
"pan": motion.pan.map(|v| v * settings.pan_speed * elapsed),
"zoom": (motion.zoom * rate * elapsed).exp()
}),
) {
app.set_status(error);
}
}
ctx.request_repaint_after(Duration::from_millis(16));
}
self.moving = moving;
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
let _ = elapsed;
}
}
impl Drop for SpaceMouse {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn set_status(shared: &Mutex<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
let Ok(mut state) = shared.lock() else { return };
let changed = state.status != status || state.device != device || state.detail != detail;
if !changed {
return;
}
if let Some(error) = &detail {
log::warn!("3D mouse: {error}");
}
state.status = status;
state.device = device;
state.detail = detail;
state.motion = motion::Motion::default();
state.last_report = None;
state.buttons = 0;
drop(state);
ctx.request_repaint();
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn retry(stop: &AtomicBool) {
for _ in 0..40 {
if stop.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn read(shared: Arc<Mutex<Shared>>, stop: Arc<AtomicBool>, ctx: egui::Context) {
while !stop.load(Ordering::Relaxed) {
let mut api = match hidapi::HidApi::new() {
Ok(api) => api,
Err(error) => {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
retry(&stop);
continue;
}
};
while !stop.load(Ordering::Relaxed) {
if let Err(error) = api.refresh_devices() {
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
break;
}
let device =
api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8);
let Some(info) = device else {
set_status(&shared, &ctx, Status::Searching, None, None);
retry(&stop);
continue;
};
let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string();
match info.open_device(&api) {
Ok(device) => {
set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None);
if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) {
set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string()));
}
}
Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())),
}
retry(&stop);
}
retry(&stop);
}
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
fn read_device(device: &hidapi::HidDevice, shared: &Mutex<Shared>, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> {
let mut filter = motion::Filter::default();
let mut buffer = [0; 64];
let mut buttons = 0;
while !stop.load(Ordering::Relaxed) {
let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) };
if recalibrate {
filter.recalibrate();
set_status(shared, ctx, Status::Calibrating, Some(name.into()), None);
}
let len = device.read_timeout(&mut buffer, 50)?;
let now = Instant::now();
let report = buffer.get(..len).and_then(motion::decode);
let Ok(mut state) = shared.lock() else { return Ok(()) };
let mut wake = false;
match report {
Some(motion::Report::Buttons(next)) => {
let pressed = next & !buttons;
buttons = next;
state.buttons |= pressed & 1;
state.recalibrate |= pressed & 2 != 0;
wake = pressed != 0;
}
Some(motion::Report::Axes { values, mask }) => {
let motion = filter.update(values, mask, now);
wake = state.motion.active() != motion.active();
state.motion = motion;
state.last_report = Some(now);
if filter.calibrated() && state.status != Status::Connected {
state.status = Status::Connected;
wake = true;
}
}
None => {
if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
state.motion = motion::Motion::default();
wake = true;
}
}
}
drop(state);
if wake {
ctx.request_repaint();
}
}
Ok(())
}
-158
View File
@@ -1,158 +0,0 @@
//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports.
use std::time::{Duration, Instant};
const FULL_SCALE: f64 = 500.0;
const DEADZONE: f64 = 0.04;
const REST_LIMIT: f64 = 60.0;
const REST_RANGE: f64 = 10.0;
const CALIBRATION_TIME: Duration = Duration::from_millis(400);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Report {
/// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report.
Axes {
values: [i16; 6],
mask: u8,
},
Buttons(u8),
}
/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined
/// report 1 has twelve. Windows may pad the short button report to the input-report size.
pub fn decode(bytes: &[u8]) -> Option<Report> {
let (&id, payload) = bytes.split_first()?;
if id == 3 {
return payload.first().copied().map(Report::Buttons);
}
let (mask, offset, count) = match id {
1 if payload.len() >= 12 => (3, 0, 6),
1 if payload.len() >= 6 => (1, 0, 3),
2 if payload.len() >= 6 => (2, 3, 3),
_ => return None,
};
let mut values = [0; 6];
for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) {
*out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]);
}
Some(Report::Axes { values, mask })
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Motion {
pub pan: [f64; 2],
pub zoom: f64,
}
impl Motion {
pub fn active(self) -> bool {
self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0
}
}
#[derive(Default)]
struct Rest {
since: Option<Instant>,
count: u32,
sum: [f64; 6],
min: [f64; 6],
max: [f64; 6],
}
impl Rest {
fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> {
if raw.iter().any(|v| v.abs() > REST_LIMIT) {
*self = Self::default();
return None;
}
if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) {
*self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw };
return None;
}
for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) {
*min = min.min(value);
*max = max.max(value);
}
// A fixed bound even if a device sends far more than the expected 125 reports/sec.
if self.count < 128 {
self.count += 1;
for (sum, value) in self.sum.iter_mut().zip(raw) {
*sum += value;
}
}
if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) {
Some(self.sum.map(|sum| sum / f64::from(self.count)))
} else {
None
}
}
}
#[derive(Default)]
pub struct Filter {
raw: [f64; 6],
bias: Option<[f64; 6]>,
rest: Rest,
parts: u8,
seen: [Option<Instant>; 2],
idle_since: Option<Instant>,
}
impl Filter {
pub fn calibrated(&self) -> bool {
self.bias.is_some()
}
pub fn recalibrate(&mut self) {
*self = Self::default();
}
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 {
*seen = Some(now);
}
}
for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() {
if mask & if index < 3 { 1 } else { 2 } != 0 {
*raw = f64::from(value);
}
}
self.parts |= mask & 3;
let complete = self.parts == 3;
if complete {
self.parts = 0;
}
if self.bias.is_none() && complete {
self.bias = self.rest.observe(self.raw, now);
}
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;
}
}
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);
*axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) };
}
// A missing rotation report must not keep zooming while translations still arrive.
for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) {
if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
group.fill(0.0);
}
}
let [tx, ty, tz, _rx, _ry, rz] = axes;
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
}
}
-182
View File
@@ -1,182 +0,0 @@
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
//! Use System Setting.
//!
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
//! winit reports no such setting. This asks the OS itself:
//!
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
//!
//! The answer goes to `CadApp::system_animations` once a frame.
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::time::{Duration, Instant};
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
const POLL: Duration = Duration::from_secs(3);
/// Watches the OS setting.
#[derive(Default)]
pub struct SystemAnimations {
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
/// when no thread could be started.
changes: Option<Receiver<Option<bool>>>,
/// The OS's last answer.
current: Option<bool>,
/// When the OS was last asked from the UI thread, where asking is one quick call.
asked: Option<Instant>,
}
impl SystemAnimations {
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
/// idle window repaints and picks it up.
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
if !ASKS_THE_PORTAL {
return Self::default();
}
let (tx, rx) = channel();
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
let mut last = read();
if tx.send(last).is_err() {
return;
}
wake();
loop {
std::thread::sleep(POLL);
let now = read();
if now != last {
last = now;
if tx.send(now).is_err() {
return;
}
wake();
}
}
});
Self { changes: started.ok().map(|_| rx), ..Self::default() }
}
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
/// Polled once a frame; an OS that stops answering keeps its last answer.
pub fn current(&mut self) -> Option<bool> {
if let Some(changes) = &self.changes {
loop {
match changes.try_recv() {
Ok(Some(animate)) => self.current = Some(animate),
Ok(None) => {}
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
}
}
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
self.asked = Some(Instant::now());
if let Some(animate) = read() {
self.current = Some(animate);
}
}
self.current
}
}
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
/// `SPI_GETCLIENTAREAANIMATION`.
#[cfg(windows)]
fn read() -> Option<bool> {
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
}
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
#[cfg(target_os = "macos")]
fn read() -> Option<bool> {
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
}
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
/// thread is its only caller.
#[cfg(all(unix, not(target_os = "macos")))]
fn read() -> Option<bool> {
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
// its portal answered before the standard key existed.
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
let connection = zbus::blocking::Connection::session().ok()?;
for (namespace, key) in KEYS {
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
// wraps it in one more variant. `parse` unwraps either.
let reply = connection
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
let Ok(reply) = reply else { continue };
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
if let Some(animate) = parse(&answer, 0) {
return Some(animate);
}
}
None
}
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
/// motion; 0 and values from a later version state no preference), `enable-animations` a
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
#[cfg(all(unix, not(target_os = "macos")))]
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
use zbus::zvariant::Value;
match value {
Value::U32(reduced) => Some(*reduced != 1),
Value::Bool(enabled) => Some(*enabled),
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
_ => None,
}
}
#[cfg(not(any(unix, windows)))]
fn read() -> Option<bool> {
None
}
// macOS is asked on the main thread only, so there is nothing to test off it.
#[cfg(all(test, not(target_os = "macos")))]
mod tests {
use super::*;
/// Windows is asked on the first frame, then not again until the poll interval has passed.
#[test]
#[cfg(windows)]
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
let mut watch = SystemAnimations::start(|| {});
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
let first = watch.current();
assert_eq!(first, read());
let asked = watch.asked;
assert!(asked.is_some());
assert_eq!(watch.current(), first);
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
}
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
/// variants (the older `Read`).
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn the_portal_answer_reads_as_show_animations_or_as_none() {
use zbus::zvariant::Value;
assert_eq!(parse(&Value::U32(1), 0), Some(false));
assert_eq!(parse(&Value::U32(0), 0), Some(true));
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
assert_eq!(parse(&Value::Str("off".into()), 0), None);
// A hostile answer nested without end is given up on.
let mut nested = Value::U32(1);
for _ in 0..16 {
nested = Value::Value(Box::new(nested));
}
assert_eq!(parse(&nested, 0), None);
}
}
-36
View File
@@ -22,14 +22,6 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.rangoDJ]
sync_github_name = true
[people.Tru10]
display_name = "by Truitt"
sync_github_name = false
@@ -45,31 +37,3 @@ sync_github_name = true
[people.WladmirJunior]
real_name = "Wladmir Júnior"
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.GJoe2]
sync_github_name = true
[people.holmbom]
real_name = "Erik Holmbom"
display_name = "Erik Holmbom"
sync_github_name = false
[people.cyfung1031]
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"
+1 -9
View File
@@ -22,7 +22,7 @@ impl Rgb {
}
pub fn parse_hex(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#');
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
if s.len() != 6 || !s.is_ascii() {
return None;
}
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
@@ -225,14 +225,6 @@ 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 palette_known_entries() {
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
+1 -5
View File
@@ -102,11 +102,7 @@ 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() };
// 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 allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
let g = collect(d, handles);
let mut out: Vec<Constraint> = Vec::new();
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
+3 -49
View File
@@ -12,15 +12,12 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, 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;
@@ -89,8 +86,6 @@ struct CompDof {
objects: Vec<Option<usize>>,
}
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
fn analyse(comp: &Comp) -> CompDof {
let free = comp.free(None);
let rows = comp.rows(None);
@@ -99,14 +94,6 @@ 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 {
@@ -139,8 +126,7 @@ 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 mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let (comps, _) = build(d, &cons, &SolveOptions::default())?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -179,7 +165,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build_budgeted(d, &[h], &mut members_left);
let m = Model::build(d, &[h]);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -207,35 +193,3 @@ 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());
}
}
-44
View File
@@ -185,43 +185,6 @@ 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> {
let v = match e {
@@ -351,13 +314,6 @@ mod tests {
assert!(chain.value("p0").is_err());
}
#[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"));
+1 -80
View File
@@ -324,24 +324,12 @@ 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_budgeted(d, &handles, members_left);
let mut model = Model::build(d, &handles);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
@@ -440,46 +428,6 @@ 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();
@@ -711,33 +659,6 @@ 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")]
+5 -107
View File
@@ -27,11 +27,6 @@ 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 {
@@ -79,21 +74,6 @@ 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!(
@@ -113,26 +93,13 @@ 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() {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
if out.len() >= MAX_RIGID_POINTS {
break;
}
*members_left -= 1;
let pts: Vec<Vec2> = match &e.kind {
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
EntityKind::Arc(a) => {
@@ -185,12 +152,6 @@ 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) {
@@ -247,15 +208,12 @@ 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)));
// 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);
m.splines.insert(h, sp.clone());
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
let pts: Vec<Vec2> = rigid_points(d, ins).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);
@@ -542,14 +500,11 @@ impl Model {
}
(EntityKind::Arc(a), Shape::Arc) => {
if let [cx, cy, r, s0, s1] = new {
// A negative radius puts every point opposite its angle: turning both
// ends half a turn keeps the same points with a positive radius.
let turn = if *r < 0.0 { std::f64::consts::PI } else { 0.0 };
a.center.x = *cx;
a.center.y = *cy;
a.radius = r.abs().max(1e-12);
a.start = cadcraft_geom::norm_angle(*s0 + turn);
a.end = cadcraft_geom::norm_angle(*s1 + turn);
a.start = cadcraft_geom::norm_angle(*s0);
a.end = cadcraft_geom::norm_angle(*s1);
}
}
(EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => {
@@ -615,60 +570,3 @@ 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());
}
}
+7 -31
View File
@@ -40,11 +40,6 @@ 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).
@@ -97,10 +92,6 @@ 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 }
}
@@ -116,16 +107,6 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
}
impl Built {
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
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> {
@@ -303,15 +284,10 @@ impl Built {
}
K::Angular => {
b.rows = 1;
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]));
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));
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;
@@ -321,7 +297,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 matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU };
let max = if b.form == Form::LL { 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());
}
@@ -485,9 +461,9 @@ impl Built {
(K::Angular, Form::ArcSweep) => {
out.push(m.arc_sweep(x, r(0)?)? - self.target);
}
(K::Angular, Form::Rays { rev }) => {
(K::Angular, _) => {
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
}
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
-26
View File
@@ -463,29 +463,3 @@ fn set_parameter_rejects_out_of_domain_dimension() {
set_parameter(&mut d, "rad1", "2", &opts).unwrap();
assert!(all_satisfied(&d));
}
/// The solver may meet a coincidence with a negative arc radius; the stored arc must still put
/// the referenced point where the solver did (issue #703), through `add` and through `solve`.
#[test]
fn arc_solved_with_a_negative_radius_keeps_its_points() {
use std::f64::consts::FRAC_PI_2;
// (arc centre, radius, start, end, point): a semicircle with the point behind it, and the
// asymmetric case from the issue.
let cases = [((0.0, 0.0), 1.0, -FRAC_PI_2, FRAC_PI_2, (-5.0, 0.0)), ((6.694, -0.686), 0.62075, 3.4824, 1.1766, (-7.562, 4.946))];
for (c, r, a0, a1, p) in cases {
for through_solve in [false, true] {
let mut d = Drawing::default();
let a = arc(&mut d, c, r, a0, a1);
let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }));
let k = con(ConstraintKind::Coincident, &[s(a, Sub::Mid), w(q)]);
if through_solve {
d.constraints.push(Constraint { id: 1, ..k });
solve(&mut d, &SolveOptions::default()).unwrap();
} else {
ok(&mut d, k);
}
assert!(all_satisfied(&d), "{c:?} solve {through_solve}: {:?}", residuals(&d));
assert!(circ(&d, a).1 > 0.0);
}
}
}
-19
View File
@@ -363,22 +363,3 @@ 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));
}
-11
View File
@@ -159,9 +159,6 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
EntityKind::MText(t) => {
t.height *= f;
t.width *= f;
if let Some(c) = &mut t.columns {
c.scale(f);
}
}
EntityKind::Dimension(dm) => {
dm.overrides.insert("scale".into(), serde_json::json!(f));
@@ -175,9 +172,6 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
t.insert = land + (t.insert - land) * f;
t.height *= f;
t.width *= f;
if let Some(c) = &mut t.columns {
c.scale(f);
}
}
if let Some(ins) = &mut m.block {
ins.insert = land + (ins.insert - land) * f;
@@ -194,11 +188,6 @@ pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
a.text.insert = o + (a.text.insert - o) * f;
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
a.text.height *= f;
if let Some(ml) = &mut a.multiline {
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
ml.mtext.height *= f;
ml.mtext.width *= f;
}
}
}
_ => {}
+1 -8
View File
@@ -248,17 +248,10 @@ 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 last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) {
Some(r) => r.block.clone_from(&ins.block),
None => match source {
None => source = Some(&ins.block),
+1 -1
View File
@@ -35,7 +35,7 @@ impl Sub {
"mid" | "midpoint" | "m" => Sub::Mid,
"center" | "centre" | "c" => Sub::Center,
_ => {
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("s", false), ("v", true)] {
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] {
if let Some(n) = l.strip_prefix(p) {
let i: u32 = n.trim().parse().ok()?;
return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) });
-565
View File
@@ -1,565 +0,0 @@
//! Dynamic blocks made in another program, read from the data CADCraft keeps for them.
//!
//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as
//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
//! [`dynamic_block`] reads them into the model below without changing the kept data, so a drawing
//! saved again still carries them exactly as read.
//!
//! Every count and index here comes from the file: they are capped by the data actually present
//! and unknown or damaged objects are skipped, never trusted.
use std::collections::HashMap;
use cadcraft_geom::{Vec2, Vec3};
use serde::Serialize;
use crate::foreign::ForeignObject;
use crate::{Drawing, Handle, RawTag};
mod apply;
mod grips;
mod reference;
pub use apply::{drag_grip, set_property};
pub use grips::{DynGrip, GripPart, GripShape, grips};
pub use reference::{CustomProperty, Reference, Value, custom_properties, reference};
/// Most objects one evaluation graph is read with.
const MAX_NODES: usize = 10_000;
/// Handles as hex strings in JSON, as commands report them.
fn hex<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&h.hex())
}
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(hs.iter().map(|h| h.hex()))
}
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i)))
}
/// A dynamic block's parameters and actions.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamicBlock {
pub parameters: Vec<Parameter>,
pub actions: Vec<Action>,
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
pub other: Vec<String>,
/// The grip objects by graph id (parameters name their grips by id; visibility states list
/// the grips shown in them by object).
#[serde(skip)]
pub grip_objects: Vec<(i32, Handle)>,
}
/// One parameter: a property a reference to the block can change.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Parameter {
/// Its id in the evaluation graph (actions and lookups refer to parameters by it).
pub node: i32,
#[serde(serialize_with = "hex")]
pub handle: Handle,
/// The name the block editor shows ("Linear", "Visibility State").
pub element: String,
/// The parameter's properties appear in a reference's Properties palette.
pub show_properties: bool,
pub kind: ParameterKind,
/// Graph ids of its grips: a two-point parameter's base and end grips (0 for none), a
/// one-point parameter's grip. A reference's history records grip drags by these.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub grips: Vec<i32>,
}
/// What a parameter is, with its definition geometry in block coordinates.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ParameterKind {
/// A distance between two points (Distance1).
Linear {
base: Vec3,
end: Vec3,
label: String,
description: String,
values: ValueSet,
/// The value changes the end point (`false`) or both points from the midpoint (`true`).
from_midpoint: bool,
},
/// A distance and an angle from a base point.
Polar { base: Vec3, end: Vec3 },
/// Horizontal and vertical distances from a base point.
Xy { base: Vec3, end: Vec3 },
/// An angle about a base point.
Rotation { base: Vec3, end: Vec3 },
/// Aligns the reference with nearby geometry.
Alignment { base: Vec3, end: Vec3 },
/// Mirrors the objects about a reflection line.
Flip { base: Vec3, end: Vec3 },
/// A position (Position1).
Point { at: Vec3, label: String, description: String },
/// Named sets of visible objects.
Visibility {
at: Vec3,
label: String,
description: String,
#[serde(serialize_with = "hexes")]
controlled: Vec<Handle>,
states: Vec<VisibilityState>,
},
/// A named value chosen from a lookup table.
Lookup { at: Vec3, label: String, description: String },
/// Moves the block's base point.
BasePoint { at: Vec3 },
/// A parameter type not modelled yet.
Other { dxf_type: String },
}
impl ParameterKind {
/// The property name a reference shows ("Distance1", "Visibility1"), when it has one.
pub fn label(&self) -> Option<&str> {
match self {
ParameterKind::Linear { label, .. }
| ParameterKind::Point { label, .. }
| ParameterKind::Visibility { label, .. }
| ParameterKind::Lookup { label, .. } => Some(label),
_ => None,
}
}
}
/// One visibility state: its name and the objects shown in it (the parameter's other controlled
/// objects are hidden).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VisibilityState {
pub name: String,
#[serde(serialize_with = "hexes")]
pub visible: Vec<Handle>,
/// The parameters and actions active in this state (their objects).
#[serde(serialize_with = "hexes")]
pub active: Vec<Handle>,
}
/// The values a distance or angle may take.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ValueSet {
/// Which of the limits below apply (see the methods).
pub flags: i32,
pub minimum: f64,
pub maximum: f64,
pub increment: f64,
/// The allowed values, for a list.
pub list: Vec<f64>,
}
impl ValueSet {
pub fn has_minimum(&self) -> bool {
self.flags & 1 != 0
}
pub fn has_maximum(&self) -> bool {
self.flags & 2 != 0
}
pub fn has_increment(&self) -> bool {
self.flags & 4 != 0
}
pub fn is_list(&self) -> bool {
self.flags & 8 != 0
}
}
/// One action: what changing parameters does to the block's objects.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Action {
pub node: i32,
#[serde(serialize_with = "hex")]
pub handle: Handle,
/// Its name in the block editor ("Stretch", "Lookup1").
pub name: String,
/// Where the block editor shows it.
pub at: Vec3,
/// The objects it acts on.
#[serde(serialize_with = "hexes")]
pub selection: Vec<Handle>,
/// Graph ids of the parameters it follows, with the parameter value each connection reads
/// ("EndXDelta").
pub connections: Vec<(i32, String)>,
pub kind: ActionKind,
}
/// What an action does.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ActionKind {
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
Stretch {
frame: Vec<Vec2>,
#[serde(serialize_with = "hex_vertices")]
vertices: Vec<(Handle, Vec<i32>)>,
multiplier: f64,
angle_offset: f64,
},
/// A table: each row sets the listed parameter values together.
Lookup {
columns: Vec<LookupColumn>,
rows: Vec<Vec<String>>,
},
/// Moves the objects by the change of the parameter values it follows.
Move {
multiplier: f64,
angle_offset: f64,
},
Scale,
Rotate,
Flip,
Array,
PolarStretch,
/// An action type not modelled yet.
Other {
dxf_type: String,
},
}
/// A lookup table column: the parameter value it reads or sets.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupColumn {
/// The parameter's graph id.
pub node: i32,
/// The value it stands for ("VisibilityState", "lookupString").
pub property: String,
/// A lookup property (shown on the reference) rather than an input.
pub lookup: bool,
/// What the reference shows when no row matches ("Custom").
pub unmatched: String,
}
/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`.
fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> {
let mut out: Vec<(&str, &[RawTag])> = Vec::new();
let mut start = 0;
let mut name = "";
for (i, t) in tags.iter().enumerate() {
if t.code == 100 {
out.push((name, tags.get(start..i).unwrap_or_default()));
name = t.value.as_str();
start = i + 1;
}
}
out.push((name, tags.get(start..).unwrap_or_default()));
out
}
/// The groups after subclass marker `marker`, empty when the object has none.
fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] {
secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t)
}
/// Reads groups in order, as an object writes them.
struct Cursor<'a> {
tags: &'a [RawTag],
at: usize,
/// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that
/// ask for it, so a hostile record could make nested counts rescan it again and again; past
/// `budget` every lookup fails at once and every count is zero.
work: usize,
budget: usize,
}
impl<'a> Cursor<'a> {
fn new(tags: &'a [RawTag]) -> Self {
Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) }
}
/// The next group with `code` from here on (the cursor moves past it).
fn next(&mut self, code: i32) -> Option<&'a str> {
if self.work >= self.budget {
return None;
}
let rest = self.tags.get(self.at..)?;
let Some(rel) = rest.iter().position(|t| t.code == code) else {
self.work = self.work.saturating_add(rest.len().max(1));
return None;
};
self.work = self.work.saturating_add(rel + 1);
self.at += rel + 1;
self.tags.get(self.at - 1).map(|t| t.value.as_str())
}
fn string(&mut self, code: i32) -> String {
self.next(code).unwrap_or_default().to_string()
}
fn int(&mut self, code: i32) -> i32 {
self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
}
fn real(&mut self, code: i32) -> f64 {
self.next(code).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0)
}
/// A count read from the file, capped by the groups left (each item needs at least one).
fn count(&mut self, code: i32) -> usize {
let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at));
if self.work >= self.budget { 0 } else { n }
}
fn handle(&mut self, code: i32) -> Option<Handle> {
self.next(code).and_then(Handle::parse_hex)
}
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
(0..n).filter_map(|_| self.handle(code)).collect()
}
/// A point written with codes `x`, `x + 10`, `x + 20`.
fn point(&mut self, x: i32) -> Vec3 {
let px = self.real(x);
let py = self.real(x + 10);
let at = self.at;
// Z is optional: only take it when it is the very next group.
let pz = match self.tags.get(at) {
Some(t) if t.code == x + 20 => self.real(x + 20),
_ => 0.0,
};
Vec3::new(px, py, pz)
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") {
graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
// 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
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
let kind = o.kind.to_ascii_uppercase();
if kind.ends_with("PARAMETER") {
let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0;
let grips = grip_ids(&secs);
out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs), grips });
} else if kind.ends_with("ACTION") {
out.actions.push(action(o.handle, node, element, &kind, &secs));
} else {
if kind.ends_with("GRIP") {
out.grip_objects.push((node, o.handle));
}
out.other.push(o.kind.clone());
}
}
Some(out)
}
/// A parameter's grip ids: after a two-point parameter's points, the grip count (170) and the
/// base and end grips (91); after a one-point parameter's point, its grip (93).
fn grip_ids(secs: &[(&str, &[RawTag])]) -> Vec<i32> {
let two = section(secs, "AcDbBlock2PtParameter");
if !two.is_empty() {
let mut c = Cursor::new(two);
c.point(1010);
c.point(1011);
let n = c.count(170).min(2);
return (0..n).map(|_| c.int(91)).collect();
}
let mut c = Cursor::new(section(secs, "AcDbBlock1PtParameter"));
c.point(1010);
c.next(93).and_then(|v| v.trim().parse().ok()).into_iter().collect()
}
fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind {
let two = || {
let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter"));
(c.point(1010), c.point(1011))
};
let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010);
match kind {
"BLOCKLINEARPARAMETER" => {
let (base, end) = two();
let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter"));
let label = c.string(305);
let description = c.string(306);
let from_midpoint = Cursor::new(section(secs, "AcDbBlock2PtParameter")).int(177) == 1;
ParameterKind::Linear { base, end, label, description, values: value_set(&mut c), from_midpoint }
}
"BLOCKPOINTPARAMETER" => {
let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter"));
ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) }
}
"BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")),
"BLOCKLOOKUPPARAMETER" => {
let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter"));
ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) }
}
"BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() },
"BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => {
let (base, end) = two();
match kind {
"BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end },
"BLOCKXYPARAMETER" => ParameterKind::Xy { base, end },
"BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end },
"BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end },
_ => ParameterKind::Flip { base, end },
}
}
other => ParameterKind::Other { dxf_type: other.to_string() },
}
}
/// A value set after a parameter's name and description: description (307), flags (96),
/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each).
fn value_set(c: &mut Cursor) -> ValueSet {
c.next(307);
let flags = c.int(96);
let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143));
let n = c.count(175);
let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
ValueSet { flags, minimum, maximum, increment, list }
}
/// A visibility parameter: its name and description (301, 302), the objects it controls (93
/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the
/// parameters and actions active in it (95, 333).
fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind {
let mut c = Cursor::new(tags);
let label = c.string(301);
let description = c.string(302);
let n = c.count(93);
let controlled = c.handles(331, n);
let states = (0..c.count(92))
.map(|_| {
let name = c.string(303);
let n = c.count(94);
let visible = c.handles(332, n);
let n = c.count(95);
VisibilityState { name, visible, active: c.handles(333, n) }
})
.collect();
ParameterKind::Visibility { at, label, description, controlled, states }
}
fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action {
let mut c = Cursor::new(section(secs, "AcDbBlockAction"));
c.next(70);
let n = c.count(71);
let selection = c.handles(330, n);
let at = c.point(1010);
let (connections, kind) = match kind {
"BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")),
"BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))),
"BLOCKMOVEACTION" => {
let tags = section(secs, "AcDbBlockMoveAction");
let mut c = Cursor::new(tags);
let (multiplier, angle_offset) = (c.real(140), c.real(141));
(two_connections(tags), ActionKind::Move { multiplier, angle_offset })
}
"BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale),
"BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate),
"BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip),
"BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array),
"BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch),
other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }),
};
Action { node, handle, name, at, selection, connections, kind }
}
/// Two connections: parameter ids (92, 93) and the values they read (301, 302). Writers order
/// these groups differently (92 93 301 302, or 92 301 93 302), so each is read on its own.
fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> {
let first = |code: i32| Cursor::new(tags).next(code).unwrap_or_default().to_string();
let int = |code: i32| first(code).trim().parse().unwrap_or(0);
vec![(int(92), first(301)), (int(93), first(302))]
}
/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects
/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then
/// the distance multiplier (140) and angle offset (141).
fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) {
let connections = two_connections(tags);
let mut c = Cursor::new(tags);
let n = c.count(72);
let frame = (0..n)
.map(|_| {
let x = c.real(1011);
Vec2::new(x, c.real(1021))
})
.collect();
let objects = c.count(73);
let vertices = (0..objects)
.filter_map(|_| {
let h = c.handle(331)?;
let n = c.count(74);
Some((h, (0..n).map(|_| c.int(94)).collect()))
})
.collect();
c.next(75);
let (multiplier, angle_offset) = (c.real(140), c.real(141));
(connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset })
}
/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header),
/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the
/// parameter value it stands for (304).
fn lookup(tags: &[RawTag]) -> ActionKind {
let mut c = Cursor::new(tags);
let rows = c.count(92);
let cols = c.count(93);
c.next(301);
let cells = rows.saturating_mul(cols).min(tags.len());
let flat: Vec<String> = (0..cells).map(|_| c.string(302)).collect();
let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect();
let columns = (0..cols)
.map(|_| {
c.next(303);
let node = c.int(94);
let lookup = c.int(282) != 0;
let unmatched = c.string(305);
let property = c.string(304);
LookupColumn { node, property, lookup, unmatched }
})
.collect();
ActionKind::Lookup { columns, rows }
}
#[cfg(test)]
mod tests {
use super::*;
fn tag(code: i32, value: &str) -> RawTag {
RawTag { code, value: value.to_string() }
}
/// Counts that promise groups the record doesn't have: each nested loop would rescan the
/// record for them. The work budget keeps this linear in the record's size.
#[test]
fn hostile_counts_with_missing_groups_stay_linear() {
let n = 20_000;
let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())];
tags.extend((0..n).map(|_| tag(94, &n.to_string())));
let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") };
assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty()));
let mut tags = vec![tag(73, &n.to_string())];
tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())]));
let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") };
let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum();
assert!(ints <= 16 * tags.len() + 1024, "{ints}");
}
}
-966
View File
@@ -1,966 +0,0 @@
//! Changing a dynamic block reference's custom properties.
//!
//! A changed reference shows an anonymous block (`*U12`) made from its current one with the
//! change applied, and says so the way AutoCAD's own references do, so AutoCAD still reads it as
//! a reference to the dynamic block: the anonymous block's BLOCK_RECORD carries
//! `AcDbBlockRepBTag` xdata naming the dynamic block's record, each copied object keeps the
//! `AcDbBlockRepETag` xdata that pairs it with its object in the dynamic block, and the reference's
//! `AcDbBlockRepresentation` data holds its values (created when it had none) and its history
//! says what was done to it (AutoCAD replays the history when the dynamic block is redefined).
//!
//! What can change so far: a visibility state, a lookup value, a distance and a position, with
//! the stretch and move actions they drive and the lookups that follow. The other parameters'
//! actions come later.
use std::collections::{BTreeMap, HashMap, HashSet};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use super::{ActionKind, DynamicBlock, ParameterKind, Value, dynamic_block, reference};
use crate::foreign::{ForeignExtra, ForeignObject};
use crate::{Block, Drawing, Entity, EntityKind, Handle, RawTag};
/// Xdata application naming a copied object's object in the dynamic block (its element tag).
const ETAG: &str = "AcDbBlockRepETag";
/// Xdata application naming an anonymous block's dynamic block.
const BTAG: &str = "AcDbBlockRepBTag";
fn tag(code: i32, value: impl Into<String>) -> RawTag {
RawTag { code, value: value.into() }
}
/// The element tag (`AcDbBlockRepETag` 1071) of object `h`, if it has one.
fn etag(d: &Drawing, h: Handle) -> Option<i64> {
let x = &d.foreign.entities.get(&h)?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case(ETAG))?;
x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1071).and_then(|t| t.value.trim().parse().ok())
}
/// Each object of `b` keyed by its element tag, or by its position when it has none.
fn by_tag(d: &Drawing, b: &Block) -> Vec<(i64, Handle)> {
b.entities.iter().enumerate().map(|(i, e)| (etag(d, e.handle).unwrap_or(i as i64 + 1_000_000), e.handle)).collect()
}
/// The file handle of block `name`'s BLOCK_RECORD, as the kept data refers to it.
fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
let key = name.to_ascii_uppercase();
d.foreign.targets.iter().find(|(_, (k, n))| k == "BLOCK_RECORD" && *n == key).map(|(h, _)| *h)
}
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
}
}
None
}
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
match t.code {
3 => key = Some(&t.value),
350 | 360 => {
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
out.push((k.to_string(), h));
}
}
_ => {}
}
}
out
}
/// A parameter's value as a change sets it (block coordinates).
#[derive(Clone, Debug, PartialEq)]
enum Val {
/// A visibility state or lookup value.
Text(String),
/// A point parameter's position.
Point(Vec3),
/// A two-point parameter's base and end points.
Points(Vec3, Vec3),
}
/// A parameter's current value on the reference: the value it was given, else the block's own.
fn current(m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32) -> Option<Val> {
let p = m.parameters.iter().find(|p| p.node == node)?;
let given = values.get(&node);
Some(match (&p.kind, given) {
(_, Some(Value::Text { value })) => Val::Text(value.clone()),
(_, Some(Value::Point { at })) => Val::Point(*at),
(_, Some(Value::Points { base, end })) => Val::Points(*base, *end),
(ParameterKind::Visibility { states, .. }, None) => Val::Text(states.first().map(|s| s.name.clone()).unwrap_or_default()),
(ParameterKind::Point { at, .. }, None) => Val::Point(*at),
(
ParameterKind::Linear { base, end, .. }
| ParameterKind::Polar { base, end }
| ParameterKind::Xy { base, end }
| ParameterKind::Rotation { base, end }
| ParameterKind::Alignment { base, end }
| ParameterKind::Flip { base, end },
None,
) => Val::Points(*base, *end),
_ => return None,
})
}
/// `v` as a value set allows it: the nearest listed value, a multiple of the increment from the
/// minimum, within the minimum and maximum.
fn allowed(set: &super::ValueSet, v: f64) -> f64 {
let mut v = v;
if set.is_list() && !set.list.is_empty() {
v = set.list.iter().copied().fold(f64::NAN, |best, x| if best.is_nan() || (x - v).abs() < (best - v).abs() { x } else { best });
}
if set.has_increment() && set.increment > 0.0 {
let from = if set.has_minimum() { set.minimum } else { 0.0 };
v = from + ((v - from) / set.increment).round() * set.increment;
}
if set.has_minimum() {
v = v.max(set.minimum);
}
if set.has_maximum() && set.maximum > 0.0 {
v = v.min(set.maximum);
}
v
}
/// A linear parameter's points for distance `dist`: the end point moves along the parameter, or
/// both points move apart from the midpoint.
fn linear_points(base: Vec3, end: Vec3, dist: f64, from_midpoint: bool) -> (Vec3, Vec3) {
let dir = end - base;
let len = dir.len();
let u = if len > 1e-12 { dir * (1.0 / len) } else { Vec3::new(1.0, 0.0, 0.0) };
if from_midpoint {
let mid = (base + end) * 0.5;
(mid - u * (dist / 2.0), mid + u * (dist / 2.0))
} else {
(base, base + u * dist)
}
}
/// The changes one property change makes, before actions run: parameter values by graph id.
#[derive(Default)]
struct Plan {
set: BTreeMap<i32, Val>,
}
impl Plan {
/// Set the value a lookup table cell gives property `property` of parameter `node`.
fn cell(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str, cell: &str) -> Result<(), String> {
let p = m.parameters.iter().find(|p| p.node == node).ok_or("A lookup table refers to a missing parameter.")?;
match (&p.kind, property.to_ascii_lowercase().as_str()) {
(_, "lookupstring") | (ParameterKind::Visibility { .. }, "visibilitystate") => {
self.set.insert(node, Val::Text(cell.to_string()));
}
(ParameterKind::Linear { from_midpoint, .. }, "updateddistance" | "distance") => {
let dist = cell
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.is_finite())
.ok_or_else(|| format!("A lookup table gives \"{cell}\" for a distance."))?;
let Some(Val::Points(b, e)) = self.set.get(&node).cloned().or_else(|| current(m, values, node)) else { return Ok(()) };
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
self.set.insert(node, Val::Points(b2, e2));
}
(_, other) => return Err(format!("A lookup table sets {other}, which CADCraft can't change yet.")),
}
Ok(())
}
/// Lookup parameter `node` set to `value`: every lookup table that sets it gives its row's
/// inputs. A table without that value is left alone; none having it is an error.
fn lookup(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, value: &str, skip: Option<usize>) -> Result<bool, String> {
let mut found = false;
for (i, a) in m.actions.iter().enumerate() {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if Some(i) == skip {
continue;
}
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
let Some(row) = rows.iter().find(|r| r.get(out).is_some_and(|v| v.trim().eq_ignore_ascii_case(value.trim()))) else { continue };
found = true;
for (c, cell) in columns.iter().zip(row) {
self.cell(m, values, c.node, &c.property, cell)?;
}
}
Ok(found)
}
/// After input property `property` of parameter `node` changed: a lookup table with that input
/// whose inputs (with the new value) match a row gives its lookup value, and the lookup's
/// other tables then set their inputs for that value (as AutoCAD does).
fn chain(&mut self, m: &DynamicBlock, values: &BTreeMap<i32, Value>, node: i32, property: &str) -> Result<(), String> {
let read = |plan: &Plan, n: i32, prop: &str| -> Option<String> {
let v = plan.set.get(&n).cloned().or_else(|| current(m, values, n))?;
match (v, prop.to_ascii_lowercase().as_str()) {
(Val::Text(t), "visibilitystate" | "lookupstring") => Some(t),
(Val::Points(b, e), "updateddistance" | "distance") => Some(format!("{}", (e - b).len())),
_ => None,
}
};
for (i, a) in m.actions.iter().enumerate() {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if !columns.iter().any(|c| !c.lookup && c.node == node && c.property.eq_ignore_ascii_case(property)) {
continue;
}
let Some(out) = columns.iter().position(|c| c.lookup) else { continue };
let lookup_node = columns.get(out).map_or(0, |c| c.node);
let hit = rows.iter().find(|r| {
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(j, c)| match (r.get(j), read(self, c.node, &c.property)) {
(Some(cell), Some(v)) => same(cell, &v),
_ => false,
})
});
if let Some(v) = hit.and_then(|r| r.get(out)).cloned() {
self.set.insert(lookup_node, Val::Text(v.clone()));
self.lookup(m, values, lookup_node, &v, Some(i))?;
}
}
Ok(())
}
}
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
fn same(cell: &str, value: &str) -> bool {
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
}
}
/// A number typed for a property, in drawing units.
fn number(value: &str) -> Result<f64, String> {
value.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| format!("\"{value}\" is not a number."))
}
/// Set custom property `name` (as the Properties palette names it, any case) of dynamic block
/// reference `insert` to `value` (lengths and positions in drawing units, as Properties shows them).
pub fn set_property(d: &mut Drawing, insert: Handle, name: &str, value: &str) -> Result<(), String> {
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let (sx, sy) = (ins.scale.x.abs().max(1e-12), ins.scale.y.abs().max(1e-12));
let block_base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
let want = name.trim();
// A point parameter's position is two properties, "Position1 X" and "Position1 Y".
let (label, axis) = match want.rsplit_once(' ') {
Some((l, a)) if a.eq_ignore_ascii_case("X") || a.eq_ignore_ascii_case("Y") => (l, Some(a.eq_ignore_ascii_case("X"))),
_ => (want, None),
};
let p = m
.parameters
.iter()
.find(|p| {
p.show_properties && p.kind.label().is_some_and(|l| l.eq_ignore_ascii_case(want) || (axis.is_some() && l.eq_ignore_ascii_case(label)))
})
.ok_or_else(|| format!("The reference has no property \"{name}\"."))?;
let mut plan = Plan::default();
match (&p.kind, axis) {
(ParameterKind::Visibility { states, label, .. }, _) => {
let s = states.iter().find(|s| s.name.eq_ignore_ascii_case(value.trim())).ok_or_else(|| {
format!("\"{value}\" is not a state of {label}: {}.", states.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))
})?;
plan.set.insert(p.node, Val::Text(s.name.clone()));
plan.chain(&m, &r.values, p.node, "VisibilityState")?;
}
(ParameterKind::Lookup { label, .. }, _) => {
plan.set.insert(p.node, Val::Text(value.trim().to_string()));
if !plan.lookup(&m, &r.values, p.node, value, None)? {
return Err(format!("\"{value}\" is not a value of {label}."));
}
}
(ParameterKind::Linear { values, from_midpoint, .. }, None) => {
let typed = number(value)? / sx;
if typed <= 0.0 {
return Err(format!("{name} must be more than 0."));
}
let dist = allowed(values, typed);
if dist <= 0.0 {
return Err(format!("{name} must be more than 0."));
}
let Some(Val::Points(b, e)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no points.")) };
let (b2, e2) = linear_points(b, e, dist, *from_midpoint);
plan.set.insert(p.node, Val::Points(b2, e2));
plan.chain(&m, &r.values, p.node, "UpdatedDistance")?;
}
(ParameterKind::Point { .. }, Some(is_x)) => {
let Some(Val::Point(at)) = current(&m, &r.values, p.node) else { return Err(format!("{name} has no position.")) };
let v = number(value)?;
let at = if is_x { Vec3::new(block_base.x + v / sx, at.y, at.z) } else { Vec3::new(at.x, block_base.y + v / sy, at.z) };
plan.set.insert(p.node, Val::Point(at));
}
_ => return Err(format!("CADCraft can't change {name} yet.")),
}
apply(d, insert, &r.definition, &r.values, &plan.set, p.node)
}
/// Drag grip `part` of parameter `node` of dynamic block reference `insert` to `to` (drawing
/// coordinates), as AutoCAD's dynamic grips do: a distance's end grip (or base grip) changes the
/// distance by the drag along the parameter, snapped to its value set (both ends move when the
/// distance is measured from its midpoint); a position grip moves the position. The actions and
/// lookups follow as for [`set_property`].
pub fn drag_grip(d: &mut Drawing, insert: Handle, node: i32, part: super::GripPart, to: Vec2) -> Result<(), String> {
use super::GripPart;
if !to.is_finite() {
return Err("Invalid point.".into());
}
let r = reference(d, insert).ok_or("Not a dynamic block reference.")?;
let m = dynamic_block(d, &r.definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
let q = ins.transform(base).inverse().ok_or("The reference has a zero scale.")?.apply(to);
let p = m.parameters.iter().find(|p| p.node == node).ok_or("The reference has no such parameter.")?;
let mut plan = Plan::default();
match (&p.kind, part) {
(ParameterKind::Linear { values, from_midpoint, label, .. }, GripPart::Base | GripPart::End) => {
let Some(Val::Points(b, e)) = current(&m, &r.values, node) else { return Err(format!("{label} has no points.")) };
let len = (e - b).len();
if len < 1e-12 {
return Err(format!("{label} has no direction."));
}
let u = (e - b) * (1.0 / len);
let q = Vec3::new(q.x, q.y, b.z);
let raw = if *from_midpoint {
2.0 * (q - (b + e) * 0.5).dot(u).abs()
} else if part == GripPart::End {
(q - b).dot(u)
} else {
(e - q).dot(u)
};
let dist = allowed(values, raw);
if !(raw > 0.0 && dist > 0.0 && dist.is_finite()) {
return Err(format!("{label} must be more than 0."));
}
let (b2, e2) = match (from_midpoint, part) {
(true, _) => linear_points(b, e, dist, true),
(false, GripPart::Base) => (e - u * dist, e),
(false, _) => (b, b + u * dist),
};
plan.set.insert(node, Val::Points(b2, e2));
plan.chain(&m, &r.values, node, "UpdatedDistance")?;
}
(ParameterKind::Point { label, .. }, GripPart::At) => {
let Some(Val::Point(at)) = current(&m, &r.values, node) else { return Err(format!("{label} has no position.")) };
plan.set.insert(node, Val::Point(Vec3::new(q.x, q.y, at.z)));
}
_ => return Err("That grip can't be dragged.".into()),
}
apply(d, insert, &r.definition, &r.values, &plan.set, node)
}
/// The change of each parameter value an action can follow, by parameter graph id and the
/// connection's name ("EndXDelta", "XDelta"…), from `old` and `new` values.
fn delta(old: &Val, new: &Val, connection: &str) -> f64 {
let c = connection.to_ascii_lowercase();
let pick = |v: Vec3| if c.contains('x') { v.x } else { v.y };
match (old, new) {
(Val::Point(a), Val::Point(b)) => pick(*b - *a),
(Val::Points(ab, ae), Val::Points(bb, be)) => {
if c.starts_with("base") || c.starts_with("updatedbase") {
pick(*bb - *ab)
} else {
pick(*be - *ae)
}
}
_ => 0.0,
}
}
/// Move entity `e` by `v`: the listed vertices of a line or polyline, else the whole object.
fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
match (&mut e.kind, vertices) {
(EntityKind::LwPolyline(pl), Some(idx)) => {
for i in idx {
if let Some(p) = usize::try_from(*i).ok().and_then(|i| pl.vertices.get_mut(i)) {
p.p += v;
}
}
}
(EntityKind::Line(l), Some(idx)) => {
for i in idx {
match i {
0 => l.a = l.a + v.to3(0.0),
1 => l.b = l.b + v.to3(0.0),
_ => {}
}
}
}
_ => e.kind.transform(&Mat3::translate(v)),
}
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
match frame {
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
f if f.len() >= 3 => {
let mut inside = false;
let mut j = f.len() - 1;
for i in 0..f.len() {
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
inside = !inside;
}
j = i;
}
inside
}
_ => true,
}
}
/// Give `insert` a new anonymous block with the plan's values applied (visibility states, and
/// the stretch and move actions of the changed parameters, with the parameters in their selections
/// carried along), and record the new values on it.
fn apply(
d: &mut Drawing,
insert: Handle,
definition: &str,
values: &BTreeMap<i32, Value>,
set: &BTreeMap<i32, Val>,
direct: i32,
) -> Result<(), String> {
let m = dynamic_block(d, definition).ok_or("Not a dynamic block reference.")?;
let EntityKind::Insert(ins) = &d.entity(insert).ok_or("No such object.")?.kind else { return Err("Not a block reference.".into()) };
let ins = ins.clone();
let def = d.block(definition).cloned().ok_or("The dynamic block is missing.")?;
let src = d.block(&ins.block).cloned().ok_or("The reference's block is missing.")?;
// The dynamic block's objects and the reference's copies of them, by element tag.
let def_tags: HashMap<Handle, i64> = by_tag(d, &def).into_iter().map(|(t, h)| (h, t)).collect();
let src_by_tag: HashMap<i64, Handle> = by_tag(d, &src).into_iter().collect();
let copy_of = |dh: &Handle| def_tags.get(dh).and_then(|t| src_by_tag.get(t)).copied();
// New values, starting from the change; actions add the parameters they carry along.
let mut new: BTreeMap<i32, Val> = set.clone();
let mut moves: HashMap<Handle, Vec<(Option<Vec<i32>>, Vec2)>> = HashMap::new();
let param_of: HashMap<Handle, i32> = m.parameters.iter().map(|p| (p.handle, p.node)).collect();
for a in &m.actions {
let changed: Vec<&(i32, String)> = a.connections.iter().filter(|(n, _)| set.contains_key(n)).collect();
if changed.is_empty() {
continue;
}
let (mult, angle, frame, vertices): (f64, f64, &[Vec2], &[(Handle, Vec<i32>)]) = match &a.kind {
ActionKind::Stretch { frame, vertices, multiplier, angle_offset } => (*multiplier, *angle_offset, frame, vertices),
ActionKind::Move { multiplier, angle_offset } => (*multiplier, *angle_offset, &[], &[]),
_ => continue,
};
// The two connections give the change in x and y.
let comp = |i: usize| {
a.connections.get(i).map_or(0.0, |(n, conn)| match (set.get(n), current(&m, values, *n)) {
(Some(nv), Some(ov)) => delta(&ov, nv, conn),
_ => 0.0,
})
};
let mult = if mult == 0.0 { 1.0 } else { mult };
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
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
// the actions from the block itself, so a point another action has moved out of
// the frame still follows).
if set.contains_key(node) {
continue;
}
let Some(cur) = new.get(node).cloned().or_else(|| current(&m, values, *node)) else { continue };
let home = current(&m, &BTreeMap::new(), *node);
let shift = |p: Vec3, at: Option<Vec3>| {
if frame.is_empty() || in_frame(frame, at.unwrap_or(p).xy()) { p + v.to3(0.0) } else { p }
};
let moved = match (cur, home) {
(Val::Point(p), Some(Val::Point(h))) => Val::Point(shift(p, Some(h))),
(Val::Point(p), _) => Val::Point(shift(p, None)),
(Val::Points(b, e), Some(Val::Points(hb, he))) => Val::Points(shift(b, Some(hb)), shift(e, Some(he))),
(Val::Points(b, e), _) => Val::Points(shift(b, None), shift(e, None)),
(t, _) => t,
};
new.insert(*node, moved);
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
moves.entry(c).or_default().push((idx, v));
}
}
// Visibility states.
let mut shown: HashMap<Handle, bool> = HashMap::new();
for (node, v) in &new {
let (Some(ParameterKind::Visibility { controlled, states, .. }), Val::Text(state)) =
(m.parameters.iter().find(|p| p.node == *node).map(|p| &p.kind), v)
else {
continue;
};
let visible: HashSet<Handle> =
states.iter().find(|s| s.name.eq_ignore_ascii_case(state)).map(|s| s.visible.iter().copied().collect()).unwrap_or_default();
for h in controlled {
shown.insert(*h, visible.contains(h));
}
}
let shown_copy: HashMap<Handle, bool> = shown.iter().filter_map(|(dh, v)| copy_of(dh).map(|c| (c, *v))).collect();
// The new anonymous block.
let name = d.anonymous_block_name("*U");
// AutoCAD's anonymous copies have no units of their own.
let mut b =
Block { name: name.clone(), anonymous: true, xref_path: None, array: None, units: 0, entities: crate::EntityStore::new(), ..(*src).clone() };
let mut copies: HashMap<Handle, Handle> = HashMap::new();
for e in src.entities.iter() {
copies.insert(e.handle, d.new_handle());
}
// How far each attribute definition moves (block coordinates): the reference's attribute of
// that tag moves with it, as AutoCAD moves it.
let attrib_moves: HashMap<String, Vec2> = src
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::AttDef(ad) => {
let v = moves.get(&e.handle).into_iter().flatten().fold(Vec2::ZERO, |acc, (_, v)| acc + *v);
(v != Vec2::ZERO).then(|| (ad.tag.to_ascii_uppercase(), v))
}
_ => None,
})
.collect();
for e in src.entities.iter() {
let Some(nh) = copies.get(&e.handle).copied() else { continue };
let mut c: Entity = (**e).clone();
c.handle = nh;
if let Some(v) = shown_copy.get(&e.handle) {
c.common.visible = *v;
}
for (idx, v) in moves.get(&e.handle).into_iter().flatten() {
move_entity(&mut c, idx.as_deref(), *v);
}
if let EntityKind::Hatch(h) = &mut c.kind {
for l in &mut h.loops {
l.sources = l.sources.iter().map(|s| copies.get(s).copied().unwrap_or(*s)).collect();
}
}
b.entities.push(c);
}
refill_hatches(&mut b);
let def_record = Some(record_handle(d, definition).unwrap_or_else(|| {
let h = d.new_handle();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.targets.insert(h, ("BLOCK_RECORD".to_string(), definition.to_ascii_uppercase()));
h
}));
let old = ins.block.clone();
d.blocks.insert(name.clone(), std::sync::Arc::new(b));
let f = std::sync::Arc::make_mut(&mut d.foreign);
for (old_h, new_h) in &copies {
if let Some(mut x) = f.entities.get(old_h).cloned() {
for t in &mut x.xdata {
if t.code == 1005 && Handle::parse_hex(&t.value) == Some(*old_h) {
t.value = new_h.hex();
}
}
x.xdict = None;
// Reactors within the block (an associative hatch's outline) follow to the copies;
// ones outside it are not ours.
x.reactors = x.reactors.iter().filter_map(|r| copies.get(r).copied()).collect();
f.entities.insert(*new_h, x);
}
}
if let Some(rec) = def_record {
let xdata = vec![tag(1001, BTAG), tag(1070, "1"), tag(1005, rec.hex())];
f.records.insert(("BLOCK_RECORD".into(), name.to_ascii_uppercase()), ForeignExtra { xdata, ..Default::default() });
}
// The block-to-drawing transform's linear part turns a block-coordinate move into the move of
// the reference's attribute.
let to_world = ins.transform(src.base.xy());
let origin = to_world.apply(Vec2::ZERO);
d.modify_entity(insert, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.block = name.clone();
for a in &mut i.attribs {
if let Some(v) = attrib_moves.get(&a.tag.to_ascii_uppercase()) {
let w = to_world.apply(*v) - origin;
let mut k = EntityKind::Text(a.text.clone());
k.transform(&Mat3::translate(w));
if let EntityKind::Text(t) = k {
a.text = t;
}
}
}
}
})
.map_err(|e| e.to_string())?;
let was = current(&m, values, direct);
record_values(d, insert, def_record, &m, &new, (direct, was.as_ref()));
drop_if_unused(d, &old);
Ok(())
}
/// Associative hatches of block `b` whose loop is one closed polyline of the block take its
/// outline again (it may have been stretched).
fn refill_hatches(b: &mut Block) {
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
.entities
.iter()
.filter_map(|e| match &e.kind {
EntityKind::LwPolyline(pl) if pl.closed => Some((e.handle, pl.vertices.clone())),
_ => None,
})
.collect();
let hatches: Vec<Handle> = b.entities.iter().filter(|e| matches!(&e.kind, EntityKind::Hatch(h) if h.associative)).map(|e| e.handle).collect();
for h in hatches {
b.entities.modify(h, |e| {
if let EntityKind::Hatch(hh) = &mut e.kind {
for l in &mut hh.loops {
if let [s] = l.sources.as_slice()
&& let Some(vs) = outlines.get(s)
{
l.vertices.clone_from(vs);
}
}
}
});
}
}
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
}
}
}
out
}
/// A dictionary object `h` owned by `owner` with `entries`.
fn dictionary(h: Handle, owner: Handle, entries: &[(&str, Handle)]) -> ForeignObject {
let mut tags = vec![tag(5, h.hex()), tag(330, owner.hex()), tag(100, "AcDbDictionary"), tag(280, "1"), tag(281, "1")];
for (k, v) in entries {
tags.push(tag(3, *k));
tags.push(tag(360, v.hex()));
}
ForeignObject { handle: h, kind: "DICTIONARY".into(), tags }
}
/// `tags` (a template's groups) as new record `h` owned by `owner`: the template's handle, owner
/// and reactors are not ours (inside reactor brackets, 102, everything goes).
fn own(mut tags: Vec<RawTag>, h: Handle, owner: Handle) -> Vec<RawTag> {
let mut inside = false;
tags.retain(|t| {
if t.code == 102 {
inside = t.value.starts_with('{');
return false;
}
!(inside || t.code == 5 || t.code == 330)
});
let at = tags.iter().position(|t| t.code == 100).unwrap_or(0);
tags.insert(at, tag(330, owner.hex()));
tags.insert(0, tag(5, h.hex()));
tags
}
/// Record values `set` (by parameter graph id) in `insert`'s representation data, and the direct
/// change (of parameter `direct`, from value `old`) in its history, creating them when the reference has none. A
/// new reference's data gets a record for everything other references of the dynamic block have
/// (AutoCAD keeps one for each parameter and stretch action), with its own values; with no other
/// reference to copy, a changed value gets a minimal record.
fn record_values(
d: &mut Drawing,
insert: Handle,
def_record: Option<Handle>,
m: &DynamicBlock,
set: &BTreeMap<i32, Val>,
(direct, old): (i32, Option<&Val>),
) {
let xdict = d.foreign.entities.get(&insert).and_then(|x| x.xdict);
let rep = xdict.and_then(|x| entry(d, x, "AcDbBlockRepresentation"));
let cache = rep.and_then(|r| entry(d, r, "AppDataCache"));
let data = cache.and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA"));
let templates = def_record.map(|r| templates(d, r)).unwrap_or_default();
let param = |node: i32| m.parameters.iter().find(|p| p.node == node).map(|p| &p.kind);
let at = |node: i32| match param(node) {
Some(
ParameterKind::Visibility { at, .. }
| ParameterKind::Lookup { at, .. }
| ParameterKind::Point { at, .. }
| ParameterKind::BasePoint { at },
) => *at,
_ => Vec3::ZERO,
};
let mut objects: Vec<ForeignObject> = Vec::new();
let mut fresh: Vec<(String, Vec<RawTag>)> = Vec::new();
let (cache, data) = match (cache, data) {
(Some(c), Some(h)) => (c, h),
_ => {
// Create the representation: reference xdict ▸ AcDbBlockRepresentation ▸ AcDbRepData
// (the dynamic block's record) and AppDataCache ▸ ACAD_ENHANCEDBLOCKDATA.
let Some(rec) = def_record else { return };
let xd = xdict.unwrap_or_else(|| d.new_handle());
let (rep, rd, cache, data) = (d.new_handle(), d.new_handle(), d.new_handle(), d.new_handle());
objects.push(dictionary(rep, xd, &[("AcDbRepData", rd), ("AppDataCache", cache)]));
objects.push(ForeignObject {
handle: rd,
kind: "ACDB_BLOCKREPRESENTATION_DATA".into(),
tags: vec![tag(5, rd.hex()), tag(330, rep.hex()), tag(100, "AcDbBlockRepresentationData"), tag(70, "1"), tag(340, rec.hex())],
});
objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", data)]));
objects.push(dictionary(data, cache, &[]));
// Every record the other references have, with this reference's values: its
// parameters' positions and defaults (lookups as Properties shows them); action
// records as they are.
let lookups: HashMap<String, String> = super::custom_properties(d, insert)
.map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.value)).collect())
.unwrap_or_default();
let mut keys: Vec<&String> = templates.keys().collect();
keys.sort();
for k in keys {
let (Ok(node), Some(t)) = (k.parse::<i32>(), templates.get(k)) else { continue };
let mut tags = t.clone();
match param(node) {
Some(ParameterKind::Visibility { .. } | ParameterKind::Point { .. } | ParameterKind::BasePoint { .. }) => {
write_value(&mut tags, &Val::Point(at(node)));
}
Some(ParameterKind::Lookup { label, .. }) => {
write_value(&mut tags, &Val::Point(at(node)));
if let Some(v) = lookups.get(&label.to_ascii_uppercase()) {
write_value(&mut tags, &Val::Text(v.clone()));
}
}
_ => {}
}
if let Some(v) = current(m, &BTreeMap::new(), node) {
write_value(&mut tags, &v);
}
fresh.push((k.clone(), tags));
}
let f = std::sync::Arc::make_mut(&mut d.foreign);
match f.objects.iter_mut().find(|o| Some(o.handle) == xdict) {
Some(x) => {
x.tags.push(tag(3, "AcDbBlockRepresentation"));
x.tags.push(tag(360, rep.hex()));
}
None => {
objects.push(dictionary(xd, insert, &[("AcDbBlockRepresentation", rep)]));
f.entities.entry(insert).or_default().xdict = Some(xd);
}
}
(cache, data)
}
};
let existing: HashMap<String, Handle> = match objects.iter().find(|o| o.handle == data) {
Some(_) => HashMap::new(),
None => entries(d, data).into_iter().collect(),
};
let mut add: Vec<(String, Handle)> = Vec::new();
for (key, mut tags) in fresh {
if let Some(v) = key.parse::<i32>().ok().and_then(|n| set.get(&n)) {
write_value(&mut tags, v);
}
let h = d.new_handle();
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
add.push((key, h));
}
for (node, v) in set {
let key = node.to_string();
if add.iter().any(|(k, _)| *k == key) {
continue;
}
match existing.get(&key) {
Some(h) => {
let f = std::sync::Arc::make_mut(&mut d.foreign);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == *h) {
write_value(&mut x.tags, v);
}
}
None => {
let h = d.new_handle();
let mut tags = templates.get(&key).cloned().unwrap_or_else(|| {
let p = at(*node);
vec![tag(100, "AcDbXrecord"), tag(280, "1"), tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]
});
write_value(&mut tags, v);
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags: own(tags, h, data) });
add.push((key, h));
}
}
}
// The history: AutoCAD replays it over the dynamic block (when the block is redefined), so
// it must say what was done to this reference.
let history = entry(d, cache, "ACAD_ENHANCEDBLOCKHISTORY").filter(|h| d.foreign.objects.iter().any(|o| o.handle == *h));
let mut new_history = None;
if let (Some(v), Some(p)) = (set.get(&direct), m.parameters.iter().find(|p| p.node == direct)) {
match history {
Some(h) => {
let f = std::sync::Arc::make_mut(&mut d.foreign);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == h) {
write_history(&mut x.tags, p, old, v);
}
}
None => {
let h = d.new_handle();
let mut tags = vec![tag(5, h.hex()), tag(330, cache.hex()), tag(100, "AcDbXrecord"), tag(280, "1"), tag(1070, "3")];
write_history(&mut tags, p, old, v);
objects.push(ForeignObject { handle: h, kind: "XRECORD".into(), tags });
new_history = Some(h);
}
}
}
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.extend(objects);
if let Some(x) = f.objects.iter_mut().find(|o| o.handle == data) {
for (k, h) in add {
x.tags.push(tag(3, k));
x.tags.push(tag(360, h.hex()));
}
}
if let (Some(h), Some(x)) = (new_history, f.objects.iter_mut().find(|o| o.handle == cache)) {
x.tags.push(tag(3, "ACAD_ENHANCEDBLOCKHISTORY"));
x.tags.push(tag(360, h.hex()));
}
}
/// Record the direct change of parameter `p` to `v` in a reference's history (groups `tags`):
/// after its header (`280 1`, `1070 3`), an entry per change: `1071` the id, `300` what, then
/// the value. A visibility state or lookup value goes by the property's name (`1` the text), as
/// does a distance (`40`, in block units: typed in Properties, it replaces its grips' drags); a
/// position as the drag of its grip, `GRIPLOC` with how far it is from where the dynamic block
/// has it (`11/21/31`), when the change moves it (from `old`). An entry for the same id is
/// replaced; a grip back in its place has none.
fn write_history(tags: &mut Vec<RawTag>, p: &super::Parameter, old: Option<&Val>, v: &Val) {
let grip = |id: i32, moved: Vec3| {
let home = moved.x.abs() < 1e-9 && moved.y.abs() < 1e-9 && moved.z.abs() < 1e-9;
let e = vec![
tag(1071, id.to_string()),
tag(300, "GRIPLOC"),
tag(11, moved.x.to_string()),
tag(21, moved.y.to_string()),
tag(31, moved.z.to_string()),
];
(id, if home { Vec::new() } else { e })
};
// Each grip with where it was, where it is and where the dynamic block has it.
let grips = |places: &[(Vec3, Vec3, Vec3)]| -> Vec<(i32, Vec<RawTag>)> {
p.grips
.iter()
.zip(places)
.filter(|(g, (was, is, _))| **g != 0 && (*was - *is).len() > 1e-9)
.map(|(g, (_, is, home))| grip(*g, *is - *home))
.collect()
};
let was = match old {
Some(Val::Point(q)) => Some(*q),
_ => None,
};
let changes: Vec<(i32, Vec<RawTag>)> = match (&p.kind, v) {
(ParameterKind::Visibility { label, .. } | ParameterKind::Lookup { label, .. }, Val::Text(t)) => {
vec![(p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(1, t.clone())])]
}
(ParameterKind::Linear { label, .. }, Val::Points(b, e)) => {
let mut c: Vec<(i32, Vec<RawTag>)> = p.grips.iter().filter(|g| **g != 0).map(|g| (*g, Vec::new())).collect();
c.push((p.node, vec![tag(1071, p.node.to_string()), tag(300, label.clone()), tag(40, (*e - *b).len().to_string())]));
c
}
(ParameterKind::Point { at, .. }, Val::Point(q)) => grips(&[(was.unwrap_or(*at), *q, *at)]),
_ => return,
};
// The header, then the entries (each from its 1071).
let start = tags.iter().position(|t| t.code == 1071).unwrap_or(tags.len());
let mut entries: Vec<Vec<RawTag>> = Vec::new();
for t in tags.drain(start..) {
if t.code == 1071 || entries.is_empty() {
entries.push(Vec::new());
}
if let Some(e) = entries.last_mut() {
e.push(t);
}
}
let id = |e: &[RawTag]| e.first().filter(|t| t.code == 1071).and_then(|t| t.value.trim().parse::<i32>().ok());
entries.retain(|e| !changes.iter().any(|(c, _)| id(e) == Some(*c)));
entries.extend(changes.into_iter().map(|(_, e)| e).filter(|e| !e.is_empty()));
tags.extend(entries.into_iter().flatten());
}
/// Write value `v` into a value record's groups: its text (group 1), or its points in order over
/// the record's points (10/20/30), adding them when it has fewer.
fn write_value(tags: &mut Vec<RawTag>, v: &Val) {
let points: Vec<Vec3> = match v {
Val::Text(t) => {
match tags.iter_mut().find(|x| x.code == 1) {
Some(x) => x.value.clone_from(t),
None => tags.push(tag(1, t.clone())),
}
return;
}
Val::Point(p) => vec![*p],
Val::Points(b, e) => vec![*b, *e],
};
for (i, p) in points.iter().enumerate() {
// The i-th point's groups, else new ones before any text.
let find = |tags: &[RawTag], code: i32, n: usize| tags.iter().enumerate().filter(|(_, t)| t.code == code).nth(n).map(|(j, _)| j);
match (find(tags, 10, i), find(tags, 20, i)) {
(Some(jx), Some(jy)) => {
if let Some(t) = tags.get_mut(jx) {
t.value = p.x.to_string();
}
if let Some(t) = tags.get_mut(jy) {
t.value = p.y.to_string();
}
if let Some(t) = find(tags, 30, i).and_then(|jz| tags.get_mut(jz)) {
t.value = p.z.to_string();
}
}
_ => {
let at = tags.iter().position(|t| t.code == 1).unwrap_or(tags.len());
tags.splice(at..at, [tag(10, p.x.to_string()), tag(20, p.y.to_string()), tag(30, p.z.to_string())]);
}
}
}
}
/// Remove anonymous block `name` when nothing refers to it any more (the block a changed
/// reference used to show), with the data kept for its record.
fn drop_if_unused(d: &mut Drawing, name: &str) {
if !name.starts_with('*') {
return;
}
let refers = |e: &Entity| matches!(&e.kind, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(name));
let used = d.model.iter().any(|e| refers(e))
|| d.layouts.iter().any(|l| l.entities.iter().any(|e| refers(e)))
|| d.blocks.values().any(|b| b.entities.iter().any(|e| refers(e)));
if used {
return;
}
if let Some(b) = d.blocks.remove(name) {
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.records.remove(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()));
f.records.remove(&("BLOCK".to_string(), name.to_ascii_uppercase()));
for e in b.entities.iter() {
f.entities.remove(&e.handle);
}
}
}
-215
View File
@@ -1,215 +0,0 @@
//! The grips of a dynamic block reference, as AutoCAD shows them on a selected reference: an
//! arrow at each end of a distance (linear parameter), a square at a position (point parameter)
//! and a down-pointing triangle for a list (visibility states, lookup values). Dragging an arrow
//! or square is [`super::drag_grip`]; a list grip offers its property's choices.
//!
//! With a visibility parameter, a state lists the grips shown in it; the others are hidden.
use std::collections::{HashMap, HashSet};
use cadcraft_geom::{Vec2, Vec3};
use serde::Serialize;
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
use crate::{Drawing, EntityKind, Handle};
/// Most grips listed for one reference.
const MAX_GRIPS: usize = 200;
/// Which of a parameter's points a grip is at.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GripPart {
/// A distance's base point.
Base,
/// A distance's end point.
End,
/// A one-point parameter's point (a position, a visibility or lookup grip).
At,
}
impl GripPart {
pub fn name(self) -> &'static str {
match self {
GripPart::Base => "base",
GripPart::End => "end",
GripPart::At => "at",
}
}
pub fn from_name(s: &str) -> Option<GripPart> {
match s.trim().to_ascii_lowercase().as_str() {
"base" => Some(GripPart::Base),
"end" => Some(GripPart::End),
"at" => Some(GripPart::At),
_ => None,
}
}
}
/// How a grip looks and what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum GripShape {
/// An arrow pointing away from the distance: drag it to change the distance.
Linear,
/// A square: drag it to move the position.
Point,
/// A down-pointing triangle: click it for the property's choices.
List,
}
/// One grip of a dynamic block reference.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DynGrip {
/// The parameter's graph id.
pub node: i32,
pub part: GripPart,
pub shape: GripShape,
/// Where it is, in drawing coordinates.
#[serde(serialize_with = "point")]
pub at: Vec2,
/// The way an arrow points (a unit vector in drawing coordinates; zero for the others).
#[serde(serialize_with = "point")]
pub dir: Vec2,
/// The property it changes, as Properties names it.
pub property: String,
/// A list grip's choices.
#[serde(skip_serializing_if = "Vec::is_empty")]
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)
}
/// The grips of dynamic block reference `insert` (none when it is not one).
pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
let Some(r) = reference(d, insert) else { return Vec::new() };
let Some(m) = dynamic_block(d, &r.definition) else { return Vec::new() };
let Some(EntityKind::Insert(ins)) = d.entity(insert).map(|e| &e.kind) else { return Vec::new() };
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
let xf = ins.transform(base);
let world = |p: Vec3| xf.apply(p.xy());
let toward = |p: Vec3, u: Vec3| {
let v = xf.apply(p.xy() + u.xy()) - xf.apply(p.xy());
if v.len() > 1e-12 { v * (1.0 / v.len()) } else { Vec2::ZERO }
};
// The grips the current visibility state shows (all of them without visibility states).
let objects: HashMap<i32, Handle> = m.grip_objects.iter().copied().collect();
let mut shown_in_state: Option<HashSet<Handle>> = None;
let mut visibility_node = None;
for p in &m.parameters {
if let ParameterKind::Visibility { states, .. } = &p.kind {
let current = match r.values.get(&p.node) {
Some(Value::Text { value }) => states.iter().find(|s| s.name.eq_ignore_ascii_case(value)),
_ => states.first(),
};
if let Some(s) = current {
shown_in_state = Some(s.active.iter().copied().collect());
}
visibility_node = Some(p.node);
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)) || 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();
let mut out = Vec::new();
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, 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 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() });
}
}
}
ParameterKind::Point { at, label, .. } => {
let at = match r.values.get(&p.node) {
Some(Value::Point { at }) => *at,
_ => *at,
};
if shown(p.node, grip(0)) {
let at = world(at);
out.push(DynGrip {
node: p.node,
part: GripPart::At,
shape: GripShape::Point,
at,
dir: Vec2::ZERO,
property: label.clone(),
choices: Vec::new(),
});
}
}
ParameterKind::Visibility { at, label, .. } | ParameterKind::Lookup { at, label, .. } => {
let Some(list) = choices.get(&label.to_ascii_uppercase()).filter(|c| !c.is_empty()) else { continue };
if shown(p.node, grip(0)) {
let at = world(*at);
out.push(DynGrip {
node: p.node,
part: GripPart::At,
shape: GripShape::List,
at,
dir: Vec2::ZERO,
property: label.clone(),
choices: list.clone(),
});
}
}
_ => {}
}
if out.len() >= MAX_GRIPS {
break;
}
}
out.retain(|g| g.at.is_finite());
out
}
-283
View File
@@ -1,283 +0,0 @@
//! Dynamic block references: which dynamic block a block reference shows, the values it was given
//! and the custom properties those make.
//!
//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`)
//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation`
//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose
//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with
//! the value in block coordinates. A reference to the dynamic block itself has the block's values.
use std::collections::{BTreeMap, HashMap};
use cadcraft_geom::Vec3;
use serde::Serialize;
use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block};
use crate::foreign::ForeignObject;
use crate::{Drawing, EntityKind, Handle};
/// Most values one reference is read with.
const MAX_VALUES: usize = 10_000;
/// A parameter's value on one reference, in block coordinates.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum Value {
/// A visibility state's name or a lookup value.
Text { value: String },
/// A point parameter's position.
Point { at: Vec3 },
/// A two-point parameter's points.
Points { base: Vec3, end: Vec3 },
}
/// A dynamic block reference.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Reference {
/// The dynamic block it shows (its name in the drawing).
pub definition: String,
/// The values it was given, by parameter graph id; parameters missing here have the block's
/// own values.
pub values: BTreeMap<i32, Value>,
}
/// One custom property of a reference, as the Properties palette shows it.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomProperty {
pub name: String,
pub value: String,
/// The values it may be set to, when it is a choice (visibility states, lookup values, a
/// distance's list).
pub choices: Vec<String>,
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
/// position, or a lookup whose tables set only those.
pub editable: bool,
}
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
}
/// The object entry `key` of dictionary `dict` refers to.
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
let o = objects.get(&dict)?;
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
}
}
None
}
/// Every entry of dictionary `dict`: (key, object).
fn entries(objects: &HashMap<Handle, &ForeignObject>, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = objects.get(&dict) else { return Vec::new() };
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
match t.code {
3 => key = Some(t.value.as_str()),
350 | 360 => {
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
out.push((k.to_string(), h));
}
}
_ => {}
}
}
out
}
/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30).
fn value(o: &ForeignObject) -> Option<Value> {
if let Some(t) = o.tags.iter().find(|t| t.code == 1) {
return Some(Value::Text { value: t.value.clone() });
}
let num = |code: i32, i: usize| {
o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
};
let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0)));
match (point(0), point(1)) {
(Some(base), Some(end)) => Some(Value::Points { base, end }),
(Some(at), None) => Some(Value::Point { at }),
_ => None,
}
}
/// The dynamic block reference `insert` is, or `None` when it isn't one.
pub fn reference(d: &Drawing, insert: Handle) -> Option<Reference> {
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
let objects = objects(d);
let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation"));
let Some(rep) = rep else {
// No representation: a reference to a dynamic block with its own values.
return dynamic_block(d, &ins.block)
.is_some()
.then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() });
};
let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?;
let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?;
let (kind, name) = d.foreign.targets.get(&target)?;
let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone();
dynamic_block(d, &definition)?;
let mut values = BTreeMap::new();
let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA"));
for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) {
if let (Ok(node), Some(v)) = (key.trim().parse::<i32>(), objects.get(&h).and_then(|o| value(o))) {
values.insert(node, v);
}
}
Some(Reference { definition, values })
}
/// A number as the drawing shows lengths (LUPREC decimals).
fn number(d: &Drawing, v: f64) -> String {
let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4);
format!("{v:.prec$}")
}
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
fn same(cell: &str, value: &str) -> bool {
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
}
}
/// The current values of a reference's parameters, for evaluating its lookup tables.
struct Current<'a> {
m: &'a DynamicBlock,
values: &'a BTreeMap<i32, Value>,
}
impl Current<'_> {
/// Parameter `node`'s value of property `property`, as table cells write it.
fn property(&self, node: i32, property: &str) -> Option<String> {
let p = self.m.parameters.iter().find(|p| p.node == node)?;
let v = self.values.get(&node);
match (&p.kind, property.to_ascii_lowercase().as_str()) {
(ParameterKind::Visibility { states, .. }, "visibilitystate") => match v {
Some(Value::Text { value }) => Some(value.clone()),
_ => states.first().map(|s| s.name.clone()),
},
(ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => {
let (b, e) = match v {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*base, *end),
};
Some(format!("{}", (e - b).len()))
}
(ParameterKind::Lookup { .. }, "lookupstring") => match v {
Some(Value::Text { value }) => Some(value.clone()),
_ => None,
},
_ => None,
}
}
/// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the
/// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table
/// has no matching row. Also the values it may be set to.
fn lookup(&self, node: i32) -> (String, Vec<String>) {
let mut found: Option<Option<String>> = None;
let mut unmatched = String::from("Custom");
let mut choices: Vec<String> = Vec::new();
for a in &self.m.actions {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
if rows.is_empty() {
continue;
}
if let Some(c) = columns.get(out) {
unmatched = c.unmatched.clone();
}
for r in rows {
if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) {
choices.push(v.clone());
}
}
let hit = rows.iter().find(|r| {
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) {
(Some(cell), Some(v)) => same(cell, &v),
_ => false,
})
});
let v = hit.and_then(|r| r.get(out).cloned());
found = Some(match found {
None => v,
Some(prev) if prev == v => prev,
Some(_) => None,
});
}
(found.flatten().unwrap_or(unmatched), choices)
}
}
/// The custom properties block reference `insert` shows (the Properties palette's Custom
/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties
/// with its value. Lengths and positions are in drawing units (block units × the reference's
/// scale); positions are relative to the block's base point.
pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec<CustomProperty>)> {
let r = reference(d, insert)?;
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
let m = dynamic_block(d, &r.definition)?;
let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
let cur = Current { m: &m, values: &r.values };
let mut out = Vec::new();
for p in m.parameters.iter().filter(|p| p.show_properties) {
let v = r.values.get(&p.node);
match &p.kind {
ParameterKind::Linear { base: b, end: e, label, values, .. } => {
let (b, e) = match v {
Some(Value::Points { base, end }) => (*base, *end),
_ => (*b, *e),
};
let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() };
out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices, editable: true });
}
ParameterKind::Point { at, label, .. } => {
let at = match v {
Some(Value::Point { at }) => *at,
_ => *at,
};
out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new(), editable: true });
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
}
ParameterKind::Visibility { label, states, .. } => {
let value = cur.property(p.node, "VisibilityState").unwrap_or_default();
out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect(), editable: true });
}
ParameterKind::Lookup { label, .. } => {
let (value, choices) = cur.lookup(p.node);
let editable = lookup_sets_what_changes(&m, p.node);
out.push(CustomProperty { name: label.clone(), value, choices, editable });
}
_ => {}
}
}
Some((r.definition, out))
}
/// Whether every lookup table that sets lookup parameter `node` sets only what
/// [`super::set_property`] can apply with it: visibility states and distances.
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
let mut any = false;
for a in &m.actions {
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
continue;
}
any = true;
if !columns
.iter()
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
{
return false;
}
}
any
}
+2 -133
View File
@@ -103,11 +103,6 @@ pub struct Common {
/// The annotation scales an annotative object supports (empty: not annotative).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub anno_scales: Vec<crate::AnnoScale>,
/// Other applications' extended data (xdata) as read from the drawing file: whole
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub xdata: Vec<RawTag>,
}
impl Default for Common {
@@ -124,7 +119,6 @@ impl Default for Common {
extrusion: Vec3::Z,
plot_style: "ByLayer".into(),
anno_scales: Vec::new(),
xdata: Vec::new(),
}
}
}
@@ -349,98 +343,6 @@ pub struct Attrib {
/// An attribute's own properties; each one left unset follows the attribute definition.
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
pub props: AttribProps,
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
/// multiline text. `text.value` holds the plain text, which value edits change.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multiline: Option<MultilineAttrib>,
/// Its other modes: Verify, Preset and Lock position.
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
pub modes: AttribModes,
}
/// An attribute's modes beyond invisible and constant.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttribModes {
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
#[serde(default)]
pub verify: bool,
/// Preset: the default value is used without asking (DXF flag 8).
#[serde(default)]
pub preset: bool,
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
/// 280 after the flags). A reference's attributes take it from their definitions.
#[serde(default)]
pub lock_position: bool,
}
impl AttribModes {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
/// only Lock position there.
pub fn for_reference(self) -> Self {
AttribModes { lock_position: self.lock_position, ..Self::default() }
}
}
/// The multiline text of a multiline attribute.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MultilineAttrib {
pub mtext: MText,
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
/// since, and its value replaces the contents.
pub read_as: String,
}
impl MultilineAttrib {
/// This text placed by `m` (a block reference's transform, for its attribute made from a
/// definition).
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
let mut k = EntityKind::MText(self.mtext.clone());
k.transform(m);
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
MultilineAttrib { mtext, read_as: self.read_as.clone() }
}
}
impl Attrib {
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
pub fn transform_multiline(&mut self, m: &Mat3) {
if let Some(ml) = &mut self.multiline {
*ml = ml.transformed(m);
}
}
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
/// replaced by the value when the value was edited since it was read.
pub fn multiline_text(&self) -> Option<MText> {
let m = self.multiline.as_ref()?;
let mut t = m.mtext.clone();
if self.text.value != m.read_as {
t.contents = mtext_literal(&self.text.value);
}
Some(t)
}
}
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
/// paragraph breaks.
pub fn mtext_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'{' => out.push_str("\\{"),
'}' => out.push_str("\\}"),
'\n' => out.push_str("\\P"),
'\r' => {}
c => out.push(c),
}
}
out
}
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
@@ -923,10 +825,6 @@ pub struct TableCell {
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formula: Option<String>,
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
/// `None`: its cell style's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_height: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -1257,10 +1155,7 @@ impl EntityKind {
r.dir = m.apply_vec(r.dir.xy()).normalized().to3(0.0);
}
EntityKind::Text(t) => transform_text(t, m, s, rot, mirror),
EntityKind::AttDef(a) => {
transform_text(&mut a.text, m, s, rot, mirror);
a.transform_multiline(m);
}
EntityKind::AttDef(a) => transform_text(&mut a.text, m, s, rot, mirror),
EntityKind::MText(t) => {
t3(&mut t.insert);
t.height *= s;
@@ -1282,7 +1177,6 @@ impl EntityKind {
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
a.transform_multiline(m);
}
i.col_spacing *= s;
i.row_spacing *= s;
@@ -1312,10 +1206,6 @@ impl EntityKind {
if let Some(t) = &mut ml.text {
t3(&mut t.insert);
t.height *= s;
t.width *= s;
if let Some(c) = &mut t.columns {
c.scale(s);
}
}
if let Some(b) = &mut ml.block {
// Transformed exactly like a block reference.
@@ -1408,12 +1298,9 @@ 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) = segs.get(i) {
if let Some(s) = pl.segments().get(i) {
g.push(s.mid());
}
}
@@ -1471,21 +1358,3 @@ 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)));
}
}
+25 -54
View File
@@ -88,12 +88,10 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
/// by block name and depth: every further reference to a block at that depth reuses them instead of
/// expanding the block again, so nested or self-referencing blocks cost at most one pass over each
/// definition per depth instead of one per reference (which grows exponentially with the nesting
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
/// bounds must not stand in for a shallower reference to the same block (#706).
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
/// by block name: every further reference to a block reuses them instead of expanding the block
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
/// per reference (which grows exponentially with the nesting depth).
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
match &e.kind {
EntityKind::Table(t) if t.is_broken() => t.bounds(),
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
@@ -108,34 +106,28 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
// insertion point. Attributes below still count.
let mut b = Bounds2::EMPTY;
if let Some(blk) = d.block(&ins.block) {
let m = ins.transform(blk.base.xy());
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
blocks.insert((blk.name.as_str(), depth), inner);
inner
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
let m = ins.transform(blk.base.xy());
let inner = match blocks.get(&blk.name) {
Some(b) => *b,
None => {
let mut inner = Bounds2::EMPTY;
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
}
};
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
if ins.cols > 1 || ins.rows > 1 {
let off =
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
blocks.insert(blk.name.clone(), inner);
inner
}
};
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
if ins.cols > 1 || ins.rows > 1 {
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
}
for a in &ins.attribs {
if !a.invisible {
b = b.union(&match a.multiline_text() {
Some(m) => mtext_box(&m),
None => text_box(&a.text, a.text.value.chars().count()),
});
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
}
}
b
@@ -165,21 +157,9 @@ 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
{
// 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);
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
b = b.union(&bounds_in(d, be, depth + 1, blocks));
}
}
b
}
@@ -329,16 +309,7 @@ mod tests {
#[test]
fn right_justified_attdef_is_left_of_alignment_point() {
let text = text(HAlign::Right, VAlign::Baseline);
let a = crate::Attrib {
tag: "HELLO".into(),
text,
invisible: false,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
let b = bounds(EntityKind::AttDef(a));
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
}
+1 -13
View File
@@ -34,8 +34,7 @@ pub struct ForeignExtra {
pub xdict: Option<Handle>,
/// Objects of [`Foreign::objects`] that react to it (persistent reactors).
pub reactors: Vec<Handle>,
/// Other applications' extended data of a table record or block: groups from `1001` on. An
/// entity's is part of the entity ([`crate::Common::xdata`]), so it follows the entity's copies.
/// Other applications' extended data: groups from `1001` on.
pub xdata: Vec<RawTag>,
/// The colour book colour (group 430) and the true colour (group 420) it was read with: it is
/// written back while the colour is unchanged.
@@ -66,11 +65,6 @@ pub struct Foreign {
pub records: BTreeMap<ForeignKey, ForeignExtra>,
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
pub targets: BTreeMap<Handle, ForeignKey>,
/// The 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`
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
pub data_store: Vec<RawTag>,
}
impl Foreign {
@@ -81,11 +75,5 @@ impl Foreign {
&& self.records.is_empty()
&& self.classes.is_empty()
&& self.targets.is_empty()
&& self.data_store.is_empty()
}
/// The entities the data store has records for (their 320 handles).
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
}
}
+1 -38
View File
@@ -15,7 +15,6 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod refedit;
mod store;
mod tables;
@@ -31,12 +30,10 @@ pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
pub use plotter::*;
pub use refedit::{RefEdit, WorkItem};
pub use store::EntityStore;
pub use tables::*;
/// File data CADCraft doesn't model, kept for saving (#528).
pub mod dynblock;
pub mod foreign;
#[derive(Debug, thiserror::Error)]
@@ -62,26 +59,6 @@ pub enum Space {
Paper(String),
}
impl Space {
/// Whether this is a layout's paper space.
pub fn is_paper(&self) -> bool {
matches!(self, Space::Paper(_))
}
/// The layout whose paper space this is; `None` for model space.
pub fn layout(&self) -> Option<&str> {
match self {
Space::Paper(n) => Some(n),
Space::Model => None,
}
}
/// The tab name (CTAB): `Model`, or the layout's name.
pub fn tab_name(&self) -> &str {
self.layout().unwrap_or("Model")
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Drawing {
pub header: Header,
@@ -118,15 +95,6 @@ pub struct Drawing {
/// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file:
/// a drawing opens with everything shown again.
pub hidden_objects: BTreeSet<Handle>,
/// What reading the file left out (a DWG's object records the reader couldn't decode), told
/// to the user when the drawing opens. Never written to a file.
pub read_notes: Vec<String>,
/// The in-place block reference edit in progress (REFEDIT). Never written to a file; files are
/// written from [`Drawing::committed`].
pub refedit: Option<RefEdit>,
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
}
impl Default for Drawing {
@@ -142,14 +110,12 @@ impl Drawing {
d.header = Header::imperial();
d
}
/// An empty metric drawing (millimetres, ISO-25 dimensions, multileaders and tables to match).
/// An empty metric drawing (millimetres, ISO-25 dimensions).
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
}
@@ -182,9 +148,6 @@ impl Drawing {
scales: imperial_scales(),
handseed: 0x100,
hidden_objects: BTreeSet::new(),
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
}
}
+21 -68
View File
@@ -153,11 +153,9 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
name: "BRICK",
description: "Brick or masonry-type surface",
lines: vec![
// Running bond: courses 0.25 high, bricks 0.5 long. Each vertical family draws the
// joints of every other course, 0.5 apart; the second is offset half a brick.
pl(0.0, (0.0, 0.0), (0.0, 0.25), &[]),
pl(90.0, (0.0, 0.0), (0.0, 0.5), &[0.25, -0.25]),
pl(90.0, (0.25, 0.0), (0.0, 0.5), &[-0.25, 0.25]),
pl(90.0, (0.0, 0.0), (0.25, 0.25), &[0.25, -0.25]),
pl(90.0, (0.25, 0.0), (0.25, 0.25), &[-0.25, 0.25]),
],
},
HatchPattern {
@@ -205,73 +203,28 @@ pub fn standard_patterns() -> Vec<HatchPattern> {
description: "Staircase effect",
lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])],
},
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
scaled_pattern(
HatchPattern {
name: "AR-CONC",
description: "Random dot and stone pattern",
lines: vec![
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
],
},
16.0,
),
// Drawn at the scale of the pattern CAD programs ship under this name (about 16x
// coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense).
scaled_pattern(
HatchPattern {
name: "AR-SAND",
description: "Random dot pattern",
lines: vec![
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
],
},
16.0,
),
HatchPattern {
name: "AR-CONC",
description: "Random dot and stone pattern",
lines: vec![
pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]),
pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]),
pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]),
pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]),
],
},
HatchPattern {
name: "AR-SAND",
description: "Random dot pattern",
lines: vec![
pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]),
pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]),
pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]),
],
},
]
}
pub fn pattern(name: &str) -> Option<HatchPattern> {
standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
}
/// The library pattern `name` as drawn in `d`. In a metric drawing (`MEASUREMENT` 1) every
/// length is multiplied by 25.4, as CAD programs use ISO-scaled pattern definitions there (as
/// for linetypes, see [`standard_linetypes_for`]): a hatch at scale 1 measures in millimetres
/// what it measures in inches in an imperial drawing.
pub fn pattern_for(d: &Drawing, name: &str) -> Option<HatchPattern> {
let p = pattern(name)?;
Some(if d.header.i64("MEASUREMENT", 0) == 1 { scaled_pattern(p, 25.4) } else { p })
}
/// `p` with every length (origins, offsets, dashes) multiplied by `k`.
fn scaled_pattern(mut p: HatchPattern, k: f64) -> HatchPattern {
for l in &mut p.lines {
l.origin = (l.origin.0 * k, l.origin.1 * k);
l.delta = (l.delta.0 * k, l.delta.1 * k);
for d in &mut l.dashes {
*d *= k;
}
}
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metric_drawings_draw_iso_scaled_patterns() {
let imp = pattern_for(&Drawing::new_imperial(), "ANSI31").map(|p| p.lines[0].delta.1);
let met = pattern_for(&Drawing::new_metric(), "ansi31").map(|p| p.lines[0].delta.1);
assert_eq!(imp, Some(0.125));
assert!(met.is_some_and(|m| (m - 0.125 * 25.4).abs() < 1e-12), "{met:?}");
assert!(pattern_for(&Drawing::new_metric(), "NOPE").is_none());
}
}
-83
View File
@@ -1,83 +0,0 @@
//! In-place editing of a block reference (REFEDIT / REFSET / REFCLOSE): the state kept with the
//! drawing while it lasts.
//!
//! The reference being edited is hidden and the block's objects stand in the drawing as ordinary
//! objects (the *working set*, transformed to where the reference shows them), the rest of the
//! block as faded copies (*ghosts*) that cannot be edited. REFCLOSE writes the working set back
//! into the block definition. The state lives in the [`Drawing`] itself, so undo and redo move
//! the working copies and the state together and can never leave one without the other.
use std::borrow::Cow;
use cadcraft_geom::Mat3;
use crate::{Drawing, Entity, Handle, Space};
/// One object of the working set.
#[derive(Clone, Debug, PartialEq)]
pub struct WorkItem {
/// The object in the drawing (a copy of the block object, or a drawing object added to the set).
pub copy: Handle,
/// The block object it was copied from; `None` for an object added from the drawing.
pub source: Option<Handle>,
}
/// An active in-place edit of a block reference. Never written to a file.
#[derive(Clone, Debug, PartialEq)]
pub struct RefEdit {
/// The block being edited.
pub block: String,
/// The reference that was picked (hidden while editing).
pub insert: Handle,
/// Whether the reference was visible before it was hidden.
pub insert_was_visible: bool,
/// The space the reference is in.
pub space: Space,
/// Block coordinates to drawing coordinates, through the picked reference.
pub to_world: Mat3,
/// The working set.
pub items: Vec<WorkItem>,
/// The rest of the block as faded copies: (copy, block object).
pub ghosts: Vec<(Handle, Handle)>,
/// Block objects taken out of the working set with their edits (block coordinates, the block
/// object's handle).
pub released: Vec<Entity>,
}
impl RefEdit {
/// Whether `h` is a working set object.
pub fn is_working(&self, h: Handle) -> bool {
self.items.iter().any(|i| i.copy == h)
}
/// Whether `h` is something the edit manages (working set, ghost, the hidden reference).
pub fn owns(&self, h: Handle) -> bool {
h == self.insert || self.is_working(h) || self.ghosts.iter().any(|(g, _)| *g == h)
}
}
impl Drawing {
/// The drawing as REFCLOSE Discard would leave it: the working copies and ghosts gone, the
/// reference shown, the block untouched. Borrowed when no edit is active. Files are written
/// from this, so a saved or autosaved drawing never holds half an edit.
pub fn committed(&self) -> Cow<'_, Drawing> {
let Some(r) = &self.refedit else { return Cow::Borrowed(self) };
let mut d = self.clone();
d.end_refedit_discard(r);
Cow::Owned(d)
}
/// Remove the edit's working copies and ghosts, show the reference again and clear the state.
pub fn end_refedit_discard(&mut self, r: &RefEdit) {
for i in &r.items {
if i.source.is_some() {
self.remove_entity(i.copy);
}
}
for (g, _) in &r.ghosts {
self.remove_entity(*g);
}
let vis = r.insert_was_visible;
let _ = self.modify_entity(r.insert, |e| e.common.visible = vis);
self.refedit = None;
}
}
-25
View File
@@ -616,13 +616,6 @@ 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 {
@@ -643,13 +636,6 @@ 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::*;
@@ -752,8 +738,6 @@ 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,
@@ -763,12 +747,6 @@ pub struct PageSetup {
/// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table
/// applied ("Display plot styles"; DXF plot layout flag 2).
pub show_plot_styles: bool,
/// 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 {
@@ -781,15 +759,12 @@ 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(),
center: false,
lineweights: true,
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
}
}
}
-115
View File
@@ -16,23 +16,6 @@ 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();
@@ -96,59 +79,6 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0));
}
#[test]
fn visible_attributes_of_a_missing_block_still_count() {
// The renderer draws the attributes of a reference whose definition is missing, so they are
// extents and selection targets, as with an empty definition. The insertion point still isn't.
let attrib = |invisible: bool| Attrib {
tag: "TAG".into(),
text: Text {
insert: Vec3::new(1000.0, 1000.0, 0.0),
align_pt: None,
height: 10.0,
value: "hello".into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
},
invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
};
let ins = |block: &str, attribs: Vec<Attrib>| {
Entity::new(
Handle(1),
EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs,
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
};
let mut d = Drawing::new_imperial();
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
let missing = entity_bounds(&d, &ins("Missing", vec![attrib(false)]), 0);
let empty = entity_bounds(&d, &ins("Empty", vec![attrib(false)]), 0);
assert!(!missing.is_empty());
assert_eq!((missing.min, missing.max), (empty.min, empty.max));
assert_eq!(missing.min, Vec2::new(1000.0, 1000.0));
// Without a visible attribute there is nothing to measure.
assert!(entity_bounds(&d, &ins("Missing", vec![]), 0).is_empty());
assert!(entity_bounds(&d, &ins("Missing", vec![attrib(true)]), 0).is_empty());
}
#[test]
fn transform_arc_mirror_keeps_shape() {
let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 });
@@ -339,38 +269,6 @@ fn extents_pair(d: &Drawing) -> (Vec2, Vec2) {
(b.min, b.max)
}
/// #706: block A reaches B once through a chain of 14 blocks (B at depth 15, its line's block E at
/// 16, past the limit) and once directly (B at depth 1). `children` picks which of the two A holds.
fn deep_and_shallow(children: &[&str]) -> Drawing {
let mut d = Drawing::new_imperial();
let mut add = |name: &str, kids: Vec<EntityKind>| {
let mut blk = Block::new(name);
for (i, k) in kids.into_iter().enumerate() {
blk.entities.push(Entity::new(Handle(i as u64 + 1), k));
}
d.blocks.insert(name.into(), std::sync::Arc::new(blk));
};
add("E", vec![line((1000.0, 1000.0), (2000.0, 1000.0))]);
add("B", vec![plain_insert("E")]);
for i in 1..=14 {
let next = if i == 14 { "B".to_string() } else { format!("C{}", i + 1) };
add(&format!("C{i}"), vec![plain_insert(&next)]);
}
add("A", children.iter().map(|c| plain_insert(c)).collect());
d.add(&Space::Model, Common::default(), plain_insert("A")).unwrap();
d
}
#[test]
fn deep_block_visit_does_not_hide_a_shallow_reference() {
let line = (Vec2::new(1000.0, 1000.0), Vec2::new(2000.0, 1000.0));
// Control: past the limit, the deep path alone contributes nothing.
assert!(deep_and_shallow(&["C1"]).extents(&Space::Model).is_empty());
assert_eq!(extents_pair(&deep_and_shallow(&["B"])), line);
assert_eq!(extents_pair(&deep_and_shallow(&["B", "C1"])), line);
assert_eq!(extents_pair(&deep_and_shallow(&["C1", "B"])), line);
}
#[test]
fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() {
let mut far = far_line("0");
@@ -456,8 +354,6 @@ fn attdef_in_block_is_ignored_in_insert_extents() {
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: None,
modes: Default::default(),
}),
);
let d = drawing_with_block(attdef);
@@ -570,14 +466,3 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
dim(&mut d, far_line("0"));
assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0));
}
#[test]
fn space_names_its_tab() {
assert!(!Space::Model.is_paper());
assert_eq!(Space::Model.layout(), None);
assert_eq!(Space::Model.tab_name(), "Model");
let p = Space::Paper("Sheet A".into());
assert!(p.is_paper());
assert_eq!(p.layout(), Some("Sheet A"));
assert_eq!(p.tab_name(), "Sheet A");
}
-2
View File
@@ -1,2 +0,0 @@
mod lin_pat_parse;
mod sub_segment_alias;
-14
View File
@@ -1,14 +0,0 @@
//! `Sub::parse` accepts every spelling its documentation lists (#710).
use cadcraft_doc::Sub;
#[test]
fn short_segment_name_matches_the_documented_forms() {
for spelling in ["seg2", "segment2", "s2"] {
assert_eq!(Sub::parse(spelling), Some(Sub::Segment(2)), "spelling: {spelling}");
}
for spelling in ["s", "start"] {
assert_eq!(Sub::parse(spelling), Some(Sub::Start), "spelling: {spelling}");
}
assert_eq!(Sub::parse("unknown"), None);
}
+1 -1
View File
@@ -10,4 +10,4 @@ workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.5"
acadrust = "0.6.3"
+6 -561
View File
@@ -4,25 +4,6 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -80,38 +61,14 @@ mod native {
/// guard: the input size before the DWG reader runs, the DXF size while it is written, so a
/// runaway conversion stops at the limit instead of filling memory first.
pub fn dwg_to_dxf_with(bytes: &[u8], limits: Limits) -> Result<Vec<u8>, String> {
dwg_to_dxf_noted(bytes, limits).map(|(dxf, _)| dxf)
}
/// [`dwg_to_dxf_with`], also returning notes on what the read left out. The reader runs in
/// failsafe mode: an object record it can't decode is skipped instead of failing the whole
/// file, and the notes say how many were skipped and of which types, so a partial read never
/// passes for a complete one.
pub fn dwg_to_dxf_noted(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
if bytes.len() > limits.max_dwg_bytes {
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
}
let outcome = std::panic::catch_unwind(|| {
acadrust::DwgReader::from_stream_with_options(Cursor::new(bytes), acadrust::DwgReadOptions::failsafe()).read_with_stats()
})
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
// Failsafe skips records, not whole files: when the objects stream itself couldn't be
// read (a damaged header or section map, or not a DWG at all), nothing was read.
if !outcome.stats.record_stream_read {
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 mut notes = read_notes(&outcome.stats);
let mut doc = outcome.document;
current_style_names(&mut doc);
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
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())?;
@@ -124,111 +81,7 @@ mod native {
));
}
written.map_err(|e| format!("DWG→DXF: {e}"))?;
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// Most record types named in the skipped-records note.
const NOTE_TYPES: usize = 6;
/// The note on object records a failsafe read skipped: how many, and how many of each type
/// (most first), or nothing when the read was complete.
pub(crate) fn read_notes(stats: &acadrust::ReadStats) -> Vec<String> {
let skipped = stats.skipped_source_records;
if skipped == 0 {
return Vec::new();
}
let label = |t: Option<&str>| match t.map(str::parse::<i16>) {
Some(Ok(code)) => type_name(code).map_or_else(|| format!("type {code}"), str::to_string),
Some(Err(_)) => t.unwrap_or_default().to_string(),
None => "unknown".to_string(),
};
let mut by_type: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for d in stats.diagnostics.iter().filter(|d| d.record_handle.is_some()) {
*by_type.entry(label(d.record_type.as_deref())).or_default() += 1;
}
let mut types: Vec<(String, usize)> = by_type.into_iter().collect();
types.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let mut list: Vec<String> = types.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t} × {n}")).collect();
if types.len() > NOTE_TYPES {
list.push(format!("{} more types", types.len() - NOTE_TYPES));
}
let of = if stats.source_records > 0 { format!(" of {}", stats.source_records) } else { String::new() };
let records = if skipped == 1 && of.is_empty() { "object record" } else { "object records" };
let (were, them) = if skipped == 1 { ("was", "it") } else { ("were", "them") };
let which = if list.is_empty() { String::new() } else { format!(" ({})", list.join(", ")) };
vec![format!(
"DWG: {skipped}{of} {records} couldn't be read and {were} left out{which}; \
saving over the original file would lose {them}"
)]
}
/// The DXF name of a DWG object type code with a fixed number (classes, 500 and up, are
/// numbered per file).
fn type_name(code: i16) -> Option<&'static str> {
Some(match code {
1 => "TEXT",
2 => "ATTRIB",
3 => "ATTDEF",
4 => "BLOCK",
5 => "ENDBLK",
6 => "SEQEND",
7 => "INSERT",
8 => "MINSERT",
10..=14 => "VERTEX",
15 | 16 | 29 | 30 => "POLYLINE",
17 => "ARC",
18 => "CIRCLE",
19 => "LINE",
20..=26 => "DIMENSION",
27 => "POINT",
28 => "3DFACE",
31 => "SOLID",
32 => "TRACE",
33 => "SHAPE",
34 => "VIEWPORT",
35 => "ELLIPSE",
36 => "SPLINE",
37 => "REGION",
38 => "3DSOLID",
39 => "BODY",
40 => "RAY",
41 => "XLINE",
42 => "DICTIONARY",
43 => "OLEFRAME",
44 => "MTEXT",
45 => "LEADER",
46 => "TOLERANCE",
47 => "MLINE",
48 => "BLOCK_CONTROL",
49 => "BLOCK_RECORD",
50 => "LAYER_CONTROL",
51 => "LAYER",
52 => "STYLE_CONTROL",
53 => "STYLE",
56 => "LTYPE_CONTROL",
57 => "LTYPE",
60 => "VIEW_CONTROL",
61 => "VIEW",
62 => "UCS_CONTROL",
63 => "UCS",
64 => "VPORT_CONTROL",
65 => "VPORT",
66 => "APPID_CONTROL",
67 => "APPID",
68 => "DIMSTYLE_CONTROL",
69 => "DIMSTYLE",
70 => "VX_CONTROL",
71 => "VX_TABLE_RECORD",
72 => "GROUP",
73 => "MLINESTYLE",
74 => "OLE2FRAME",
77 => "LWPOLYLINE",
78 => "HATCH",
79 => "XRECORD",
80 => "ACDBPLACEHOLDER",
82 => "LAYOUT",
_ => return None,
})
Ok(add_header_vars(out.buf, &doc.header))
}
/// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
@@ -290,117 +143,11 @@ 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
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
let version = doc.version;
let unwritable: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Unknown(u)
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
Some(u.common.handle)
}
_ => None,
})
.collect();
for h in &unwritable {
doc.remove_entity(*h);
}
let objects: Vec<acadrust::Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
Some(*h)
}
_ => None,
})
.collect();
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
/// the HEADER section: (name, group code, value).
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
[
// DWG holds radians; `$ANGBASE` is in degrees.
("$ANGBASE", 50, h.angle_base.to_degrees().to_string()),
("$ANGBASE", 50, h.angle_base.to_string()),
("$ANGDIR", 70, h.angle_direction.to_string()),
("$FILLETRAD", 40, h.fillet_radius.to_string()),
("$CHAMFERA", 40, h.chamfer_distance_a.to_string()),
@@ -478,52 +225,7 @@ 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())?
@@ -536,45 +238,15 @@ 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);
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}"))
}
/// 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"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -588,10 +260,6 @@ pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_noted(_bytes: &[u8], _limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String> {
Err("DWG files can't be opened in the web build yet; save as DXF".into())
}
@@ -615,232 +283,9 @@ 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"]);
}
}
+1 -5
View File
@@ -16,16 +16,12 @@ 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.13", default-features = false }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
rstar = { version = "0.12", default-features = false }
[dev-dependencies]
# Real DXF files for the external reference tests (io is a lower layer).
-163
View File
@@ -1,163 +0,0 @@
//! 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();
}
}
-6
View File
@@ -82,12 +82,6 @@ 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.
+20 -70
View File
@@ -109,9 +109,6 @@ pub enum Job {
struct Tracked {
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
written: Option<Weak<Drawing>>,
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
/// editor is closed): saving the editor's changes into the block changes only this.
host: Option<Weak<Drawing>>,
/// Its autosave file, once written (or adopted).
file: Option<String>,
/// When a change not yet autosaved was first seen (seconds).
@@ -125,9 +122,8 @@ pub struct Autosaver {
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// Writes in progress; each tells whether its file was written.
#[cfg(not(target_arch = "wasm32"))]
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
running: Vec<(u64, std::thread::JoinHandle<()>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
@@ -153,9 +149,8 @@ impl Autosaver {
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
/// once the drawing is saved or closed.
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
let st = s.docs.iter().find(|d| d.uid == uid);
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
let written = s.docs.iter().find(|d| d.uid == uid).map(|d| Arc::downgrade(&d.doc));
self.docs.insert(uid, Tracked { written, file: Some(path.to_string()), since: None });
}
/// The autosave files this app has written or adopted.
@@ -167,12 +162,6 @@ impl Autosaver {
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
/// files of drawings that were saved (no unsaved changes left) or closed.
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
self.plan_with(s, now, minutes, dir, false)
}
/// [`Autosaver::plan`], with `urgent` writing every drawing with changes not yet autosaved
/// now instead of `minutes` after the change (autosave still off when `minutes` is 0).
fn plan_with(&mut self, s: &Session, now: f64, minutes: u32, dir: &str, urgent: bool) -> Vec<Job> {
let mut jobs = Vec::new();
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
let busy = &self.busy;
@@ -198,14 +187,13 @@ impl Autosaver {
*t = Tracked::default();
continue;
}
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
let changed = t.written.as_ref().is_none_or(|w| !std::ptr::eq(w.as_ptr(), Arc::as_ptr(&st.doc)));
if !changed {
t.since = None;
continue;
}
let since = *t.since.get_or_insert(now);
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
if minutes == 0 || dir.trim().is_empty() || now - since < f64::from(minutes) * 60.0 {
continue;
}
let path = t
@@ -214,10 +202,8 @@ impl Autosaver {
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
t.file = Some(path.clone());
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
t.since = None;
// In the Block Editor: the drawing with the editor's unsaved changes in the block.
jobs.push(Job::Write { uid: st.uid, path, doc: st.recovery_drawing() });
jobs.push(Job::Write { uid: st.uid, path, doc: st.doc.clone() });
}
jobs
}
@@ -339,9 +325,9 @@ mod native {
if let Some(dir) = Path::new(path).parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).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())
}
impl Autosaver {
@@ -349,7 +335,14 @@ mod native {
/// Returns how long until the next autosave is due, so the host can wake up for it.
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
let now = self.start.elapsed().as_secs_f64();
self.reap();
let busy = &mut self.busy;
self.running.retain(|(uid, h)| {
let running = !h.is_finished();
if !running {
busy.remove(uid);
}
running
});
let dir = savefilepath();
let minutes = savetime();
let jobs = self.plan(s, now, minutes, &dir);
@@ -358,40 +351,6 @@ mod native {
self.next_due(now, minutes).map(Duration::from_secs_f64)
}
/// Autosave every drawing with changes not yet autosaved right away, without waiting for
/// SAVETIME (the app may not survive what just happened, e.g. a GPU error). Returns how
/// many drawings are being written.
pub fn save_now(&mut self, s: &Session) -> usize {
let now = self.start.elapsed().as_secs_f64();
let jobs = self.plan_with(s, now, savetime(), &savefilepath(), true);
let writes = jobs.iter().filter(|j| matches!(j, Job::Write { .. })).count();
self.perform(jobs);
// The lock file marks the files as this running app's (Drawing Recovery skips them).
self.last_beat = None;
self.heartbeat(&savefilepath(), now);
writes
}
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
pub fn reap(&mut self) {
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
self.running = running;
for (uid, h) in done {
self.busy.remove(&uid);
if !h.join().unwrap_or(false) {
self.unwritten(uid);
}
}
}
fn unwritten(&mut self, uid: u64) {
if let Some(t) = self.docs.get_mut(&uid) {
t.written = None;
}
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
pub fn perform(&mut self, jobs: Vec<Job>) {
for job in jobs {
@@ -403,24 +362,15 @@ mod native {
continue;
};
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
Ok(()) => {
log::info!("autosaved {path}");
true
}
Err(e) => {
log::warn!("autosave {path}: {e}");
false
}
Ok(()) => log::info!("autosaved {path}"),
Err(e) => log::warn!("autosave {path}: {e}"),
});
match spawned {
Ok(h) => {
self.busy.insert(uid);
self.running.push((uid, h));
}
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid);
}
Err(e) => log::warn!("autosave: {e}"),
}
}
}
-313
View File
@@ -1,313 +0,0 @@
//! The Block Editor's state (BEDIT). While it is open, the drawing's tab shows the block's objects
//! as its model space, moved so that the block's base point is at the origin, and every command
//! edits them as it would any drawing. The drawing itself waits, untouched, with its undo history,
//! selection and views: BSAVE writes the objects back into the block definition (so every
//! reference shows them), BCLOSE puts the drawing back. The commands are in
//! `cmd::blocks::bedit`.
//!
//! Layer, style and block table changes made in the editor belong to the drawing too, as in
//! AutoCAD: saving keeps the editor's tables and only puts back what is the drawing's own (its
//! model and paper space objects, groups, constraints, tiled viewports and hidden objects).
//!
//! The editor can also open on the drawing itself ([`CURRENT_DRAWING`], AutoCAD's
//! `<Current Drawing>`): a dynamic block's own drawing, whose model space holds the block's
//! objects and authoring elements. Its objects are edited where they are; BSAVE keeps the
//! changes as the drawing's, BCLOSE without saving puts the drawing back as it was.
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityKind, EntityStore};
use cadcraft_geom::{Mat3, Vec2};
use crate::{DocState, EngineError, Result, Snapshot};
/// The undo step a save into the block records in the drawing.
const UNDO_LABEL: &str = "Block Editor";
/// The name the Block Editor gives the drawing itself (BEDIT `<Current Drawing>`).
pub const CURRENT_DRAWING: &str = "<Current Drawing>";
/// Whether block `name` (or `*Model_Space`, a drawing's own) carries dynamic block data from
/// another program: parameters and actions in the `ACAD_ENHANCEDBLOCK` dictionary of its
/// BLOCK_RECORD table entry.
pub fn has_dynamic_data(d: &Drawing, name: &str) -> bool {
let key = ("BLOCK_RECORD".to_string(), name.to_ascii_uppercase());
let Some(xdict) = d.foreign.records.get(&key).and_then(|x| x.xdict) else { return false };
d.foreign.objects.iter().any(|o| o.handle == xdict && o.tags.iter().any(|t| t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK")))
}
/// Whether drawing `d` has authoring elements: it is a dynamic block's own drawing, its model
/// space carrying the block's parameters and actions (AutoCAD offers to open it in the Block
/// Editor).
pub fn has_authoring_elements(d: &Drawing) -> bool {
has_dynamic_data(d, "*Model_Space")
}
/// The Block Editor open in a drawing.
#[derive(Clone, Debug)]
pub struct BlockEdit {
/// The block being edited, as the drawing names it.
pub name: String,
/// The drawing outside the editor: its drawing, undo history, selection and views, put back
/// by BCLOSE. Its own `block_edit` is always `None`.
pub host: Box<DocState>,
/// The editor's drawing as last saved into the block; later changes are unsaved.
pub saved: Arc<Drawing>,
/// The block's objects as the drawing has them (block coordinates): objects left unchanged in
/// the editor are saved back exactly as they were.
original: EntityStore,
/// The block's base point, at the origin in the editor.
base: Vec2,
/// The editor is open on the drawing itself ([`CURRENT_DRAWING`]).
pub current: bool,
}
/// Why a block can't be opened in the Block Editor, if it can't.
pub fn refusal(d: &Drawing, name: &str) -> Option<String> {
let Some(b) = d.block(name) else { return Some(format!("Block \"{name}\" not found.")) };
if b.xref_path.is_some() {
return Some(format!("\"{}\" is an external reference; open its drawing (XOPEN) to edit it.", b.name));
}
if b.anonymous || b.name.starts_with('*') {
return Some(format!("\"{}\" is an anonymous block made by another object; it can't be edited in the Block Editor.", b.name));
}
None
}
/// `store` moved by `m`, objects in the same order with the same handles.
fn moved(store: &EntityStore, m: &Mat3) -> EntityStore {
let mut out = EntityStore::new();
for e in store.iter() {
let mut e = (**e).clone();
e.kind.transform(m);
out.push(e);
}
out
}
/// Whether any object of `store` refers to block `name`, directly or through nested blocks of `d`.
fn refers_to(d: &Drawing, store: &EntityStore, name: &str) -> bool {
let mut todo: Vec<String> = store.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
let mut seen = std::collections::HashSet::new();
while let Some(b) = todo.pop() {
if b.eq_ignore_ascii_case(name) {
return true;
}
if seen.insert(b.to_ascii_uppercase())
&& let Some(blk) = d.block(&b)
{
todo.extend(blk.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }));
}
}
false
}
impl BlockEdit {
/// The editor's objects in block coordinates, ready to be the block's: unchanged objects are
/// the block's own, the others are moved back from the editor's origin to the base point.
fn block_objects(&self, editor: &Drawing) -> EntityStore {
if self.base == Vec2::ZERO {
return editor.model.clone();
}
let (to_editor, back) = (Mat3::translate(-self.base), Mat3::translate(self.base));
let mut out = EntityStore::new();
for e in editor.model.iter() {
let unchanged = self.original.get(e.handle).filter(|o| {
let mut shown = (***o).clone();
shown.kind.transform(&to_editor);
shown == **e
});
match unchanged {
Some(o) => out.push((**o).clone()),
None => {
let mut e = (**e).clone();
e.kind.transform(&back);
out.push(e);
}
}
}
out
}
/// The drawing with the editor's objects saved into block `name` (the edited block, or a new
/// name for BSAVEAS), from `editor`, the editor's drawing.
pub fn compose(&self, editor: &Drawing, name: &str) -> Result<Drawing> {
// The drawing itself: the editor's drawing is the drawing.
if self.current {
if !name.eq_ignore_ascii_case(CURRENT_DRAWING) {
return Err(EngineError::Other("The current drawing can't be saved as a block from the Block Editor; use WBLOCK or BLOCK.".into()));
}
return Ok(editor.clone());
}
if refers_to(editor, &editor.model, name) {
return Err(EngineError::Other(format!("Block \"{name}\" can't contain a reference to itself.")));
}
let host = &self.host.doc;
let mut d = editor.clone();
d.model = host.model.clone();
d.layouts = host.layouts.clone();
d.groups = host.groups.clone();
d.constraints = host.constraints.clone();
d.vports = host.vports.clone();
d.hidden_objects = host.hidden_objects.clone();
let entities = self.block_objects(editor);
// The edited block's properties (the editor's definition, else the drawing's): the objects
// are placed for its base point, also when saved under another name (BSAVEAS).
let template = editor.block(&self.name).or_else(|| host.block(&self.name));
let mut b = match template {
Some(b) => (**b).clone(),
None => cadcraft_doc::Block::new(name),
};
// Block names are case-insensitive: an existing block keeps its name's spelling.
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
if !b.name.eq_ignore_ascii_case(name) {
b.name = key.clone();
}
b.base.x = self.base.x;
b.base.y = self.base.y;
b.entities = entities;
d.blocks.insert(key, Arc::new(b));
Ok(d)
}
}
impl DocState {
/// The drawing as a file saves it: outside the Block Editor's changes while it is open.
pub fn drawing(&self) -> &Arc<Drawing> {
match &self.block_edit {
Some(be) => &be.host.doc,
None => &self.doc,
}
}
/// [`DocState::drawing`], to change it (a save's file version).
pub fn drawing_arc_mut(&mut self) -> &mut Arc<Drawing> {
match &mut self.block_edit {
Some(be) => &mut be.host.doc,
None => &mut self.doc,
}
}
/// The block the Block Editor is editing, when it is open.
pub fn block_editor(&self) -> Option<&str> {
self.block_edit.as_ref().map(|b| b.name.as_str())
}
/// The Block Editor has changes not yet saved into the block.
pub fn block_unsaved(&self) -> bool {
self.block_edit.as_ref().is_some_and(|b| !Arc::ptr_eq(&b.saved, &self.doc))
}
/// The drawing to keep for recovery (autosave): with the Block Editor's unsaved changes in the
/// block, so a crash doesn't lose them.
pub fn recovery_drawing(&self) -> Arc<Drawing> {
match &self.block_edit {
Some(be) if self.block_unsaved() => be.compose(&self.doc, &be.name).map(Arc::new).unwrap_or_else(|_| be.host.doc.clone()),
Some(be) => be.host.doc.clone(),
None => self.doc.clone(),
}
}
/// Open the Block Editor on block `name` (BEDIT).
/// With `create`, a name no block has yet starts a new, empty block, which BSAVE creates.
pub fn open_block_editor(&mut self, name: &str, create: bool) -> Result<()> {
if let Some(open) = self.block_editor() {
return Err(EngineError::Other(format!("The Block Editor is already open on \"{open}\"; close it first (BCLOSE).")));
}
if name.trim().eq_ignore_ascii_case(CURRENT_DRAWING) {
let host = Box::new(self.clone());
let editor = self.doc.clone();
self.enter_block_editor(BlockEdit {
name: CURRENT_DRAWING.into(),
host,
saved: editor,
original: EntityStore::new(),
base: Vec2::ZERO,
current: true,
});
return Ok(());
}
let block = match self.doc.block(name) {
None if create => Arc::new(cadcraft_doc::Block::new(name)),
_ => {
if let Some(why) = refusal(&self.doc, name) {
return Err(EngineError::Other(why));
}
self.doc.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?
}
};
let base = block.base.xy();
let mut d = (*self.doc).clone();
d.model = if base == Vec2::ZERO { block.entities.clone() } else { moved(&block.entities, &Mat3::translate(-base)) };
// The drawing's own groups, constraints and hidden objects stay with it.
d.groups.clear();
d.constraints.clear();
d.hidden_objects.clear();
let host = Box::new(self.clone());
let editor = Arc::new(d);
self.doc = editor.clone();
self.enter_block_editor(BlockEdit { name: block.name.clone(), host, saved: editor, original: block.entities.clone(), base, current: false });
Ok(())
}
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
/// undo history, nothing selected, model space.
fn enter_block_editor(&mut self, be: BlockEdit) {
self.undo.clear();
self.redo.clear();
self.undo_ctl = Default::default();
self.selection.clear();
self.previous_selection.clear();
self.space = cadcraft_doc::Space::Model;
self.mspace = None;
self.tiles = Default::default();
self.views.clear();
self.view_history.clear();
self.block_edit = Some(be);
self.revision += 1;
}
/// Save the Block Editor's objects into the block (BSAVE), or into a new block `as_name`
/// (BSAVEAS), which the editor then edits. One undo step in the drawing.
pub fn save_block_editor(&mut self, as_name: Option<&str>) -> Result<()> {
let Some(be) = &self.block_edit else { return Err(EngineError::Other("The Block Editor is not open.".into())) };
let name = as_name.unwrap_or(&be.name).to_string();
let d = be.compose(&self.doc, &name)?;
let original = d.block(&name).map(|b| b.entities.clone()).unwrap_or_default();
let name = d.block(&name).map(|b| b.name.clone()).unwrap_or(name);
let editor = self.doc.clone();
let Some(be) = &mut self.block_edit else { return Ok(()) };
let before = be.host.doc.clone();
let selection = be.host.selection.clone();
be.host.push_undo(Snapshot { label: UNDO_LABEL.into(), doc: before, selection });
be.host.doc = Arc::new(d);
be.host.revision += 1;
be.original = original;
be.saved = editor;
be.name = name;
self.revision += 1;
Ok(())
}
/// Close the Block Editor (BCLOSE), saving its changes into the block first when `save`.
pub fn close_block_editor(&mut self, save: bool) -> Result<()> {
if self.block_edit.is_none() {
return Err(EngineError::Other("The Block Editor is not open.".into()));
}
if save && self.block_unsaved() {
self.save_block_editor(None)?;
}
let Some(be) = self.block_edit.take() else { return Ok(()) };
let host = *be.host;
self.doc = host.doc;
self.undo = host.undo;
self.redo = host.redo;
self.undo_ctl = host.undo_ctl;
// The drawing comes back with nothing selected; Previous is what was selected before.
self.selection.clear();
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
self.space = host.space;
self.mspace = host.mspace;
self.tiles = host.tiles;
self.views = host.views;
self.view_history = host.view_history;
self.revision += 1;
Ok(())
}
}
-84
View File
@@ -1,84 +0,0 @@
//! 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();
}
}
+14 -12
View File
@@ -260,16 +260,18 @@ 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_at(k, at),
Some(at) => text_inside(k, c, c.dist(pt), at),
None => k,
};
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
/// 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 {
/// 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
{
d.text_mid = v3(at);
d.user_text_pos = true;
}
@@ -427,13 +429,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();
// 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;
// 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;
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 } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
let dogleg = if landing { st.dogleg * k } else { 0.0 };
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
Some(EntityKind::MLeader(MLeader {
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
landing: v3(*landing_pt),
@@ -451,7 +453,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
columns: None,
}),
style: st.name.clone(),
arrow_size: st.arrow_size,
arrow_size: st.arrow_size * k,
spline: false,
block: None,
}))
@@ -1104,7 +1106,7 @@ impl RadialM {
} else {
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "")
});
text_at(k, at)
text_inside(k, c, r, at)
}
}
+1 -4
View File
@@ -447,9 +447,6 @@ impl Interactive for ArrayM {
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default()
}
fn drag(&self, s: &Session, cursor: Vec2) -> Option<crate::prompt::Drag> {
self.inner.as_ref().and_then(|m| m.drag(s, cursor))
}
}
// ---------------- ARRAYRECT ----------------
@@ -626,7 +623,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).corner(c),
Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(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),
+1 -29
View File
@@ -84,11 +84,7 @@ fn run_audit(s: &mut Session, p: &Value) -> Result<Value> {
if fix && !issues.is_empty() {
s.state_mut()?.doc = Arc::new(copy);
}
let report = crate::audit::Report { issues, fixed: fix };
if s.settings.auditctl {
write_audit_log(s, &report);
}
Ok(report.to_json())
Ok(crate::audit::Report { issues, fixed: fix }.to_json())
}
/// Open a drawing file as tolerantly as the readers allow, then fix what AUDIT finds. The
@@ -123,36 +119,12 @@ 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}")))
+10 -128
View File
@@ -1,7 +1,5 @@
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing, the
//! Block Editor.
//! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing.
pub(crate) mod bedit;
mod purge;
use std::sync::Arc;
@@ -16,10 +14,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;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -47,24 +42,6 @@ pub fn specs() -> Vec<CommandSpec> {
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference", "Dynamic Block Reference", run_reference)
.params("{handle (a block reference)} → {definition, values (by parameter graph id), properties: [{name, value, choices}]} | null (not a dynamic block reference)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference.set", "Set Dynamic Property", run_reference_set)
.params("{handle (a dynamic block reference), name (a custom property as Properties names it: a visibility state, a lookup, a distance or \"Position1 X\"), value (lengths in drawing units)} → {properties}")
.enabled(has_doc),
CommandSpec::new("blocks.reference.grips", "Dynamic Block Grips", dyngrip::run_grips)
.params("{handle} → [{node, part: base|end|at, shape: linear|point|list, at: [x,y], dir: [x,y], property, choices?}] (empty: not a dynamic block reference)")
.enabled(has_doc)
.noundo(),
CommandSpec::new("blocks.reference.grip", "Drag Dynamic Grip", dyngrip::run_grip)
.params("{handle, node (the parameter's graph id), part: base|end|at, to: [x,y] (drawing coordinates)} → {properties}: a distance's arrow changes the distance (snapped to its values), a position's square moves it")
.enabled(has_doc),
CommandSpec::new("purge", "Purge", purge::run)
.alias(&["pu", "-purge"])
.params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged")
@@ -73,14 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
battman::battman_spec(),
attribs::attdisp_spec(),
attribs::attsync_spec(),
burst::burst_spec(),
extract::dataextraction_spec(),
extract::eattext_spec(),
]
.into_iter()
.chain(xref::specs())
.chain(refedit::specs())
.chain(bedit::specs())
.collect()
}
@@ -110,41 +84,8 @@ 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: Keep, description: &str) -> Result<Option<Handle>> {
pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result<Option<Handle>> {
if !valid_block_name(name) {
return Err(bad("block", "invalid block name"));
}
@@ -156,9 +97,6 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
if references_block(d, &ents, name) {
return Err(bad("block", format!("Block \"{name}\" references itself.")));
}
if d.refedit.as_ref().is_some_and(|r| r.block.eq_ignore_ascii_case(name)) {
return Err(bad("block", format!("Block \"{name}\" is being edited in place; finish with REFCLOSE first.")));
}
// Block names are case-insensitive: redefining keeps the existing definition's name.
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
let name = name.as_str();
@@ -171,13 +109,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 != Keep::Retain {
if keep != "retain" {
for h in hs {
doc.remove_entity(*h);
}
}
s.set_selection(Vec::new());
if keep == Keep::Convert {
if keep == "convert" {
let h = insert(s, name, base, 1.0, 0.0, &serde_json::Map::new())?;
return Ok(Some(h));
}
@@ -242,8 +180,6 @@ pub(crate) fn insert_with(
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
modes: ad.modes.for_reference(),
});
}
}
@@ -254,8 +190,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 = keep_param("block", p, Keep::Convert)?;
let r = make_block(s, &name, base, &hs, keep, str_param(p, "description").unwrap_or(""))?;
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(""))?;
// An annotative block: its references (the converted selection too) are annotative.
if bool_or(p, "annotative", false) {
let d = s.doc_mut()?;
@@ -316,8 +252,6 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he
constant: false,
prompt: prompt.into(),
props: Default::default(),
multiline: None,
modes: Default::default(),
})
}
@@ -387,61 +321,9 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
}
/// A dynamic block's parameters and actions as read from the file (null for a block that has
/// none).
fn run_dynamic(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("blocks.dynamic", "`name` is required"))?;
let d = s.doc()?;
if d.block(name).is_none() && !name.trim().eq_ignore_ascii_case("*Model_Space") {
return Err(bad("blocks.dynamic", format!("no block `{name}`")));
}
serde_json::to_value(cadcraft_doc::dynblock::dynamic_block(d, name)).map_err(|e| bad("blocks.dynamic", e.to_string()))
}
/// The dynamic block a block reference shows, the values it was given and its custom properties
/// (null for any other reference).
fn run_reference(s: &mut Session, p: &Value) -> Result<Value> {
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference", "`handle` is required"))?;
let d = s.doc()?;
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(_)) => {}
Some(_) => return Err(bad("blocks.reference", "`handle` is not a block reference")),
None => return Err(bad("blocks.reference", format!("no object {}", h.hex()))),
}
let (Some(r), Some((_, properties))) = (cadcraft_doc::dynblock::reference(d, h), cadcraft_doc::dynblock::custom_properties(d, h)) else {
return Ok(Value::Null);
};
Ok(json!({ "definition": r.definition, "values": r.values, "properties": properties }))
}
/// Change a custom property of a dynamic block reference (one undo step).
fn run_reference_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "blocks.reference.set";
let h = super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
let value = match p.get("value") {
Some(Value::String(v)) => v.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => return Err(bad(cmd, "`value` is required")),
};
if !matches!(s.doc()?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
return Err(bad(cmd, "`handle` is not a block reference"));
}
if super::curves::is_locked(s, h) {
return Err(bad(cmd, "the reference is on a locked layer"));
}
let mut d = s.doc()?.clone();
cadcraft_doc::dynblock::set_property(&mut d, h, name, &value).map_err(|e| bad(cmd, e))?;
let properties = cadcraft_doc::dynblock::custom_properties(&d, h).map(|(_, p)| p).unwrap_or_default();
*s.doc_mut()? = d;
Ok(json!({ "properties": properties }))
Ok(
json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description})).collect::<Vec<_>>() }),
)
}
// ---------------- interactive ----------------
@@ -530,7 +412,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 { Keep::Delete } else { Keep::Convert };
let keep = if self.dash { "delete" } else { "convert" };
if let Err(e) = make_block(s, &name, base, hs, keep, "") {
s.echo(e.to_string());
return Ok(Step::Done);
+1 -10
View File
@@ -97,16 +97,7 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
.find(|a| a.tag.eq_ignore_ascii_case(&ad.tag))
.map(|a| a.text.value.clone())
.unwrap_or_else(|| ad.text.value.clone());
Attrib {
tag: ad.tag.clone(),
text: t,
invisible: ad.invisible,
constant: false,
prompt: String::new(),
props: Default::default(),
multiline: ad.multiline.as_ref().map(|ml| ml.transformed(&m)),
modes: ad.modes.for_reference(),
}
Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() }
})
.collect()
}
-320
View File
@@ -1,320 +0,0 @@
//! BEDIT, BSAVE, BSAVEAS and BCLOSE: the Block Editor. BEDIT opens a block definition in the
//! drawing's own tab, its base point at the origin; the block's objects are edited with the
//! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the
//! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved.
//! The state is [`crate::blockedit`].
use cadcraft_doc::{Drawing, EntityKind, Handle};
use serde_json::{Value, json};
use crate::cmd::curves::{KW, handle_value};
use crate::cmd::{CommandSpec, bad, bool_or, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("bedit", "Block Editor", run_bedit)
.menu(&["Tools", "Block Editor"])
.alias(&["be", "-bedit"])
.params("{name? (\"<Current Drawing>\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}")
.enabled(not_editing)
.noundo()
.interactive(|_| Ok(Box::new(BeditM))),
CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(),
CommandSpec::new("bsaveas", "Save Block As", run_bsaveas)
.params("{name, replace?: bool (redefine an existing block of that name)} → {block}")
.enabled(editing_a_block)
.noundo()
.interactive(|_| Ok(Box::new(BsaveasM::default()))),
CommandSpec::new("bclose", "Close Block Editor", run_bclose)
.params("{save?: bool (default true; false discards the changes not saved into the block)} → {block, saved}")
.enabled(editing)
.noundo()
.interactive(|_| Ok(Box::new(BcloseM))),
]
}
fn editing(s: &Session) -> std::result::Result<(), String> {
match s.state() {
Ok(st) if st.block_edit.is_some() => Ok(()),
Ok(_) => Err("The Block Editor is not open.".into()),
Err(e) => Err(e.to_string()),
}
}
/// The Block Editor is open on a block (not on the drawing itself, which BSAVEAS can't save as one).
fn editing_a_block(s: &Session) -> std::result::Result<(), String> {
editing(s)?;
match s.state() {
Ok(st) if st.block_edit.as_ref().is_some_and(|b| b.current) => {
Err("The Block Editor is open on the current drawing; there is no block to save under another name.".into())
}
_ => Ok(()),
}
}
fn not_editing(s: &Session) -> std::result::Result<(), String> {
match s.state() {
Ok(st) => match st.block_editor() {
Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")),
None => Ok(()),
},
Err(e) => Err(e.to_string()),
}
}
/// Commands that leave model space, which the Block Editor is: not available while it is open.
const NOT_IN_EDITOR: &[&str] = &[
"layout",
"layout.new",
"layout.delete",
"layout.rename",
"layout.copy",
"layout.set",
"mview",
"mview.polygonal",
"mspace",
"pspace",
"chspace",
];
/// Why command `id` can't run now, when the Block Editor is open and it is one of [`NOT_IN_EDITOR`].
pub(crate) fn refused_in_editor(s: &Session, id: &str) -> Option<String> {
let name = s.state().ok()?.block_editor()?;
NOT_IN_EDITOR.contains(&id).then(|| format!("Not available in the Block Editor (editing \"{name}\"); close it first (BCLOSE)."))
}
/// The block a block reference refers to.
fn block_of(d: &Drawing, h: Handle) -> Option<String> {
match d.entity(h).map(|e| &e.kind) {
Some(EntityKind::Insert(i)) => Some(dynamic_definition(d, &i.block).unwrap_or_else(|| i.block.clone())),
_ => None,
}
}
/// The dynamic block whose anonymous block `name` is (a dynamic block reference with values of
/// its own shows one, `*U12`): its BLOCK_RECORD's `AcDbBlockRepBTag` xdata names the dynamic
/// block's record. `None` for other blocks.
fn dynamic_definition(d: &Drawing, name: &str) -> Option<String> {
if !name.starts_with('*') {
return None;
}
let x = &d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.to_ascii_uppercase()))?.xdata;
let at = x.iter().position(|t| t.code == 1001 && t.value.eq_ignore_ascii_case("AcDbBlockRepBTag"))?;
let record = x.iter().skip(at + 1).take_while(|t| t.code != 1001).find(|t| t.code == 1005).and_then(|t| Handle::parse_hex(&t.value))?;
let (kind, block) = d.foreign.targets.get(&record)?;
(kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten()
}
/// Open the Block Editor on block `name` and frame its objects.
fn open(s: &mut Session, name: &str, create: bool) -> Result<Value> {
let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none();
s.state_mut()?.open_block_editor(name, create)?;
let name = s.state()?.block_editor().unwrap_or(name).to_string();
// The block's objects with room around them (and the editor's bar above them).
s.zoom_extents()?;
let c = s.state()?.view().center;
s.zoom_about(0.75, c)?;
s.echo(if new {
format!("Block Editor: new block \"{name}\". BSAVE creates it in the drawing; BCLOSE closes the editor.")
} else {
format!("Block Editor: editing \"{name}\". BSAVE saves it into the drawing; BCLOSE closes the editor.")
});
let dynamic =
|be: &crate::blockedit::BlockEdit| crate::blockedit::has_dynamic_data(&be.host.doc, if be.current { "*Model_Space" } else { &be.name });
if s.state().is_ok_and(|st| st.block_edit.as_ref().is_some_and(dynamic)) {
s.echo("This is a dynamic block: its parameters and actions are kept but can't be edited in CADCraft yet.");
}
Ok(json!({ "block": name, "new": new }))
}
/// The one block reference selected, if exactly one is.
fn selected_reference(s: &Session) -> Option<String> {
let d = s.doc().ok()?;
let refs: Vec<String> = s.selection().iter().filter_map(|h| block_of(d, *h)).collect();
match refs.as_slice() {
[one] => Some(one.clone()),
_ => None,
}
}
fn run_bedit(s: &mut Session, p: &Value) -> Result<Value> {
let name = match (str_param(p, "name"), p.get("handle")) {
(Some(n), _) => n.to_string(),
(None, Some(h)) => {
let h = handle_value(h).ok_or_else(|| bad("bedit", "`handle` must be a handle"))?;
block_of(s.doc()?, h).ok_or_else(|| bad("bedit", "`handle` is not a block reference"))?
}
(None, None) => selected_reference(s).ok_or_else(|| bad("bedit", "`name` or `handle` is required (or select one block reference)"))?,
};
let create = bool_or(p, "create", false);
let name = name.trim();
let current = name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING);
if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) {
return Err(bad("bedit", format!("\"{name}\" is not a valid block name")));
}
open(s, name, create)
}
fn run_bsave(s: &mut Session, _p: &Value) -> Result<Value> {
s.state_mut()?.save_block_editor(None)?;
let name = s.state()?.block_editor().unwrap_or_default().to_string();
s.echo(format!("Block \"{name}\" saved."));
Ok(json!({ "block": name }))
}
/// Check a BSAVEAS name: valid, and not another existing block unless `replace`.
fn check_save_as(s: &Session, name: &str, replace: bool) -> Result<()> {
if !super::valid_block_name(name) {
return Err(bad("bsaveas", format!("\"{name}\" is not a valid block name")));
}
let d = s.doc()?;
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(name));
if !current && !replace && d.block(name).is_some() {
return Err(bad("bsaveas", format!("Block \"{name}\" already exists; pass `replace: true` to redefine it")));
}
if let Some(why) = d.block(name).filter(|_| !current).and_then(|_| crate::blockedit::refusal(d, name)) {
return Err(bad("bsaveas", why));
}
Ok(())
}
fn run_bsaveas(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("bsaveas", "`name` is required"))?.trim().to_string();
check_save_as(s, &name, bool_or(p, "replace", false))?;
s.state_mut()?.save_block_editor(Some(&name))?;
let name = s.state()?.block_editor().unwrap_or_default().to_string();
s.echo(format!("Block \"{name}\" saved; the Block Editor now edits it."));
Ok(json!({ "block": name }))
}
fn close(s: &mut Session, save: bool) -> Result<Value> {
let st = s.state()?;
let name = st.block_editor().unwrap_or_default().to_string();
let saved = save && st.block_unsaved();
s.state_mut()?.close_block_editor(save)?;
if saved {
s.echo(format!("Block \"{name}\" saved."));
}
Ok(json!({ "block": name, "saved": saved }))
}
fn run_bclose(s: &mut Session, p: &Value) -> Result<Value> {
close(s, bool_or(p, "save", true))
}
// ---------------- interactive ----------------
/// BEDIT: the selected block reference's block, else a block name typed or a reference picked.
struct BeditM;
impl Interactive for BeditM {
fn name(&self) -> &'static str {
"BEDIT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
match selected_reference(s) {
Some(name) => open(s, &name, false).map(|_| Step::Done),
None => Ok(Step::Continue),
}
}
fn prompt(&self, _s: &Session) -> Prompt {
Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true })
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Text(t) if t.trim() == "?" => {
let names: Vec<String> = {
let d = s.doc()?;
d.blocks.values().filter(|b| crate::blockedit::refusal(d, &b.name).is_none()).map(|b| b.name.clone()).collect()
};
s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") });
Ok(Step::Continue)
}
Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done),
Input::Pick(hs) => {
let name = {
let d = s.doc()?;
hs.iter().find_map(|h| block_of(d, *h))
};
match name {
Some(n) => open(s, &n, false).map(|_| Step::Done),
None => Err(EngineError::Other("Select a block reference.".into())),
}
}
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
}
/// BCLOSE: asks whether to save changes not saved yet.
struct BcloseM;
impl Interactive for BcloseM {
fn name(&self) -> &'static str {
"BCLOSE"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if s.state()?.block_unsaved() { Ok(Step::Continue) } else { close(s, false).map(|_| Step::Done) }
}
fn prompt(&self, s: &Session) -> Prompt {
let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default();
Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y")
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let answer = match i {
Input::Enter => "Y".to_string(),
Input::Keyword(k) | Input::Text(k) => k,
_ => return Ok(Step::Continue),
};
match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) {
Some('Y') => close(s, true).map(|_| Step::Done),
Some('N') => close(s, false).map(|_| Step::Done),
_ => Err(EngineError::Other("Enter Yes or No.".into())),
}
}
}
/// BSAVEAS: a new block name; an existing block is redefined only when confirmed.
#[derive(Default)]
struct BsaveasM {
/// The name given, waiting for "redefine?" because a block of that name exists.
name: Option<String>,
}
impl BsaveasM {
fn save(s: &mut Session, name: &str, replace: bool) -> Result<Step> {
run_bsaveas(s, &json!({ "name": name, "replace": replace })).map(|_| Step::Done)
}
}
impl Interactive for BsaveasM {
fn name(&self) -> &'static str {
"BSAVEAS"
}
fn prompt(&self, _s: &Session) -> Prompt {
match &self.name {
Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), KW).kw(&["Yes", "No"]).default("N"),
None => Prompt::new("Enter block name", Accept::TEXT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(n) = self.name.clone() {
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_uppercase().starts_with('Y'));
return if yes { Self::save(s, &n, true) } else { Ok(Step::Done) };
}
let name = match i {
Input::Text(t) => t.trim().to_string(),
Input::Enter => return Ok(Step::Done),
_ => return Ok(Step::Continue),
};
let current = s.state()?.block_editor().is_some_and(|b| b.eq_ignore_ascii_case(&name));
if !current && s.doc()?.block(&name).is_some() {
check_save_as(s, &name, true)?;
self.name = Some(name);
return Ok(Step::Continue);
}
Self::save(s, &name, false)
}
}
-57
View File
@@ -1,57 +0,0 @@
//! 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
}
-200
View File
@@ -1,200 +0,0 @@
//! Grips of dynamic block references: `blocks.reference.grips` lists them, `blocks.reference.grip`
//! drags one (one undo step), and [`Session::start_dyn_grip`] starts the drag the UI's click on a
//! 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::{Common, Drawing, Entity, EntityKind, Handle, Lineweight, Transparency};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use crate::cmd::{bad, point_param, str_param};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Running, Session, Step};
/// Most objects one preview draws.
const MAX_PREVIEW: usize = 2000;
/// The grips of a dynamic block reference (an empty list for any other object).
pub(super) fn run_grips(s: &mut Session, p: &Value) -> Result<Value> {
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad("blocks.reference.grips", "`handle` is required"))?;
serde_json::to_value(dynblock::grips(s.doc()?, h)).map_err(|e| bad("blocks.reference.grips", e.to_string()))
}
/// Drag a grip of a dynamic block reference to a point (one undo step).
pub(super) fn run_grip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "blocks.reference.grip";
let h = super::super::curves::handle_param(p, "handle").ok_or_else(|| bad(cmd, "`handle` is required"))?;
let node = p
.get("node")
.and_then(Value::as_i64)
.and_then(|n| i32::try_from(n).ok())
.ok_or_else(|| bad(cmd, "`node` (a parameter's graph id) is required"))?;
let part = str_param(p, "part").and_then(GripPart::from_name).ok_or_else(|| bad(cmd, "`part` must be base, end or at"))?;
let to = point_param(p, "to").ok_or_else(|| bad(cmd, "`to` [x, y] is required"))?;
drag(s, h, node, part, to).map_err(|e| bad(cmd, e))?;
let properties = dynblock::custom_properties(s.doc()?, h).map(|(_, p)| p).unwrap_or_default();
Ok(json!({ "properties": properties }))
}
/// Drag grip (`node`, `part`) of reference `h` to `to` in the session's drawing.
fn drag(s: &mut Session, h: Handle, node: i32, part: GripPart, to: Vec2) -> std::result::Result<(), String> {
if !matches!(s.doc().map_err(|e| e.to_string())?.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {
return Err("`handle` is not a block reference".into());
}
if super::super::curves::is_locked(s, h) {
return Err("the reference is on a locked layer".into());
}
let mut d = s.doc().map_err(|e| e.to_string())?.clone();
dynblock::drag_grip(&mut d, h, node, part, to)?;
*s.doc_mut().map_err(|e| e.to_string())? = d;
Ok(())
}
/// 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<Entity> = b
.entities
.iter()
.filter(|e| e.common.visible && !matches!(&e.kind, EntityKind::AttDef(_) | EntityKind::Wipeout(_)))
.take(MAX_PREVIEW)
.map(|e| {
let mut kind = e.kind.clone();
kind.transform(&m);
Entity { handle: Handle(0), common: under(&e.common, r), kind }
})
.collect();
// 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
}
/// A dynamic grip being dragged.
struct DynGripEdit {
handle: Handle,
node: i32,
part: GripPart,
/// Where the grip is (the rubber band's start).
from: Vec2,
/// The property it changes, and whether it is a distance (a typed number sets it).
property: String,
distance: bool,
}
impl Interactive for DynGripEdit {
fn name(&self) -> &'static str {
"GRIP"
}
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
let (msg, accept) = if self.distance {
("** STRETCH ** Specify stretch point or distance", Accept::POINT_OR_NUMBER)
} else {
("** STRETCH ** Specify stretch point", Accept::POINT)
};
Prompt::new(msg, accept).kw(&["eXit"]).base(self.from)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let to = match i {
Input::Point(p) => p,
Input::Deferred(d) => d.at,
Input::Text(t) if self.distance => {
let mut d = s.doc()?.clone();
dynblock::set_property(&mut d, self.handle, &self.property, t.trim()).map_err(EngineError::Other)?;
*s.doc_mut()? = d;
return Ok(Step::Done);
}
Input::Keyword(k) if k == "eXit" => return Ok(Step::Done),
Input::Enter | Input::Cancel => return Ok(Step::Done),
_ => return Err(EngineError::Other("Requires a point.".into())),
};
drag(s, self.handle, self.node, self.part, to).map_err(EngineError::Other)?;
Ok(Step::Done)
}
/// 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();
match dynblock::drag_grip(&mut d, self.handle, self.node, self.part, c) {
Ok(()) => drawn(&d, self.handle),
Err(_) => Vec::new(),
}
}
}
impl Session {
/// Start dragging grip `part` of parameter `node` of dynamic block reference `handle` (the
/// UI's click on a dynamic grip). A list grip (visibility, lookup) has no drag: the UI offers
/// its choices and sets them with `blocks.reference.set`.
pub fn start_dyn_grip(&mut self, handle: Handle, node: i32, part: GripPart) -> Result<()> {
if self.running.is_some() {
self.cancel();
}
let g = dynblock::grips(self.doc()?, handle)
.into_iter()
.find(|g| g.node == node && g.part == part)
.ok_or_else(|| EngineError::Other("no such grip".into()))?;
if g.shape == GripShape::List {
return Err(EngineError::Other("a list grip is chosen from, not dragged".into()));
}
let edit = DynGripEdit { handle, node, part, from: g.at, property: g.property, distance: g.shape == GripShape::Linear };
let machine = crate::guard::Guarded::wrap(Box::new(edit));
let st = self.state()?;
let (before, selection_before) = (st.doc.clone(), st.selection.clone());
self.running = Some(Running { id: "grip".into(), machine, before, selection_before });
self.pending_window = None;
self.point_mods.clear();
self.echo("** STRETCH **");
Ok(())
}
}
+6 -28
View File
@@ -1,12 +1,12 @@
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, multiline
//! styles, groups) and zero-length geometry or empty text.
//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and
//! zero-length geometry or empty text.
//!
//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type,
//! the names and whether to verify each one, as AutoCAD's -PURGE does.
use std::collections::{HashSet, VecDeque};
use cadcraft_doc::{Drawing, EntityKind, Handle, STANDARD_MLINE_STYLE};
use cadcraft_doc::{Drawing, EntityKind, Handle};
use serde_json::{Map, Value, json};
use super::super::qselect::wildcard;
@@ -21,7 +21,6 @@ enum Kind {
Layers,
Linetypes,
MleaderStyles,
MlineStyles,
TableStyles,
TextStyles,
ZeroLength,
@@ -44,7 +43,7 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
("Plotstyles", &["plotstyles"], Kind::Absent),
("SHapes", &["shapes"], Kind::Absent),
("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles),
("Mlinestyles", &["mlinestyles", "mlinestyle"], Kind::MlineStyles),
("Mlinestyles", &["mlinestyles"], Kind::Absent),
("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent),
("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles),
("Visualstyles", &["visualstyles"], Kind::Absent),
@@ -57,17 +56,8 @@ const KINDS: &[(&str, &[&str], Kind)] = &[
/// The named kinds `All` purges, in an order where purging one frees the next (blocks free
/// layers, layers free linetypes, dimension styles free text styles).
const ALL: &[Kind] = &[
Kind::Blocks,
Kind::Groups,
Kind::Dimstyles,
Kind::MleaderStyles,
Kind::MlineStyles,
Kind::TableStyles,
Kind::Layers,
Kind::Linetypes,
Kind::TextStyles,
];
const ALL: &[Kind] =
&[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles];
fn norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
@@ -87,7 +77,6 @@ fn noun(kind: Kind) -> &'static str {
Kind::Layers => "layer",
Kind::Linetypes => "linetype",
Kind::MleaderStyles => "multileader style",
Kind::MlineStyles => "multiline style",
Kind::TableStyles => "table style",
Kind::TextStyles => "text style",
Kind::ZeroLength => "zero-length object",
@@ -104,7 +93,6 @@ fn json_key(kind: Kind) -> &'static str {
Kind::Layers => "layers",
Kind::Linetypes => "linetypes",
Kind::MleaderStyles => "mleaderstyles",
Kind::MlineStyles => "mlinestyles",
Kind::TableStyles => "tablestyles",
Kind::TextStyles => "textstyles",
Kind::ZeroLength => "zeroLength",
@@ -210,15 +198,6 @@ fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.map(|t| t.name.clone())
.collect()
}
Kind::MlineStyles => {
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLine(m) = &e.kind { Some(m.style.clone()) } else { None }));
let cur = d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE);
d.mline_styles
.iter()
.filter(|t| !is(&t.name, &[STANDARD_MLINE_STYLE, &cur]) && !used.contains(&t.name.to_ascii_lowercase()))
.map(|t| t.name.clone())
.collect()
}
Kind::TableStyles => {
let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None }));
let cur = d.header.str("CTABLESTYLE", "Standard");
@@ -259,7 +238,6 @@ fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result<bool> {
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name),
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name),
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name),
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, name),
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name),
Kind::Groups => retain(&mut d.groups, |g| &g.name, name),
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
-469
View File
@@ -1,469 +0,0 @@
//! REFEDIT, REFSET and REFCLOSE: edit a block reference in place.
//!
//! REFEDIT hides the picked reference and puts the block's objects (all, or the ones named) into
//! the drawing where the reference shows them: the *working set*, ordinary objects that every
//! command edits. The rest of the block stands there as faded copies, and everything else in the
//! drawing is locked ([`crate::cmd::curves::is_locked`], enforced by [`after_command`]). Objects
//! drawn while editing join the working set. REFSET adds objects to or removes them from it.
//! REFCLOSE writes the working set back into the block definition, so every reference to the
//! block shows the change, or discards the edit.
//!
//! The state is [`Drawing::refedit`]: it travels with undo, is never written to a file (files
//! are written from [`Drawing::committed`]) and ends with the drawing it belongs to.
//!
//! Not yet: external references (their source drawing would have to be written back), nested
//! references picked at a depth, annotative scale contexts and attribute synchronisation.
use std::collections::HashSet;
use std::sync::Arc;
use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, RefEdit, Transparency, WorkItem};
use cadcraft_geom::Mat3;
use serde_json::{Value, json};
use super::super::curves::handle_value;
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::references_block;
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Transparency of the faded copies of the block's other objects.
const GHOST_FADE: u8 = 70;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("refedit", "Edit Reference In-Place", run_refedit)
.menu(&["Tools", "Xref and Block In-place Editing", "Edit Reference In-Place"])
.params("{handle? | handles? | name? (a block reference, default the selection), objects?: [block object handles] (default all)} → {block, insert, working: [handles], faded}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(SelectRun::single("refedit", "REFEDIT", "Select reference")))),
CommandSpec::new("refset", "Working Set", run_refset)
.menu(&["Tools", "Xref and Block In-place Editing", "Add to Working Set"])
.params("{add?: [handles], remove?: [handles]} (or {handles, mode?: \"add\"|\"remove\"}); with neither: lists the working set → {working: [handles], added, removed}")
.enabled(editing)
.interactive(|_| Ok(Box::new(RefsetM::default()))),
CommandSpec::new("refclose", "Save Reference Edits", run_refclose)
.menu(&["Tools", "Xref and Block In-place Editing", "Save Reference Edits"])
.params("{option?: \"save\"|\"discard\" | save?: bool (default true)} → {block, saved, objects}")
.enabled(editing)
.interactive(|_| Ok(Box::new(RefcloseM))),
]
}
fn editing(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.doc().is_ok_and(|d| d.refedit.is_some()) { Ok(()) } else { Err("no block reference is being edited (REFEDIT)".into()) }
}
fn active(s: &Session, cmd: &str) -> Result<RefEdit> {
s.doc()?.refedit.clone().ok_or_else(|| bad(cmd, "no block reference is being edited (REFEDIT)"))
}
/// A block object as the drawing shows it through the edited reference.
fn to_host(to_world: &Mat3, e: &Entity, h: Handle, ghost: bool) -> Entity {
let mut c = e.clone();
c.handle = h;
c.kind.transform(to_world);
if ghost {
c.common.transparency = Transparency::Percent(GHOST_FADE);
}
c
}
/// A drawing object as it belongs in the block, with the block object's handle `h`.
fn to_block(inv: &Mat3, e: &Entity, h: Handle) -> Entity {
let mut c = e.clone();
c.handle = h;
c.kind.transform(inv);
c
}
fn put(d: &mut Drawing, r: &RefEdit, e: Entity) -> Result<()> {
d.ensure_layer(&e.common.layer);
d.space_mut(&r.space).ok_or_else(|| bad("refedit", "the space of the reference is gone"))?.push(e);
Ok(())
}
fn hexes(hs: impl IntoIterator<Item = Handle>) -> Vec<String> {
hs.into_iter().map(|h| h.hex()).collect()
}
// ---------------- REFEDIT ----------------
fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.refedit.is_some() {
return Err(bad("refedit", "a reference is already being edited; finish with REFCLOSE"));
}
let d = s.doc()?;
let picked = match str_param(p, "name") {
Some(n) => d
.model
.iter()
.chain(d.layouts.iter().flat_map(|l| l.entities.iter()))
.find(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(n)))
.map(|e| e.handle),
None => targets(s, p)?.into_iter().find(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, cadcraft_doc::EntityKind::Insert(_)))),
};
let ih = picked.ok_or_else(|| bad("refedit", "select a block reference"))?;
let (ins, space) = match d.entity(ih).map(|e| &e.kind) {
Some(cadcraft_doc::EntityKind::Insert(i)) => (i.clone(), d.space_of(ih).ok_or_else(|| bad("refedit", "the reference is in no space"))?),
_ => return Err(bad("refedit", "select a block reference")),
};
let blk = d.block(&ins.block).cloned().ok_or_else(|| bad("refedit", format!("block \"{}\" not found", ins.block)))?;
if blk.xref_path.is_some() {
return Err(bad(
"refedit",
format!(
"\"{}\" is an external reference: editing it in place would write its source drawing, which is not available yet. Open it with XOPEN, edit and save it, then reload.",
blk.name
),
));
}
if blk.name.starts_with('*') || blk.array.is_some() {
return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name)));
}
let to_world = ins.transform(blk.base.xy());
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
let finite = [to_world.a, to_world.b, to_world.c, to_world.d, to_world.e, to_world.f].iter().all(|v| v.is_finite());
if !finite || to_world.det().abs() < 1e-12 || (sx - sy).abs() > 1e-9 * sx.max(sy) {
return Err(bad("refedit", "the reference must be scaled equally in X and Y (and not flat) to be edited in place"));
}
let chosen: Option<HashSet<Handle>> = match p.get("objects") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => None,
Some(Value::Array(a)) => {
let mut set = HashSet::new();
for v in a {
let h = handle_value(v).ok_or_else(|| bad("refedit", format!("`objects`: {v} is not an object handle")))?;
if blk.entities.get(h).is_none() {
return Err(bad("refedit", format!("object {} is not in block \"{}\"", h.hex(), blk.name)));
}
set.insert(h);
}
if set.is_empty() {
return Err(bad("refedit", "`objects` is empty"));
}
Some(set)
}
Some(v) => return Err(bad("refedit", format!("`objects` must be \"all\" or an array of handles, got {v}"))),
};
let was_visible = d.entity(ih).is_some_and(|e| e.common.visible);
let mut r = RefEdit {
block: blk.name.clone(),
insert: ih,
insert_was_visible: was_visible,
space,
to_world,
items: Vec::new(),
ghosts: Vec::new(),
released: Vec::new(),
};
let doc = s.doc_mut()?;
for e in blk.entities.iter() {
let working = chosen.as_ref().is_none_or(|c| c.contains(&e.handle));
let h = doc.new_handle();
put(doc, &r, to_host(&to_world, e, h, !working))?;
if working {
r.items.push(WorkItem { copy: h, source: Some(e.handle) });
} else {
r.ghosts.push((h, e.handle));
}
}
doc.modify_entity(ih, |e| e.common.visible = false).map_err(|e| bad("refedit", e.to_string()))?;
let out = json!({ "block": r.block, "insert": ih.hex(), "working": hexes(r.items.iter().map(|i| i.copy)), "faded": r.ghosts.len() });
let sel: Vec<Handle> = r.items.iter().map(|i| i.copy).collect();
doc.refedit = Some(r);
s.set_selection(sel);
Ok(out)
}
// ---------------- REFSET ----------------
fn handle_list(p: &Value, key: &str) -> Result<Vec<Handle>> {
match p.get(key) {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::Array(a)) => {
a.iter().map(|v| handle_value(v).ok_or_else(|| bad("refset", format!("`{key}`: {v} is not an object handle")))).collect()
}
Some(v) => Err(bad("refset", format!("`{key}` must be an array of object handles, got {v}"))),
}
}
fn run_refset(s: &mut Session, p: &Value) -> Result<Value> {
let (mut add, mut remove) = (handle_list(p, "add")?, handle_list(p, "remove")?);
let hs = handle_list(p, "handles")?;
if str_param(p, "mode").is_some_and(|m| m.to_ascii_lowercase().starts_with('r')) {
remove.extend(hs)
} else {
add.extend(hs)
}
apply_refset(s, &add, &remove)
}
fn apply_refset(s: &mut Session, add: &[Handle], remove: &[Handle]) -> Result<Value> {
let mut r = active(s, "refset")?;
let inv = r.to_world.inverse().ok_or_else(|| bad("refset", "the reference can't be inverted"))?;
let d = s.doc()?;
for h in add.iter().chain(remove) {
if d.entity(*h).is_none() {
return Err(bad("refset", format!("no object {}", h.hex())));
}
}
if let Some(h) = remove.iter().find(|h| !r.is_working(**h)) {
return Err(bad("refset", format!("object {} is not in the working set", h.hex())));
}
let blk = d.block(&r.block).cloned().ok_or_else(|| bad("refset", format!("block \"{}\" is gone; REFCLOSE Discard", r.block)))?;
let (mut added, mut removed) = (0usize, 0usize);
let doc = s.doc_mut()?;
for h in add {
if *h == r.insert || r.is_working(*h) {
continue;
}
if let Some(pos) = r.ghosts.iter().position(|(g, _)| g == h) {
// A faded block object joins the working set (with the edits it had before).
let (g, src) = r.ghosts.remove(pos);
doc.remove_entity(g);
let cur = match r.released.iter().position(|e| e.handle == src) {
Some(i) => Some(r.released.remove(i)),
None => blk.entities.get(src).map(|e| (**e).clone()),
};
if let Some(cur) = cur {
let c = doc.new_handle();
put(doc, &r, to_host(&r.to_world, &cur, c, false))?;
r.items.push(WorkItem { copy: c, source: Some(src) });
added += 1;
}
} else {
r.items.push(WorkItem { copy: *h, source: None });
added += 1;
}
}
for h in remove {
let Some(pos) = r.items.iter().position(|i| i.copy == *h) else { continue };
let item = r.items.remove(pos);
if let Some(src) = item.source
&& let Some(e) = doc.entity(*h).map(|e| (**e).clone())
{
// Back to the block, edits kept, shown faded.
let kept = to_block(&inv, &e, src);
doc.remove_entity(*h);
let g = doc.new_handle();
put(doc, &r, to_host(&r.to_world, &kept, g, true))?;
r.ghosts.push((g, src));
r.released.push(kept);
}
removed += 1;
}
let working = hexes(r.items.iter().map(|i| i.copy));
doc.refedit = Some(r);
Ok(json!({ "working": working, "added": added, "removed": removed }))
}
// ---------------- REFCLOSE ----------------
fn run_refclose(s: &mut Session, p: &Value) -> Result<Value> {
let save = match str_param(p, "option") {
Some(o) if o.to_ascii_lowercase().starts_with('d') => false,
Some(o) if o.to_ascii_lowercase().starts_with('s') => true,
Some(o) => return Err(bad("refclose", format!("unknown option \"{o}\" (save or discard)"))),
None => bool_or(p, "save", true),
};
close(s, save)
}
fn close(s: &mut Session, save: bool) -> Result<Value> {
let r = active(s, "refclose")?;
// Work on a copy: the drawing changes only when everything went through.
let mut nd: Drawing = s.doc()?.clone();
let mut objects = 0;
if save {
objects = commit_block(&mut nd, &r)?;
}
nd.end_refedit_discard(&r);
*s.doc_mut()? = nd;
s.set_selection(Vec::new());
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
}
/// Replace the block's objects by the working set (and move the drawing objects added to it
/// into the block). Returns the number of objects the block has now.
fn commit_block(nd: &mut Drawing, r: &RefEdit) -> Result<usize> {
let blk = nd.block(&r.block).cloned().ok_or_else(|| bad("refclose", format!("block \"{}\" is gone; use REFCLOSE Discard", r.block)))?;
let inv = r.to_world.inverse().ok_or_else(|| bad("refclose", "the reference can't be inverted; use REFCLOSE Discard"))?;
let mut ents: Vec<Entity> = Vec::new();
for e in blk.entities.iter() {
match r.items.iter().find(|i| i.source == Some(e.handle)) {
// A working object the user erased leaves the block.
Some(i) => {
if let Some(c) = nd.entity(i.copy) {
ents.push(to_block(&inv, c, e.handle));
}
}
None => match r.released.iter().find(|x| x.handle == e.handle) {
Some(x) => ents.push(x.clone()),
None => ents.push((**e).clone()),
},
}
}
let mut moved = Vec::new();
for i in r.items.iter().filter(|i| i.source.is_none()) {
if let Some(c) = nd.entity(i.copy).map(|c| (**c).clone()) {
let h = nd.new_handle();
ents.push(to_block(&inv, &c, h));
moved.push(i.copy);
}
}
if references_block(nd, &ents, &r.block) {
return Err(bad("refclose", format!("Block \"{}\" would reference itself; remove that reference or use REFCLOSE Discard.", r.block)));
}
let n = ents.len();
let mut store = EntityStore::new();
for e in ents {
store.push(e);
}
let key = nd.blocks.keys().find(|k| k.eq_ignore_ascii_case(&r.block)).cloned().unwrap_or_else(|| r.block.clone());
let mut nb = (*blk).clone();
nb.entities = store;
nd.blocks.insert(key, Arc::new(nb));
for h in moved {
nd.remove_entity(h);
}
Ok(n)
}
// ---------------- while editing ----------------
/// After a command ran: objects it created join the working set, and a command that changed
/// anything outside the working set is undone (returns the reason).
pub(crate) fn after_command(s: &mut Session, before: &Arc<Drawing>, id: &str) -> Option<String> {
if matches!(id, "refedit" | "refset" | "refclose") || !crate::cmd::find_command(id).is_some_and(|c| c.undoable) {
return None;
}
let rb = before.refedit.as_ref()?;
let st = s.state_mut().ok()?;
if Arc::ptr_eq(before, &st.doc) {
return None;
}
let ra = st.doc.refedit.as_ref().filter(|ra| ra.insert == rb.insert)?;
let owned: HashSet<Handle> = rb.items.iter().map(|i| i.copy).chain(rb.ghosts.iter().map(|g| g.0)).chain([rb.insert]).collect();
let stores = |d: &Drawing| std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).cloned().collect::<Vec<_>>();
let touched = stores(before)
.iter()
.any(|store| store.iter().any(|e| !owned.contains(&e.handle) && !st.doc.entity(e.handle).is_some_and(|a| Arc::ptr_eq(a, e))));
if touched {
st.doc = before.clone();
return Some("Only the working set can be edited during REFEDIT (REFSET adds objects, REFCLOSE ends the edit).".into());
}
let seed = before.handseed;
let mut new: Vec<Handle> = Vec::new();
for store in stores(&st.doc) {
new.extend(store.iter().map(|e| e.handle).filter(|h| h.0 >= seed && !ra.owns(*h)));
}
if !new.is_empty()
&& let Some(r) = Arc::make_mut(&mut st.doc).refedit.as_mut()
{
r.items.extend(new.into_iter().map(|copy| WorkItem { copy, source: None }));
}
None
}
/// Whether `h` may not be edited because a reference is being edited and it is not in the working set.
pub(crate) fn locked(d: &Drawing, h: Handle) -> bool {
d.refedit.as_ref().is_some_and(|r| !r.is_working(h))
}
// ---------------- interactive ----------------
#[derive(Default)]
enum RStage {
#[default]
Mode,
Pick(bool),
}
#[derive(Default)]
struct RefsetM {
stage: RStage,
sel: SelectPhase,
}
impl Interactive for RefsetM {
fn name(&self) -> &'static str {
"REFSET"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
active(s, "refset").map(|_| Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
RStage::Mode => {
let mut p = Prompt::new("Enter an option [Add/Remove] <Add>", Accept::TEXT);
p.keywords = vec!["Add".into(), "Remove".into()];
p.default = Some("Add".into());
p
}
RStage::Pick(remove) => {
let mut p = self.sel.prompt();
p.message = format!("Select objects to {}", if remove { "remove" } else { "add" });
p
}
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
RStage::Mode => {
let remove = match i {
Input::Keyword(k) | Input::Text(k) => k.to_ascii_lowercase().starts_with('r'),
Input::Enter => false,
_ => return Ok(Step::Cancel),
};
self.sel = SelectPhase::begin(s);
self.stage = RStage::Pick(remove);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.finish(s, remove, &hs);
}
Ok(Step::Continue)
}
RStage::Pick(remove) => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.finish(s, remove, &hs),
},
}
}
}
impl RefsetM {
fn finish(&mut self, s: &mut Session, remove: bool, hs: &[Handle]) -> Result<Step> {
let r = if remove { apply_refset(s, &[], hs) } else { apply_refset(s, hs, &[]) }?;
s.echo(format!("{} object(s) in the working set.", r.get("working").and_then(Value::as_array).map_or(0, Vec::len)));
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
struct RefcloseM;
impl Interactive for RefcloseM {
fn name(&self) -> &'static str {
"REFCLOSE"
}
fn prompt(&self, _s: &Session) -> Prompt {
let mut p = Prompt::new("Enter option [Save/Discard reference changes] <Save>", Accept::TEXT);
p.keywords = vec!["Save".into(), "Discard".into()];
p.default = Some("Save".into());
p
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let save = match i {
Input::Keyword(k) | Input::Text(k) => !k.to_ascii_lowercase().starts_with('d'),
Input::Enter => true,
_ => return Ok(Step::Cancel),
};
let r = close(s, save)?;
s.echo(if save { "Reference changes saved." } else { "Reference changes discarded." });
let _ = r;
Ok(Step::Done)
}
}
+13 -10
View File
@@ -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 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());
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());
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(), true);
let out = load::load(&mut d, name, host.as_deref());
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,7 +227,11 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"reload" | "r" => {
let names = named(s, "-xref", p)?;
let out = load::load_each(s.doc_mut()?, &names, host.as_deref(), false);
let mut out = Vec::new();
for n in &names {
let d = s.doc_mut()?;
out.extend(load::load(d, n, host.as_deref()));
}
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<_>>() }),
@@ -267,13 +271,14 @@ 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 d = s.doc_mut()?;
let mut out = Vec::new();
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 }))
}
@@ -362,10 +367,8 @@ 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 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}")),
})?;
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")))?;
s.execute("open", &json!({ "path": file.to_string_lossy() }))
}
+60 -142
View File
@@ -3,10 +3,8 @@
//! `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 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
//! 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
//! 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.
@@ -15,8 +13,6 @@ 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).
@@ -24,9 +20,6 @@ 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)]
@@ -126,38 +119,16 @@ 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<&Path>) -> Option<PathBuf> {
let p = host?.parent()?;
fn folder(host: Option<&str>) -> Option<PathBuf> {
let p = Path::new(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())
@@ -167,94 +138,58 @@ fn canonical(p: &Path) -> PathBuf {
p.to_path_buf()
}
/// 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();
/// 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());
let mut out = Vec::new();
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),
}
match dir {
Some(d) if !p.is_absolute() => out.push(d.join(&p)),
_ => out.push(p.clone()),
}
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);
if let (Some(d), Some(f)) = (dir, p.file_name()) {
out.push(d.join(f));
}
(out, refused)
out
}
/// 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> {
/// 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> {
if path.trim().is_empty() {
return Err(Missing::NotFound);
return None;
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
let found = files.into_iter().find(|p| p.is_file());
{
candidates(path, dir).into_iter().find(|p| p.is_file())
}
#[cfg(target_arch = "wasm32")]
let found: Option<PathBuf> = {
let _ = files;
{
let _ = candidates(path, dir);
None
};
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
}
/// 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> {
/// Read a drawing file through the host's io hooks.
fn read(file: &Path) -> 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"))]
{
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 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));
}
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);
let bytes = std::fs::read(file).map_err(|e| e.to_string())?;
(hooks.read)(&bytes, &file.to_string_lossy())
}
#[cfg(target_arch = "wasm32")]
{
let _ = (hooks, file, cx);
let _ = (hooks, file);
Err("files are not available on the web".into())
}
}
/// Load every xref of `d` that isn't unloaded from the paths saved in it; `host` is the drawing's
/// own path.
/// Load every xref of `d` that isn't unloaded; `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
@@ -262,44 +197,28 @@ 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();
load_each(d, &names, host, false)
names.iter().flat_map(|n| load(d, n, host)).collect()
}
/// 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);
/// 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);
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.drain(..).map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out.extend(cx.notes.into_iter().map(|n| Outcome { name: String::new(), status: XrefStatus::Unresolved, detail: n }));
out
}
fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> (XrefStatus, String) {
fn load_in(d: &mut Drawing, name: &str, dir: Option<&Path>, 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, host, chosen, cx, depth) {
let (status, detail) = match fetch(&path, dir, 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),
@@ -315,12 +234,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
}
/// Read the drawing referenced as `path` and load its own attached xrefs into it.
fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usize) -> Result<(Drawing, PathBuf), (XrefStatus, String)> {
fn fetch(path: &str, dir: Option<&Path>, 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 file = find(path, host, chosen).map_err(|m| m.outcome(path))?;
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 canon = canonical(&file);
if cx.chain.contains(&canon) {
return Err(unresolved("circular reference"));
@@ -329,12 +251,13 @@ fn fetch(path: &str, host: Option<&Path>, chosen: bool, cx: &mut Ctx, depth: usi
return Err(unresolved("too many referenced files"));
}
cx.files += 1;
let mut x = read(&file, cx).map_err(|e| (XrefStatus::Unresolved, format!("{}: {e}", file.to_string_lossy())))?;
let mut x = read(&file).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, Some(&file), false, cx, depth + 1);
let (status, detail) = load_in(&mut x, &n, xdir.as_deref(), cx, depth + 1);
if status != XrefStatus::Loaded {
cx.notes.push(format!("nested xref \"{n}\" not loaded: {detail}"));
}
@@ -507,23 +430,18 @@ mod tests {
#[test]
fn paths_resolve_against_the_host_folder_then_by_name() {
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));
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")]);
if !cfg!(windows) {
// A Windows path saved elsewhere still finds the file next to the host.
assert_eq!(c("C:\\jobs\\site.dwg", false), (vec![dir.join("site.dwg")], None));
let c = candidates("C:\\jobs\\site.dwg", Some(dir));
assert_eq!(c.last(), Some(&dir.join("site.dwg")));
}
// 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));
assert_eq!(candidates("a.dxf", None), vec![PathBuf::from("a.dxf")]);
assert!(find("", Some(dir)).is_none());
}
#[test]
+2 -14
View File
@@ -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}; {name, rename: newName} renames it in every expression"),
.params("{} lists; {name, expr | value, description?} sets (new name = user parameter) and re-solves; {delete: name}"),
CommandSpec::new("constraintsettings", "Constraint Settings", run_constraintsettings)
.menu(&["Tools", "Parametric", "Constraint Settings"])
.alias(&["csettings"])
@@ -873,10 +873,6 @@ 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(),
@@ -956,13 +952,6 @@ 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;
}
@@ -1147,9 +1136,8 @@ 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() && !restored && satisfied {
if purged.is_empty() && satisfied {
return;
}
let mut msg = Vec::new();
-116
View File
@@ -56,42 +56,6 @@ 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();
@@ -267,83 +231,3 @@ 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));
}
+2 -8
View File
@@ -36,9 +36,9 @@ pub fn handle_param(p: &serde_json::Value, key: &str) -> Option<Handle> {
p.get(key).and_then(handle_value)
}
/// Is the entity on a locked layer, or outside the working set while a block reference is edited in place (REFEDIT)?
/// Is the entity on a locked layer?
pub fn is_locked(s: &Session, h: Handle) -> bool {
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked) || super::blocks::refedit::locked(d, h))
s.doc().ok().is_some_and(|d| d.entity(h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked))
}
// ---------------- arc constructions ----------------
@@ -129,12 +129,6 @@ 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 => {
+8 -55
View File
@@ -271,7 +271,7 @@ fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
}
Spline::fit_with(&f, bool_or(p, "closed", false), super::spline_opts::json_options(s, p)?)
} else if let Some(c) = points_param(p, "control") {
if c.len() < 2 || c.len() > super::curves::MAX_GEN {
if c.len() < 2 {
return Err(bad("spline", "need 2+ control points"));
}
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
@@ -378,7 +378,7 @@ fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
attach,
rotation: f64_or(p, "rotation", 0.0).to_radians(),
style,
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
contents: super::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"))?,
line_spacing: f64_or(p, "lineSpacing", 1.0),
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
columns: super::mtext_opts::json_columns(p, height, width)?,
@@ -428,21 +428,7 @@ impl Interactive for LineM {
&& let Some(first) = self.first
{
match i {
Input::Point(p) => {
// A deferred TAN first point with a typed `@d<a` next point: resolve
// exactly from the direction and distance the user gave.
if let Some((dist, ang)) = s.last_polar.take()
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
{
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
self.handles.push(s.add_entity(line(t, e))?);
self.pts = vec![t, e];
s.last_point = e;
self.first = None;
return Ok(Step::Continue);
}
return self.resolve_first(s, first, snap::LineEnd::Point(p));
}
Input::Point(p) => return self.resolve_first(s, first, snap::LineEnd::Point(p)),
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
_ => {}
}
@@ -755,12 +741,9 @@ 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()).distance()
}
(0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()),
(0, _) => {
let p =
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied());
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
}
(1, k) => Prompt::new(
@@ -797,9 +780,6 @@ impl Interactive for CircleM {
Ok(Step::Continue)
}
Input::Point(p) => {
if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
self.pts.push(p);
let done = match self.mode {
0 if self.pts.len() == 2 => {
@@ -893,7 +873,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).distance(),
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
@@ -915,9 +895,6 @@ impl Interactive for PolygonM {
let n = self.sides.unwrap_or(4);
if self.edge {
if let Input::Point(p) = i {
if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) {
return Err(crate::EngineError::Other("Edge endpoints must differ.".into()));
}
self.edge_pts.push(p);
if self.edge_pts.len() == 2 {
s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?;
@@ -940,17 +917,11 @@ impl Interactive for PolygonM {
s.last_used.polygon_circumscribed = !ins;
}
(Some(c), Some(ins), Input::Point(p)) => {
if c.near(p, 1e-12) {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?;
return Ok(Step::Done);
}
(Some(c), Some(ins), Input::Text(t)) => {
let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?;
if !r.is_finite() || r <= 0.0 {
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
}
let rp = polygon_typed_radius_point(c, n, r, ins);
s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
return Ok(Step::Done);
@@ -1441,22 +1412,11 @@ struct TextM {
justify: (HAlign, VAlign),
asking_justify: bool,
line: usize,
/// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's
/// `command` runs TEXT; typed and in scripts each line asks for the next.
single_line: bool,
}
impl TextM {
fn new(s: &Session) -> Self {
TextM {
at: None,
height: None,
rotation: None,
justify: (HAlign::Left, VAlign::Baseline),
asking_justify: false,
line: 0,
single_line: s.lisp_running(),
}
fn new(_s: &Session) -> Self {
TextM { at: None, height: None, rotation: None, justify: (HAlign::Left, VAlign::Baseline), asking_justify: false, line: 0 }
}
}
@@ -1517,9 +1477,6 @@ impl Interactive for TextM {
let k = make_text(s, at, &t, h, r, self.justify);
s.add_entity(k)?;
self.line += 1;
if self.single_line {
return Ok(Step::Done);
}
}
(Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
_ => {}
@@ -1605,10 +1562,6 @@ impl Interactive for MTextM {
}
Ok(Step::Continue)
}
fn editing_mtext(&self) -> Option<MText> {
let a = self.first?;
(self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new()))
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match (self.first, self.boxed(), self.ask) {
(Some(a), false, None) => {
+11 -37
View File
@@ -12,7 +12,6 @@ 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> {
@@ -134,7 +133,6 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))),
];
v.extend(mline::specs());
v.extend(breakline::specs());
v
}
@@ -219,7 +217,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), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), (E, 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))],
@@ -233,7 +231,7 @@ impl ArcV {
],
}
}
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
fn build(self, v: &[Val]) -> 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 {
@@ -245,14 +243,7 @@ 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)?),
// 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::Cse => Some(Arc::from_start_center_end(p(1)?, p(0)?, p(2)?)),
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
ArcV::CircleCd => {
@@ -347,11 +338,6 @@ 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."))?;
@@ -394,7 +380,7 @@ impl Interactive for SeqM {
if self.vals.len() < self.v.steps().len() {
return Ok(Step::Continue);
}
match self.v.build(&self.vals, s.ctrl_down) {
match self.v.build(&self.vals) {
Some(k) => {
if let EntityKind::Circle(c) = &k {
let r = c.radius;
@@ -411,9 +397,8 @@ 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() };
@@ -422,7 +407,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, s.ctrl_down));
out.extend(self.v.build(&vals));
}
out
}
@@ -475,10 +460,7 @@ 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"))?;
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)
added(s.add_entity(arc(&a))?)
}
#[derive(Default)]
@@ -503,8 +485,6 @@ 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."))?;
@@ -515,14 +495,8 @@ impl Interactive for ArcContinueM {
_ => Err(other("Point required.")),
}
}
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()
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()
}
}
@@ -601,7 +575,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)).distance();
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1));
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
}
}
@@ -1718,7 +1692,7 @@ 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"]).corner(p),
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).base(p),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
-301
View File
@@ -1,301 +0,0 @@
//! 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(),
}
}
}
+5 -24
View File
@@ -576,7 +576,8 @@ 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::ClosedTee | Tool::OpenTee | Tool::MergedTee => {
Tool::MergedTee => return Err(other("Merged tee is not available yet.")),
Tool::ClosedTee | Tool::OpenTee => {
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*"))?;
@@ -592,36 +593,16 @@ 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 };
// 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 {
a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?;
if tool == Tool::OpenTee {
// 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));
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))) {
if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).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 });
}
}
}
}
}
}
-135
View File
@@ -121,28 +121,6 @@ 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]
@@ -372,118 +350,6 @@ 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]
@@ -756,7 +622,6 @@ fn new_draw_commands_survive_garbage_input() {
"revcloud.polygonal",
"revcloud.freehand",
"wipeout",
"breakline",
"3dpoly",
"mline",
"helix",
-6
View File
@@ -609,12 +609,6 @@ impl Interactive for PasteM {
_ => Ok(Step::Continue),
}
}
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
if self.mode == PasteMode::Orig || s.clipboard.is_empty() {
return None;
}
Some(crate::prompt::Drag { handles: Vec::new(), matrix: Mat3::translate(c - s.clipboard_base), extra: Vec::new(), clipboard: true })
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
let m = Mat3::translate(c - s.clipboard_base);
s.clipboard
+17 -166
View File
@@ -43,17 +43,12 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("preview", "Plot Preview", run_preview)
.menu(&["File", "Plot Preview"])
.alias(&["pre"])
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
.noundo(),
CommandSpec::new("publish", "Batch Publish...", run_publish)
.menu(&["File", "Batch Publish..."])
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
.noundo(),
CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)
.menu(&["File", "Sheet Books to PDF..."])
.alias(&["printpdf", "pdfb"])
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
.noundo(),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
})
@@ -79,9 +74,10 @@ fn extension(path: &str) -> String {
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
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(())
}
#[cfg(target_arch = "wasm32")]
{
@@ -291,9 +287,9 @@ fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64
fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
let space = plot_space("preview", s, p)?;
let d = s.doc()?;
let mut page = match space.layout() {
Some(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
None => {
let mut page = match &space {
Space::Paper(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
Space::Model => {
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
let mut page = PageSetup::default();
if paper::paper_unit_mm(d) == 1.0
@@ -315,9 +311,11 @@ 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::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 sheet = Sheet::from_page(&page, paper::paper_unit_mm(d));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(match space {
Space::Paper(_) => page.lineweights,
Space::Model => true,
});
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
@@ -333,7 +331,6 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
text: true,
fill: true,
lineweights,
transparency,
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
@@ -358,7 +355,10 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let layout = space.tab_name();
let layout = match &space {
Space::Model => "Model".to_string(),
Space::Paper(n) => n.clone(),
};
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
@@ -421,122 +421,6 @@ fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "sheets": out, "message": message }))
}
// ---------- PDFBOOK ----------
/// Frame sizes match a paper to this many millimetres.
const FRAME_TOL_MM: f64 = 0.5;
/// Most frames one PDFBOOK looks at.
const MAX_FRAMES: usize = 100_000;
/// A sheet frame found in model space.
struct Frame {
window: Bounds2,
paper: &'static str,
landscape: bool,
}
/// The ISO/ANSI paper a frame of `w` x `h` mm fits (either orientation), and whether it is landscape.
fn paper_for(w: f64, h: f64) -> Option<(&'static str, bool)> {
cadcraft_render::PAPER_SIZES.iter().find_map(|p| {
let (pw, ph) = (p.width_mm, p.height_mm);
if (pw - w).abs() <= FRAME_TOL_MM && (ph - h).abs() <= FRAME_TOL_MM {
Some((p.name, false))
} else if (ph - w).abs() <= FRAME_TOL_MM && (pw - h).abs() <= FRAME_TOL_MM {
Some((p.name, true))
} else {
None
}
})
}
/// Sheet frames grouped into books: rows of equal lower edge, bottom to top, each left to right.
fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, usize) {
let mut found: Vec<(i64, f64, Frame)> = Vec::new();
let mut skipped = 0;
for e in d.model.iter().take(MAX_FRAMES) {
let EntityKind::LwPolyline(pl) = &e.kind else { continue };
if !pl.closed {
continue;
}
let b = cadcraft_doc::entity_bounds(d, e, 0);
if b.is_empty() || !b.width().is_finite() || !b.height().is_finite() {
continue;
}
let (w, h) = (b.width() * unit, b.height() * unit);
if (h - frame_height).abs() > 0.0005 {
continue;
}
match paper_for(w, h) {
Some((paper, landscape)) => {
let row = (b.min.y * 1000.0).round();
if row.is_finite() && row.abs() < 9e15 {
found.push((row as i64, b.min.x, Frame { window: b, paper, landscape }));
}
}
None => skipped += 1,
}
}
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut books: Vec<Vec<Frame>> = Vec::new();
let mut last = None;
for (row, _, f) in found {
if last != Some(row) {
books.push(Vec::new());
last = Some(row);
}
if let Some(b) = books.last_mut() {
b.push(f);
}
}
(books, skipped)
}
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
if io().and_then(|h| h.plot).is_none() {
return Err(bad("pdfbook", "plotting is not available in this build"));
}
let num = |k: &str, default: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(default);
let (frame_height, unit) = (num("frameHeight", 297.0), num("unit", 1.0));
let (books, skipped) = frame_books(s.doc()?, frame_height, unit);
if books.is_empty() {
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
}
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
let pb = target.as_deref().map(std::path::Path::new);
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
let stem = pb
.and_then(std::path::Path::file_stem)
.map(|x| x.to_string_lossy().to_string())
.filter(|x| !x.is_empty())
.unwrap_or_else(|| "Drawing".into());
let names: Vec<String> = match p.get("names").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),
None => stem.split('^').map(str::to_string).collect(),
};
let write = pb.is_some();
let mut out = Vec::new();
for (i, frames) in books.iter().enumerate() {
// A row without its own name is numbered after the drawing.
let name = names.get(i).map(|n| file_part(n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}-{}", file_part(&stem), i + 1));
let pages: Vec<Value> = frames
.iter()
.map(|f| json!({ "window": [[f.window.min.x, f.window.min.y], [f.window.max.x, f.window.max.y]], "paper": f.paper, "landscape": f.landscape }))
.collect();
let mut q = json!({ "layout": "Model", "pages": pages, "margins": [0, 0, 0, 0] });
if write {
q["path"] = json!(dir.join(format!("{name}.pdf")).to_string_lossy());
}
let mut r = s.execute("plot", &q).map_err(|e| bad("pdfbook", format!("{name}: {e}")))?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(name));
o.insert("pages".into(), json!(frames.len()));
}
out.push(r);
}
let message = format!("Plotted {} book(s), {} sheet(s)", out.len(), books.iter().map(Vec::len).sum::<usize>());
Ok(json!({ "books": out, "skipped": skipped, "message": message }))
}
#[cfg(test)]
mod tests {
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
@@ -703,39 +587,6 @@ mod tests {
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
}
#[test]
fn pdfbook_collects_frames_into_one_pdf_per_row() {
our_hooks();
if io().and_then(|h| h.plot).is_none() {
return;
}
let mut s = Session::new();
// Row 1 (y = 0): A4, A3 landscape (drawn right to left); row 2 (y = 400): one A4; a stray rectangle.
for c in ["pline 210,0 420,0 420,297 210,297 c", "pline 0,0 210,0 210,297 0,297 c", "pline 420,0 840,0 840,297 420,297 c"] {
s.cmdline(c).unwrap();
s.cmdline("").unwrap();
}
s.cmdline("pline 0,400 210,400 210,697 0,697 c").unwrap();
s.cmdline("").unwrap();
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
s.cmdline("").unwrap();
let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));
let r = r.unwrap();
let books = r["books"].as_array().unwrap();
assert_eq!(books.len(), 2);
assert_eq!(books[0]["pages"], 3);
assert_eq!(books[1]["pages"], 1);
let first = temp("a.pdf");
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
assert!(a4 < a3, "frames go left to right: {text}");
assert!(text.contains("\"landscape\":true"));
let _ = std::fs::remove_file(first);
let _ = std::fs::remove_file(temp("b.pdf"));
// No frames: an error, not a panic.
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
}
#[test]
fn publish_plots_every_sheet() {
our_hooks();
+23 -255
View File
@@ -12,22 +12,11 @@ pub use cadcraft_dxf::Version;
use serde_json::{Value, json};
use super::*;
use crate::{Accept, Input, Prompt, Result, Session, Step};
use crate::{Result, Session};
/// 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>;
@@ -48,24 +37,6 @@ pub fn io() -> Option<&'static IoHooks> {
IO.get()
}
/// An entity as DXF groups and back, for AutoLISP's `entget`, `entmake` and `entmod`
/// (installed by the app with the DXF reader and writer, a lower layer).
pub struct EntityCodec {
/// The entity's type name and the groups of its record, without its `0` group.
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
/// An entity from a type name and groups.
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
}
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
pub fn set_entity_codec(c: EntityCodec) {
let _ = CODEC.set(c);
}
pub fn entity_codec() -> Option<&'static EntityCodec> {
CODEC.get()
}
/// COMPARE (drawing comparison).
pub mod compare;
@@ -146,13 +117,11 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
if opts.binary && !is_dxf(path) {
return Err(bad(cmd, "binary applies to DXF files only"));
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
(hooks.write)(d, path)
(hooks.write)(s.doc()?, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
w(d, path, opts)
w(s.doc()?, path, opts)
};
r.map_err(|e| bad(cmd, e))
}
@@ -169,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("open", "Open...", run_open)
.menu(&["File", "Open..."])
.key("Cmd+O")
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
.params("{path} | {data: base64, name}")
.enabled(always)
.noundo(),
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
@@ -184,70 +153,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
.alias(&["scr"])
.params("{path: script (.scr) file} | {data: base64} → {lines}")
.noundo()
.interactive(|_| Ok(Box::new(ScriptM))),
]
}
/// SCRIPT: run the lines of a `.scr` file through [`Session::script`] — the same execution the
/// CLI, MCP and the control channel already use, now reachable from the command line and menus.
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
let text = if let Some(_path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
let path = _path;
#[cfg(not(target_arch = "wasm32"))]
{
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")]
return Err(bad("script", "paths are not available on the web; pass `data`"));
} else {
let data = str_param(p, "data").ok_or_else(|| bad("script", "`path` or `data` (base64) is required"))?;
let bytes = base64_decode(data).ok_or_else(|| bad("script", "invalid base64"))?;
String::from_utf8_lossy(&bytes).into_owned()
};
let lines = text.lines().count();
s.script(&text)?;
Ok(json!({ "lines": lines }))
}
/// SCRIPT typed at the command line or chosen from the menu: asks for the script file by name
/// (the prompt form; the menu item runs this same machine).
struct ScriptM;
impl Interactive for ScriptM {
fn name(&self) -> &'static str {
"SCRIPT"
}
fn prompt(&self, _: &Session) -> Prompt {
Prompt::new("Enter name of script file", Accept::TEXT)
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if matches!(i, Input::Cancel | Input::Enter) {
return Ok(Step::Cancel);
}
let Input::Text(t) = i else { return Ok(Step::Continue) };
let t = t.trim();
if t.is_empty() {
return Ok(Step::Cancel);
}
match run_script(s, &json!({ "path": t })) {
Ok(_) => Ok(Step::Done),
Err(EngineError::BadParams { msg, .. }) => {
s.echo(msg);
Ok(Step::Done)
}
Err(e) => Err(e),
}
}
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = template::run_new(s, p)? {
return Ok(r);
@@ -260,95 +168,27 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// A file read and decoded but not yet added to a [`Session`]. Reading is the slow part of OPEN and
/// needs no session, so a host can do it on a worker thread and hand the result to
/// [`finish_open`] on the thread that owns the session.
pub struct ReadFile {
drawing: Drawing,
binary_dxf: bool,
name: String,
path: Option<String>,
}
/// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere.
pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let r = std::panic::catch_unwind(|| {
let bytes =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
read_bytes(&bytes, path, Some(path.to_string()))
});
match r {
Ok(r) => r.map_err(|e| e.to_string()),
Err(_) => Err(bad("open", format!("{path}: the file could not be read")).to_string()),
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(bad("open", "paths are not available on the web; pass `data`").to_string())
}
}
/// Add a [`read_file`] result to the session as the active drawing: the fast half of OPEN.
pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
let ReadFile { drawing, binary_dxf, name, path } = f;
let i = add_read(s, drawing, binary_dxf, &name, path);
super::blocks::xref::after_open(s, i);
Ok(opened(s, i, true))
}
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
let drawing = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
// asks where to save instead of exporting over the original.
let path = path.filter(|p| is_drawing_file(p));
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
}
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
let notes = drawing.read_notes.clone();
let i = s.open_drawing(drawing, &file_name(name), path);
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
let i = s.open_drawing(d, &file_name(name), path);
if let Some(st) = s.docs.get_mut(i) {
// QSAVE keeps a binary DXF binary.
st.binary_dxf = binary_dxf;
st.binary_dxf = cadcraft_dxf::is_binary(bytes);
}
for note in notes {
s.echo(note);
}
i
Ok(i)
}
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
let ReadFile { drawing, binary_dxf, name, path } = read_bytes(bytes, name, path)?;
Ok(add_read(s, drawing, binary_dxf, &name, path))
}
/// OPEN's reply: the drawing's index and what reading it left out.
fn opened(s: &Session, i: usize, entities: bool) -> Value {
let d = s.docs.get(i).map(|st| &st.doc);
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
if entities {
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
} else {
json!({ "index": i, "warnings": warnings })
}
}
/// 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 =
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
let bytes = std::fs::read(path).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));
return Ok(json!({ "index": i, "entities": s.docs.get(i).map(|d| d.doc.entity_count()) }));
}
#[cfg(target_arch = "wasm32")]
return Err(bad("open", "paths are not available on the web; pass `data`"));
@@ -358,7 +198,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
let i = open_bytes(s, &bytes, name, None)?;
super::blocks::xref::after_open(s, i);
Ok(opened(s, i, false))
Ok(json!({ "index": i }))
}
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
@@ -393,19 +233,16 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
if s.doc()?.refedit.is_some() {
return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
}
let bytes = write_bytes(s, cmd, path, opts)?;
let n = commit_save(s, path, &bytes, store)?;
if is_drawing_file(path) {
let st = s.state_mut()?;
st.binary_dxf = opts.binary;
if let Some(v) = opts.version
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
&& st.doc.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
{
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
st.saved = st.drawing().clone();
std::sync::Arc::make_mut(&mut st.doc).header.set_str("ACADVER", v.code());
st.saved = st.doc.clone();
}
}
Ok(n)
@@ -416,9 +253,10 @@ 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"))]
{
// 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}")))
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(())
}
#[cfg(target_arch = "wasm32")]
{
@@ -440,7 +278,7 @@ fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&st
return Ok(bytes.len());
}
let st = s.state_mut()?;
st.saved = st.drawing().clone();
st.saved = st.doc.clone();
st.path = Some(path.to_string());
st.title = file_name(path);
// A BEgin group still open when the drawing is saved ends there.
@@ -499,8 +337,7 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let fmt = str_param(p, "format").unwrap_or("dxf");
let opts = save_options("document.bytes", p)?;
let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}")));
let lost = not_saved_as(s.doc()?, version);
let lost = not_saved(s.doc()?);
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if drops_unknown(&format!("drawing.{fmt}")) {
@@ -511,28 +348,12 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
/// not written), counted by DXF type across model space, layouts and block definitions. Saved
/// at the drawing's own version; see [`not_saved_as`].
/// not written), counted by DXF type across model space, layouts and block definitions.
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default();
not_saved_as(d, v)
}
/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data
/// store section, which is written back with the drawing (`Foreign::data_store`).
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted.
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
*n = n.saturating_add(1);
}
@@ -573,10 +394,7 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
// What the file will hold depends on the version it is saved as (the data store from 2013).
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let lost = s.doc().map(not_saved).unwrap_or_default();
let mut r = run(s, p)?;
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
@@ -644,56 +462,6 @@ mod tests {
s.state().unwrap().title.clone()
}
fn temp_script(name: &str, text: &str) -> String {
let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));
std::fs::write(&path, text).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn script_command_runs_a_scr_file() {
let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
let mut s = Session::new();
let r = s.execute("script", &json!({ "path": path })).unwrap();
assert_eq!(r["lines"], 3);
assert_eq!(s.state().unwrap().doc.model.len(), 2, "the comment is skipped, circle and rectang draw");
}
#[test]
fn script_command_reports_a_missing_file() {
let mut s = Session::new();
let e = s.execute("script", &json!({ "path": "no/such/script.scr" })).unwrap_err().to_string();
assert!(e.contains("no/such/script.scr"), "{e}");
assert_eq!(s.state().unwrap().doc.model.len(), 0);
}
/// A script that runs itself must be refused, not recurse until the stack dies.
#[test]
fn a_script_cannot_start_a_script() {
let inner = temp_script("inner.scr", "circle 0,0 1\n");
let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
let mut s = Session::new();
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
assert!(e.contains("already running"), "{e}");
}
#[test]
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
let good = temp_script("typed.scr", "circle 0,0 1\n");
let mut s = Session::new();
s.cmdline("script").unwrap();
assert!(s.running.is_some(), "SCRIPT asks for the file name");
s.cmdline(&good).unwrap();
assert!(s.running.is_none());
assert_eq!(s.state().unwrap().doc.model.len(), 1);
// A file that cannot be read: the error is echoed, the command still ends.
s.cmdline("script").unwrap();
s.cmdline("no/such/script.scr").unwrap();
assert!(s.running.is_none());
assert!(s.log.iter().any(|l| l.contains("no/such/script.scr")), "{:?}", s.log);
}
#[test]
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
let mut s = Session::new();
+2 -3
View File
@@ -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.
pub fn command_id(target: &str) -> Option<String> {
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.
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
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,7 +193,6 @@ 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(),
+17 -250
View File
@@ -1,7 +1,6 @@
//! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects.
mod patfile;
pub use patfile::{PatternChoice, pattern_choices};
pub(super) use patfile::{lines_for, run_patterns};
use cadcraft_color::Color;
@@ -11,26 +10,19 @@ use serde_json::{Value, json};
use super::helpers::lwpoly;
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step};
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod assoc;
mod edit;
mod pick;
pub(crate) use assoc::update as refill_associative;
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("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("hatch", "Hatch...", run_hatch).menu(&["Draw", "Hatch..."]).alias(&["h", "bh", "-hatch"]).params("{points?: [[x,y]] (internal points) | 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("gradient", "Gradient...", run_gradient).menu(&["Draw", "Gradient..."]).alias(&["gd"]).params("{points? | handles?, color1?, color2?, angle?}").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], 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)
.noundo()
.interactive(|_| Ok(Box::new(pick::HatchPickM::default()))),
CommandSpec::new("hatchedit", "Hatch Edit", edit::run_hatchedit).menu(&["Modify", "Object", "Hatch..."]).alias(&["he", "-hatchedit"]).params("{handles?, pattern?, scale?, angle?, 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)))),
]
}
@@ -257,17 +249,6 @@ pub(crate) fn loops_from_handles(s: &Session, hs: &[Handle]) -> Result<Vec<Hatch
Ok(loops)
}
/// Gradient fills by name: the shapes the renderer draws (`render/src/hatch.rs`); the INV forms
/// swap the two colours.
pub(crate) const GRADIENT_NAMES: [&str; 10] =
["LINEAR", "CYLINDER", "CURVED", "SPHERICAL", "HEMISPHERICAL", "INVLINEAR", "INVCYLINDER", "INVCURVED", "INVSPHERICAL", "INVHEMISPHERICAL"];
/// A gradient name as stored (upper case), or None when it isn't one of [`GRADIENT_NAMES`].
pub(crate) fn gradient_name(t: &str) -> Option<&'static str> {
let up = t.trim().to_ascii_uppercase();
GRADIENT_NAMES.iter().copied().find(|n| *n == up)
}
#[derive(Clone)]
struct HatchSettings {
pattern: String,
@@ -291,11 +272,11 @@ impl HatchSettings {
));
let pattern = str_param(p, "pattern").map(str::to_ascii_uppercase).unwrap_or(hdr.0);
let g = gradient.then(|| Gradient {
name: str_param(p, "name").and_then(gradient_name).unwrap_or("LINEAR").to_string(),
name: str_param(p, "name").unwrap_or("LINEAR").to_string(),
color1: str_param(p, "color1").and_then(Color::parse).unwrap_or(Color::Index(5)),
color2: str_param(p, "color2").and_then(Color::parse).unwrap_or(Color::Index(7)),
angle: f64_or(p, "angle", 0.0).to_radians(),
centered: bool_or(p, "centered", true),
centered: true,
});
HatchSettings {
pattern,
@@ -351,21 +332,14 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
if str_param(p, "originMode").is_some_and(|t| OriginMode::parse(t).is_none()) {
return Err(bad(cmd, "unknown `originMode`"));
}
if gradient && str_param(p, "name").is_some_and(|t| gradient_name(t).is_none()) {
return Err(bad(cmd, format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))));
}
let st = HatchSettings::from(s, p, gradient);
// Loops by picked point or selected object: one hatch each when separate.
let mut groups: Vec<Vec<HatchLoop>> = Vec::new();
// Internal points and boundary objects can come together (the dialog's Pick points and
// Select objects); points alone don't fall back to the selection.
let pts = points_param(p, "points");
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
let objects = pts.is_none() || given("handles") || given("handle");
for q in pts.into_iter().flatten() {
groups.push(loops_at(s, q)?);
}
if objects {
if let Some(pts) = points_param(p, "points") {
for q in pts {
groups.push(loops_at(s, q)?);
}
} else {
let hs = targets(s, p)?;
if st.separate {
groups.extend(loops_from_handles(s, &hs)?.into_iter().map(|l| vec![HatchLoop { outer: true, ..l }]));
@@ -381,63 +355,9 @@ fn make(s: &mut Session, p: &Value, gradient: bool) -> Result<Value> {
Ok(json!({ "handle": hex.first(), "handles": hex }))
}
/// True when two loops have the same vertex sequence (ignoring cyclic shift and winding order).
fn same_loop(a: &HatchLoop, b: &HatchLoop) -> bool {
if a.vertices.len() != b.vertices.len() {
return false;
}
let n = a.vertices.len();
if n == 0 {
return true;
}
let near = |v1: &PolyVertex, v2: &PolyVertex| v1.p.near(v2.p, 1e-6) && (v1.bulge - v2.bulge).abs() < 1e-6;
(0..n).any(|k| (0..n).all(|i| near(&a.vertices[i], &b.vertices[(i + k) % n])))
|| (0..n).any(|k| {
(0..n).all(|i| {
let j = (k + n - (i % n)) % n;
let prev_j = (j + n - 1) % n;
a.vertices[i].p.near(b.vertices[j].p, 1e-6) && (a.vertices[i].bulge + b.vertices[prev_j].bulge).abs() < 1e-6
})
})
}
/// Deduplicate boundary loops that describe the same boundary.
fn dedup_loops(loops: &mut Vec<HatchLoop>) {
let mut unique: Vec<HatchLoop> = Vec::with_capacity(loops.len());
for l in loops.drain(..) {
if let Some(existing) = unique.iter_mut().find(|u| same_loop(u, &l)) {
for s in l.sources {
if !existing.sources.contains(&s) {
existing.sources.push(s);
}
}
existing.outer = existing.outer || l.outer;
} else {
unique.push(l);
}
}
*loops = unique;
}
/// Add the hatches for groups of loops: one per group when separate, otherwise one for all.
fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>) -> Result<Vec<Handle>> {
let groups: Vec<Vec<HatchLoop>> = if st.separate {
let mut unique_groups: Vec<Vec<HatchLoop>> = Vec::new();
for mut g in groups {
dedup_loops(&mut g);
if g.is_empty() {
continue;
}
if !unique_groups.iter().any(|u| u.len() == g.len() && u.iter().zip(&g).all(|(a, b)| same_loop(a, b))) {
unique_groups.push(g);
}
}
unique_groups
} else {
let mut all: Vec<HatchLoop> = groups.into_iter().flatten().collect();
dedup_loops(&mut all);
vec![all]
};
let groups: Vec<Vec<HatchLoop>> = if st.separate { groups } else { vec![groups.into_iter().flatten().collect()] };
let mut out = Vec::new();
for loops in groups.into_iter().filter(|g| !g.is_empty()) {
let h = s.add_entity(st.hatch(loops))?;
@@ -453,13 +373,6 @@ fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec<Vec<HatchLoop>>)
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
patfile::load_param(p)?;
// Like the prompt: a name without a definition would be drawn as ANSI31.
if let Some(up) = str_param(p, "pattern").map(str::to_ascii_uppercase)
&& cadcraft_doc::library::pattern(&up).is_none()
&& patfile::user_pattern(&up).is_none()
{
return Err(bad("hatch", format!("unknown pattern {up}")));
}
make(s, p, false)
}
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
@@ -475,27 +388,11 @@ fn boundary_at(s: &mut Session, q: Vec2) -> Result<Vec<Handle>> {
Ok(out)
}
/// Most internal points one BOUNDARY call accepts (checked before the list is copied).
const MAX_BOUNDARY_POINTS: usize = 256;
/// Most polylines one BOUNDARY call may create. Each pick adds polylines the next pick traces
/// against, so this bounds the aggregate tracing work of the batch.
const MAX_BOUNDARY_OUTPUT: usize = 1024;
fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("boundary", "`points` is required"))?;
if raw.len() > MAX_BOUNDARY_POINTS {
return Err(bad("boundary", format!("at most {MAX_BOUNDARY_POINTS} points per call (got {})", raw.len())));
}
let pts = points_param(p, "points").ok_or_else(|| bad("boundary", "`points` is required"))?;
let before = s.state()?.doc.clone();
let mut out = Vec::new();
for q in pts {
out.extend(boundary_at(s, q)?.into_iter().map(|h| h.hex()));
if out.len() > MAX_BOUNDARY_OUTPUT {
// The refused call leaves no partial result behind.
s.state_mut()?.doc = before;
return Err(bad("boundary", format!("more than {MAX_BOUNDARY_OUTPUT} boundary polylines in one call; split the work")));
}
}
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
}
@@ -631,11 +528,7 @@ impl Interactive for HatchM {
fn prompt(&self, s: &Session) -> Prompt {
// The sub-prompts offer the current value: Enter keeps it.
match self.asking {
Some(HatchAsk::Pattern) => {
return Prompt::new("Enter a pattern name", Accept::TEXT)
.default(self.settings.pattern.clone())
.picker(Picker::HatchPattern(self.settings.pattern.clone()));
}
Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()),
Some(HatchAsk::Scale) => {
return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale));
}
@@ -649,10 +542,8 @@ impl Interactive for HatchM {
Some(HatchAsk::Origin) => return Prompt::new("Specify the pattern origin", Accept::POINT).kw(&ORIGIN_KW).default("Use current"),
None => {}
}
// The pattern picker stays offered while boundaries are picked: a choice sets the pattern.
let picker = |p: Prompt| if self.gradient { p } else { p.picker(Picker::HatchPattern(self.settings.pattern.clone())) };
if self.selecting {
return picker(Prompt::new("Select objects", Accept::SELECT).kw(&[
return Prompt::new("Select objects", Accept::SELECT).kw(&[
"picK internal point",
"Undo",
"seTtings",
@@ -660,9 +551,9 @@ impl Interactive for HatchM {
"Angle",
"Origin",
"sEparate",
]));
]);
}
picker(Prompt::new("Pick internal point", Accept::POINT).kw(&[
Prompt::new("Pick internal point", Accept::POINT).kw(&[
"Select objects",
"Undo",
"seTtings",
@@ -671,7 +562,7 @@ impl Interactive for HatchM {
"Angle",
"Origin",
"sEparate",
]))
])
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(a) = self.asking.take() {
@@ -725,11 +616,6 @@ impl Interactive for HatchM {
}
Ok(Step::Continue)
}
// A pattern chosen in the picker while boundaries are picked.
Input::Text(t) if !self.gradient => {
self.answer(s, HatchAsk::Pattern, &t)?;
Ok(Step::Continue)
}
// Enter ends boundary picking in either mode and creates the hatch.
Input::Enter => self.finish(s),
_ => Ok(Step::Continue),
@@ -797,123 +683,4 @@ mod tests {
assert!(!hatch.solid);
assert!(hatch.gradient.is_none());
}
#[test]
fn hatchedit_solid_replaces_gradient() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("gradient", &json!({ "points": [[5, 5]], "color1": "1", "color2": "3" })).unwrap();
let (h, before) = only_hatch(&s);
assert!(before.gradient.is_some());
s.execute("hatchedit", &json!({ "handles": [h.clone()], "pattern": "SOLID" })).unwrap();
let (h2, hatch) = only_hatch(&s);
assert_eq!(h2, h);
assert!(hatch.solid);
assert!(hatch.gradient.is_none(), "SOLID is a plain fill, not the gradient");
assert_eq!(hatch.loops, before.loops);
}
#[test]
fn the_pattern_picker_is_offered_and_its_choice_sets_the_pattern() {
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.start("hatch").unwrap();
let picker = s.current_prompt().and_then(|p| p.picker);
assert_eq!(picker, Some(Picker::HatchPattern(HatchSettings::from(&s, &Value::Null, false).pattern)));
// A choice made in the picker while boundaries are picked.
s.input(Input::Text("AR-CONC".into())).unwrap();
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("AR-CONC".into())));
// And at the pattern prompt.
s.input(Input::Keyword("Pattern".into())).unwrap();
assert!(s.current_prompt().unwrap().picker.is_some());
s.input(Input::Text("BRICK".into())).unwrap();
s.input(Input::Point(Vec2::new(5.0, 5.0))).unwrap();
s.input(Input::Enter).unwrap();
assert_eq!(only_hatch(&s).1.pattern, "BRICK");
// An unknown name keeps the pattern.
s.start("hatch").unwrap();
s.input(Input::Text("NO-SUCH".into())).unwrap();
assert_eq!(s.current_prompt().and_then(|p| p.picker), Some(Picker::HatchPattern("BRICK".into())));
// GRADIENT offers no pattern picker.
s.cancel();
s.start("gradient").unwrap();
assert!(s.current_prompt().unwrap().picker.is_none());
}
#[test]
fn json_hatch_refuses_an_undefined_pattern() {
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": "NOPE" })).unwrap_err();
assert!(e.to_string().contains("unknown pattern NOPE"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), 1, "only the rectangle");
for name in ["ansi31", "SOLID"] {
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": name })).unwrap();
}
assert_eq!(s.doc().unwrap().model.len(), 3);
}
#[test]
fn pattern_choices_list_the_library_with_its_lines() {
let all = pattern_choices();
assert_eq!(all.first().map(|c| c.name.as_str()), Some("SOLID"));
assert!(all[0].lines.is_empty());
let ansi31 = all.iter().find(|c| c.name == "ANSI31").unwrap();
assert_eq!(ansi31.lines.len(), 1);
assert!(!ansi31.description.is_empty());
}
#[test]
fn repeated_picks_and_handles_in_one_region_do_not_produce_duplicate_loops() {
// One internal point
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap();
let (_, h1) = only_hatch(&s);
assert_eq!(h1.loops.len(), 1);
// Two different points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[2, 2], [8, 8]], "pattern": "SOLID" })).unwrap();
let (_, h2) = only_hatch(&s);
assert_eq!(h2.loops.len(), 1, "two points in same region must deduplicate loops");
// The same point twice
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5], [5, 5]], "pattern": "SOLID" })).unwrap();
let (_, h3) = only_hatch(&s);
assert_eq!(h3.loops.len(), 1, "identical points must deduplicate loops");
// Three points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[2, 2], [5, 5], [8, 8]], "pattern": "SOLID" })).unwrap();
let (_, h4) = only_hatch(&s);
assert_eq!(h4.loops.len(), 1);
// Same boundary handle twice
let mut s = Session::new();
let r = s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
let handle = r["handle"].as_str().unwrap();
s.execute("hatch", &json!({ "handles": [handle, handle], "pattern": "SOLID" })).unwrap();
let (_, h5) = only_hatch(&s);
assert_eq!(h5.loops.len(), 1, "repeated boundary handle must deduplicate loops");
// Two points in disjoint rectangles: both regions preserved
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap();
s.execute("hatch", &json!({ "points": [[5, 5], [25, 5]], "pattern": "SOLID" })).unwrap();
let (_, h6) = only_hatch(&s);
assert_eq!(h6.loops.len(), 2, "disjoint rectangles must both be kept");
// Gradient with duplicate points in the same rectangle
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.execute("gradient", &json!({ "points": [[2, 2], [8, 8]], "color1": "1", "color2": "3" })).unwrap();
let (_, hg) = only_hatch(&s);
assert_eq!(hg.loops.len(), 1, "gradient with repeated picks in same region must deduplicate loops");
}
}
+1 -13
View File
@@ -137,18 +137,6 @@ 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) {
@@ -165,7 +153,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) && !valid_loop_edit(p, h, after) => {
Some(p) if reshaped(p, h) => {
detach(&mut nh);
return Some((nh, None));
}
+8 -254
View File
@@ -2,16 +2,15 @@
//! associativity (unlink, or link to boundary objects), separate hatches and recreated
//! boundaries of existing hatches.
use cadcraft_color::Color;
use cadcraft_doc::{Drawing, EntityKind, Gradient, Handle, Hatch, HatchLoop, entity_bounds};
use cadcraft_doc::{Drawing, EntityKind, Handle, Hatch, HatchLoop, entity_bounds};
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, dedup_loops, gradient_name, island_style, loops_at, loops_from_handles};
use super::{ORIGIN_KW, OriginMode, closed_loop, island_style};
use crate::cmd::helpers::lwpoly;
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};
use crate::cmd::{bad, bool_or, point_param, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Upper bound on hatches edited by one call.
const MAX_EDITED: usize = 10_000;
@@ -146,10 +145,9 @@ fn properties(h: &mut Hatch, p: &Value) {
if let Some(v) = str_param(p, "pattern").map(str::trim).filter(|v| !v.is_empty()) {
h.pattern = v.to_ascii_uppercase();
h.solid = h.pattern == "SOLID";
// A named pattern, SOLID included, replaces the gradient fill; the renderer draws the
// gradient first when it is set.
h.gradient = None;
if !h.solid {
// A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set.
h.gradient = None;
// A library pattern is drawn from the library; a loaded .pat pattern brings its lines;
// any other name is the hatch's own and keeps its definition.
if cadcraft_doc::library::pattern(&h.pattern).is_some() {
@@ -167,203 +165,8 @@ fn properties(h: &mut Hatch, p: &Value) {
}
}
/// HATCHEDIT's `gradient`: the fields to change, all checked before any hatch is touched.
struct GradientEdit {
name: Option<&'static str>,
color1: Option<Color>,
color2: Option<Color>,
/// Radians.
angle: Option<f64>,
centered: Option<bool>,
}
impl GradientEdit {
/// Make `h` a gradient fill (from the default LINEAR blue-to-white one when it has none) and
/// set the given fields.
fn apply(&self, h: &mut Hatch) {
let mut g = h.gradient.clone().unwrap_or(Gradient {
name: "LINEAR".into(),
color1: Color::Index(5),
color2: Color::Index(7),
angle: 0.0,
centered: true,
});
if let Some(n) = self.name {
g.name = n.into();
}
if let Some(c) = self.color1 {
g.color1 = c;
}
if let Some(c) = self.color2 {
g.color2 = c;
}
if let Some(a) = self.angle {
g.angle = a;
}
if let Some(c) = self.centered {
g.centered = c;
}
h.gradient = Some(g);
h.pattern = "SOLID".into();
h.solid = true;
h.pattern_lines.clear();
}
}
fn gradient_edit(p: &Value) -> Result<Option<GradientEdit>> {
let g = match p.get("gradient") {
None | Some(Value::Null) => return Ok(None),
Some(Value::Object(g)) => g,
Some(_) => return Err(bad("hatchedit", "`gradient` is an object {name?, color1?, color2?, angle?, centered?}")),
};
let field = |k: &str| g.get(k).filter(|v| !v.is_null());
let name = match field("name") {
None => None,
Some(v) => Some(
v.as_str()
.and_then(gradient_name)
.ok_or_else(|| bad("hatchedit", format!("unknown gradient `name`; use one of {}", GRADIENT_NAMES.join(", "))))?,
),
};
let color = |k: &str| -> Result<Option<Color>> {
match field(k) {
None => Ok(None),
Some(v) => v
.as_str()
.and_then(Color::parse)
.map(Some)
.ok_or_else(|| bad("hatchedit", format!("`gradient.{k}` is a colour such as \"5\", \"red\" or \"255,128,0\""))),
}
};
let angle = match field("angle") {
None => None,
Some(v) => Some(
v.as_f64().map(f64::to_radians).filter(|a| a.is_finite()).ok_or_else(|| bad("hatchedit", "`gradient.angle` is a number of degrees"))?,
),
};
let centered = match field("centered") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad("hatchedit", "`gradient.centered` is true or false"))?),
};
Ok(Some(GradientEdit { name, color1: color("color1")?, color2: color("color2")?, angle, centered }))
}
/// An explicit `scale` must be positive and an explicit `angle` a number, checked before anything
/// is edited so a refused request changes nothing (absent and null leave them as they are).
fn check_numbers(p: &Value) -> Result<()> {
size_param("hatchedit", p, "scale", false)?;
match p.get("angle") {
None | Some(Value::Null) => Ok(()),
Some(v) if v.as_f64().is_some_and(f64::is_finite) => Ok(()),
Some(_) => Err(bad("hatchedit", "`angle` must be a number of degrees")),
}
}
/// 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,
@@ -382,10 +185,6 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result<Value> {
}
Some(_) => return Err(bad("hatchedit", "`recreate` is \"polyline\"")),
};
let grad = gradient_edit(p)?;
if grad.is_some() && str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).is_some_and(|v| !v.is_empty() && v != "SOLID") {
return Err(bad("hatchedit", "give `pattern` or `gradient`, not both"));
}
// Like the prompt: a pattern must be one CADCraft defines, SOLID, or the hatches' own (a
// pattern read from a file keeps its definition).
if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty())
@@ -410,18 +209,7 @@ 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);
}
if let Some(st) = style {
h.style = st;
}
@@ -470,9 +258,6 @@ 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,
@@ -483,18 +268,7 @@ enum Ask {
Link,
}
const OPTIONS: [&str; 10] = [
"Disassociate",
"Style",
"Properties",
"recreate Boundary",
"ASsociate",
"separate Hatches",
"Origin",
"add Point",
"add Object",
"remove Boundary",
];
const OPTIONS: [&str; 7] = ["Disassociate", "Style", "Properties", "recreate Boundary", "ASsociate", "separate Hatches", "Origin"];
/// -HATCHEDIT: select hatches, then one option.
pub(crate) struct HatchEditM {
@@ -546,9 +320,6 @@ 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",
@@ -557,10 +328,7 @@ impl Interactive for HatchEditM {
};
Prompt::new("Island detection style", Accept::TEXT).kw(&["Ignore", "Outer", "Normal"]).default(cur)
}
Ask::Pattern => {
let current = h.map(|h| h.pattern).unwrap_or_default();
Prompt::new("Enter a pattern name", Accept::TEXT).default(current.clone()).picker(Picker::HatchPattern(current))
}
Ask::Pattern => Prompt::new("Enter a pattern name", Accept::TEXT).default(h.map(|h| h.pattern).unwrap_or_default()),
Ask::Scale => Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", h.map_or(1.0, |h| h.scale))),
Ask::Angle => Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER)
.default(format!("{:.0}", h.map_or(0.0, |h| h.angle.to_degrees()))),
@@ -602,24 +370,10 @@ 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) => {
-19
View File
@@ -44,25 +44,6 @@ pub(crate) fn names() -> Vec<String> {
v
}
/// A pattern offered for new hatches: a library pattern or one a `.pat` file loaded.
#[derive(Clone, Debug, PartialEq)]
pub struct PatternChoice {
pub name: String,
pub description: String,
/// The line families at unit scale (none for SOLID).
pub lines: Vec<PatternLine>,
}
/// Every pattern offered for new hatches, in the order of [`names`], for a pattern picker.
pub fn pattern_choices() -> Vec<PatternChoice> {
let mut v: Vec<PatternChoice> = standard_patterns()
.into_iter()
.map(|p| PatternChoice { name: p.name.to_string(), description: p.description.to_string(), lines: p.lines })
.collect();
v.extend(store().iter().map(|p| PatternChoice { name: p.name.clone(), description: p.description.clone(), lines: p.lines.clone() }));
v
}
/// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is
/// replaced). Returns the names added and the file's problems.
pub(crate) fn load(text: &str) -> (Vec<String>, Vec<String>) {
-224
View File
@@ -1,224 +0,0 @@
//! HATCHPICK: the Hatch and Gradient dialog's picks (internal points, boundary objects, a pattern
//! origin, or the hatches to edit), kept in the session for the dialog to take. It never changes
//! the drawing.
use cadcraft_doc::{EntityKind, Handle, HatchLoop};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::{HatchSettings, closed_loop, loops_at, loops_from_handles};
use crate::cmd::{bad, points_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Upper bound on the points and objects one HATCHPICK keeps.
pub(crate) const MAX_PICKS: usize = 1000;
/// The first prompt's keywords: what to pick.
const MODES: [&str; 4] = ["Points", "Objects", "oRigin", "Hatch"];
/// What HATCHPICK picks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PickMode {
/// Internal points, each inside a closed boundary.
#[default]
Points,
/// Closed boundary objects.
Objects,
/// One point: the pattern origin.
Origin,
/// Hatches to edit.
Hatch,
}
/// What the last HATCHPICK kept ([`Session::hatch_picks`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HatchPicks {
pub mode: PickMode,
pub points: Vec<Vec2>,
/// Closed boundary objects (Objects) or hatches (Hatch).
pub handles: Vec<Handle>,
/// The pattern origin (Origin).
pub origin: Option<Vec2>,
}
fn mode_named(t: &str) -> Option<PickMode> {
Some(match t.trim().to_ascii_lowercase().as_str() {
"points" | "p" => PickMode::Points,
"objects" | "o" => PickMode::Objects,
"origin" | "r" => PickMode::Origin,
"hatch" | "h" => PickMode::Hatch,
_ => return None,
})
}
/// JSON form: `{points?, handles?}`, checked as the prompts check them.
pub(super) fn run_hatchpick(s: &mut Session, p: &Value) -> Result<Value> {
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
if !given("points") && !given("handles") {
return Err(bad("hatchpick", "give internal `points` or boundary `handles`"));
}
let mut picks = HatchPicks::default();
if given("points") {
let pts = points_param(p, "points").ok_or_else(|| bad("hatchpick", "`points` is an array of [x, y]"))?;
if pts.len() > MAX_PICKS {
return Err(bad("hatchpick", format!("at most {MAX_PICKS} points")));
}
for q in pts {
loops_at(s, q).map_err(|_| bad("hatchpick", format!("no closed boundary around [{}, {}]", q.x, q.y)))?;
picks.points.push(q);
}
}
if given("handles") {
let hs = targets(s, p)?;
if hs.len() > MAX_PICKS {
return Err(bad("hatchpick", format!("at most {MAX_PICKS} objects")));
}
let d = s.doc()?;
for h in hs {
match d.entity(h) {
Some(e) if closed_loop(d, e).is_some() => picks.handles.push(h),
_ => return Err(bad("hatchpick", format!("{} is not a closed object", h.hex()))),
}
}
if picks.points.is_empty() {
picks.mode = PickMode::Objects;
}
}
let out = json!({
"points": picks.points.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>(),
"handles": picks.handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
});
s.hatch_picks = Some(picks);
Ok(out)
}
/// One pick, so Undo can drop it.
enum Pick {
Point(Vec2, Vec<HatchLoop>),
Objects(Vec<Handle>, Vec<HatchLoop>),
}
#[derive(Default)]
pub(super) struct HatchPickM {
mode: Option<PickMode>,
picks: Vec<Pick>,
}
impl HatchPickM {
fn count(&self) -> usize {
self.picks
.iter()
.map(|p| match p {
Pick::Point(..) => 1,
Pick::Objects(hs, _) => hs.len(),
})
.sum()
}
fn keep(&self, s: &mut Session, mode: PickMode) {
let mut out = HatchPicks { mode, ..HatchPicks::default() };
for p in &self.picks {
match p {
Pick::Point(q, _) => out.points.push(*q),
Pick::Objects(hs, _) => out.handles.extend(hs.iter().copied()),
}
}
s.hatch_picks = Some(out);
}
}
impl Interactive for HatchPickM {
fn name(&self) -> &'static str {
"HATCHPICK"
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.mode {
None => Prompt::new("Enter what to pick", Accept::TEXT).kw(&MODES),
Some(PickMode::Points) => Prompt::new("Pick internal point", Accept::POINT).kw(&["Undo"]),
Some(PickMode::Objects) => Prompt::new("Select objects", Accept::SELECT).kw(&["Undo"]),
Some(PickMode::Origin) => Prompt::new("Specify the pattern origin", Accept::POINT),
Some(PickMode::Hatch) => Prompt::new("Select hatch object", Accept::SELECT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let Some(mode) = self.mode else {
match &i {
Input::Keyword(t) | Input::Text(t) => self.mode = mode_named(t),
Input::Enter | Input::Cancel => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
};
match (mode, i) {
(_, Input::Cancel) => Ok(Step::Cancel),
(PickMode::Points | PickMode::Objects, Input::Keyword(k)) if k == "Undo" => {
if self.picks.pop().is_none() {
s.echo("Nothing to undo.");
}
Ok(Step::Continue)
}
(PickMode::Points | PickMode::Objects, Input::Enter) => {
self.keep(s, mode);
Ok(Step::Done)
}
(PickMode::Points, Input::Point(q)) => {
if self.count() >= MAX_PICKS {
s.echo(format!("At most {MAX_PICKS} picks."));
return Ok(Step::Continue);
}
match loops_at(s, q) {
Ok(l) => {
s.echo(format!("Analyzing the selected data... {} loop(s)", l.len()));
self.picks.push(Pick::Point(q, l));
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Continue)
}
(PickMode::Objects, Input::Pick(hs)) => {
let room = MAX_PICKS.saturating_sub(self.count());
let closed: Vec<Handle> = {
let d = s.doc()?;
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| closed_loop(d, e).is_some())).take(room).collect()
};
let others = hs.len().saturating_sub(closed.len());
if !closed.is_empty() {
let loops = loops_from_handles(s, &closed)?;
self.picks.push(Pick::Objects(closed.clone(), loops));
}
s.echo(if others == 0 { format!("{} found", closed.len()) } else { format!("{} found, {others} not closed", closed.len()) });
Ok(Step::Continue)
}
(PickMode::Origin, Input::Point(q)) => {
s.hatch_picks = Some(HatchPicks { mode, origin: Some(q), ..HatchPicks::default() });
Ok(Step::Done)
}
(PickMode::Hatch, Input::Pick(hs)) => {
let hatches: Vec<Handle> = {
let d = s.doc()?;
hs.into_iter().filter(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(_)))).take(MAX_PICKS).collect()
};
if hatches.is_empty() {
s.echo("No hatch selected.");
return Ok(Step::Continue);
}
s.hatch_picks = Some(HatchPicks { mode, handles: hatches, ..HatchPicks::default() });
Ok(Step::Done)
}
(PickMode::Origin | PickMode::Hatch, Input::Enter) => Ok(Step::Done),
_ => Ok(Step::Continue),
}
}
fn preview(&self, s: &Session, _c: Vec2) -> Vec<EntityKind> {
let loops: Vec<HatchLoop> = self
.picks
.iter()
.flat_map(|p| match p {
Pick::Point(_, l) | Pick::Objects(_, l) => l.iter().cloned(),
})
.collect();
if loops.is_empty() {
return Vec::new();
}
vec![HatchSettings::from(s, &Value::Null, false).hatch(loops)]
}
}
+30 -215
View File
@@ -1,7 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2};
use serde_json::{Value, json};
use super::*;
@@ -9,7 +9,6 @@ use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod regions;
mod shapes;
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
@@ -42,11 +41,7 @@ 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?, 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("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")
.params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}")
.noundo(),
CommandSpec::new("countlist", "Count List", count::run_countlist)
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
@@ -73,119 +68,28 @@ fn fmt_a(s: &Session, v: f64) -> String {
format_angle(v, h.0, h.1)
}
/// 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 {
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
let d = b - a;
format!(
"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)),
"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()),
fmt_d(s, d.x),
fmt_d(s, d.y),
fmt_d(s, d.z)
fmt_d(s, 0.0)
)
}
/// 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 = 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))
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) }))
}
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
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]) })
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)) }))
}
fn list_text(s: &Session, h: Handle) -> String {
@@ -205,25 +109,23 @@ 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, l.a.z),
fmt_d(s, 0.0),
fmt_d(s, l.b.x),
fmt_d(s, l.b.y),
fmt_d(s, l.b.z)
fmt_d(s, 0.0)
));
t.push_str(&format!(
"\n Length = {}, Angle in XY Plane = {}",
fmt_d(s, (l.b - l.a).len()),
fmt_d(s, l.a.xy().dist(l.b.xy())),
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, c.center.z),
fmt_d(s, 0.0),
fmt_d(s, c.radius),
fmt_d(s, c.radius * cadcraft_geom::TAU),
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
@@ -234,7 +136,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, a.center.z),
fmt_d(s, 0.0),
fmt_d(s, a.radius),
fmt_a(s, a.start),
fmt_a(s, a.end)
@@ -248,20 +150,16 @@ 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, z)));
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)));
}
}
EntityKind::Text(tx) => {
t.push_str(&format!(
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
"\n Style = \"{}\"\n start point, X={} Y={}\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)
@@ -282,7 +180,7 @@ fn run_list(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": texts.join("\n\n") }))
}
pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
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();
@@ -304,16 +202,14 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
}
/// Area and perimeter of a closed object (AREA's Object option).
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
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 };
let drawn = Polyline { vertices: pl.vertices.clone(), closed: pl.closed };
(g.area().abs(), drawn.len())
(g.area().abs(), g.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 };
@@ -322,9 +218,7 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
}
let a = el.major.xy().len();
let b = a * el.ratio;
// 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))
(cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt()))
}
EntityKind::Hatch(hh) => {
// Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island.
@@ -389,8 +283,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"title": st.title,
"path": st.path,
"dirty": st.is_dirty(),
"blockEditor": st.block_editor(),
"space": space.tab_name(),
"space": match &space { cadcraft_doc::Space::Model => "Model".to_string(), cadcraft_doc::Space::Paper(n) => n.clone() },
"entityCount": d.entity_count(),
"counts": counts,
"extents": if ext.is_empty() { Value::Null } else { json!([[ext.min.x, ext.min.y], [ext.max.x, ext.max.y]]) },
@@ -404,8 +297,6 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
@@ -438,17 +329,14 @@ 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)))
@@ -458,82 +346,10 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
}
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
/// multileaders, with where they are, filtered by content, layer and a window around their
/// insertion points. Read-only (FIND selects and can replace; this only reports).
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "texts";
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_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 contains = text("contains")?;
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(cadcraft_geom::Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
let mut found: Vec<Value> = Vec::new();
for e in store.iter() {
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
continue;
}
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
let value = value.trim().to_string();
if value.is_empty()
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
{
return;
}
let mut v = json!({
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
});
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
o.insert("tag".into(), json!(t));
}
found.push(v);
};
match &e.kind {
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::Insert(i) => {
for a in i.attribs.iter().filter(|a| !a.invisible) {
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
}
}
EntityKind::MLeader(m) => {
if let Some(t) = &m.text {
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
}
}
_ => {}
}
}
let n = found.len();
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
}
mod count;
#[derive(Default)]
pub(super) struct DistM {
struct DistM {
first: Option<Vec2>,
first_source: Option<crate::snap::ViewportPoint>,
/// Multiple points: the points so far, the first included.
@@ -659,8 +475,7 @@ impl Interactive for DistM {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (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));
let t = dist_text(s, a, b);
for l in t.lines() {
s.echo(l.to_string());
}
@@ -707,7 +522,7 @@ impl Interactive for IdM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Input::Point(p) = i {
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
s.echo(m);
}
Ok(Step::Done)
@@ -769,7 +584,7 @@ enum AreaMode {
}
#[derive(Default)]
pub(super) struct AreaM {
struct AreaM {
pts: Vec<Vec2>,
mode: AreaMode,
total: f64,

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