mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-15 10:23:43 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227e3c385b |
@@ -23,5 +23,39 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
|
||||
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
|
||||
|
||||
@@ -27,10 +27,6 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "8 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -28,9 +24,47 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
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: credits
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
@@ -38,7 +72,6 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
+1
-2
@@ -62,7 +62,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | @neronek22 (Echo Rozumu & Gadajło; AI-assisted) | Original Polish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `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 |
|
||||
@@ -77,6 +76,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `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); 46 later rows: @V1dalTheAbidal with Claude Code (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; the 46 later rows were read by their contributor, a native Greek speaker; independent native proofreading of the whole file pending |
|
||||
| `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 |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian 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; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
|
||||
|
||||
Generated
+36
-61
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -631,7 +631,6 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -645,7 +644,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -661,14 +660,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -678,7 +677,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -687,19 +686,36 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"craft-dxf",
|
||||
"libc",
|
||||
"log",
|
||||
"rstar",
|
||||
@@ -710,10 +726,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"craft-dxf",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
@@ -723,23 +739,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-dwg",
|
||||
"craft-dxf",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -752,16 +768,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"craft-jsonrpc",
|
||||
"craft-mcp",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
@@ -769,7 +782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -783,14 +796,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
@@ -811,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
@@ -1138,25 +1150,6 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dwg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=006e4df225fca57e95ead0fb1f91920900798a99#006e4df225fca57e95ead0fb1f91920900798a99"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1185,23 +1178,6 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jsonrpc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-mcp"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
|
||||
dependencies = [
|
||||
"craft-jsonrpc",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1236,7 +1212,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1246,7 +1222,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6016,7 +5991,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+4
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.21.0"
|
||||
version = "0.19.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,9 +13,8 @@ repository = "https://github.com/storytold/cadcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
cadcraft-geom = { path = "crates/geom" }
|
||||
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
|
||||
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
|
||||
cadcraft-dxf = { path = "crates/dxf" }
|
||||
cadcraft-dwg = { path = "crates/dwg" }
|
||||
cadcraft-color = { path = "crates/color" }
|
||||
cadcraft-doc = { path = "crates/doc" }
|
||||
cadcraft-constraints = { path = "crates/constraints" }
|
||||
@@ -25,8 +24,6 @@ cadcraft-io = { path = "crates/io" }
|
||||
cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
craft-mcp = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
|
||||
craft-jsonrpc = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
@@ -74,7 +71,7 @@ egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
|
||||
@@ -55,11 +55,7 @@ muda = { version = "0.21", default-features = false }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
|
||||
# in Cargo.lock through eframe.
|
||||
pollster = "1"
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -250,18 +250,17 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Value::Object(mut obj) = msg else {
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(Value::String(method)) = obj.remove("method") else {
|
||||
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());
|
||||
};
|
||||
// Moved out of the parsed object, not cloned: `params` can be most of the line.
|
||||
Line::Request {
|
||||
id: obj.remove("id").unwrap_or(Value::Null),
|
||||
method,
|
||||
params: obj.remove("params").unwrap_or(json!({})),
|
||||
token: obj.remove("token").and_then(|t| if let Value::String(t) = t { Some(t) } else { None }),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,20 +148,7 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
// A message can carry text from a file (a code page name, a path), so it must not be able to
|
||||
// start what looks like another record: control characters other than tab and line feed are
|
||||
// shown escaped, and every continuation line is indented, so only this function's own
|
||||
// timestamp is ever at the start of a line.
|
||||
let mut safe = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => safe.push_str("\n "),
|
||||
'\t' => safe.push('\t'),
|
||||
c if c.is_control() => safe.extend(c.escape_default()),
|
||||
c => safe.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -454,14 +441,8 @@ mod tests {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
|
||||
);
|
||||
// A multi-line message keeps its lines, indented so they can't pass for records; the
|
||||
// record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
|
||||
// Control characters in a message (from a file) can't forge a record.
|
||||
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
|
||||
assert_eq!(forged.lines().count(), 2);
|
||||
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
|
||||
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+27
-40
@@ -23,15 +23,13 @@ mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod sheet;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -161,44 +159,39 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
|
||||
start_pick: None,
|
||||
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())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| {
|
||||
filters_for_save(name)
|
||||
.iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
|
||||
let dialog = match dir {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
|
||||
/// macOS sheets.
|
||||
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
match pick {
|
||||
Pick::Open => vec![
|
||||
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
|
||||
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
|
||||
(i18n::t("All files"), vec!["*"]),
|
||||
],
|
||||
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
|
||||
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
|
||||
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
|
||||
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
|
||||
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
match pick {
|
||||
Pick::Save(name) => dialog.set_file_name(name),
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
|
||||
_ => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// File types for the Save dialog, the suggested name's type first: Windows adds the first
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
@@ -368,12 +361,6 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
|
||||
// app when the dialog is resized (#1527).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = cc.winit_window().cloned() {
|
||||
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
|
||||
}
|
||||
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
|
||||
if let Some(ui) = restored {
|
||||
app.ui = ui;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
|
||||
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
|
||||
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
|
||||
//! the window keeps running; a thread of its own waits for the answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_ui_egui::{Pick, StartPick};
|
||||
|
||||
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
|
||||
|
||||
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
|
||||
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
|
||||
Box::new(move |pick, done| {
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
|
||||
let ui = ui.clone();
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
|
||||
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
|
||||
// shown.
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
done(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("file dialog: {e}");
|
||||
return;
|
||||
}
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog =
|
||||
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: Answer = match pick {
|
||||
Pick::Save(name) => {
|
||||
let picked = dialog.set_file_name(name).save_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) => {
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Open => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
};
|
||||
let _ = sheet_tx.send(answer);
|
||||
})
|
||||
}
|
||||
@@ -118,6 +118,3 @@ sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.V1dalTheAbidal]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -232,10 +232,6 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
let mut extra = Vec::new();
|
||||
let mut on = Vec::new();
|
||||
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
|
||||
// Nothing is added once the candidates are full, so the points left need no search.
|
||||
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
|
||||
break;
|
||||
}
|
||||
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
|
||||
continue;
|
||||
}
|
||||
@@ -271,22 +267,20 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
|
||||
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
|
||||
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
|
||||
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
|
||||
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
|
||||
break 'per;
|
||||
}
|
||||
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
|
||||
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
|
||||
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
|
||||
let ang = ang_between(*b1 - *a1, *b2 - *a2);
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
@@ -138,9 +138,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
|
||||
let selected: HashSet<Handle> = handles.iter().copied().collect();
|
||||
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
|
||||
+1
-31
@@ -204,23 +204,6 @@ impl ArrayParams {
|
||||
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
|
||||
}
|
||||
|
||||
/// [`block_for`](Self::block_for) for many items: the replacements are indexed once (the last
|
||||
/// entry for a place wins, as there), so each lookup is O(1) instead of a scan of `replaced`.
|
||||
pub fn block_lookup<'a>(&'a self) -> impl Fn(u32, u32, u32) -> &'a str + 'a {
|
||||
// Only places `items()` generates are indexed, so stray out-of-range records cost nothing.
|
||||
let (cols, rows) = match &self.kind {
|
||||
ArrayKind::Rect(r) => (r.cols, r.rows),
|
||||
ArrayKind::Polar(p) => (p.items, p.rows),
|
||||
};
|
||||
let index: std::collections::HashMap<(u32, u32, u32), &str> = self
|
||||
.replaced
|
||||
.iter()
|
||||
.filter(|r| r.i < cols && r.j < rows && r.level < self.levels)
|
||||
.map(|r| ((r.i, r.j, r.level), r.block.as_str()))
|
||||
.collect();
|
||||
move |i, j, level| index.get(&(i, j, level)).copied().unwrap_or(self.source.as_str())
|
||||
}
|
||||
|
||||
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
|
||||
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
|
||||
/// parameters are not [valid](Self::is_valid).
|
||||
@@ -234,10 +217,9 @@ impl ArrayParams {
|
||||
..Common::default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let block_for = self.block_lookup();
|
||||
for it in items {
|
||||
let ins = Insert {
|
||||
block: block_for(it.i, it.j, it.level).to_string(),
|
||||
block: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
insert: it.insert,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: it.rotation,
|
||||
@@ -319,18 +301,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_lookup_agrees_with_block_for_and_the_last_replacement_wins() {
|
||||
let mut p = rect(3, 2);
|
||||
let r = |i, j, level, b: &str| ReplacedItem { i, j, level, block: b.into() };
|
||||
p.replaced = vec![r(1, 0, 0, "A"), r(1, 0, 0, "B"), r(2, 1, 1, "C")];
|
||||
let look = p.block_lookup();
|
||||
for (i, j, level) in [(0, 0, 0), (1, 0, 0), (2, 1, 1), (2, 1, 0)] {
|
||||
assert_eq!(look(i, j, level), p.block_for(i, j, level));
|
||||
}
|
||||
assert_eq!((look(1, 0, 0), look(0, 0, 0)), ("B", "*U1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_items_go_column_row_level() {
|
||||
let items = rect(3, 2).items().unwrap_or_default();
|
||||
|
||||
@@ -674,10 +674,6 @@ fn apply(
|
||||
/// 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) {
|
||||
// Each loop gets its own copy of the outline, so many small loops on one big outline multiply it;
|
||||
// past this many vertices in all, the remaining loops keep the outline they have.
|
||||
const MAX_REFILLED_VERTICES: usize = 4_000_000;
|
||||
let mut budget = MAX_REFILLED_VERTICES;
|
||||
let outlines: HashMap<Handle, Vec<cadcraft_geom::PolyVertex>> = b
|
||||
.entities
|
||||
.iter()
|
||||
@@ -693,9 +689,7 @@ fn refill_hatches(b: &mut Block) {
|
||||
for l in &mut hh.loops {
|
||||
if let [s] = l.sources.as_slice()
|
||||
&& let Some(vs) = outlines.get(s)
|
||||
&& let Some(left) = budget.checked_sub(vs.len())
|
||||
{
|
||||
budget = left;
|
||||
l.vertices.clone_from(vs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,24 +486,16 @@ impl DimStyle {
|
||||
|
||||
/// Set fields from a JSON object of field or DIM* variable names (numbers, booleans as
|
||||
/// 0/1, colours as ACI numbers or names). Returns the names that were not recognised or
|
||||
/// had unusable values. When several spellings name one field, the last one wins and the
|
||||
/// superseded ones are not validated.
|
||||
/// had unusable values.
|
||||
pub fn apply_fields(&mut self, src: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
|
||||
use serde_json::Value;
|
||||
let mut rejected = Vec::new();
|
||||
let Ok(mut cur) = serde_json::to_value(&*self) else { return src.keys().cloned().collect() };
|
||||
// Spellings of one field (`DIMPOST`, `dimpost`, `textPrefix`…): only the last is applied, so
|
||||
// the style is checked once per field, not once per spelling (each check copies its strings).
|
||||
let last_spelling: std::collections::HashMap<&str, &String> =
|
||||
src.keys().filter_map(|k| DimStyle::field_name(k).filter(|f| *f != "name").map(|f| (f, k))).collect();
|
||||
for (k, v) in src {
|
||||
let Some(field) = DimStyle::field_name(k).filter(|f| *f != "name") else {
|
||||
rejected.push(k.clone());
|
||||
continue;
|
||||
};
|
||||
if last_spelling.get(field) != Some(&k) {
|
||||
continue;
|
||||
}
|
||||
let Some(obj) = cur.as_object_mut() else { break };
|
||||
let Some(old) = obj.get(field).cloned() else {
|
||||
rejected.push(k.clone());
|
||||
|
||||
@@ -714,14 +714,3 @@ fn space_names_its_tab() {
|
||||
assert_eq!(p.layout(), Some("Sheet A"));
|
||||
assert_eq!(p.tab_name(), "Sheet A");
|
||||
}
|
||||
|
||||
/// Several spellings of one DIM field apply once, the last in the map's order, and none is rejected.
|
||||
#[test]
|
||||
fn dim_style_spellings_of_one_field_apply_once() {
|
||||
let mut st = DimStyle::default();
|
||||
let m = serde_json::json!({ "textHeight": 2.5, "TEXTHEIGHT": 3.5, "DIMTXT": 4.5 });
|
||||
let m = m.as_object().unwrap();
|
||||
let want = m.values().next_back().and_then(serde_json::Value::as_f64).unwrap();
|
||||
assert!(st.apply_fields(m).is_empty());
|
||||
assert_eq!(st.text_height, want);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "cadcraft-dwg"
|
||||
description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
|
||||
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
|
||||
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
|
||||
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
|
||||
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
|
||||
acadrust = "0.6.6"
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
|
||||
//! there return an error that says to use DXF.
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
|
||||
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
|
||||
println!("cargo::rustc-cfg=no_dwg_bridge");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
//! Another application's entities and their classes through the DWG bridge: on reading a DWG,
|
||||
//! registered entities acadrust has no model for become proxy entities of their class; on writing
|
||||
//! one, the DWG gets the source DXF's classes in their places, so every proxy names its own class.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::{DxfClass, ProxyFlags};
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData, ProxyEntityData};
|
||||
use acadrust::objects::{ObjectType, ProxyPayload};
|
||||
|
||||
fn class_number(index: usize) -> Result<i16, String> {
|
||||
index.checked_add(500).and_then(|n| i16::try_from(n).ok()).ok_or_else(|| "DXF: too many application classes".into())
|
||||
}
|
||||
|
||||
/// After a DWG read: each registered entity of another application's class that acadrust keeps
|
||||
/// only as its record and opaque data (no decoded properties) becomes a proxy entity of that class
|
||||
/// (DXF Reference, ACAD_PROXY_ENTITY), with its graphics, data bits, R2007+ strings and object
|
||||
/// references, so the DXF keeps it; and every proxy's class number becomes 500 plus its class's
|
||||
/// place in the classes, as a DXF numbers it. A record that can't be converted (its class is
|
||||
/// missing or not unique) is left out and named in the notes; one with decoded properties stays as
|
||||
/// read, also named, as its opaque data has no DXF form beside them.
|
||||
pub(super) fn prepare(doc: &mut CadDocument) -> Vec<String> {
|
||||
let classes: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
let mut numbers = HashMap::new();
|
||||
let mut identities = HashMap::new();
|
||||
for (index, class) in classes.iter().enumerate() {
|
||||
let number = class_number(index).ok();
|
||||
numbers.entry(i32::from(class.class_number)).and_modify(|v| *v = None).or_insert(number.map(i32::from));
|
||||
let identity = (class.dxf_name.as_str(), class.cpp_class_name.as_str(), class.is_an_entity);
|
||||
identities.entry(identity).and_modify(|v| *v = None).or_insert(number);
|
||||
}
|
||||
let identity = |dxf: &str, cpp: &str, entity: bool| {
|
||||
identities
|
||||
.get(&(dxf, cpp, entity))
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(i32::from)
|
||||
.ok_or_else(|| format!("missing or ambiguous application CLASS for {dxf:.80} ({cpp:.80})"))
|
||||
};
|
||||
let Some(dwg_version) = super::native::proxy_dwg_version(doc.dwg_source_version.unwrap_or(doc.version)) else { return Vec::new() };
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
let remap = |id: i32| numbers.get(&id).copied().flatten().ok_or_else(|| format!("proxy names missing or ambiguous application class {id}"));
|
||||
// Only mutate proxies: entities_mut invalidates the decoder's verbatim records for every entity.
|
||||
let handles: Vec<_> = doc
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(_) => Some(e.common.handle),
|
||||
ExtendedEntityData::RegisteredClass(data) if data.properties.is_empty() || data.payload.bit_count > 0 => Some(e.common.handle),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut notes = Vec::new();
|
||||
let mut entity_count = 0usize;
|
||||
let mut object_count = 0usize;
|
||||
let mut semantic = 0usize;
|
||||
for handle in handles {
|
||||
// Ok(None): not an application record; Ok(Some(None)): kept as read, with its properties.
|
||||
let converted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<Option<Option<ExtendedEntityData>>, String> {
|
||||
let Some(EntityType::Extended(entity)) = doc.get_entity(handle) else { return Ok(None) };
|
||||
let data = match &entity.data {
|
||||
ExtendedEntityData::Proxy(data) => {
|
||||
let mut proxy = data.clone();
|
||||
proxy.class_id = remap(proxy.class_id)?;
|
||||
ExtendedEntityData::Proxy(proxy)
|
||||
}
|
||||
ExtendedEntityData::RegisteredClass(data) => {
|
||||
if !data.properties.is_empty() {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
let class_id = identity(&data.dxf_name, &data.cpp_class_name, true)?;
|
||||
let text_payload = entity.common.raw_record.as_deref().map(registered_text).transpose()?.unwrap_or_default();
|
||||
ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: String::new(),
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::from_bytes(entity.common.graphic_data.as_deref().unwrap_or_default()),
|
||||
payload: data.payload.clone(),
|
||||
text_payload,
|
||||
object_ids: data.object_ids.clone(),
|
||||
})
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(data)))
|
||||
}))
|
||||
.unwrap_or_else(|_| Err("application record conversion failed".into()));
|
||||
match converted {
|
||||
Ok(Some(Some(data))) => {
|
||||
if let Some(EntityType::Extended(entity)) = doc.get_entity_mut(handle) {
|
||||
entity.data = data;
|
||||
}
|
||||
}
|
||||
Ok(Some(None)) => semantic = semantic.saturating_add(1),
|
||||
Ok(None) => {}
|
||||
Err(why) => {
|
||||
doc.remove_entity(handle);
|
||||
entity_count = entity_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "entity", handle, &why);
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.objects.retain(|handle, object| {
|
||||
if let ObjectType::ProxyObject(proxy) = object {
|
||||
match remap(proxy.class_id) {
|
||||
Ok(id) => proxy.class_id = id,
|
||||
Err(why) => {
|
||||
object_count = object_count.saturating_add(1);
|
||||
skipped_note(&mut notes, "object", *handle, &why);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
if entity_count > 0 || object_count > 0 {
|
||||
notes.insert(0, format!("DWG: partial read; application proxy records left out (entities: {entity_count}, objects: {object_count})"));
|
||||
}
|
||||
if semantic > 0 {
|
||||
notes.push(format!(
|
||||
"DWG: {semantic} registered entities with decoded properties keep them, without their other data; saving over the original file would lose it"
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn skipped_note(notes: &mut Vec<String>, kind: &str, handle: acadrust::types::Handle, why: &str) {
|
||||
if notes.len() < 6 {
|
||||
let why: String = why.chars().take(256).collect();
|
||||
notes.push(format!("DWG: proxy {kind} {handle} left out: {why}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder's registered-class representation omits the separate R2007+ string stream.
|
||||
fn registered_text(raw: &acadrust::entities::RawRecord) -> Result<ProxyPayload, String> {
|
||||
use acadrust::io::dwg::DwgVersion;
|
||||
use acadrust::io::dwg::dwg_stream_readers::object_reader::DwgObjectReader;
|
||||
use acadrust::io::dwg::dwg_stream_writers::bit_writer::DwgBitWriter;
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
if raw.version < DxfVersion::AC1021 {
|
||||
return Ok(ProxyPayload::default());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "DWG proxy text: negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("DWG proxy text: record too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("DWG proxy text: handle stream exceeds record".into());
|
||||
}
|
||||
// Frame the verbatim record for the dependency's checked object reader.
|
||||
let mut frame = DwgBitWriter::new(version, raw.version);
|
||||
frame.write_modular_short(raw.data.len());
|
||||
if raw.version >= DxfVersion::AC1024 {
|
||||
frame.write_modular_char(handle_bits);
|
||||
}
|
||||
frame.write_bytes(&raw.data);
|
||||
let reader = DwgObjectReader::new(frame.into_bytes(), raw.version, HashMap::new()).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
reader.read_common_entity_data(&mut streams, kind);
|
||||
if kind == 498 {
|
||||
// An existing registered-class envelope is a proxy and carries its class name first.
|
||||
streams.read_variable_text();
|
||||
} else if kind < 500 {
|
||||
return Err("DWG proxy text: registered entity has no application class".into());
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "DWG proxy text: negative string stream size")?;
|
||||
if bits > total_bits {
|
||||
return Err("DWG proxy text: string stream exceeds record".into());
|
||||
}
|
||||
let count = u32::try_from(bits).map_err(|_| "DWG proxy text: string stream too large")?;
|
||||
let mut bytes = vec![0; bits.div_ceil(8)];
|
||||
for (index, byte) in bytes.iter_mut().enumerate() {
|
||||
for bit in 0..8.min(bits.saturating_sub(index * 8)) {
|
||||
if streams.read_text_bit() {
|
||||
*byte |= 0x80 >> bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
streams.check_error().map_err(|e| format!("DWG proxy text: {e}"))?;
|
||||
Ok(ProxyPayload::from_bits(&bytes, count))
|
||||
}
|
||||
|
||||
/// ASCII DXF pairs decoded with the bridge's public code-page and escape codecs. A malformed
|
||||
/// pair yields `None`, so both class scans fall back rather than guessing its identity.
|
||||
pub(super) fn text_pairs(dxf: &[u8]) -> Option<impl Iterator<Item = Option<(i32, String)>> + '_> {
|
||||
use acadrust::io::dxf::code_page::{decode_legacy_escapes, encoding_from_code_page};
|
||||
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let version =
|
||||
super::native::header_value(dxf, b"$ACADVER").and_then(|(_, v)| std::str::from_utf8(v).ok()).map(acadrust::DxfVersion::from_version_string);
|
||||
let unicode = version.is_some_and(|v| v >= acadrust::DxfVersion::AC1021);
|
||||
let encoding = if version.is_some_and(|v| v < acadrust::DxfVersion::AC1021) {
|
||||
super::native::header_value(dxf, b"$DWGCODEPAGE").and_then(|(_, page)| std::str::from_utf8(page).ok()).and_then(encoding_from_code_page)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| line.strip_suffix(b"\r").unwrap_or(line));
|
||||
Some(std::iter::from_fn(move || {
|
||||
let code = lines.next()?;
|
||||
let Some(value) = lines.next() else { return (!code.trim_ascii().is_empty()).then_some(None) };
|
||||
let pair = || {
|
||||
let code = std::str::from_utf8(code).ok()?.trim().parse().ok()?;
|
||||
let text = if let Some(encoding) = encoding {
|
||||
encoding.decode(value).0.into_owned()
|
||||
} else if unicode {
|
||||
String::from_utf8_lossy(value).into_owned()
|
||||
} else {
|
||||
// The decoder uses UTF-8 with Latin-1 fallback when no encoding was declared.
|
||||
std::str::from_utf8(value).map_or_else(|_| value.iter().map(|&b| char::from(b)).collect(), str::to_string)
|
||||
};
|
||||
let text = decode_legacy_escapes(&text);
|
||||
// DXF's escaped control characters are decoded after CIF/MIF, as in the bridge.
|
||||
let text = text.replace("^J", "\n").replace("^M", "\r").replace("^I", "\t").replace("^ ", "^");
|
||||
Some((code, text))
|
||||
};
|
||||
Some(pair())
|
||||
}))
|
||||
}
|
||||
|
||||
/// The CLASS records of an ASCII DXF, in CLASSES order and numbered by place from 500 (DXF
|
||||
/// Reference, CLASSES; a proxy's group 91 is that number). `None` for a binary DXF (CADCraft's own
|
||||
/// bridge DXF is ASCII), and for one with a record that can't be written back as it is: no names,
|
||||
/// flags or instance count out of range, more classes than the numbers allow. Then
|
||||
/// `proxy_classes` matches the classes by name instead.
|
||||
pub(super) fn source_classes(dxf: &[u8]) -> Option<Vec<DxfClass>> {
|
||||
let pairs = text_pairs(dxf)?;
|
||||
let mut records: Vec<Vec<(i32, String)>> = Vec::new();
|
||||
let (mut section, mut in_classes) = (false, false);
|
||||
for pair in pairs {
|
||||
let (code, value) = pair?;
|
||||
match (code, value.as_str()) {
|
||||
(0, "ENDSEC") if in_classes => break,
|
||||
(0, "SECTION") => section = true,
|
||||
(2, name) if section => {
|
||||
in_classes = name == "CLASSES";
|
||||
section = false;
|
||||
}
|
||||
(0, kind) if in_classes => {
|
||||
if kind != "CLASS" {
|
||||
return None;
|
||||
}
|
||||
records.push(Vec::new());
|
||||
}
|
||||
(code, value) if in_classes => records.last_mut()?.push((code, value.to_string())),
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
records.iter().enumerate().map(|(index, groups)| source_class(groups, class_number(index).ok()?)).collect()
|
||||
}
|
||||
|
||||
/// One CLASS record's groups as the class `number`, or `None` when it can't be written back.
|
||||
fn source_class(groups: &[(i32, String)], number: i16) -> Option<DxfClass> {
|
||||
let text = |code| groups.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let integer = |code| groups.iter().find(|(c, _)| *c == code).map_or(Some(0), |(_, v)| v.trim().parse::<i64>().ok());
|
||||
let mut class = DxfClass::new(text(1), text(2));
|
||||
if class.dxf_name.is_empty() || class.cpp_class_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
class.application_name = text(3);
|
||||
class.proxy_flags = ProxyFlags(u16::try_from(integer(90)?).ok()?);
|
||||
class.instance_count = i32::try_from(integer(91)?).ok()?;
|
||||
class.was_zombie = integer(280)? != 0;
|
||||
class.is_an_entity = integer(281)? != 0;
|
||||
class.item_class_id = if class.is_an_entity { 498 } else { 499 };
|
||||
class.class_number = number;
|
||||
Some(class)
|
||||
}
|
||||
|
||||
/// Give a DWG of 2013 or later the source DXF's classes (`classes`, from [`source_classes`]) in
|
||||
/// their places, with their metadata and duplicate names, so each proxy's DXF class number names
|
||||
/// its own class; the decoder's default classes the source lacks follow them. A proxy whose number
|
||||
/// names no source class is left out, as `proxy_classes` leaves it out. Returns how many proxy objects and
|
||||
/// entities were left out; nothing changes when the drawing has no proxies. `None`, with nothing
|
||||
/// changed, when the classes would be more than the class numbers allow.
|
||||
pub(super) fn restore_classes(doc: &mut CadDocument, classes: Vec<DxfClass>) -> Option<(usize, usize)> {
|
||||
let fits = |number: i32| number.checked_sub(500).and_then(|n| usize::try_from(n).ok()).is_some_and(|i| i < classes.len());
|
||||
let mut any = false;
|
||||
let mut entities = Vec::new();
|
||||
for entity in doc.entities() {
|
||||
if let EntityType::Extended(e) = entity
|
||||
&& let ExtendedEntityData::Proxy(p) = &e.data
|
||||
{
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
entities.push(e.common.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut objects = Vec::new();
|
||||
for (handle, object) in &doc.objects {
|
||||
if let ObjectType::ProxyObject(p) = object {
|
||||
any = true;
|
||||
if !fits(p.class_id) {
|
||||
objects.push(*handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Some((0, 0));
|
||||
}
|
||||
let mut names: HashSet<_> = classes.iter().map(|c| c.dxf_name.to_uppercase()).collect();
|
||||
let defaults: Vec<_> = doc.classes.iter().filter(|c| names.insert(c.dxf_name.to_uppercase())).cloned().collect();
|
||||
if let Some(last) = classes.len().checked_add(defaults.len())?.checked_sub(1) {
|
||||
class_number(last).ok()?;
|
||||
}
|
||||
for handle in &entities {
|
||||
doc.remove_entity(*handle);
|
||||
}
|
||||
for handle in &objects {
|
||||
doc.objects.remove(handle);
|
||||
}
|
||||
doc.classes.clear();
|
||||
for (index, mut class) in classes.into_iter().chain(defaults).enumerate() {
|
||||
class.class_number = class_number(index).ok()?;
|
||||
doc.classes.push_preserving(class);
|
||||
}
|
||||
Some((objects.len(), entities.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
|
||||
fn proxy_doc() -> CadDocument {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.version = DxfVersion::AC1032;
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id: 501,
|
||||
dxf_subclass: String::new(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
text_payload: ProxyPayload::default(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// An ASCII DXF of `groups`, `(code, value)` per pair of lines.
|
||||
fn dxf(groups: &[(i32, &str)]) -> Vec<u8> {
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect::<String>().into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_keep_duplicates_and_metadata_before_defaults() {
|
||||
let bytes = dxf(&[
|
||||
(0, "SECTION"),
|
||||
(2, "HEADER"),
|
||||
(9, "$DIMSTYLE"),
|
||||
(2, "CLASSES"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "CLASSES"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "First"),
|
||||
(3, "Первый"),
|
||||
(90, "1025"),
|
||||
(91, "7"),
|
||||
(280, "1"),
|
||||
(281, "1"),
|
||||
(0, "CLASS"),
|
||||
(1, "Объект"),
|
||||
(2, "Second"),
|
||||
(3, "Второй"),
|
||||
(90, "128"),
|
||||
(281, "1"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
]);
|
||||
let classes = source_classes(&bytes).unwrap();
|
||||
assert_eq!(classes.len(), 2);
|
||||
assert_eq!(classes[0].application_name, "Первый");
|
||||
assert_eq!(classes[0].proxy_flags.0, 1025);
|
||||
assert_eq!(classes[0].instance_count, 7);
|
||||
assert!(classes[0].was_zombie);
|
||||
assert_eq!((classes[0].class_number, classes[1].class_number), (500, 501));
|
||||
assert_eq!(classes[1].cpp_class_name, "Second");
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, classes.clone()), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().take(2).cloned().collect::<Vec<_>>(), classes);
|
||||
// A binary DXF, and a CLASS record that can't be written back as it is, give no classes.
|
||||
assert!(source_classes(b"AutoCAD Binary DXF\r\n\x1a\0").is_none());
|
||||
for (code, value) in [(1, ""), (2, ""), (90, "65536"), (91, "9223372036854775807"), (281, "x")] {
|
||||
let mut class = vec![(1, "A"), (2, "A")];
|
||||
class.retain(|(c, _)| *c != code);
|
||||
class.push((code, value));
|
||||
let mut groups = vec![(0, "SECTION"), (2, "CLASSES"), (0, "CLASS")];
|
||||
groups.extend(class);
|
||||
groups.extend([(0, "ENDSEC"), (0, "EOF")]);
|
||||
assert!(source_classes(&dxf(&groups)).is_none(), "{code} {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_classes_decode_escapes_once_and_respect_legacy_code_pages() {
|
||||
let body = "0\nCLASS\n1\n\\U+041E\\U+0431\\U+044A\\U+0435\\U+043A\\U+0442\n2\nClass\n3\nCaf\\U+00E9\\U+D83D\\U+DE42\\U+005CU+0041\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
let text = format!("0\nSECTION\n2\nCLASSES\n{body}");
|
||||
let classes = source_classes(text.as_bytes()).unwrap();
|
||||
assert_eq!(classes[0].dxf_name, "Объект");
|
||||
assert_eq!(classes[0].application_name, "Café🙂\\U+0041");
|
||||
|
||||
let prefix =
|
||||
b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n";
|
||||
let encoded = [prefix.as_slice(), b"\xCF\xF0\xE8\xEC\xE5\xF0", b"\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n"].concat();
|
||||
assert_eq!(source_classes(&encoded).unwrap()[0].dxf_name, "Пример");
|
||||
// R2007+ text is UTF-8 even with a legacy code-page declaration.
|
||||
let modern = String::from_utf8_lossy(prefix).replace("AC1015", "AC1032") + "Пример\n2\nClass\n3\nApp\n281\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
assert_eq!(source_classes(modern.as_bytes()).unwrap()[0].dxf_name, "Пример");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_of_missing_classes_are_left_out() {
|
||||
for id in [-1, 499, 501, i32::MAX] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = id;
|
||||
}
|
||||
let mut class = DxfClass::new_entity("ENTITY", "Entity");
|
||||
class.class_number = 500;
|
||||
assert_eq!(restore_classes(&mut doc, vec![class]), Some((0, 1)), "{id}");
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
}
|
||||
// Class 501 is there: the proxy keeps it, in its place.
|
||||
let mut doc = proxy_doc();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A"), DxfClass::new_entity("B", "B")]), Some((0, 0)));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert_eq!(doc.classes.iter().nth(1).map(|c| (c.dxf_name.clone(), c.class_number)), Some(("B".to_string(), 501)));
|
||||
// Without proxies the classes stay the decoder's.
|
||||
let mut doc = CadDocument::new();
|
||||
let before: Vec<_> = doc.classes.iter().cloned().collect();
|
||||
assert_eq!(restore_classes(&mut doc, vec![DxfClass::new("A", "A")]), Some((0, 0)));
|
||||
assert_eq!(doc.classes.iter().cloned().collect::<Vec<_>>(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrepresentable_proxies_keep_other_geometry_and_bounded_diagnostics() {
|
||||
use acadrust::entities::{Line, RawRecord};
|
||||
use acadrust::objects::ProxyObject;
|
||||
use acadrust::types::{Handle, Vector3};
|
||||
for count in [1, 10] {
|
||||
let mut doc = proxy_doc();
|
||||
if let Some(EntityType::Extended(e)) = doc.entities_mut().next()
|
||||
&& let ExtendedEntityData::Proxy(p) = &mut e.data
|
||||
{
|
||||
p.class_id = i32::MAX;
|
||||
}
|
||||
let template = doc.entities().next().unwrap().clone();
|
||||
for _ in 1..count {
|
||||
let mut entity = template.clone();
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
doc.add_entity(entity).unwrap();
|
||||
}
|
||||
let object = Handle::new(u64::MAX - 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
doc.objects.insert(object, ObjectType::ProxyObject(ProxyObject { handle: object, class_id: i32::MAX, ..Default::default() }));
|
||||
let line = doc.add_entity(EntityType::Line(Line::from_points(Vector3::new(2.0, 3.0, 0.0), Vector3::new(12.0, 13.0, 0.0)))).unwrap();
|
||||
let raw = RawRecord { data: vec![0xAA, 0x55], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
doc.get_entity_mut(line).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
let notes = prepare(&mut doc);
|
||||
assert_eq!(notes.len(), (count + 1).min(6) + 1);
|
||||
assert!(notes.iter().all(|n| n.starts_with("DWG:")));
|
||||
assert!(notes[0].contains(&format!("entities: {count}, objects: 1")));
|
||||
assert_eq!(doc.entities().count(), 1);
|
||||
assert!(!doc.objects.contains_key(&object));
|
||||
let Some(EntityType::Line(actual)) = doc.get_entity(line) else { panic!("readable line") };
|
||||
assert_eq!(actual.start, Vector3::new(2.0, 3.0, 0.0));
|
||||
assert_eq!(actual.end, Vector3::new(12.0, 13.0, 0.0));
|
||||
assert_eq!(doc.get_entity(line).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn opaque_registered_class_ambiguity_is_not_guessed() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.classes.push_preserving(DxfClass::new_entity("CUSTOM", "Custom"));
|
||||
doc.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM".into(),
|
||||
cpp_class_name: "Custom".into(),
|
||||
properties: vec![],
|
||||
payload: ProxyPayload::from_bits(&[0x80], 1),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("ambiguous")));
|
||||
assert_eq!(doc.entities().count(), 0);
|
||||
assert!(class_number(usize::MAX).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn semantic_records_keep_verbatim_data_and_mixed_payloads_are_reported() {
|
||||
use acadrust::entities::RawRecord;
|
||||
use acadrust::objects::{SemanticProperty, SemanticPropertyValue};
|
||||
for payload in [ProxyPayload::default(), ProxyPayload::from_bits(&[0x80], 1)] {
|
||||
let mut doc = CadDocument::new();
|
||||
let raw = RawRecord { data: vec![0xAA], handle_bits: 0, version: DxfVersion::AC1032 };
|
||||
let handle = doc
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SEMANTIC".into(),
|
||||
cpp_class_name: "Semantic".into(),
|
||||
properties: vec![SemanticProperty {
|
||||
subclass: "Semantic".into(),
|
||||
code: 1,
|
||||
value: SemanticPropertyValue::Text("value".into()),
|
||||
}],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
doc.get_entity_mut(handle).unwrap().common_mut().raw_record = Some(std::sync::Arc::new(raw.clone()));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
if payload.bit_count == 0 {
|
||||
assert!(prepare(&mut doc).is_empty());
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
} else {
|
||||
// Kept as read, and named: its opaque data has no DXF form beside the properties.
|
||||
assert!(prepare(&mut doc).iter().any(|n| n.contains("decoded properties")));
|
||||
assert_eq!(doc.get_entity(handle).unwrap().common().raw_record.as_deref(), Some(&raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod string_stream_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{EntityCommon, ExtendedEntity, RawRecord, RegisteredClassEntityData};
|
||||
use acadrust::types::DxfVersion;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn registered_transport_recovers_separate_non_byte_aligned_strings() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new_entity("CUSTOM_TEXT", "CustomText"));
|
||||
let class_id = i32::from(source.classes.get_by_name("CUSTOM_TEXT").unwrap().class_number);
|
||||
let text = ProxyPayload::from_bits(&[0xA5, 0xC7, 0xE0], 19);
|
||||
let payload = ProxyPayload::from_bits(&[0x80], 1);
|
||||
source
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon::default(),
|
||||
data: ExtendedEntityData::Proxy(ProxyEntityData {
|
||||
proxy_id: 498,
|
||||
class_id,
|
||||
dxf_subclass: "CustomText".into(),
|
||||
version: 33,
|
||||
dwg_version: 33,
|
||||
maintenance_version: 0,
|
||||
from_dxf: false,
|
||||
graphics: ProxyPayload::default(),
|
||||
payload: payload.clone(),
|
||||
text_payload: text.clone(),
|
||||
object_ids: vec![],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
let bytes = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
let mut decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let entity = decoded.entities().next().unwrap();
|
||||
let handle = entity.common().handle;
|
||||
let raw = entity.common().raw_record.clone().unwrap();
|
||||
assert_eq!(registered_text(&raw).unwrap().data(), text.data());
|
||||
assert_eq!(registered_text(&raw).unwrap().bit_count, 19);
|
||||
let EntityType::Extended(entity) = decoded.get_entity_mut(handle).unwrap() else { panic!() };
|
||||
entity.data = ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "CUSTOM_TEXT".into(),
|
||||
cpp_class_name: "CustomText".into(),
|
||||
properties: vec![],
|
||||
payload: payload.clone(),
|
||||
object_ids: vec![],
|
||||
});
|
||||
entity.common.raw_record = Some(raw);
|
||||
assert!(prepare(&mut decoded).is_empty());
|
||||
for _ in 0..2 {
|
||||
let mut dxf = Vec::new();
|
||||
acadrust::DxfWriter::new(&decoded).write_to_writer(&mut dxf).unwrap();
|
||||
let bytes = crate::dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
decoded = acadrust::DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let p = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(p.payload.bit_count, 1);
|
||||
assert_eq!(p.payload.data(), payload.data());
|
||||
assert_eq!(p.text_payload.bit_count, 19);
|
||||
assert_eq!(p.text_payload.data(), text.data());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_string_stream_boundaries_return_errors() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(registered_text(&raw).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! The DWG reader and writer in a child process.
|
||||
//!
|
||||
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
|
||||
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
|
||||
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
|
||||
//! that reads one request from standard input and writes one reply to standard output. A file that
|
||||
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
|
||||
//!
|
||||
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
|
||||
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
|
||||
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
|
||||
//!
|
||||
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
|
||||
//! untrusted input.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::{RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::Limits;
|
||||
|
||||
/// The argument that makes the app a worker.
|
||||
pub const WORKER_ARG: &str = "--dwg-worker";
|
||||
/// Bumped when the messages change: an app updated while running may start a newer copy.
|
||||
const PROTOCOL: u8 = 2;
|
||||
const DECODE: u8 = 1;
|
||||
const ENCODE: u8 = 2;
|
||||
const CRASH: u8 = 0xff;
|
||||
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
/// Most bytes accepted for what a reply carries besides the drawing.
|
||||
const MAX_NOTES_BYTES: u64 = 64 << 20;
|
||||
/// Most bytes accepted as a written DWG.
|
||||
const MAX_DWG_REPLY: u64 = 2 << 30;
|
||||
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
|
||||
const MAX_REQUEST: u64 = (2 << 30) + 256;
|
||||
|
||||
struct Worker {
|
||||
program: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
|
||||
|
||||
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
|
||||
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
|
||||
if let Ok(mut w) = WORKER.lock() {
|
||||
*w = Some(Worker { program, args });
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
|
||||
/// found, the reader and writer stay in this process.
|
||||
pub fn use_own_worker() {
|
||||
match std::env::current_exe() {
|
||||
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
|
||||
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request and return the exit code if this process was started as a worker.
|
||||
pub fn serve_if_asked() -> Option<i32> {
|
||||
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
|
||||
}
|
||||
|
||||
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
|
||||
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
|
||||
// Before starting a process, and so the same limit holds with or without one.
|
||||
if bytes.len() > limits.max_dwg_bytes {
|
||||
return super::dwg_to_dxf_noted(bytes, limits);
|
||||
}
|
||||
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
|
||||
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
|
||||
Some(reply) => reply?,
|
||||
None => super::dwg_to_dxf_noted(bytes, limits),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
|
||||
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
|
||||
encode_noted(dxf, version).map(|(dwg, _)| dwg)
|
||||
}
|
||||
|
||||
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
|
||||
/// back as notes of `TYPE<tab>count`.
|
||||
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
encode_kept_noted(dxf, version, None)
|
||||
}
|
||||
|
||||
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
|
||||
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
|
||||
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
if u8::try_from(version.len()).is_err() {
|
||||
return Err(super::unsupported(version));
|
||||
}
|
||||
// A kept version too long to send is no version CADCraft knows: it changes nothing.
|
||||
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
|
||||
let mut header = request(ENCODE, Limits::DEFAULT, version);
|
||||
push_str(&mut header, kept.unwrap_or(""));
|
||||
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
|
||||
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(¬es))),
|
||||
None => encode_here(dxf, version, kept),
|
||||
}
|
||||
}
|
||||
|
||||
/// The conversion in this process.
|
||||
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
|
||||
match kept {
|
||||
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
|
||||
None => super::dxf_to_dwg_noted(dxf, version),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwritten_from(notes: &[String]) -> super::Unwritten {
|
||||
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
|
||||
super::Unwritten::new(),
|
||||
|mut m, (kind, n)| {
|
||||
*m.entry(kind).or_insert(0) = n;
|
||||
m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
|
||||
#[doc(hidden)]
|
||||
pub fn crash_worker_for_test() -> Result<(), String> {
|
||||
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
|
||||
}
|
||||
|
||||
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
|
||||
let mut r = vec![PROTOCOL, op];
|
||||
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
|
||||
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
|
||||
push_str(&mut r, version);
|
||||
r
|
||||
}
|
||||
|
||||
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
|
||||
fn push_str(r: &mut Vec<u8>, text: &str) {
|
||||
r.push(u8::try_from(text.len()).unwrap_or(0));
|
||||
r.extend(text.as_bytes());
|
||||
}
|
||||
|
||||
type Reply = Result<(Vec<u8>, Vec<String>), String>;
|
||||
|
||||
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
|
||||
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
|
||||
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
|
||||
let mut command = {
|
||||
let worker = WORKER.lock().ok()?;
|
||||
let worker = worker.as_ref()?;
|
||||
let mut c = Command::new(&worker.program);
|
||||
c.args(&worker.args);
|
||||
c
|
||||
};
|
||||
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = match command.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("DWG worker: can't start it: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let received = std::thread::scope(|scope| {
|
||||
// Written beside the read: a worker that replies before it has read everything, or dies,
|
||||
// must block neither side. A failed write shows up as the reply going wrong.
|
||||
scope.spawn(move || {
|
||||
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
scope.spawn(move || {
|
||||
let mut reply = Vec::new();
|
||||
let read = from_child.take(reply_limit).read_to_end(&mut reply);
|
||||
let _ = tx.send(read.map(|_| reply));
|
||||
});
|
||||
let received = rx.recv_timeout(TIMEOUT);
|
||||
if matches!(received, Err(RecvTimeoutError::Timeout)) {
|
||||
let _ = child.kill();
|
||||
}
|
||||
received
|
||||
});
|
||||
let status = child.wait();
|
||||
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
|
||||
Some(match (received, status) {
|
||||
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
|
||||
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
|
||||
_ => Err(stopped()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take `n` bytes off the front of `b`.
|
||||
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
|
||||
let (head, tail) = b.split_at_checked(n)?;
|
||||
*b = tail;
|
||||
Some(head)
|
||||
}
|
||||
|
||||
fn take_u32(b: &mut &[u8]) -> Option<usize> {
|
||||
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
|
||||
usize::try_from(u32::from_le_bytes(bytes)).ok()
|
||||
}
|
||||
|
||||
fn take_u64(b: &mut &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
|
||||
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
|
||||
/// of the memory the app may use: they are kept, not copied.
|
||||
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
|
||||
let total = reply.len();
|
||||
let mut b = &reply[..];
|
||||
match *take(&mut b, 1)?.first()? {
|
||||
0 => {
|
||||
let count = take_u32(&mut b)?;
|
||||
let mut notes = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = take_u32(&mut b)?;
|
||||
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
|
||||
}
|
||||
let n = usize::try_from(take_u64(&mut b)?).ok()?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
reply.drain(..total - n);
|
||||
Some(Ok((reply, notes)))
|
||||
}
|
||||
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
|
||||
match reply {
|
||||
Ok((bytes, notes)) => {
|
||||
out.write_all(&[0])?;
|
||||
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
|
||||
for note in ¬es {
|
||||
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
|
||||
if u32::try_from(note.len()).is_ok() {
|
||||
out.write_all(note.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
|
||||
out.write_all(&bytes)?;
|
||||
}
|
||||
Err(e) => {
|
||||
out.write_all(&[1])?;
|
||||
out.write_all(e.as_bytes())?;
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
/// What a request asks for, or why it can't be served.
|
||||
fn handle(mut b: &[u8]) -> Reply {
|
||||
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
|
||||
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
|
||||
return Err(bad());
|
||||
}
|
||||
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
|
||||
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
match op {
|
||||
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
|
||||
ENCODE => {
|
||||
// The version the DXF's carried-over groups were read from; empty for none.
|
||||
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
|
||||
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
|
||||
encode_here(b, version, (!kept.is_empty()).then_some(kept))
|
||||
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
|
||||
}
|
||||
CRASH => std::process::abort(),
|
||||
_ => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
|
||||
fn serve(input: impl Read, mut output: impl Write) -> i32 {
|
||||
let mut request = Vec::new();
|
||||
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
|
||||
Ok(_) => handle(&request),
|
||||
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
|
||||
};
|
||||
i32::from(write_reply(&mut output, reply).is_err())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wire(reply: Reply) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
write_reply(&mut out, reply).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_writer_left_out_survives_the_reply() {
|
||||
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
|
||||
assert_eq!(unwritten_from(¬es), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_round_trip() {
|
||||
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
|
||||
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
|
||||
let failed: Reply = Err("DWG: no".into());
|
||||
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_cut_short_or_made_up_is_refused() {
|
||||
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
|
||||
for n in 0..whole.len() {
|
||||
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
|
||||
}
|
||||
assert_eq!(parse_reply(vec![7, 0, 0]), None);
|
||||
// A note count far beyond what follows ends at the first missing note.
|
||||
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
|
||||
for request in [
|
||||
&[][..],
|
||||
&[PROTOCOL + 1, DECODE],
|
||||
&[PROTOCOL],
|
||||
&[PROTOCOL, DECODE, 1, 2, 3],
|
||||
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
] {
|
||||
assert!(handle(request).is_err(), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An encode request carries the kept version after the target one; a request without it is
|
||||
/// one it can't read, and one with it reaches the converter.
|
||||
#[test]
|
||||
fn an_encode_request_carries_the_kept_version() {
|
||||
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
|
||||
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
assert_eq!(handle(&short).unwrap_err(), bad);
|
||||
for kept in ["", "AC1032"] {
|
||||
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
|
||||
push_str(&mut request, kept);
|
||||
request.extend(b"not a drawing");
|
||||
let err = handle(&request).unwrap_err();
|
||||
assert_ne!(err, bad, "kept {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_reports_a_decode_failure_as_a_reply() {
|
||||
let mut request = self::request(DECODE, Limits::DEFAULT, "");
|
||||
request.extend(b"AC1032 not really a drawing");
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(serve(&request[..], &mut out), 0);
|
||||
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "cadcraft-dxf"
|
||||
description = "Standalone DXF tag reader/writer (ASCII and binary, pre-2007 code pages), from the published DXF reference"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Text decoding for DXF files.
|
||||
//!
|
||||
//! R2007 (AC1021) and later DXF files are UTF-8. Older ones store text in the code page named by
|
||||
//! the `$DWGCODEPAGE` header variable (`ANSI_1252`, `ANSI_932`, `DOS850`, …); characters outside
|
||||
//! it are written as `\U+XXXX` (any character) or `\M+nXXXX` (a double-byte character of an East
|
||||
//! Asian code page) escapes, which [`crate::parse`] decodes after the bytes.
|
||||
//!
|
||||
//! The Windows, ISO and East Asian code pages are decoded with `encoding_rs`; DOS 437, 850 and
|
||||
//! 852 with the tables below (the upper halves of the IBM PC code pages; the lower halves are
|
||||
//! ASCII). Decoding never fails: a byte sequence that is invalid in the code page (a truncated
|
||||
//! double-byte character, an unassigned byte) becomes U+FFFD and the bytes after it decode
|
||||
//! normally, so line ends and the group structure survive.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
|
||||
/// How the bytes of a code page become text.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Decoder {
|
||||
Utf8,
|
||||
Encoding(&'static Encoding),
|
||||
/// Single-byte DOS code page: ASCII, then these 128 characters for bytes 0x80–0xFF.
|
||||
Table(&'static [char; 128]),
|
||||
}
|
||||
|
||||
/// The decoder for a `$DWGCODEPAGE` value (case-insensitive), or `None` when CADCraft doesn't
|
||||
/// know the code page.
|
||||
pub(crate) fn decoder(code_page: &str) -> Option<Decoder> {
|
||||
use encoding_rs as e;
|
||||
let name = code_page.trim().to_ascii_uppercase().replace(['-', ' '], "_");
|
||||
let enc = match name.as_str() {
|
||||
"UTF8" | "UTF_8" | "UNICODE" => return Some(Decoder::Utf8),
|
||||
"ASCII" | "ANSI_1252" | "ISO8859_1" | "ISO_8859_1" => e::WINDOWS_1252,
|
||||
"ANSI_874" => e::WINDOWS_874,
|
||||
"ANSI_932" | "DOS932" | "SHIFT_JIS" => e::SHIFT_JIS,
|
||||
"ANSI_936" | "GB2312" | "GBK" => e::GBK,
|
||||
"ANSI_949" | "KSC5601" | "KOREAN" => e::EUC_KR,
|
||||
"ANSI_950" | "BIG5" => e::BIG5,
|
||||
"ANSI_1250" => e::WINDOWS_1250,
|
||||
"ANSI_1251" => e::WINDOWS_1251,
|
||||
"ANSI_1253" => e::WINDOWS_1253,
|
||||
"ANSI_1254" | "ISO8859_9" | "ISO_8859_9" => e::WINDOWS_1254,
|
||||
"ANSI_1255" => e::WINDOWS_1255,
|
||||
"ANSI_1256" => e::WINDOWS_1256,
|
||||
"ANSI_1257" => e::WINDOWS_1257,
|
||||
"ANSI_1258" => e::WINDOWS_1258,
|
||||
"ISO8859_2" | "ISO_8859_2" => e::ISO_8859_2,
|
||||
"ISO8859_3" | "ISO_8859_3" => e::ISO_8859_3,
|
||||
"ISO8859_4" | "ISO_8859_4" => e::ISO_8859_4,
|
||||
"ISO8859_5" | "ISO_8859_5" => e::ISO_8859_5,
|
||||
"ISO8859_6" | "ISO_8859_6" => e::ISO_8859_6,
|
||||
"ISO8859_7" | "ISO_8859_7" => e::ISO_8859_7,
|
||||
"ISO8859_8" | "ISO_8859_8" => e::ISO_8859_8,
|
||||
"DOS866" => e::IBM866,
|
||||
"MACINTOSH" | "MAC_ROMAN" => e::MACINTOSH,
|
||||
"DOS437" => return Some(Decoder::Table(&DOS437)),
|
||||
"DOS850" => return Some(Decoder::Table(&DOS850)),
|
||||
"DOS852" => return Some(Decoder::Table(&DOS852)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Decoder::Encoding(enc))
|
||||
}
|
||||
|
||||
/// Decode `bytes` from the code page `code_page` names (a `$DWGCODEPAGE` value such as
|
||||
/// `ANSI_1251`), or `None` when CADCraft doesn't know that code page. Invalid sequences become
|
||||
/// U+FFFD; escapes (`\U+XXXX`, `\M+nXXXX`) are left as they are.
|
||||
pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
|
||||
decoder(code_page).map(|d| decode(bytes, d).into_owned())
|
||||
}
|
||||
|
||||
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
|
||||
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
|
||||
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
|
||||
/// reader expects.
|
||||
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
|
||||
if code_page.trim().eq_ignore_ascii_case("ASCII") {
|
||||
return None;
|
||||
}
|
||||
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
|
||||
}
|
||||
|
||||
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
|
||||
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
|
||||
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
|
||||
let mut buf = [0; 4];
|
||||
match d {
|
||||
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
|
||||
Decoder::Utf8 => return false,
|
||||
Decoder::Encoding(e) => {
|
||||
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
|
||||
// A code page byte that is ASCII would read back as another character, and a NUL,
|
||||
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
|
||||
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
|
||||
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
|
||||
return false;
|
||||
}
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
Decoder::Table(t) => {
|
||||
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
|
||||
out.push(0x80 | i);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
|
||||
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match d {
|
||||
Decoder::Utf8 => match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => encoding_rs::WINDOWS_1252.decode_without_bom_handling(bytes).0,
|
||||
},
|
||||
Decoder::Encoding(e) => e.decode_without_bom_handling(bytes).0,
|
||||
Decoder::Table(t) => match std::str::from_utf8(bytes) {
|
||||
Ok(s) if s.is_ascii() => Cow::Borrowed(s),
|
||||
_ => Cow::Owned(
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| if b.is_ascii() { char::from(b) } else { t.get(usize::from(b & 0x7F)).copied().unwrap_or('\u{FFFD}') })
|
||||
.collect(),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$DWGCODEPAGE` value of an ASCII DXF file: the line two lines after a `$DWGCODEPAGE`
|
||||
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
|
||||
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
|
||||
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
|
||||
while let Some(line) = lines.next() {
|
||||
if line == b"ENDSEC" {
|
||||
return None;
|
||||
}
|
||||
if line.eq_ignore_ascii_case(b"$DWGCODEPAGE") {
|
||||
return match (lines.next(), lines.next()) {
|
||||
(Some(b"3"), Some(value)) => Some(value),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
|
||||
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
|
||||
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
|
||||
fn is_east_asian(self) -> bool {
|
||||
use encoding_rs as e;
|
||||
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
|
||||
/// text, and older files from writers that ignored the code page); anything else comes from the
|
||||
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
|
||||
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
|
||||
/// from the code page (see [`BinaryText::string`]).
|
||||
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => decode(bytes, d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
|
||||
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
|
||||
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
|
||||
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
|
||||
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
|
||||
/// that code page, because a short double-byte string (two half-width katakana, many kanji
|
||||
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
|
||||
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let d = match ascii_code_page(bytes) {
|
||||
Some(name) => {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
decoder(&name).unwrap_or_else(|| {
|
||||
log::warn!("DXF: unknown code page `{name}`; reading the text as ANSI_1252");
|
||||
Decoder::Utf8
|
||||
})
|
||||
}
|
||||
None => Decoder::Utf8,
|
||||
};
|
||||
if d.is_east_asian() {
|
||||
return decode(bytes, d);
|
||||
}
|
||||
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
|
||||
// a multi-byte character of the supported code pages) and decode each line on its own.
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
|
||||
out.push_str(&decode_text(line, d));
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
|
||||
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
|
||||
/// first in the HEADER section and are ASCII, so every later string is decoded right.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BinaryText {
|
||||
/// The header variable whose value comes next (after a group 9).
|
||||
var: Option<HeaderVar>,
|
||||
utf8: bool,
|
||||
code_page: Option<Decoder>,
|
||||
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
|
||||
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
|
||||
code_page_only: bool,
|
||||
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
|
||||
ea_utf8: bool,
|
||||
ea_other: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum HeaderVar {
|
||||
AcadVer,
|
||||
CodePage,
|
||||
}
|
||||
|
||||
impl BinaryText {
|
||||
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
|
||||
/// not: every string of an East Asian code page is decoded from that code page.
|
||||
pub(crate) fn code_page_pass() -> Self {
|
||||
Self { code_page_only: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
|
||||
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
|
||||
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
|
||||
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
|
||||
pub(crate) fn needs_code_page_pass(&self) -> bool {
|
||||
!self.code_page_only && self.ea_utf8 && self.ea_other
|
||||
}
|
||||
|
||||
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
|
||||
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
|
||||
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
|
||||
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
|
||||
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
|
||||
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
|
||||
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
|
||||
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
|
||||
let s = if d.is_east_asian() && !self.code_page_only {
|
||||
if !raw.is_ascii() {
|
||||
if std::str::from_utf8(raw).is_ok() {
|
||||
self.ea_utf8 = true;
|
||||
} else {
|
||||
self.ea_other = true;
|
||||
}
|
||||
}
|
||||
decode_text(raw, d).into_owned()
|
||||
} else {
|
||||
decode(raw, d).into_owned()
|
||||
};
|
||||
match (code, self.var.take()) {
|
||||
(9, _) => {
|
||||
self.var = match s.trim() {
|
||||
"$ACADVER" => Some(HeaderVar::AcadVer),
|
||||
"$DWGCODEPAGE" => Some(HeaderVar::CodePage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(1, Some(HeaderVar::AcadVer)) => self.utf8 = is_utf8_version(&s),
|
||||
(3, Some(HeaderVar::CodePage)) => {
|
||||
self.code_page = decoder(&s);
|
||||
if self.code_page.is_none() {
|
||||
log::warn!("DXF: unknown code page `{}`; reading the text as ANSI_1252", s.trim());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an `$ACADVER` of R2007 (AC1021) or later, whose text is UTF-8.
|
||||
fn is_utf8_version(acadver: &str) -> bool {
|
||||
acadver.trim().strip_prefix("AC").and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1021)
|
||||
}
|
||||
|
||||
/// The double-byte code page of a `\M+n` escape: 1 Japanese (Shift-JIS), 2 Traditional Chinese
|
||||
/// (Big5), 3 Korean (Wansung), 5 Simplified Chinese (GB 2312). 4 (Korean Johab) is not
|
||||
/// supported, so its escapes stay literal.
|
||||
pub(crate) fn mif_encoding(n: char) -> Option<&'static Encoding> {
|
||||
match n {
|
||||
'1' => Some(encoding_rs::SHIFT_JIS),
|
||||
'2' => Some(encoding_rs::BIG5),
|
||||
'3' => Some(encoding_rs::EUC_KR),
|
||||
'5' => Some(encoding_rs::GBK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Generated from the IBM PC code page definitions (bytes 0x80–0xFF in order).
|
||||
const DOS437: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{255C}', '\u{255B}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}', '\u{255F}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}',
|
||||
'\u{2558}', '\u{2552}', '\u{2553}', '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}', '\u{2580}',
|
||||
'\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}', '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}',
|
||||
'\u{221E}', '\u{03C6}', '\u{03B5}', '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}', '\u{2248}',
|
||||
'\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS850: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00F8}', '\u{00A3}', '\u{00D8}', '\u{00D7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{00AE}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{00C0}', '\u{00A9}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{00A2}', '\u{00A5}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{00E3}', '\u{00C3}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{00F0}', '\u{00D0}', '\u{00CA}', '\u{00CB}',
|
||||
'\u{00C8}', '\u{0131}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{00A6}', '\u{00CC}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{00D2}', '\u{00F5}', '\u{00D5}', '\u{00B5}', '\u{00FE}', '\u{00DE}', '\u{00DA}', '\u{00DB}', '\u{00D9}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{00AF}', '\u{00B4}', '\u{00AD}', '\u{00B1}', '\u{2017}', '\u{00BE}', '\u{00B6}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{00B7}', '\u{00B9}', '\u{00B3}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS852: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{016F}', '\u{0107}', '\u{00E7}', '\u{0142}', '\u{00EB}', '\u{0150}', '\u{0151}',
|
||||
'\u{00EE}', '\u{0179}', '\u{00C4}', '\u{0106}', '\u{00C9}', '\u{0139}', '\u{013A}', '\u{00F4}', '\u{00F6}', '\u{013D}', '\u{013E}', '\u{015A}',
|
||||
'\u{015B}', '\u{00D6}', '\u{00DC}', '\u{0164}', '\u{0165}', '\u{0141}', '\u{00D7}', '\u{010D}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{0104}', '\u{0105}', '\u{017D}', '\u{017E}', '\u{0118}', '\u{0119}', '\u{00AC}', '\u{017A}', '\u{010C}', '\u{015F}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{011A}', '\u{015E}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{017B}', '\u{017C}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{0102}', '\u{0103}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{0111}', '\u{0110}', '\u{010E}', '\u{00CB}',
|
||||
'\u{010F}', '\u{0147}', '\u{00CD}', '\u{00CE}', '\u{011B}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{0162}', '\u{016E}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{0143}', '\u{0144}', '\u{0148}', '\u{0160}', '\u{0161}', '\u{0154}', '\u{00DA}', '\u{0155}', '\u{0170}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{0163}', '\u{00B4}', '\u{00AD}', '\u{02DD}', '\u{02DB}', '\u{02C7}', '\u{02D8}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{02D9}', '\u{0171}', '\u{0158}', '\u{0159}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_page_names() {
|
||||
let d = |bytes: &[u8], cp: &str| decode_code_page(bytes, cp);
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", "ANSI_1251").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", " ansi_1251 ").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\x90\x7D\x96\xCA", "ANSI_932").as_deref(), Some("図面"));
|
||||
assert_eq!(d(b"\xCD\xBC\xB2\xE3", "GB2312").as_deref(), Some("图层"));
|
||||
assert_eq!(d(b"\xB9\xCF\xBC\x68", "BIG5").as_deref(), Some("圖層"));
|
||||
assert_eq!(d(b"\xB5\xB5\xB8\xE9", "ANSI_949").as_deref(), Some("도면"));
|
||||
assert_eq!(d(b"\xD3\xF7\xDD\xE4\xE9\xEF", "ANSI_1253").as_deref(), Some("Σχέδιο"));
|
||||
assert_eq!(d(b"\xC0\xD2\xBE", "ANSI_874").as_deref(), Some("ภาพ"));
|
||||
assert_eq!(d(b"\x80", "ANSI_1252").as_deref(), Some("€"));
|
||||
// DOS 437 and 850 agree on letters but differ on 0x9B (¢ / ø).
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "DOS437").as_deref(), Some("Größe ¢"));
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "dos850").as_deref(), Some("Größe ø"));
|
||||
assert_eq!(d(b"\xBD\xA2\x88w", "DOS852").as_deref(), Some("Żółw"));
|
||||
assert_eq!(d(b"\x91\xAB\xAE\xA9", "DOS866").as_deref(), Some("Слой"));
|
||||
assert_eq!(d("é".as_bytes(), "UTF8").as_deref(), Some("é"));
|
||||
assert_eq!(d(b"x", "ANSI_1361"), None);
|
||||
assert_eq!(d(b"x", "EBCDIC"), None);
|
||||
assert_eq!(d(b"x", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sequences_become_replacement_characters() {
|
||||
// A Shift-JIS lead byte cut off by the line end keeps the line end.
|
||||
assert_eq!(decode_code_page(b"\x90\n1\n", "ANSI_932").as_deref(), Some("\u{FFFD}\n1\n"));
|
||||
assert_eq!(decode_code_page(b"\x90", "ANSI_932").as_deref(), Some("\u{FFFD}"));
|
||||
assert_eq!(decode_code_page(b"\xFF\xFF", "ANSI_936").as_deref(), Some("\u{FFFD}\u{FFFD}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_scan() {
|
||||
let head = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\r\n0\nENDSEC\n";
|
||||
assert_eq!(ascii_code_page(head), Some(&b"ANSI_1251"[..]));
|
||||
// Only the HEADER section counts.
|
||||
assert_eq!(ascii_code_page(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n9\n$DWGCODEPAGE\n3\nANSI_1251\n"), None);
|
||||
// Wrong group code, or the file ends early.
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n1\nANSI_1251\n"), None);
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n3"), None);
|
||||
assert!(is_utf8_version("AC1021") && is_utf8_version(" AC1032 "));
|
||||
assert!(!is_utf8_version("AC1018") && !is_utf8_version("AC1009") && !is_utf8_version("garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mif_escapes() {
|
||||
let one = |s: &str| crate::parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap()[0].str();
|
||||
assert_eq!(one("\\M+5BCFE\\M+1907D\\M+2B9CF\\M+3B5B5!"), "件図圖도!");
|
||||
assert_eq!(one("\\M+5BCFE\\U+0021"), "件!");
|
||||
// Johab, unknown code pages, bad hex, short escapes and unassigned codes stay literal.
|
||||
for s in ["\\M+4ABCD", "\\M+9BCFE", "\\M+5GGGG", "\\M+5BC", "\\M+", "\\M+50041", "\\M+5FFFF"] {
|
||||
assert_eq!(one(s), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
//! A standalone DXF tag reader and writer.
|
||||
//!
|
||||
//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
|
||||
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
|
||||
//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
|
||||
//! Reference; depends on nothing else in the workspace.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
mod version;
|
||||
pub use version::Version;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DxfError {
|
||||
#[error("not a DXF file")]
|
||||
NotDxf,
|
||||
#[error("line {line}: bad group code `{text}`")]
|
||||
BadCode { line: usize, text: String },
|
||||
#[error("unexpected end of file")]
|
||||
Eof,
|
||||
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
|
||||
/// limit was reached), `limit` the maximum.
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
|
||||
/// A typed group value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Real(f64),
|
||||
Int(i64),
|
||||
Bool(bool),
|
||||
/// Binary chunk (310–319, 1004) as hex text.
|
||||
Hex(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
Value::Str(s) | Value::Hex(s) => s.clone(),
|
||||
Value::Real(r) => format_real(*r),
|
||||
Value::Int(i) => i.to_string(),
|
||||
Value::Bool(b) => i64::from(*b).to_string(),
|
||||
}
|
||||
}
|
||||
pub fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Value::Real(r) if r.is_finite() => *r,
|
||||
Value::Int(i) => *i as f64,
|
||||
Value::Str(s) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
Value::Bool(b) => f64::from(u8::from(*b)),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
match self {
|
||||
Value::Int(i) => *i,
|
||||
Value::Real(r) if r.is_finite() => *r as i64,
|
||||
Value::Str(s) => {
|
||||
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
|
||||
}
|
||||
Value::Bool(b) => i64::from(*b),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub code: i32,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn new(code: i32, value: Value) -> Self {
|
||||
Tag { code, value }
|
||||
}
|
||||
pub fn s(code: i32, v: impl Into<String>) -> Self {
|
||||
Tag { code, value: Value::Str(v.into()) }
|
||||
}
|
||||
pub fn f(code: i32, v: f64) -> Self {
|
||||
Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) }
|
||||
}
|
||||
pub fn i(code: i32, v: i64) -> Self {
|
||||
Tag { code, value: Value::Int(v) }
|
||||
}
|
||||
pub fn str(&self) -> String {
|
||||
self.value.as_str()
|
||||
}
|
||||
pub fn f64(&self) -> f64 {
|
||||
self.value.as_f64()
|
||||
}
|
||||
pub fn i64(&self) -> i64 {
|
||||
self.value.as_i64()
|
||||
}
|
||||
}
|
||||
|
||||
/// The value type for a group code (DXF Reference, "Group Code Value Types").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Str,
|
||||
Real,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
Bool,
|
||||
Hex,
|
||||
}
|
||||
|
||||
pub fn kind_of(code: i32) -> Kind {
|
||||
match code {
|
||||
0..=9 => Kind::Str,
|
||||
10..=59 => Kind::Real,
|
||||
60..=79 => Kind::Int16,
|
||||
90..=99 => Kind::Int32,
|
||||
100..=109 => Kind::Str,
|
||||
110..=149 => Kind::Real,
|
||||
160..=169 => Kind::Int64,
|
||||
170..=179 => Kind::Int16,
|
||||
210..=239 => Kind::Real,
|
||||
270..=289 => Kind::Int16,
|
||||
290..=299 => Kind::Bool,
|
||||
300..=309 => Kind::Str,
|
||||
310..=319 => Kind::Hex,
|
||||
320..=369 => Kind::Str,
|
||||
370..=389 => Kind::Int16,
|
||||
390..=399 => Kind::Str,
|
||||
400..=409 => Kind::Int16,
|
||||
410..=419 => Kind::Str,
|
||||
420..=429 => Kind::Int32,
|
||||
430..=439 => Kind::Str,
|
||||
440..=449 => Kind::Int32,
|
||||
450..=459 => Kind::Int32,
|
||||
460..=469 => Kind::Real,
|
||||
470..=479 => Kind::Str,
|
||||
480..=481 => Kind::Str,
|
||||
999 => Kind::Str,
|
||||
1000..=1003 => Kind::Str,
|
||||
1004 => Kind::Hex,
|
||||
1005..=1009 => Kind::Str,
|
||||
1010..=1059 => Kind::Real,
|
||||
1060..=1070 => Kind::Int16,
|
||||
1071 => Kind::Int32,
|
||||
_ => Kind::Str,
|
||||
}
|
||||
}
|
||||
|
||||
fn typed(code: i32, text: &str) -> Value {
|
||||
let t = text.trim();
|
||||
match kind_of(code) {
|
||||
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
|
||||
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
|
||||
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
|
||||
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
|
||||
// the integer spelling reads them); NaN, inf and text become 0.
|
||||
let (lo, hi) = match kind_of(code) {
|
||||
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
|
||||
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
|
||||
_ => (i64::MIN, i64::MAX),
|
||||
};
|
||||
t.parse::<i64>()
|
||||
.map(Value::Int)
|
||||
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
|
||||
}
|
||||
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
|
||||
Kind::Hex => Value::Hex(normalize_hex(t)),
|
||||
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, max_tags())
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in
|
||||
/// milliseconds instead of after it has filled memory.
|
||||
pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
|
||||
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
|
||||
// EOF just as the parser does. Blank lines do not count toward the limit.
|
||||
let mut lines = byte_lines(bytes);
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'U') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let hex = chars.get(i + 3..i + 7)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
hex.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
|
||||
/// double-byte code page and `XXXX` is the character's two bytes in hex.
|
||||
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let enc = codepage::mif_encoding(*chars.get(i + 3)?)?;
|
||||
let hex = chars.get(i + 4..i + 8)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
let code = hex.iter().try_fold(0u16, |acc, c| Some(acc * 16 + u16::try_from(c.to_digit(16)?).ok()?))?;
|
||||
let bytes = code.to_be_bytes();
|
||||
let text = enc.decode_without_bom_handling_and_without_replacement(&bytes)?;
|
||||
let mut it = text.chars();
|
||||
it.next().filter(|_| it.next().is_none())
|
||||
}
|
||||
|
||||
/// Unescape the `\U+XXXX` sequences pre-R2007 DXF uses for characters outside the file's code
|
||||
/// page (four hex digits, one UTF-16 code unit; characters above U+FFFF are a surrogate pair of
|
||||
/// escapes), and the `\M+nXXXX` sequences for double-byte East Asian characters. Malformed
|
||||
/// escapes, unpaired surrogates and unknown `\M+` code pages are kept as literal text.
|
||||
fn unescape_unicode(s: &str) -> String {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if let Some(ch) = mif_escape_at(&chars, i) {
|
||||
out.push(ch);
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
if let Some(unit) = escape_at(&chars, i) {
|
||||
if let Some(ch) = char::from_u32(unit) {
|
||||
out.push(ch);
|
||||
i += 7;
|
||||
continue;
|
||||
}
|
||||
if (0xD800..0xDC00).contains(&unit)
|
||||
&& let Some(low) = escape_at(&chars, i + 7).filter(|u| (0xDC00..0xE000).contains(u))
|
||||
&& let Some(ch) = char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00))
|
||||
{
|
||||
out.push(ch);
|
||||
i += 14;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
|
||||
/// [`str::lines`] otherwise (no empty line after a final terminator).
|
||||
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
|
||||
let mut rest = text;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
|
||||
let line = rest.get(..end).unwrap_or_default();
|
||||
let after = rest.get(end..).unwrap_or_default();
|
||||
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
|
||||
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
|
||||
let line = &rest[..end];
|
||||
let after = &rest[end..];
|
||||
rest = match after {
|
||||
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
|
||||
_ => after,
|
||||
};
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
}
|
||||
|
||||
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
|
||||
/// with an allocation that can fail: where the system refuses that much memory, opening the file
|
||||
/// is an error instead of the end of the app. Growing the list instead could also briefly take
|
||||
/// half as much again.
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
if ct.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?;
|
||||
let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) };
|
||||
let v = typed(code, val);
|
||||
let v = match v {
|
||||
Value::Str(s) => Value::Str(unescape_unicode(&s)),
|
||||
o => o,
|
||||
};
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
struct Cur<'a> {
|
||||
b: &'a [u8],
|
||||
p: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&[u8]> {
|
||||
let end = self.p.checked_add(n).ok_or(DxfError::Eof)?;
|
||||
let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?;
|
||||
self.p = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8> {
|
||||
Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?)
|
||||
}
|
||||
fn i16(&mut self) -> Result<i16> {
|
||||
let s = self.take(2)?;
|
||||
Ok(i16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
fn i32(&mut self) -> Result<i32> {
|
||||
let s = self.take(4)?;
|
||||
Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(i64::from_le_bytes(a))
|
||||
}
|
||||
fn f64(&mut self) -> Result<f64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(f64::from_le_bytes(a))
|
||||
}
|
||||
/// The bytes of a zero-terminated string (without the zero), decoded by the caller.
|
||||
fn cstr(&mut self) -> Result<&'a [u8]> {
|
||||
let start = self.p;
|
||||
while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
|
||||
self.p += 1;
|
||||
}
|
||||
let s = self.b.get(start..self.p).unwrap_or(&[]);
|
||||
if self.p >= self.b.len() {
|
||||
return Err(DxfError::Eof);
|
||||
}
|
||||
self.p += 1;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut text = codepage::BinaryText::default();
|
||||
let tags = parse_binary_pass(b, max_tags, &mut text)?;
|
||||
if !text.needs_code_page_pass() {
|
||||
return Ok(tags);
|
||||
}
|
||||
// The first pass's tags go before the second pass runs: both held at once would double
|
||||
// the peak near the tag limit.
|
||||
drop(tags);
|
||||
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
|
||||
}
|
||||
|
||||
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
|
||||
let mut c = Cur { b, p: 0 };
|
||||
let mut tags = Vec::new();
|
||||
while c.p < b.len() {
|
||||
let mut code = i32::from(c.i16()?);
|
||||
if code == 255 {
|
||||
code = i32::from(c.i16()?);
|
||||
}
|
||||
let v = match kind_of(code) {
|
||||
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
|
||||
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
|
||||
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
|
||||
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
|
||||
Kind::Int64 => Value::Int(c.i64()?),
|
||||
Kind::Bool => Value::Bool(c.u8()? != 0),
|
||||
Kind::Hex => {
|
||||
let n = usize::from(c.u8()?);
|
||||
let bytes = c.take(n)?;
|
||||
let mut s = String::with_capacity(n * 2);
|
||||
for x in bytes {
|
||||
let _ = write!(s, "{x:02X}");
|
||||
}
|
||||
Value::Hex(s)
|
||||
}
|
||||
};
|
||||
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point).
|
||||
pub fn format_real(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0.0".into();
|
||||
}
|
||||
let s = format!("{v}");
|
||||
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") }
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional).
|
||||
///
|
||||
/// The output is pure ASCII: non-ASCII characters in strings are written as `\U+XXXX` escapes,
|
||||
/// the pre-R2007 convention, so it is correct whatever `$DWGCODEPAGE` the header declares.
|
||||
pub fn write_ascii(tags: &[Tag]) -> String {
|
||||
write_ascii_as(tags, false)
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF with text in UTF-8 (`utf8`, the R2007+ convention: non-ASCII
|
||||
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
|
||||
/// breaks inside strings become spaces either way.
|
||||
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
|
||||
// Both forms are UTF-8 (the escaped one is ASCII).
|
||||
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
|
||||
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
|
||||
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
|
||||
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
|
||||
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
|
||||
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
|
||||
/// DXF that is valid UTF-8 as UTF-8.
|
||||
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
|
||||
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
|
||||
return ascii(tags, Strings::Escaped(None));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// How a writer stores string values.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Strings {
|
||||
/// As they are (R2007+).
|
||||
Utf8,
|
||||
/// ASCII as it is, other characters in this code page when it has them, otherwise as
|
||||
/// `\U+XXXX` escapes (before R2007).
|
||||
Escaped(Option<codepage::Decoder>),
|
||||
}
|
||||
|
||||
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(tags.len() * 16);
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) => push_string(s, strings, &mut out),
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
|
||||
Value::Hex(h) => {
|
||||
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
|
||||
// longer data continues in further groups of the same code. Only hex digits are
|
||||
// written, so a hostile value can't inject tags.
|
||||
let digits = normalize_hex(h);
|
||||
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push("");
|
||||
}
|
||||
for (n, chunk) in chunks.into_iter().enumerate() {
|
||||
if n > 0 {
|
||||
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
|
||||
}
|
||||
out.extend_from_slice(chunk.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel,
|
||||
/// then per tag a 2-byte little-endian group code and the value in the binary form of the code's
|
||||
/// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte
|
||||
/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into
|
||||
/// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as
|
||||
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
|
||||
/// is stored as a number; a code outside the 16-bit range is skipped.
|
||||
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
|
||||
/// [`write_ascii_in`] does.
|
||||
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
|
||||
}
|
||||
|
||||
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
|
||||
out.extend_from_slice(BINARY_SENTINEL);
|
||||
for t in tags {
|
||||
let Ok(code) = i16::try_from(t.code) else { continue };
|
||||
let code_bytes = code.to_le_bytes();
|
||||
match kind_of(t.code) {
|
||||
Kind::Str => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
let s = match &t.value {
|
||||
Value::Str(s) => s.clone(),
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = Vec::with_capacity(s.len());
|
||||
push_string(&s, strings, &mut text);
|
||||
out.extend(text.into_iter().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
Kind::Real => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_f64().to_le_bytes());
|
||||
}
|
||||
Kind::Int16 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int32 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int64 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_i64().to_le_bytes());
|
||||
}
|
||||
Kind::Bool => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::from(t.value.as_i64() != 0));
|
||||
}
|
||||
Kind::Hex => {
|
||||
let bytes = hex_bytes(&t.value.as_str());
|
||||
// An empty chunk is still one group, as in ASCII.
|
||||
let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push(&[]);
|
||||
}
|
||||
for chunk in chunks {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::try_from(chunk.len()).unwrap_or(u8::MAX));
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
|
||||
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
|
||||
fn normalize_hex(s: &str) -> String {
|
||||
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push('0');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
|
||||
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
|
||||
}
|
||||
|
||||
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
|
||||
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
|
||||
fn wrap_i16(v: i64) -> [u8; 2] {
|
||||
match u16::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_i32(v: i64) -> [u8; 4] {
|
||||
match u32::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
|
||||
pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(BINARY_SENTINEL)
|
||||
}
|
||||
|
||||
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
|
||||
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
|
||||
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
|
||||
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
|
||||
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
|
||||
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
|
||||
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
|
||||
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
|
||||
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match (c, strings) {
|
||||
('\r' | '\n', _) => out.push(b' '),
|
||||
('\\', _) if escape_at(i + 1) => {
|
||||
out.extend_from_slice(b"\\U+005C");
|
||||
}
|
||||
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
|
||||
(c, Strings::Escaped(_)) => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
let _ = write!(out, "\\U+{u:04X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
|
||||
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
|
||||
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
|
||||
let start = out.len();
|
||||
if !codepage::encode_char(c, d, out) {
|
||||
return false;
|
||||
}
|
||||
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
|
||||
out.truncate(start);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
pub name: String,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// Split a tag stream into sections.
|
||||
pub fn sections(tags: &[Tag]) -> Vec<Section> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tags.len() {
|
||||
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
|
||||
if is_sec {
|
||||
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
|
||||
j += 1;
|
||||
}
|
||||
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0).
|
||||
pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
let mut out: Vec<(String, Vec<Tag>)> = Vec::new();
|
||||
for t in tags {
|
||||
if t.code == 0 {
|
||||
out.push((t.str(), Vec::new()));
|
||||
} else if let Some(last) = out.last_mut() {
|
||||
last.1.push(t.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_do_not_consume_group_code_limit() {
|
||||
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
|
||||
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "LINE"),
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(10, 1.5),
|
||||
Tag::f(20, 2.0),
|
||||
Tag::i(62, 1),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let text = write_ascii(&tags);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back, tags);
|
||||
let secs = sections(&back);
|
||||
assert_eq!(secs.len(), 1);
|
||||
assert_eq!(secs[0].name, "ENTITIES");
|
||||
let recs = records(&secs[0].tags);
|
||||
assert_eq!(recs[0].0, "LINE");
|
||||
assert_eq!(recs[0].1.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_parse() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
let push_str = |b: &mut Vec<u8>, code: i16, s: &str| {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s.as_bytes());
|
||||
b.push(0);
|
||||
};
|
||||
push_str(&mut b, 0, "LINE");
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&3.25f64.to_le_bytes());
|
||||
b.extend_from_slice(&62i16.to_le_bytes());
|
||||
b.extend_from_slice(&5i16.to_le_bytes());
|
||||
push_str(&mut b, 0, "EOF");
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[1], Tag::f(10, 3.25));
|
||||
assert_eq!(tags[2], Tag::i(62, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse(b"abc\nxyz\n").is_err());
|
||||
assert!(parse(b"0\n").is_err());
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&[1, 2, 3]);
|
||||
assert!(parse(&b).is_err());
|
||||
// Bad numbers become 0 rather than failing.
|
||||
let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].f64(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_and_latin1() {
|
||||
let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrogate_pair_escapes() {
|
||||
let t = parse(b"1\n\\U+D83D\\U+DE00 \\u+56fe\\U+53f7\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "😀 \\u+56fe号");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_escapes_stay_literal() {
|
||||
for s in [
|
||||
"\\U+",
|
||||
"\\U",
|
||||
"\\",
|
||||
"a\\U+ZZZZ",
|
||||
"\\U+12",
|
||||
"\\U+D800",
|
||||
"\\U+DC00",
|
||||
"\\U+D800\\U+0041",
|
||||
"\\U+D83D\\U+DE",
|
||||
"\\U++123",
|
||||
"\\U+-123",
|
||||
"x\\U+12\u{e9}4",
|
||||
] {
|
||||
let t = parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap();
|
||||
let expect = if s == "\\U+D800\\U+0041" { "\\U+D800A" } else { s };
|
||||
assert_eq!(t[0].str(), expect, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_non_ascii_as_escapes() {
|
||||
let tags = vec![Tag::s(1, "图号 İø 😀\r\nend"), Tag::s(1000, "Ø"), Tag::s(0, "EOF")];
|
||||
let text = write_ascii(&tags);
|
||||
assert!(text.is_ascii());
|
||||
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+D83D\\U+DE00 end\r\n"));
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back[0].str(), "图号 İø 😀 end");
|
||||
assert_eq!(back[1].str(), "Ø");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_strings_unescape() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"\\U+56FE\\U+53F7\0");
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_limit_defaults_and_env_override() {
|
||||
assert_eq!(max_tags_from(None), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
|
||||
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
|
||||
const { assert!(MAX_TAGS >= 100_000_000) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
|
||||
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
|
||||
assert_eq!((tags.len(), tags.capacity()), (3, 3));
|
||||
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`).
|
||||
|
||||
/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format
|
||||
/// also serves the releases after it (2018 is still AutoCAD's current format).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Version {
|
||||
/// AutoCAD 2000/2002 (AC1015).
|
||||
R2000,
|
||||
/// AutoCAD 2004–2006 (AC1018).
|
||||
R2004,
|
||||
/// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8.
|
||||
R2007,
|
||||
/// AutoCAD 2010–2012 (AC1024).
|
||||
R2010,
|
||||
/// AutoCAD 2013–2017 (AC1027).
|
||||
R2013,
|
||||
/// AutoCAD 2018 and later (AC1032), the default.
|
||||
#[default]
|
||||
R2018,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// Every version, oldest first.
|
||||
pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018];
|
||||
|
||||
/// The `$ACADVER` code ("AC1032").
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "AC1015",
|
||||
Version::R2004 => "AC1018",
|
||||
Version::R2007 => "AC1021",
|
||||
Version::R2010 => "AC1024",
|
||||
Version::R2013 => "AC1027",
|
||||
Version::R2018 => "AC1032",
|
||||
}
|
||||
}
|
||||
|
||||
/// The release year that names the version ("2018").
|
||||
pub fn year(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "2000",
|
||||
Version::R2004 => "2004",
|
||||
Version::R2007 => "2007",
|
||||
Version::R2010 => "2010",
|
||||
Version::R2013 => "2013",
|
||||
Version::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case.
|
||||
pub fn parse(s: &str) -> Option<Version> {
|
||||
let s = s.trim();
|
||||
let year = s.strip_prefix(['R', 'r']).unwrap_or(s);
|
||||
Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes
|
||||
/// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when
|
||||
/// the code isn't a version code at all.
|
||||
pub fn for_acadver(code: &str) -> Option<Version> {
|
||||
let code = code.trim();
|
||||
if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) {
|
||||
return Some(v);
|
||||
}
|
||||
code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?;
|
||||
let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::<u32>().ok())?;
|
||||
Some(match n {
|
||||
..1018 => Version::R2000,
|
||||
1018..1021 => Version::R2004,
|
||||
1021..1024 => Version::R2007,
|
||||
1024..1027 => Version::R2010,
|
||||
1027..1032 => Version::R2013,
|
||||
_ => Version::R2018,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in
|
||||
/// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.
|
||||
pub fn utf8(self) -> bool {
|
||||
self >= Version::R2007
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.year())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_years_and_codes() {
|
||||
assert_eq!(Version::parse("2018"), Some(Version::R2018));
|
||||
assert_eq!(Version::parse(" r2004 "), Some(Version::R2004));
|
||||
assert_eq!(Version::parse("ac1021"), Some(Version::R2007));
|
||||
assert_eq!(Version::parse("2005"), None);
|
||||
assert_eq!(Version::parse(""), None);
|
||||
for v in Version::ALL {
|
||||
assert_eq!(Version::parse(v.code()), Some(v));
|
||||
assert_eq!(Version::parse(v.year()), Some(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_versions_map_to_the_nearest_writable_one() {
|
||||
assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010));
|
||||
assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018));
|
||||
assert_eq!(Version::for_acadver("MC0.0"), None);
|
||||
assert_eq!(Version::for_acadver("AC"), None);
|
||||
assert_eq!(Version::for_acadver("AC99999999999999999999"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
|
||||
|
||||
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_bytes_are_skipped() {
|
||||
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(BODY.as_bytes());
|
||||
let with_bom = cadcraft_dxf::parse(&bytes);
|
||||
assert!(with_bom.is_ok(), "{with_bom:?}");
|
||||
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bom_text_is_skipped() {
|
||||
let text = format!("\u{feff}{BODY}");
|
||||
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`,
|
||||
//! and hostile binary input never panics.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn sample() -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(5, "1F"),
|
||||
Tag::f(10, -3.25),
|
||||
Tag::i(62, 5),
|
||||
Tag::i(90, 70_000),
|
||||
Tag::i(160, 1 << 40),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FFA5".into())),
|
||||
Tag::s(1, "图号 Café 😀"),
|
||||
Tag::i(1071, -2),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrips_every_value_kind() {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = write_binary(&sample(), utf8);
|
||||
assert!(is_binary(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}");
|
||||
}
|
||||
// Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8.
|
||||
let old = write_binary(&sample(), false);
|
||||
assert!(old.windows(7).any(|w| w == b"\\U+56FE"));
|
||||
let new = write_binary(&sample(), true);
|
||||
assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_converts_values_to_their_codes_kind() {
|
||||
// A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk.
|
||||
let long = "AB".repeat(300);
|
||||
let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, 12));
|
||||
assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX)));
|
||||
assert_eq!(back[2].code, 310);
|
||||
assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255");
|
||||
assert_eq!(back[2].str().len() + back[3].str().len(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_ascii_keeps_text_and_drops_line_breaks() {
|
||||
let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true);
|
||||
assert!(text.contains("图号 Ä\r\n"));
|
||||
assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_binary_input_never_panics() {
|
||||
let good = write_binary(&sample(), true);
|
||||
// Every truncation, and every single-byte corruption of the body.
|
||||
for n in 0..good.len() {
|
||||
let _ = parse(good.get(..n).unwrap());
|
||||
}
|
||||
for i in 22..good.len() {
|
||||
for b in [0u8, 0x7f, 0xff] {
|
||||
let mut bad = good.clone();
|
||||
bad[i] = b;
|
||||
let _ = parse(&bad);
|
||||
}
|
||||
}
|
||||
// A sentinel and nothing else is not a drawing.
|
||||
assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err());
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
|
||||
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
|
||||
|
||||
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
|
||||
|
||||
fn file(code_page: &str, text: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, "AC1015"),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, code_page),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(1, text),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
fn contains(bytes: &[u8], part: &[u8]) -> bool {
|
||||
bytes.windows(part.len()).any(|w| w == part)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_written_in_the_code_page() {
|
||||
// (code page, text, the bytes written for it)
|
||||
let cases: [(&str, &str, &[u8]); 6] = [
|
||||
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
|
||||
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
|
||||
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
|
||||
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
|
||||
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
|
||||
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
|
||||
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
|
||||
];
|
||||
for (cp, text, raw) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
|
||||
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
|
||||
#[test]
|
||||
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
|
||||
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
|
||||
let tags = file(cp, "Ação\r\nfim");
|
||||
let bytes = write_ascii_in(&tags, cp);
|
||||
assert!(bytes.is_ascii(), "{cp}");
|
||||
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
|
||||
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
|
||||
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
|
||||
}
|
||||
}
|
||||
|
||||
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
|
||||
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
|
||||
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
|
||||
#[test]
|
||||
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
|
||||
let tags = file("ANSI_1251", "СІ");
|
||||
let ascii = write_ascii_in(&tags, "ANSI_1251");
|
||||
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
|
||||
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
|
||||
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
|
||||
let binary = write_binary_in(&tags, "ANSI_1251");
|
||||
assert!(contains(&binary, b"\xD1\xB2"));
|
||||
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
|
||||
// Bytes that are not valid UTF-8 stay as they are.
|
||||
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
|
||||
}
|
||||
|
||||
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
|
||||
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
|
||||
/// would read as UTF-8.
|
||||
#[test]
|
||||
fn literal_escapes_survive_code_page_text() {
|
||||
for (cp, text) in
|
||||
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
|
||||
{
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
|
||||
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
|
||||
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
|
||||
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
|
||||
#[test]
|
||||
fn a_trail_byte_backslash_never_starts_an_escape() {
|
||||
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
|
||||
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
|
||||
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
|
||||
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
|
||||
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
|
||||
];
|
||||
for (cp, text, written, never) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
|
||||
//! Handwritten examples, not files from a CAD application or generated by our writer.
|
||||
//! Background on the tag model and file structure:
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
|
||||
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
|
||||
|
||||
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
|
||||
vec![
|
||||
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
|
||||
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
|
||||
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
|
||||
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
|
||||
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
|
||||
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
|
||||
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
|
||||
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
|
||||
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
|
||||
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
|
||||
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
|
||||
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
|
||||
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independently_authored_ascii_and_binary_values() {
|
||||
for (expected, ascii, binary) in fixtures() {
|
||||
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
|
||||
let bytes = [SENTINEL, binary].concat();
|
||||
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_independently_specified_ascii_and_binary_bytes() {
|
||||
for (tag, ascii, binary) in fixtures() {
|
||||
for utf8 in [false, true] {
|
||||
let tags = std::slice::from_ref(&tag);
|
||||
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
|
||||
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_code_ranges_have_independent_binary_widths() {
|
||||
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
|
||||
// can round-trip every boundary value while still using the wrong number of bytes.
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, width, min, max) in widths {
|
||||
for code in ranges.into_iter().flatten() {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
|
||||
// little-endian two's-complement integer. Encoding is version-independent.
|
||||
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
|
||||
let value_bytes = value.to_le_bytes();
|
||||
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
|
||||
let tags = [Tag::i(code, value)];
|
||||
for utf8 in [false, true] {
|
||||
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
|
||||
}
|
||||
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_encoding_has_independent_wire_expectations() {
|
||||
let tags = [Tag::s(1, "é😀")];
|
||||
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
|
||||
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
|
||||
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
|
||||
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
|
||||
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
|
||||
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
|
||||
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
|
||||
assert_eq!(parse(&ascii).unwrap(), tags);
|
||||
assert_eq!(parse(&binary).unwrap(), tags);
|
||||
}
|
||||
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
|
||||
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
|
||||
let binary = [
|
||||
SENTINEL,
|
||||
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
|
||||
]
|
||||
.concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
let tags = parse(bytes).unwrap();
|
||||
assert_eq!(tags[7], Tag::s(1, "ێ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
|
||||
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
|
||||
for newline in ["\n", "\r\n"] {
|
||||
for final_newline in [false, true] {
|
||||
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
|
||||
if final_newline {
|
||||
text.push_str(newline);
|
||||
}
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_truncated_values_are_errors_not_panics() {
|
||||
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
|
||||
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
|
||||
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
|
||||
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
|
||||
for (_, _, binary) in fixtures() {
|
||||
// Every cut inside the first record is an error, unlike cuts between complete records:
|
||||
// the tag API deliberately accepts streams without an EOF record.
|
||||
for cut in 1..binary.len() {
|
||||
let bytes = [SENTINEL, &binary[..cut]].concat();
|
||||
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
|
||||
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
|
||||
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
for limit in 0..3 {
|
||||
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
|
||||
}
|
||||
for limit in [3, 4] {
|
||||
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
|
||||
let mut b = SENTINEL.to_vec();
|
||||
f(&mut b);
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reals_that_are_not_finite_become_zero() {
|
||||
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let b = bin_with(|b| {
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&bits.to_le_bytes());
|
||||
});
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_bom_and_cr_only_line_ends() {
|
||||
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
|
||||
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), want);
|
||||
let mut bom = b"\xEF\xBB\xBF".to_vec();
|
||||
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
|
||||
assert_eq!(parse(&bom).unwrap(), want);
|
||||
// Mixed terminators.
|
||||
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr_only_files_count_against_the_tag_limit() {
|
||||
let mut text = String::new();
|
||||
for _ in 0..50 {
|
||||
text.push_str("999\rcomment\r");
|
||||
}
|
||||
text.push_str("0\rEOF\r");
|
||||
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
|
||||
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_byte_spoils_one_line_not_the_file() {
|
||||
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
|
||||
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
|
||||
b.extend_from_slice("Größe".as_bytes());
|
||||
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
|
||||
let t = parse(&b).unwrap();
|
||||
assert_eq!(t[1].str(), "ANSI_1251");
|
||||
assert_eq!(t[2].str(), "Größe");
|
||||
assert_eq!(t[3].str(), "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
|
||||
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
|
||||
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
|
||||
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
|
||||
let utf8 = "Größe".as_bytes();
|
||||
let latin = b"Caf\xE9";
|
||||
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
|
||||
a.extend_from_slice(utf8);
|
||||
a.extend_from_slice(b"\n1\n");
|
||||
a.extend_from_slice(latin);
|
||||
a.extend_from_slice(b"\n0\nEOF\n");
|
||||
let from_ascii = parse(&a).unwrap();
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
let from_binary = parse(&b).unwrap();
|
||||
assert_eq!(from_ascii[2].str(), "Größe");
|
||||
assert_eq!(from_ascii[3].str(), "Café");
|
||||
assert_eq!(from_binary[2].str(), "Größe");
|
||||
assert_eq!(from_binary[3].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_spaces_keep_sections_and_eof() {
|
||||
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
|
||||
// The EOF with a trailing space still ends the file.
|
||||
assert_eq!(t.len(), 5);
|
||||
let s = sections(&t);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].name, "ENTITIES");
|
||||
assert_eq!(s[0].tags.len(), 1);
|
||||
// Binary EOF too.
|
||||
let mut b = SENTINEL.to_vec();
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF \0");
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"after\0");
|
||||
assert_eq!(parse(&b).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_groups_reject_non_finite_and_clamp() {
|
||||
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], Tag::i(70, 0));
|
||||
assert_eq!(t[1], Tag::i(70, 0));
|
||||
// Clamped to what the group's width holds, unsigned values included (the binary writer
|
||||
// keeps up to 65535 and 4294967295 as their bit patterns).
|
||||
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
|
||||
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
|
||||
assert_eq!(t[5], Tag::i(160, i64::MAX));
|
||||
assert_eq!(t[6], Tag::i(160, 0));
|
||||
assert_eq!(t[7], Tag::i(62, 12));
|
||||
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
|
||||
}
|
||||
|
||||
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
|
||||
/// `65535.0` is 65535 like `65535`, not 32767.
|
||||
#[test]
|
||||
fn unsigned_values_read_the_same_in_either_spelling() {
|
||||
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], t[1]);
|
||||
assert_eq!(t[0], Tag::i(70, 65535));
|
||||
assert_eq!(t[2], t[3]);
|
||||
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_groups_pad_odd_digits_and_drop_junk() {
|
||||
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
|
||||
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
|
||||
assert_eq!(t[2].value, Value::Hex(String::new()));
|
||||
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
|
||||
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
|
||||
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
|
||||
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
|
||||
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
|
||||
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.len(), 2, "{text:?}");
|
||||
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
|
||||
let long = "AB".repeat(300);
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
|
||||
assert_eq!(sizes, vec![254, 254, 92]);
|
||||
// An empty chunk is still one group.
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
assert_eq!(back[0].code, 310);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
|
||||
let tags = [
|
||||
Tag::i(70, 65535),
|
||||
Tag::i(70, 40000),
|
||||
Tag::i(70, 70000),
|
||||
Tag::i(70, -40000),
|
||||
Tag::i(90, 0xFFFF_FFFF),
|
||||
Tag::i(90, 1 << 40),
|
||||
Tag::i(90, -(1 << 40)),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, -1));
|
||||
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
|
||||
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
|
||||
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(back[4], Tag::i(90, -1));
|
||||
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
|
||||
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
|
||||
}
|
||||
|
||||
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
|
||||
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
|
||||
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
|
||||
for s in strings {
|
||||
a.extend_from_slice(b"1\n");
|
||||
a.extend_from_slice(s);
|
||||
a.push(b'\n');
|
||||
}
|
||||
a.extend_from_slice(b"0\nEOF\n");
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
[parse(&a).unwrap(), parse(&b).unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
|
||||
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
|
||||
// string is plainly Shift-JIS, so the file is not UTF-8.
|
||||
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
|
||||
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
|
||||
assert_eq!(t[3].str(), "図面");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
|
||||
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
|
||||
assert_eq!(t[2].str(), "図面");
|
||||
assert_eq!(t[3].str(), "ñ");
|
||||
assert_eq!(t[4].str(), "plain");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
|
||||
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
|
||||
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
|
||||
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
|
||||
//! Pending writer regressions extend the chunk-size finding in issue #1025.
|
||||
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
|
||||
|
||||
fn payload(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
||||
}
|
||||
|
||||
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
|
||||
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
|
||||
}
|
||||
|
||||
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_ascii_as(&input(code, bytes), utf8);
|
||||
let lines: Vec<_> = wire.lines().collect();
|
||||
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
|
||||
assert_eq!(following[0].trim(), "70");
|
||||
assert_eq!(following[1], "-7");
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let (records, remainder) = chunks.as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "incomplete group/value pair");
|
||||
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
for record in records {
|
||||
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
|
||||
let text = record[1];
|
||||
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
|
||||
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
|
||||
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "{context}: partial byte");
|
||||
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_binary(&input(code, bytes), utf8);
|
||||
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
while !chunks.is_empty() {
|
||||
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
|
||||
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
|
||||
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
|
||||
let (chunk, next) = rest.split_at(usize::from(length));
|
||||
back.extend_from_slice(chunk);
|
||||
chunks = next;
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = payload(length);
|
||||
check_ascii(code, &bytes, utf8);
|
||||
check_binary(code, &bytes, utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reader_tolerates_the_full_length_byte_domain() {
|
||||
// This is deliberately permissive input, not an assertion that oversized chunks conform
|
||||
// or that the writer should produce them. The following record catches framing mistakes.
|
||||
for code in (310_u16..=319).chain([1004]) {
|
||||
for length in 0..=u8::MAX {
|
||||
let bytes = payload(usize::from(length));
|
||||
let mut wire = SENTINEL.to_vec();
|
||||
wire.extend_from_slice(&code.to_le_bytes());
|
||||
wire.push(length);
|
||||
wire.extend_from_slice(&bytes);
|
||||
wire.extend_from_slice(FOLLOWING_BINARY);
|
||||
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
|
||||
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_ascii(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
|
||||
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_binary(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Deterministic tag preservation through the public byte API, in both encodings and every
|
||||
//! supported version. Inputs have the group code's type and representable values; lossy
|
||||
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn check(case: &str, body: &[Tag]) {
|
||||
for version in Version::ALL {
|
||||
let mut original = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, version.code()),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, "ANSI_1252"),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
];
|
||||
original.extend_from_slice(body);
|
||||
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
for binary in [false, true] {
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() };
|
||||
assert_eq!(is_binary(&bytes), binary, "{context}");
|
||||
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
|
||||
assert_eq!(back.len(), original.len(), "{context}: tag count");
|
||||
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: tag[{index}]");
|
||||
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
|
||||
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
|
||||
}
|
||||
}
|
||||
assert_eq!(back, current, "{context}: no additional changes on resave");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_width_boundaries() {
|
||||
// Explicit reference ranges, not obtained from the codec's kind_of().
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, min, max) in widths {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
|
||||
check(&format!("integer/{min}..{max}/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_reals_keep_all_bits() {
|
||||
let values = [
|
||||
0.0,
|
||||
-0.0,
|
||||
f64::from_bits(1),
|
||||
-f64::from_bits(1),
|
||||
f64::MIN_POSITIVE,
|
||||
f64::MIN_POSITIVE.next_down(),
|
||||
0.1,
|
||||
-3.25,
|
||||
1.0_f64.next_down(),
|
||||
1.0,
|
||||
1.0_f64.next_up(),
|
||||
1e-100,
|
||||
-1e100,
|
||||
f64::MAX,
|
||||
f64::MIN,
|
||||
];
|
||||
for value in values {
|
||||
let tags: Vec<_> =
|
||||
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
|
||||
check(&format!("real/{:016X}", value.to_bits()), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans() {
|
||||
for value in [false, true] {
|
||||
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
|
||||
check(&format!("bool/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
|
||||
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
|
||||
let values = [
|
||||
("empty", ""),
|
||||
("spaces", " leading and trailing "),
|
||||
("tabs", "\tindented\t"),
|
||||
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
|
||||
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
|
||||
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
|
||||
];
|
||||
for (case, value) in values {
|
||||
let tags: Vec<_> =
|
||||
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|code| Tag::s(code, value))
|
||||
.collect();
|
||||
check(case, &tags);
|
||||
}
|
||||
for length in [1, 254, 255, 256, 1024] {
|
||||
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_chunks_keep_bytes_and_order() {
|
||||
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
|
||||
// byte can represent more, but that is reader tolerance, not writer conformance.
|
||||
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
|
||||
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
|
||||
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
|
||||
check(&format!("hex/bytes={length}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_repeated_codes_keep_order() {
|
||||
check(
|
||||
"mixed/repeated-codes",
|
||||
&[
|
||||
Tag::s(0, "TEST"),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::s(1, "first"),
|
||||
Tag::f(10, -2.5),
|
||||
Tag::i(70, -7),
|
||||
Tag::s(1, "second"),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FF80".into())),
|
||||
Tag::i(160, i64::MAX),
|
||||
Tag::s(1, ""),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
|
||||
@@ -16,31 +16,10 @@ use std::fs::{File, OpenOptions, Permissions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
|
||||
/// combine permissions from one version of a path with the group of another.
|
||||
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
|
||||
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
|
||||
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
|
||||
let key = match (absolute.parent(), absolute.file_name()) {
|
||||
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
|
||||
_ => absolute,
|
||||
};
|
||||
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
locks.retain(|_, lock| lock.strong_count() > 0);
|
||||
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
|
||||
lock
|
||||
} else {
|
||||
let lock = Arc::new(Mutex::new(()));
|
||||
locks.insert(key, Arc::downgrade(&lock));
|
||||
lock
|
||||
}
|
||||
}
|
||||
|
||||
/// How many fresh temporary names to try before giving up (each collision is another local race).
|
||||
const MAX_TRIES: u32 = 32;
|
||||
|
||||
@@ -54,15 +33,13 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
// A symlinked destination is resolved so the link stays a link and its target is replaced.
|
||||
let resolved = resolve_symlink(path)?;
|
||||
let path = resolved.as_deref().unwrap_or(path);
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
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),
|
||||
};
|
||||
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
|
||||
write_atomic_with(path, permissions, |file| file.write_all(bytes))
|
||||
}
|
||||
|
||||
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
|
||||
@@ -90,30 +67,10 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
|
||||
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
|
||||
/// at `path` is replaced by the new file, never written through.
|
||||
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
write_atomic_with_locked(path, permissions, fill)
|
||||
}
|
||||
|
||||
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
|
||||
pub fn write_atomic_with_permissions(
|
||||
path: &Path,
|
||||
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
|
||||
fill: impl FnOnce(&mut File) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let lock = path_lock(path);
|
||||
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let permissions = permissions()?;
|
||||
write_atomic_with_locked(path, permissions, fill)
|
||||
}
|
||||
|
||||
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
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 {
|
||||
#[cfg(unix)]
|
||||
let permissions = keep_group(&file, path, permissions);
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
fill(&mut file)?;
|
||||
@@ -133,20 +90,6 @@ fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
|
||||
/// if the file is still in the old file's group, so the group is restored first. When it can't be
|
||||
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
|
||||
#[cfg(unix)]
|
||||
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
|
||||
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
|
||||
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
|
||||
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
|
||||
return permissions;
|
||||
}
|
||||
Permissions::from_mode(permissions.mode() & !0o070)
|
||||
}
|
||||
|
||||
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
|
||||
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
|
||||
/// refuse to sync a directory, and the file itself is already on disk.
|
||||
@@ -288,33 +231,6 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
|
||||
/// says) together with its mode, so the group bits never name a different group.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", 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();
|
||||
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
|
||||
let own = std::fs::metadata(&dest).unwrap().gid();
|
||||
let other = std::process::Command::new("id")
|
||||
.arg("-G")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
|
||||
if let Some(gid) = other {
|
||||
chown(&dest, None, Some(gid)).unwrap();
|
||||
}
|
||||
let want = std::fs::metadata(&dest).unwrap();
|
||||
write_atomic(&dest, b"new").unwrap();
|
||||
let got = std::fs::metadata(&dest).unwrap();
|
||||
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
|
||||
@@ -144,10 +144,10 @@ impl BlockEdit {
|
||||
}
|
||||
let host = &self.host.doc;
|
||||
let mut d = editor.clone();
|
||||
// When saving a copy, the source keeps its last saved definition. The editor's own
|
||||
// table entry can still be the one from before BSAVE; the copy gets the edited data.
|
||||
// When the block is saved under another name, the edited block keeps the drawing's
|
||||
// description and block `name` gets the description changed in the editor.
|
||||
if !name.eq_ignore_ascii_case(&self.name) {
|
||||
restore_saved_definition(&mut d, host, &self.name);
|
||||
keep_description(&mut d, host, &self.name);
|
||||
}
|
||||
d.model = host.model.clone();
|
||||
d.layouts = host.layouts.clone();
|
||||
@@ -176,13 +176,16 @@ impl BlockEdit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
|
||||
/// Without a saved source, drop the empty definition an editor description may have created.
|
||||
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
|
||||
/// drop the empty definition a description gives a block the editor started and never saved.
|
||||
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
|
||||
match host.block(name) {
|
||||
Some(h) => {
|
||||
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
|
||||
d.blocks.insert(key, h.clone());
|
||||
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
&& b.description != h.description
|
||||
{
|
||||
Arc::make_mut(b).description = h.description.clone();
|
||||
}
|
||||
}
|
||||
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
|
||||
}
|
||||
@@ -299,7 +302,7 @@ impl DocState {
|
||||
let was = be.name.clone();
|
||||
let reconcile = |doc: &mut Arc<Drawing>| {
|
||||
let editor = Arc::make_mut(doc);
|
||||
restore_saved_definition(editor, &d, &was);
|
||||
keep_description(editor, &d, &was);
|
||||
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
|
||||
editor.blocks.insert(name.clone(), b.clone());
|
||||
};
|
||||
|
||||
@@ -178,34 +178,14 @@ fn create(s: &mut Session, objs: &[Handle], base: Vec2, p: ArrayParams, assoc: b
|
||||
Ok(json!({ "created": n }))
|
||||
}
|
||||
|
||||
/// The most objects EXPLODE of one associative array makes from its source objects (ten times the
|
||||
/// default MAXARRAY).
|
||||
const MAX_EXPLODED_OBJECTS: usize = 1_000_000;
|
||||
|
||||
/// EXPLODE of an associative array: every item becomes the objects of its block (the source
|
||||
/// objects, or a replacement block's reference), in world coordinates. `None` for other objects.
|
||||
///
|
||||
/// An array that would explode into more than [`MAX_EXPLODED_OBJECTS`] objects (items times the
|
||||
/// objects of the source block, which a file sets freely) is not expanded here: `None`, so EXPLODE
|
||||
/// takes the array block's own items.
|
||||
pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return None };
|
||||
let p = d.array_params(e)?;
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
// Count first: a hostile array must be refused before any entity is cloned.
|
||||
let total = blk
|
||||
.entities
|
||||
.iter()
|
||||
.map(|it| match &it.kind {
|
||||
EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&p.source) => d.block(&i.block).map_or(1, |b| b.entities.len()),
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0usize, usize::saturating_add);
|
||||
if total > MAX_EXPLODED_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(total);
|
||||
let mut out = Vec::new();
|
||||
for item in blk.entities.iter() {
|
||||
let item = placed(e, item, &m);
|
||||
let source = match &item.kind {
|
||||
@@ -213,9 +193,7 @@ pub(crate) fn explode_array(d: &Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
_ => None,
|
||||
};
|
||||
match source {
|
||||
Some((im, b)) => {
|
||||
out.extend(b.entities.iter().map(|se| placed(&item, se, &im)));
|
||||
}
|
||||
Some((im, b)) => out.extend(b.entities.iter().map(|se| placed(&item, se, &im))),
|
||||
None => out.push(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +188,8 @@ fn item_at(d: &Drawing, h: Handle, at: Vec2, tol: f64) -> Option<(u32, u32, u32)
|
||||
let mut best: Option<((u32, u32, u32), f64)> = None;
|
||||
// Each block's own extents, measured once.
|
||||
let mut extents: HashMap<String, (Vec2, Bounds2)> = HashMap::new();
|
||||
let block_for = p.block_lookup();
|
||||
for it in p.items()? {
|
||||
let name = block_for(it.i, it.j, it.level);
|
||||
let name = p.block_for(it.i, it.j, it.level);
|
||||
let (base, local) = match extents.get(name) {
|
||||
Some(x) => *x,
|
||||
None => {
|
||||
|
||||
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
|
||||
pub(super) fn blockreplace_spec() -> CommandSpec {
|
||||
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
|
||||
.menu(&["Tools", "Additional Tools", "Replace Block..."])
|
||||
.menu(&["Tools", "Express Tools", "Replace Block..."])
|
||||
.alias(&["-blockreplace"])
|
||||
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
|
||||
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
|
||||
@@ -184,10 +184,8 @@ impl Interactive for BlockreplaceM {
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]).takes_block(),
|
||||
Stage::New => {
|
||||
Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]).takes_block()
|
||||
}
|
||||
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
|
||||
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
|
||||
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Result, Session};
|
||||
|
||||
pub(super) fn burst_spec() -> CommandSpec {
|
||||
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
|
||||
.menu(&["Tools", "Additional Tools", "Explode Attributes to Text"])
|
||||
.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"))))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! block name and values, or one row per reference with its handle, layer, position and
|
||||
//! rotation — returned, written to a CSV file and/or inserted as a table. Tags match in any case.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert};
|
||||
use cadcraft_doc::{EntityKind, Insert};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -88,73 +88,20 @@ fn matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| wildcard(p.trim(), text))
|
||||
}
|
||||
|
||||
/// The constant attribute values of a block definition whose tags are selected for output.
|
||||
fn constants(b: &cadcraft_doc::Block, selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = Vec::new();
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& selected.iter().any(|tag| tag == &a.tag.to_ascii_uppercase())
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The attribute values of `ins` that `o.tags` selects: its own, then the constant ones of its
|
||||
/// block (`consts`, read once per block). Values no column shows are not copied.
|
||||
fn values(ins: &Insert, consts: &[(String, String)], selected: &[String]) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins
|
||||
.attribs
|
||||
.iter()
|
||||
.map(|a| (a.tag.to_ascii_uppercase(), a))
|
||||
.filter(|(t, _)| selected.contains(t))
|
||||
.map(|(t, a)| (t, a.text.value.clone()))
|
||||
.collect();
|
||||
for (t, val) in consts {
|
||||
if !v.iter().any(|(k, _)| k.eq_ignore_ascii_case(t)) {
|
||||
v.push((t.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Select the output columns before copying any attribute text. A block definition can contain
|
||||
/// many large constants that do not survive the table's 190-column limit.
|
||||
fn selected_tags(ents: &[std::sync::Arc<cadcraft_doc::Entity>], d: &Drawing, o: &Options) -> Vec<String> {
|
||||
let mut selected = Vec::new();
|
||||
let mut add = |tag: &str| {
|
||||
let tag = tag.to_ascii_uppercase();
|
||||
if selected.len() < MAX_TAGS && matches(&o.tags, &tag) && !selected.contains(&tag) {
|
||||
selected.push(tag);
|
||||
}
|
||||
};
|
||||
let mut scanned = std::collections::HashSet::new();
|
||||
for entity in ents {
|
||||
let EntityKind::Insert(ins) = &entity.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
if anonymous || !matches(&o.blocks, &ins.block) {
|
||||
continue;
|
||||
}
|
||||
for attribute in &ins.attribs {
|
||||
add(&attribute.tag);
|
||||
}
|
||||
// A block's constants are read once, however many inserts reference it.
|
||||
if scanned.insert(ins.block.as_str())
|
||||
&& let Some(block) = d.block(&ins.block)
|
||||
{
|
||||
for entity in block.entities.iter() {
|
||||
if let EntityKind::AttDef(attribute) = &entity.kind
|
||||
&& attribute.constant
|
||||
{
|
||||
add(&attribute.tag);
|
||||
}
|
||||
/// The attribute values of `ins`: its own, then the constant ones of its block.
|
||||
fn values(d: &cadcraft_doc::Drawing, ins: &Insert) -> Vec<(String, String)> {
|
||||
let mut v: Vec<(String, String)> = ins.attribs.iter().map(|a| (a.tag.to_ascii_uppercase(), a.text.value.clone())).collect();
|
||||
if let Some(b) = d.block(&ins.block) {
|
||||
for e in b.entities.iter() {
|
||||
if let EntityKind::AttDef(a) = &e.kind
|
||||
&& a.constant
|
||||
&& !v.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))
|
||||
{
|
||||
v.push((a.tag.to_ascii_uppercase(), a.text.value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
v
|
||||
}
|
||||
|
||||
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
||||
@@ -165,9 +112,7 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
} else {
|
||||
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
|
||||
};
|
||||
let selected = selected_tags(&ents, d, o);
|
||||
let mut out = Vec::new();
|
||||
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for e in &ents {
|
||||
let EntityKind::Insert(ins) = &e.kind else { continue };
|
||||
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
|
||||
@@ -176,17 +121,12 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
out.push(Found {
|
||||
block,
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
block: block.clone(),
|
||||
values: values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
),
|
||||
values: values(d, ins),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -85,8 +85,7 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
.noundo(),
|
||||
CommandSpec::new("-xref", "External References", run_dash_xref)
|
||||
.alias(&["-xr"])
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
|
||||
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
|
||||
CommandSpec::new("xbind", "Bind...", run_xbind)
|
||||
.menu(&["Modify", "Object", "External Reference", "Bind..."])
|
||||
@@ -201,46 +201,6 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The xrefs a JSON call names (`names`, default all); an error when none match.
|
||||
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
|
||||
if p.get("exact").and_then(Value::as_bool) == Some(true) {
|
||||
let lits: Vec<&str> = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
|
||||
Some(Value::String(t)) => vec![t.as_str()],
|
||||
_ => str_param(p, "name").into_iter().collect(),
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let mut names = Vec::new();
|
||||
let mut selected = std::collections::HashSet::new();
|
||||
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
|
||||
let mut folded = std::collections::HashMap::new();
|
||||
for literal in &lits {
|
||||
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
|
||||
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
|
||||
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
|
||||
}
|
||||
if selected.insert(block.name.clone()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let normalized = literal.to_ascii_lowercase();
|
||||
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
|
||||
if let Some(block) = matches.next() {
|
||||
if matches.next().is_some() {
|
||||
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
|
||||
}
|
||||
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
|
||||
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
|
||||
}
|
||||
if selected.insert(block.name.clone()) {
|
||||
names.push(block.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
|
||||
}
|
||||
return Ok(names);
|
||||
}
|
||||
let pattern = match p.get("names") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
|
||||
Some(Value::String(t)) => t.clone(),
|
||||
@@ -263,19 +223,6 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
|
||||
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
|
||||
/// command must refuse before it changes anything.
|
||||
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
|
||||
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
|
||||
for n in names {
|
||||
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
|
||||
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
|
||||
let host = host(s);
|
||||
@@ -305,7 +252,6 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
"detach" | "d" => {
|
||||
let names = named(s, "-xref", p)?;
|
||||
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
|
||||
let mut erased = 0;
|
||||
for n in &names {
|
||||
erased += load::detach(s.doc_mut()?, n);
|
||||
@@ -330,7 +276,6 @@ 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)?;
|
||||
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
|
||||
let d = s.doc_mut()?;
|
||||
for n in &names {
|
||||
if let Some(b) = d.blocks.get_mut(n) {
|
||||
|
||||
@@ -313,23 +313,7 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
|
||||
/// [`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 are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
names
|
||||
.iter()
|
||||
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
|
||||
std::collections::hash_map::Entry::Vacant(v) => {
|
||||
v.insert(n.as_str());
|
||||
load_with(d, n, host, chosen, &mut cx)
|
||||
}
|
||||
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
|
||||
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
|
||||
name: String::new(),
|
||||
status: XrefStatus::Unresolved,
|
||||
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
|
||||
}],
|
||||
})
|
||||
.collect()
|
||||
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
|
||||
@@ -453,32 +453,14 @@ impl Chain {
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
self.at_length_in(&self.end_lengths(), d)
|
||||
}
|
||||
/// The arc length from the chain's start to the end of each segment, for [`Self::at_length_in`].
|
||||
pub fn end_lengths(&self) -> Vec<f64> {
|
||||
self.segs
|
||||
.iter()
|
||||
.scan(0.0, |acc, s| {
|
||||
*acc += s.len();
|
||||
Some(*acc)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// [`Self::at_length`] with the segment end lengths computed once (`ends` from
|
||||
/// [`Self::end_lengths`]), so many samples along one chain each cost a binary search.
|
||||
pub fn at_length_in(&self, ends: &[f64], d: f64) -> Option<(Vec2, Vec2)> {
|
||||
let first = ends.partition_point(|e| d > e + 1e-12);
|
||||
// A segment of no length can't hold the point; the next one that has length does.
|
||||
// `ends` never decreases, so the first segment past `first` with length is found by search
|
||||
// (a run of zero-length segments can be long) instead of a scan.
|
||||
let base = first.checked_sub(1).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let i = first + ends.get(first..).unwrap_or_default().partition_point(|e| *e <= base);
|
||||
let i = (i < self.segs.len()).then_some(i);
|
||||
if let Some(s) = i.and_then(|i| self.segs.get(i)) {
|
||||
let start = i.and_then(|i| i.checked_sub(1)).and_then(|p| ends.get(p)).copied().unwrap_or(0.0);
|
||||
let t = ((d - start) / s.len()).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
let mut acc = 0.0;
|
||||
for s in &self.segs {
|
||||
let l = s.len();
|
||||
if d <= acc + l + 1e-12 && l > 0.0 {
|
||||
let t = ((d - acc) / l).clamp(0.0, 1.0);
|
||||
return Some((s.at(t), s.tangent(t)));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
@@ -658,34 +640,6 @@ pub fn reverse_vertices(vs: &[PolyVertex], closed: bool) -> Vec<PolyVertex> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The indexed lookup gives the point and tangent a scan from the start gives, also past the
|
||||
/// ends and over a segment of no length.
|
||||
#[test]
|
||||
fn at_length_in_matches_a_scan() {
|
||||
let l = |a: (f64, f64), b: (f64, f64)| Segment::Line(Line::new(Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)));
|
||||
let c = Chain {
|
||||
segs: vec![l((0.0, 0.0), (0.0, 0.0)), l((0.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 0.0)), l((4.0, 0.0), (4.0, 3.0))],
|
||||
closed: false,
|
||||
};
|
||||
let ends = c.end_lengths();
|
||||
for d in [-1.0, 0.0, 1.5, 4.0, 4.0 + 1e-13, 5.0, 7.0, 9.0] {
|
||||
// The scan this replaced.
|
||||
let mut acc = 0.0;
|
||||
let mut want = c.segs.last().map(|s| (s.end(), s.tangent(1.0)));
|
||||
for s in &c.segs {
|
||||
let len = s.len();
|
||||
if d <= acc + len + 1e-12 && len > 0.0 {
|
||||
let t = ((d - acc) / len).clamp(0.0, 1.0);
|
||||
want = Some((s.at(t), s.tangent(t)));
|
||||
break;
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
assert_eq!(c.at_length_in(&ends, d), want, "d = {d}");
|
||||
assert_eq!(c.at_length(d), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_builders() {
|
||||
let a = arc_sca(Vec2::new(1.0, 0.0), Vec2::ZERO, std::f64::consts::FRAC_PI_2).unwrap();
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
|
||||
.menu(&["Tools", "Additional Tools", "Break-line Symbol"])
|
||||
.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}",
|
||||
|
||||
@@ -36,18 +36,16 @@ fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
|
||||
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let range = if c.closed { 0..n } else { 1..n };
|
||||
let ends = c.end_lengths();
|
||||
range.filter_map(|i| c.at_length_in(&ends, l * i as f64 / n as f64)).collect()
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let ends = c.end_lengths();
|
||||
let mut out = Vec::new();
|
||||
let mut k = 1usize;
|
||||
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
|
||||
let d = k as f64 * step;
|
||||
out.extend(c.at_length_in(&ends, d));
|
||||
out.extend(c.at_length(d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -58,9 +58,8 @@ pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, re
|
||||
closed: true,
|
||||
};
|
||||
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
|
||||
let ends = chain.end_lengths();
|
||||
for j in 0..k {
|
||||
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
|
||||
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
|
||||
@@ -557,15 +557,9 @@ fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let space = s.space();
|
||||
let d = s.doc()?;
|
||||
let frozen = s.viewport_frozen();
|
||||
let hs: Vec<Handle> = d
|
||||
.space(&space)
|
||||
.map(|st| {
|
||||
st.iter()
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && d.layer(&e.common.layer).is_none_or(|l| !l.locked))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
})
|
||||
.map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
|
||||
.unwrap_or_default();
|
||||
let n = hs.len();
|
||||
s.set_selection(hs);
|
||||
@@ -586,19 +580,16 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let add = bool_or(p, "add", false);
|
||||
let picked: Vec<Handle> = if let Some(w) = points_param(p, "window") {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
|
||||
crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
|
||||
} else if let Some(at) = point_param(p, "at") {
|
||||
let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
|
||||
crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), at, ap).into_iter().collect()
|
||||
crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
|
||||
} else if let Some(f) = points_param(p, "fence") {
|
||||
crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &f)
|
||||
} else if ["handles", "handle"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
targets(s, p)?
|
||||
crate::select::select_fence(s.doc()?, &space, &f)
|
||||
} else {
|
||||
// Nothing to select with: the selection stays, also what the active viewport does not draw.
|
||||
s.stored_selection()
|
||||
targets(s, p)?
|
||||
};
|
||||
let mut sel = if add { s.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if add { s.selection() } else { Vec::new() };
|
||||
sel.extend(picked);
|
||||
s.set_selection(sel);
|
||||
let sel = s.selection();
|
||||
|
||||
@@ -204,8 +204,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
|
||||
+19
-103
@@ -8,7 +8,7 @@ mod template;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cadcraft_doc::{Drawing, Format};
|
||||
pub use craft_dxf::Version;
|
||||
pub use cadcraft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
||||
@@ -17,14 +17,14 @@ use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
|
||||
pub(crate) use pgp::{greek_alias, specs as alias_specs, user_alias};
|
||||
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"))]
|
||||
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
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;
|
||||
@@ -53,9 +53,9 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
/// (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<craft_dxf::Tag>),
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
|
||||
/// An entity from a type name and groups.
|
||||
pub entity: fn(&str, &[craft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
}
|
||||
|
||||
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
||||
@@ -340,7 +340,7 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
|
||||
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
|
||||
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
}
|
||||
Ok(ReadFile { drawing, binary_dxf: craft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
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 {
|
||||
@@ -503,7 +503,7 @@ pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || craft_dxf::is_binary(bytes) {
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
@@ -533,7 +533,7 @@ pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if craft_dxf::is_binary(head) {
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
@@ -625,40 +625,6 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
|
||||
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
|
||||
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
|
||||
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
|
||||
#[cfg(unix)]
|
||||
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
|
||||
let mut mode = src_mode & 0o777;
|
||||
if let Some((old_mode, old_gid)) = old {
|
||||
mode &= old_mode;
|
||||
if old_gid != src_gid {
|
||||
mode = without_group(src_mode, mode);
|
||||
}
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
|
||||
/// drawing's group bits (`src_mode`).
|
||||
#[cfg(unix)]
|
||||
fn without_group(src_mode: u32, mode: u32) -> u32 {
|
||||
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
|
||||
}
|
||||
|
||||
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
|
||||
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
|
||||
#[cfg(unix)]
|
||||
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
|
||||
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
|
||||
}
|
||||
|
||||
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
|
||||
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
|
||||
/// to keep when there is no file yet. A file that doesn't look like a drawing
|
||||
@@ -682,23 +648,19 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
|
||||
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
|
||||
// written through.
|
||||
#[cfg(unix)]
|
||||
let source_metadata = src.metadata()?;
|
||||
#[cfg(unix)]
|
||||
let first_backup = std::cell::Cell::new(false);
|
||||
#[cfg(unix)]
|
||||
let permissions = || {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
|
||||
first_backup.set(old.is_none());
|
||||
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut mode = src.metadata()?.permissions().mode() & 0o777;
|
||||
if let Ok(m) = std::fs::symlink_metadata(&bak)
|
||||
&& m.is_file()
|
||||
{
|
||||
mode &= m.permissions().mode();
|
||||
}
|
||||
Some(std::fs::Permissions::from_mode(mode))
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = || Ok(None);
|
||||
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
|
||||
#[cfg(unix)]
|
||||
if first_backup.get() {
|
||||
adopt_source_group(out, &source_metadata)?;
|
||||
}
|
||||
let permissions = None;
|
||||
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
|
||||
std::io::Write::write_all(out, &head)?;
|
||||
std::io::copy(&mut src, out).map(|_| ())
|
||||
})
|
||||
@@ -1069,52 +1031,6 @@ mod tests {
|
||||
use crate::Session;
|
||||
use crate::temp_dir::TempDir;
|
||||
|
||||
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
|
||||
/// it would keep the old `.bak`'s group while the drawing is in another.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backup_mode_drops_group_bits_for_a_different_group() {
|
||||
use super::backup_mode;
|
||||
assert_eq!(backup_mode(0o640, 10, None), 0o640);
|
||||
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
|
||||
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
|
||||
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
|
||||
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
|
||||
}
|
||||
|
||||
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
|
||||
/// group, as `id -G` says), never the group the new file would be created in.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn first_backup_takes_the_drawings_group() {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
|
||||
let dir = TempDir::new("first-bak-grp");
|
||||
let src = dir.join("a.dxf");
|
||||
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
|
||||
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
|
||||
let own = std::fs::metadata(&src).unwrap().gid();
|
||||
let other = std::process::Command::new("id")
|
||||
.arg("-G")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
|
||||
if let Some(gid) = other {
|
||||
chown(&src, None, Some(gid)).unwrap();
|
||||
}
|
||||
super::backup(&src).unwrap();
|
||||
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
|
||||
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
|
||||
}
|
||||
|
||||
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn without_group_drops_group_bits_and_caps_other() {
|
||||
assert_eq!(super::without_group(0o640, 0o640), 0o600);
|
||||
assert_eq!(super::without_group(0o604, 0o604), 0o600);
|
||||
assert_eq!(super::without_group(0o644, 0o644), 0o604);
|
||||
}
|
||||
|
||||
fn active_title(s: &Session) -> String {
|
||||
s.state().unwrap().title.clone()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
|
||||
fn read_path(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
|
||||
@@ -375,20 +375,16 @@ fn run_compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let Diff { added, removed, changed, common, truncated } = diff(&cur, &other, &space);
|
||||
let mut boxes = Vec::new();
|
||||
// One block-bounds cache for each drawing, so a block many differing entities refer to is
|
||||
// measured once, not once per entity.
|
||||
let (mut cur_cache, mut other_cache) = (BlockBoundsCache::default(), BlockBoundsCache::default());
|
||||
fn bound<'a>(boxes: &mut Vec<Bounds2>, d: &'a Drawing, h: Handle, cache: &mut BlockBoundsCache<'a>) {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds_cached(d, e, 0, cache)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite())
|
||||
{
|
||||
let mut bound = |d: &Drawing, h: Handle| {
|
||||
if let Some(b) = d.entity(h).map(|e| entity_bounds(d, e, 0)).filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
|
||||
boxes.push(b);
|
||||
}
|
||||
}
|
||||
added.iter().for_each(|h| bound(&mut boxes, &cur, *h, &mut cur_cache));
|
||||
removed.iter().for_each(|h| bound(&mut boxes, &other, *h, &mut other_cache));
|
||||
};
|
||||
added.iter().for_each(|h| bound(&cur, *h));
|
||||
removed.iter().for_each(|h| bound(&other, *h));
|
||||
for (a, b, _, _) in &changed {
|
||||
bound(&mut boxes, &cur, *a, &mut cur_cache);
|
||||
bound(&mut boxes, &other, *b, &mut other_cache);
|
||||
bound(&cur, *a);
|
||||
bound(&other, *b);
|
||||
}
|
||||
let regions = regions(&boxes);
|
||||
let ch: Vec<Value> = changed
|
||||
|
||||
@@ -59,12 +59,6 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).cloned()
|
||||
}
|
||||
|
||||
/// The user alias that is the Greek word `word` in another case or accent.
|
||||
pub(crate) fn greek_alias(word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
store().map.keys().find(|alias| same(alias)).cloned()
|
||||
}
|
||||
|
||||
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
|
||||
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
|
||||
pub fn alias_profile() -> Value {
|
||||
|
||||
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -43,8 +43,7 @@ pub(crate) struct GripEdit {
|
||||
hot: Vec<(Handle, usize)>,
|
||||
/// Typed `GRIP`: still collecting grips ("Select grips").
|
||||
picking: bool,
|
||||
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
|
||||
/// selection that the view being edited shows.
|
||||
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection.
|
||||
targets: Vec<Handle>,
|
||||
base: Vec2,
|
||||
mode: GripMode,
|
||||
@@ -70,13 +69,6 @@ fn positive(v: Option<f64>) -> Result<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
|
||||
}
|
||||
|
||||
/// Whether the view being edited shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer,
|
||||
/// a layer frozen in the active MSPACE viewport) takes no part in a grip edit, even when Undo or
|
||||
/// a selection command put it back into the selection.
|
||||
fn shown(s: &Session, h: Handle) -> bool {
|
||||
s.state().is_ok_and(|st| st.doc.entity(h).is_some_and(|e| st.shows(e)))
|
||||
}
|
||||
|
||||
impl GripEdit {
|
||||
fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result<Self> {
|
||||
let mut g = GripEdit {
|
||||
@@ -95,22 +87,16 @@ impl GripEdit {
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
|
||||
/// the view being edited does not show is left out, and so are the hidden objects of the
|
||||
/// selection.
|
||||
/// Make grip `index` of `h` hot (the first one becomes the base point).
|
||||
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
|
||||
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
|
||||
return Ok(());
|
||||
}
|
||||
let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?;
|
||||
// After the lookup: a missing object or grip stays the error it was.
|
||||
if !shown(s, h) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.hot.is_empty() {
|
||||
self.base = p;
|
||||
self.targets.push(h);
|
||||
self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x)));
|
||||
self.targets.extend(s.selection().into_iter().filter(|x| *x != h));
|
||||
} else if !self.targets.contains(&h) {
|
||||
self.targets.push(h);
|
||||
}
|
||||
@@ -121,13 +107,13 @@ impl GripEdit {
|
||||
/// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there.
|
||||
fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> {
|
||||
let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len()));
|
||||
let Ok(st) = s.state() else { return Vec::new() };
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for h in s.selection().into_iter().take(MAX_HOT) {
|
||||
if super::curves::is_locked(s, h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = st.doc.entity(h).filter(|e| st.shows(e)) else { continue };
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let gs = e.kind.grips();
|
||||
// The nearest grip of this object within the tolerance.
|
||||
let best = gs
|
||||
@@ -484,9 +470,7 @@ impl Interactive for GripEdit {
|
||||
impl Session {
|
||||
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
|
||||
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
|
||||
/// modes and their options then run at the command line like any prompt. Grips of objects the
|
||||
/// view being edited does not show are left out (the first grip left is the base grip), and it
|
||||
/// is an error when none is left.
|
||||
/// modes and their options then run at the command line like any prompt.
|
||||
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
@@ -495,9 +479,6 @@ impl Session {
|
||||
return Err(other("no grip"));
|
||||
}
|
||||
let g = GripEdit::new(self, hot.to_vec())?;
|
||||
if g.hot.is_empty() {
|
||||
return Err(other("no grip"));
|
||||
}
|
||||
let label = g.label();
|
||||
let machine = crate::guard::Guarded::wrap(Box::new(g));
|
||||
let st = self.state()?;
|
||||
|
||||
@@ -28,8 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
const MEMBER_PICKS: &[&str] = &["groupedit", "-group"];
|
||||
|
||||
/// `hs` plus the other members of the selectable groups they belong to (in the space being
|
||||
/// edited, visible, and inside an MSPACE viewport not on a layer frozen in it), when PICKSTYLE
|
||||
/// has group selection on. Order: `hs` first.
|
||||
/// edited, visible), when PICKSTYLE has group selection on. Order: `hs` first.
|
||||
pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
if s.settings.pickstyle & 1 == 0 || hs.is_empty() || s.running.as_ref().is_some_and(|r| MEMBER_PICKS.contains(&r.id.as_str())) {
|
||||
return hs;
|
||||
@@ -40,7 +39,6 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let space = s.space();
|
||||
let Some(store) = d.space(&space) else { return hs };
|
||||
let frozen = s.viewport_frozen();
|
||||
// Member → its selectable groups, built once (window selections may hold many objects).
|
||||
let mut of: HashMap<Handle, Vec<usize>> = HashMap::new();
|
||||
for (i, g) in d.groups.iter().enumerate().filter(|(_, g)| g.selectable) {
|
||||
@@ -58,7 +56,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
|
||||
}
|
||||
let Some(g) = d.groups.get(*gi) else { continue };
|
||||
for m in &g.members {
|
||||
if store.get(*m).is_some_and(|e| crate::select::selectable(d, e, frozen)) && seen.insert(*m) {
|
||||
if store.get(*m).is_some_and(|e| d.is_visible(e)) && seen.insert(*m) {
|
||||
out.push(*m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,19 +533,6 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "message": format!("{n} Region(s) created.") }))
|
||||
}
|
||||
|
||||
/// Whether the upper-case `name` is a pattern: a standard one or a loaded one.
|
||||
fn pattern_exists(name: &str) -> bool {
|
||||
cadcraft_doc::library::pattern(name).is_some() || patfile::user_pattern(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing pattern `name` means, typed at the pattern prompt: read upper-cased, else a loaded
|
||||
/// pattern's own name when only its Greek spelling differs in case or accent (σολιδ for ΣΟΛΙΔ).
|
||||
pub(crate) fn pattern_named(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let up = name.to_ascii_uppercase();
|
||||
if pattern_exists(&up) { Some(up) } else { patfile::user_pattern_name(name) }
|
||||
}
|
||||
|
||||
/// The origin sub-prompt's keywords (a point sets a new origin).
|
||||
const ORIGIN_KW: [&str; 6] = ["Bottom left", "bottom Right", "Top left", "top riGht", "Center", "Use current"];
|
||||
|
||||
@@ -610,7 +597,7 @@ impl HatchM {
|
||||
Some(n) => n,
|
||||
None => t.trim().to_ascii_uppercase(),
|
||||
};
|
||||
if pattern_exists(&up) {
|
||||
if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() {
|
||||
self.settings.pattern = up.clone();
|
||||
s.doc_mut()?.header.set_str("HPNAME", &up);
|
||||
} else {
|
||||
|
||||
@@ -28,13 +28,6 @@ pub(crate) fn user_pattern(name: &str) -> Option<UserPattern> {
|
||||
store().iter().find(|p| p.name.eq_ignore_ascii_case(name)).cloned()
|
||||
}
|
||||
|
||||
/// The name of the user pattern that is the Greek word `name` in another case or accent
|
||||
/// ([`user_pattern`] folds ASCII only).
|
||||
pub(crate) fn user_pattern_name(name: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(name)?;
|
||||
store().iter().find(|p| same(&p.name)).map(|p| p.name.clone())
|
||||
}
|
||||
|
||||
/// The line families a new hatch of pattern `name` carries: a user pattern's, else none (the
|
||||
/// library's patterns are drawn and saved from the library).
|
||||
pub(crate) fn lines_for(name: &str) -> Vec<PatternLine> {
|
||||
|
||||
@@ -591,7 +591,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
|
||||
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
|
||||
"selection": st.shown_selection().iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
||||
"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.
|
||||
|
||||
@@ -303,9 +303,11 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
|
||||
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -26,30 +26,11 @@ const MAX_PURGE_PASSES: usize = 64;
|
||||
|
||||
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
|
||||
pub(super) fn default_path(s: &Session, layout: &str) -> String {
|
||||
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
|
||||
// beside the drawing, so those become `_`.
|
||||
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
|
||||
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
|
||||
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
|
||||
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
|
||||
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
|
||||
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file),
|
||||
None => std::path::PathBuf::from(file),
|
||||
};
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().into_owned();
|
||||
let file = format!("{layout}.dwt");
|
||||
match s.state().ok().and_then(|st| st.path.clone()) {
|
||||
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
|
||||
None => file,
|
||||
}
|
||||
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
|
||||
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
|
||||
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
|
||||
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
|
||||
let alternative = (2u64..)
|
||||
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
|
||||
.find(|alternative| !alternative.exists())
|
||||
.unwrap_or(candidate.clone());
|
||||
alternative.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
|
||||
@@ -152,32 +133,3 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod default_path_tests {
|
||||
use super::*;
|
||||
|
||||
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
|
||||
#[test]
|
||||
fn layout_name_is_one_file_name() {
|
||||
let s = Session::empty();
|
||||
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
|
||||
let p = default_path(&s, name);
|
||||
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
|
||||
assert!(p.ends_with(".dwt"));
|
||||
}
|
||||
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
|
||||
}
|
||||
|
||||
/// Windows device names are prefixed so the suggestion is never a device.
|
||||
#[test]
|
||||
fn reserved_device_names_are_prefixed() {
|
||||
let s = Session::empty();
|
||||
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
|
||||
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
|
||||
}
|
||||
for name in ["COM0", "CONSOLE", "COM10"] {
|
||||
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,14 @@ impl StretchArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to `hs` the multilines in the edit space that a selection can take there (visible, and
|
||||
/// inside an MSPACE viewport not on a layer frozen in it) with a control vertex inside `area`. A
|
||||
/// crossing selection tests the drawn element lines, which sit away from the control vertices by
|
||||
/// the multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(s: &Session, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Ok(d) = s.doc() else { return };
|
||||
let Some(store) = d.space(&s.space()) else { return };
|
||||
let frozen = s.viewport_frozen();
|
||||
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
|
||||
/// selection tests the drawn element lines, which sit away from the control vertices by the
|
||||
/// multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return };
|
||||
for e in store.iter() {
|
||||
if let EntityKind::MLine(m) = &e.kind
|
||||
&& crate::select::selectable(d, e, frozen)
|
||||
&& d.is_visible(e)
|
||||
&& !hs.contains(&e.handle)
|
||||
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
|
||||
{
|
||||
@@ -97,19 +94,19 @@ pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// A fence marks no area: the objects it crosses move whole.
|
||||
if let Some(fence) = listed("fence", 2)? {
|
||||
let hs = crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &fence);
|
||||
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
return Ok(json!({ "stretched": moved.len() }));
|
||||
}
|
||||
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
|
||||
(crate::select::select_polygon_in(s.doc()?, &space, s.viewport_frozen(), &poly, true), StretchArea::Polygon(poly))
|
||||
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
|
||||
} else {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, true), StretchArea::Box(bx))
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
add_mline_vertex_hits(s, &area, &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
|
||||
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
@@ -176,7 +173,7 @@ impl StretchM {
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(mut hs) = m.select_in(s.doc()?, &space, s.viewport_frozen()) else { return Ok(()) };
|
||||
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
|
||||
(SelectKind::Box, Some((a, b))) => b.x < a.x,
|
||||
@@ -192,7 +189,7 @@ impl StretchM {
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), true, false) = (&area, crossing, self.removing) {
|
||||
add_mline_vertex_hits(s, a, &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
|
||||
}
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
@@ -231,10 +228,10 @@ impl StretchM {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, p.x < c.x);
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
if p.x < c.x {
|
||||
add_mline_vertex_hits(s, &StretchArea::Box(bx), &mut hs);
|
||||
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
|
||||
}
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
@@ -243,7 +240,7 @@ impl StretchM {
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), p, aperture) {
|
||||
match crate::select::pick(s.doc()?, &space, p, aperture) {
|
||||
Some(h) => self.add(s, vec![h], true),
|
||||
None => self.corner = Some(p),
|
||||
}
|
||||
@@ -254,7 +251,6 @@ impl StretchM {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let frozen = s.viewport_frozen();
|
||||
let word = t.trim().to_ascii_lowercase();
|
||||
// `_C`, `_L`: the English name, as scripts write it.
|
||||
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
|
||||
@@ -271,14 +267,9 @@ impl StretchM {
|
||||
self.removing = false;
|
||||
None
|
||||
}
|
||||
"all" => Some(
|
||||
store.map(|st| st.iter().filter(|e| crate::select::selectable(d, e, frozen)).map(|e| e.handle).collect()).unwrap_or_default(),
|
||||
),
|
||||
"l" | "last" => Some(store.and_then(|st| crate::select::last_in(st, frozen)).map(|h| vec![h]).unwrap_or_default()),
|
||||
"p" | "previous" => {
|
||||
let st = s.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
|
||||
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
|
||||
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
|
||||
.interactive(|_| Ok(Box::new(NcopyM::default()))),
|
||||
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
|
||||
.menu(&["Tools", "Additional Tools", "Move/Copy/Rotate"])
|
||||
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
|
||||
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
|
||||
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
|
||||
CommandSpec::new("chspace", "Change Space", run_chspace)
|
||||
|
||||
@@ -117,12 +117,6 @@ pub(super) fn run_mocoro(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Each Copy clones every selected object: the copies together stay within MAXARRAY, as an array's do.
|
||||
// Erased handles are skipped by `apply`, so only live objects count (none: nothing is copied).
|
||||
let live = counts(s, &objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, ops.iter().filter(|o| matches!(o, Op::Copy(_))).count() as u64, live)?;
|
||||
}
|
||||
let mut copies = Vec::new();
|
||||
for op in ops {
|
||||
copies.extend(apply(s, &objs, &mut base, op)?);
|
||||
@@ -156,12 +150,6 @@ pub(super) struct MocoroM {
|
||||
|
||||
impl MocoroM {
|
||||
fn run(&mut self, s: &mut Session, op: Op) -> Result<Step> {
|
||||
if matches!(op, Op::Copy(_)) {
|
||||
let live = counts(s, &self.objs)?.0;
|
||||
if live > 0 {
|
||||
crate::cmd::array::check_size(s, 1, live)?;
|
||||
}
|
||||
}
|
||||
let before = s.state()?.doc.clone();
|
||||
let base = self.base;
|
||||
apply(s, &self.objs, &mut self.base, op)?;
|
||||
|
||||
@@ -34,12 +34,10 @@ pub(crate) fn arraypath(
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
// Segment end lengths once: each sample is then a binary search, not a scan from the start.
|
||||
let ends = c.end_lengths();
|
||||
let Some((p0, t0)) = c.at_length_in(&ends, 0.0) else { return Ok(Vec::new()) };
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
let mut mats = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length_in(&ends, step * i as f64) else { continue };
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let mut m = Mat3::translate(p - p0);
|
||||
if align {
|
||||
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
|
||||
|
||||
@@ -108,9 +108,8 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
|
||||
|
||||
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
|
||||
pub fn wildcard(pat: &str, text: &str) -> bool {
|
||||
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
|
||||
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
|
||||
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
|
||||
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
|
||||
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
|
||||
// Iterative matcher with single back-track point (linear-ish, no recursion).
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
@@ -176,14 +175,8 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
|
||||
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
|
||||
return true;
|
||||
}
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
|
||||
// long text (a layer name from a file) isn't parsed, copied and split per entity.
|
||||
const MAX_COLOR_TEXT: usize = 64;
|
||||
let (pc, vc) = (ptxt.trim(), vtxt.trim());
|
||||
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
|
||||
return false;
|
||||
}
|
||||
match (Color::parse(pc), Color::parse(vc)) {
|
||||
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
|
||||
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
@@ -280,7 +273,7 @@ fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel = match mode.as_str() {
|
||||
"exclude" => excluded,
|
||||
"append" => {
|
||||
let mut cur = s.stored_selection();
|
||||
let mut cur = s.selection();
|
||||
for h in matched {
|
||||
if !cur.contains(&h) {
|
||||
cur.push(h);
|
||||
@@ -407,26 +400,3 @@ mod tests {
|
||||
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod long_text_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long string property is compared as text only, and a colour name still equals its index.
|
||||
#[test]
|
||||
fn long_values_skip_colour_parsing() {
|
||||
let long = Value::String("x".repeat(100_000));
|
||||
assert!(compare(&long, Op::Eq, &long));
|
||||
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
|
||||
}
|
||||
|
||||
/// Padding is trimmed before the length limit, so a padded colour still matches.
|
||||
#[test]
|
||||
fn padded_colours_still_match() {
|
||||
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
|
||||
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
|
||||
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
|
||||
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,21 +99,14 @@ pub(crate) fn similar(e: &Entity, r: &Entity, mode: u32) -> bool {
|
||||
&& (mode & NAME == 0 || same_text(object_name(e), object_name(r)))
|
||||
}
|
||||
|
||||
/// The objects of the current space like any of `refs` that a selection can take there: visible,
|
||||
/// and inside an MSPACE viewport not on a layer frozen in it.
|
||||
/// The visible objects of the current space like any of `refs`.
|
||||
fn similar_objects(s: &Session, d: &Drawing, refs: &[Handle], mode: u32) -> Vec<Handle> {
|
||||
let refs: Vec<&Entity> = refs.iter().filter_map(|h| d.entity(*h).map(|e| e.as_ref())).collect();
|
||||
if refs.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(store) = d.space(&s.space()) else { return Vec::new() };
|
||||
let frozen = s.viewport_frozen();
|
||||
store
|
||||
.iter()
|
||||
.take(MAX_SCAN)
|
||||
.filter(|e| crate::select::selectable(d, e, frozen) && refs.iter().any(|r| similar(e, r, mode)))
|
||||
.map(|e| e.handle)
|
||||
.collect()
|
||||
store.iter().take(MAX_SCAN).filter(|e| d.is_visible(e) && refs.iter().any(|r| similar(e, r, mode))).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
fn mode_param(s: &Session, p: &Value) -> Result<u32> {
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
/// Largest linked file read (bytes).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
const MAX_LINK_BYTES: u64 = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,9 +238,11 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
|
||||
// that misreports its length can't be buffered to EOF.
|
||||
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
if meta.len() > MAX_LINK_BYTES {
|
||||
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
|
||||
}
|
||||
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -368,16 +370,6 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A linked path naming an endless device is refused instead of buffered to EOF.
|
||||
#[cfg(all(unix, not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn link_to_endless_device_is_rejected() {
|
||||
let dev = std::path::Path::new("/dev/zero");
|
||||
if dev.exists() {
|
||||
assert!(read_link("datalink", dev).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
|
||||
|
||||
@@ -59,7 +59,7 @@ fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bo
|
||||
let fb = Bounds2::from_points(pts.iter().copied());
|
||||
let tol = crate::select::window_tol(&fb);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_fence_in(d, &space, s.viewport_frozen(), pts) {
|
||||
for h in crate::select::select_fence(d, &space, pts) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let polys = crate::select::hit_polylines(d, e, tol);
|
||||
let mut xs: Vec<Vec2> = Vec::new();
|
||||
@@ -96,7 +96,7 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
|
||||
];
|
||||
let inside = |p: Vec2| bx.expand(tol).contains(p);
|
||||
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
|
||||
for h in crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, true) {
|
||||
for h in crate::select::select_window(d, &space, bx, true) {
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let mut best: Option<Vec2> = None;
|
||||
for pl in crate::select::hit_polylines(d, e, tol) {
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
|
||||
.menu(&["Tools", "Additional Tools", "Convert Text to Mtext"])
|
||||
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
|
||||
.params(
|
||||
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
|
||||
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
|
||||
|
||||
@@ -8,12 +8,6 @@ pub struct Snapshot {
|
||||
pub selection: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// [`DocState::drawn_in_viewport`] looks its handles up one by one while there is fewer than one
|
||||
/// of them for every `LOOKUP_COST` objects of model space, and passes over model space once
|
||||
/// otherwise. Timed on drawings of 40,000 and of 200,000 lines, the two cost the same at about
|
||||
/// one handle for every 47 and for every 18 objects.
|
||||
const LOOKUP_COST: usize = 32;
|
||||
|
||||
/// One open drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
@@ -125,65 +119,13 @@ impl DocState {
|
||||
}
|
||||
/// The active MSPACE viewport, if it still exists in the current layout.
|
||||
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
|
||||
self.active_viewport_ref().map(|(h, v)| (h, v.clone()))
|
||||
}
|
||||
/// [`DocState::active_viewport`] without copying the viewport.
|
||||
fn active_viewport_ref(&self) -> Option<(Handle, &cadcraft_doc::Viewport)> {
|
||||
let h = self.mspace?;
|
||||
let e = self.doc.space(&self.space)?.get(h)?;
|
||||
match &e.kind {
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v)),
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport (VPLAYER, "VP Freeze"): that viewport does
|
||||
/// not draw their objects, so picks, selections and grips made through it leave them out.
|
||||
/// Empty in paper space and on the Model tab.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
match self.active_viewport_ref() {
|
||||
Some((_, v)) => &v.frozen_layers,
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
/// Whether `e` is a model-space object the active MSPACE viewport does not draw: it is on a
|
||||
/// layer frozen in that viewport ([`DocState::viewport_frozen`]). Objects on the sheet are not
|
||||
/// drawn through the viewport, so its frozen layers do not hide them.
|
||||
pub fn hidden_in_viewport(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
crate::select::on_frozen_layer(self.viewport_frozen(), e) && self.doc.model.contains(e.handle)
|
||||
}
|
||||
/// Whether the view being edited shows `e`: the drawing shows it ([`Drawing::is_visible`]),
|
||||
/// and the active MSPACE viewport, if there is one, draws it
|
||||
/// ([`DocState::hidden_in_viewport`]).
|
||||
pub fn shows(&self, e: &cadcraft_doc::Entity) -> bool {
|
||||
self.doc.is_visible(e) && !self.hidden_in_viewport(e)
|
||||
}
|
||||
/// `handles` without the objects the active MSPACE viewport does not draw
|
||||
/// ([`DocState::hidden_in_viewport`]): a set of objects made earlier (the selection, the
|
||||
/// Previous set) as that viewport takes it. All of them anywhere else.
|
||||
pub(crate) fn drawn_in_viewport(&self, handles: &[Handle]) -> Vec<Handle> {
|
||||
let frozen = self.viewport_frozen();
|
||||
if frozen.is_empty() || handles.is_empty() {
|
||||
return handles.to_vec();
|
||||
}
|
||||
let model = &self.doc.model;
|
||||
let hidden = |e: &Arc<cadcraft_doc::Entity>| crate::select::on_frozen_layer(frozen, e);
|
||||
// The UI asks for the selection several times a frame. A few handles are looked up one
|
||||
// by one; beyond that, one pass over model space costs less than the lookups.
|
||||
if handles.len().saturating_mul(LOOKUP_COST) < model.len() {
|
||||
return handles.iter().copied().filter(|h| !model.get(*h).is_some_and(hidden)).collect();
|
||||
}
|
||||
let mut gone: Vec<Handle> = model.iter().filter(|e| hidden(e)).map(|e| e.handle).collect();
|
||||
gone.sort_unstable();
|
||||
handles.iter().copied().filter(|h| gone.binary_search(h).is_err()).collect()
|
||||
}
|
||||
/// The selection as the view being edited has it: [`DocState::selection`] without the objects
|
||||
/// the active MSPACE viewport does not draw. Those stay in [`DocState::selection`] until the
|
||||
/// selection is replaced or cleared, and count as selected again once that viewport draws
|
||||
/// them; until then nothing that starts from the selection (a command, a grip, a palette)
|
||||
/// edits them through that viewport.
|
||||
pub fn shown_selection(&self) -> Vec<Handle> {
|
||||
self.drawn_in_viewport(&self.selection)
|
||||
}
|
||||
pub fn paper_view(&self) -> View {
|
||||
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
//! Words typed on a Greek keyboard layout.
|
||||
//!
|
||||
//! With the Greek layout active, the keys that type LINE produce ΛΙΝΕ. Command names, aliases and
|
||||
//! prompt keywords are Latin, so a Greek speaker would have to switch layouts for every command.
|
||||
//! [`latin_keys`] reads a word as the Latin keys that produced it (the layout puts each Greek
|
||||
//! letter on the key of the Latin letter it sounds like): the command line then accepts ΛΙΝΕ and
|
||||
//! ΣΤΡΕΤΨΗ as LINE and STRETCH. Accents (the layout's dead key) are ignored.
|
||||
|
||||
/// The Latin key a Greek letter is typed on (lower case, accents removed).
|
||||
fn key_of(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'α' | 'ά' => 'a',
|
||||
'β' => 'b',
|
||||
'γ' => 'g',
|
||||
'δ' => 'd',
|
||||
'ε' | 'έ' => 'e',
|
||||
'ζ' => 'z',
|
||||
'η' | 'ή' => 'h',
|
||||
'θ' => 'u',
|
||||
'ι' | 'ί' | 'ϊ' | 'ΐ' => 'i',
|
||||
'κ' => 'k',
|
||||
'λ' => 'l',
|
||||
'μ' => 'm',
|
||||
'ν' => 'n',
|
||||
'ξ' => 'j',
|
||||
'ο' | 'ό' => 'o',
|
||||
'π' => 'p',
|
||||
'ρ' => 'r',
|
||||
'σ' => 's',
|
||||
// The W key types the final sigma.
|
||||
'ς' => 'w',
|
||||
'τ' => 't',
|
||||
'υ' | 'ύ' | 'ϋ' | 'ΰ' => 'y',
|
||||
'φ' => 'f',
|
||||
'χ' => 'x',
|
||||
'ψ' => 'c',
|
||||
'ω' | 'ώ' => 'v',
|
||||
// The Q key types the semicolon, which Greek text writes as ";" or U+037E.
|
||||
';' | '\u{37e}' => 'q',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `c` is a Greek letter (the basic and accented ranges the layout types).
|
||||
fn is_greek_letter(c: char) -> bool {
|
||||
matches!(c, '\u{386}'..='\u{3ce}') && c != '\u{387}'
|
||||
}
|
||||
|
||||
/// `word` read as the Latin keys of a Greek-layout keyboard, lower case: `Some` only when the word
|
||||
/// has a Greek letter and every character is a Greek letter the layout types, the semicolon on
|
||||
/// the Q key, or ASCII (itself, in lower case). A combining tonos or dialytika, the accent of
|
||||
/// decomposed text, is ignored like the accent of a precomposed letter, but only straight after
|
||||
/// the Greek letter it accents and once each: anywhere else the word is not Greek-layout typing.
|
||||
pub fn latin_keys(word: &str) -> Option<String> {
|
||||
let mut greek = false;
|
||||
// What the previous Greek letter already carries (tonos, dialytika); `None` when the previous
|
||||
// character is not a Greek letter, which leaves a combining mark nothing to accent.
|
||||
let mut marks: Option<(bool, bool)> = None;
|
||||
let mut out = String::with_capacity(word.len());
|
||||
for c in word.chars() {
|
||||
if matches!(c, '\u{301}' | '\u{308}') {
|
||||
let (tonos, dialytika) = marks.as_mut()?;
|
||||
if std::mem::replace(if c == '\u{301}' { tonos } else { dialytika }, true) {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c.is_ascii() {
|
||||
// The Q key types the ASCII semicolon in the Greek layout; every other ASCII character is itself.
|
||||
out.push(if c == ';' { 'q' } else { c.to_ascii_lowercase() });
|
||||
marks = None;
|
||||
continue;
|
||||
}
|
||||
let lower = c.to_lowercase().next().unwrap_or(c);
|
||||
out.push(key_of(lower)?);
|
||||
let letter = is_greek_letter(lower);
|
||||
greek |= letter;
|
||||
marks = letter.then_some((false, false));
|
||||
}
|
||||
greek.then_some(out)
|
||||
}
|
||||
|
||||
/// `word` with its case, accents and final sigma folded away, when it is Greek-layout typing: every
|
||||
/// spelling of one Greek word gives the same string. It is the word's keys, with the final sigma
|
||||
/// read as the letter sigma instead of the W key.
|
||||
pub fn greek_fold(word: &str) -> Option<String> {
|
||||
latin_keys(&word.replace('ς', "σ"))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are one Greek word, whatever their case, accents or final sigma.
|
||||
pub fn same_greek_word(a: &str, b: &str) -> bool {
|
||||
same_greek_word_as(b).is_some_and(|same| same(a))
|
||||
}
|
||||
|
||||
/// A test for "is the Greek word `word`, whatever the case, accents or final sigma", or `None`
|
||||
/// when `word` is not Greek-layout typing. `word` is folded here, once: looking a typed word up
|
||||
/// among many names costs its length once, not once for every name.
|
||||
pub fn same_greek_word_as(word: &str) -> Option<impl Fn(&str) -> bool> {
|
||||
let fold = greek_fold(word)?;
|
||||
Some(move |name: &str| greek_fold(name).as_deref() == Some(fold.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{latin_keys, same_greek_word};
|
||||
|
||||
/// One Greek word compares equal in any case, accent or final sigma; another word, or a Latin
|
||||
/// one, does not.
|
||||
#[test]
|
||||
fn one_greek_word_is_the_same_in_any_case_accent_or_final_sigma() {
|
||||
for (a, b) in [("ΣΟΛΙΔ", "σολιδ"), ("ΣΟΛΙΔ", "σολίδ"), ("ΛΑΣ", "λας"), ("ΛΑΣ", "λασ"), ("ΛΙ\u{301}ΝΕ", "λινε")]
|
||||
{
|
||||
assert!(same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
for (a, b) in [("ΣΟΛΙΔ", "ΣΟΛΙ"), ("ΣΟΛΙΔ", "solid"), ("solid", "SOLID")] {
|
||||
assert!(!same_greek_word(a, b), "{a} {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A character the layout does not type is never taken for the Greek letter Unicode upper-cases
|
||||
/// it to: the micro sign is not μ, the long s and the sharp s are not σ, an alpha with an iota
|
||||
/// subscript is not αι, the theta symbol is not θ.
|
||||
#[test]
|
||||
fn a_character_the_layout_does_not_type_is_no_spelling_of_a_greek_letter() {
|
||||
let pairs = [("\u{b5}", "μ"), ("Λ\u{17f}", "ΛΣ"), ("Λ\u{df}", "ΛΣΣ"), ("\u{1fb3}", "αι"), ("\u{3d1}", "θ")];
|
||||
let same: Vec<_> = pairs.iter().filter(|(a, b)| same_greek_word(a, b) || same_greek_word(b, a)).collect();
|
||||
assert!(same.is_empty(), "{same:?}");
|
||||
}
|
||||
|
||||
/// Greek-layout typing reads as its Latin keys: plain, accented and decomposed letters, the Q
|
||||
/// and W keys, ASCII prefixes.
|
||||
#[test]
|
||||
fn greek_layout_commands_read_as_their_latin_keys() {
|
||||
assert_eq!(latin_keys("ΛΙΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("λινε").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΣΤΡΕΤΨΗ").as_deref(), Some("stretch"));
|
||||
assert_eq!(latin_keys("ΜΙΡΡΟΡ").as_deref(), Some("mirror"));
|
||||
assert_eq!(latin_keys("ΘΝΔΟ").as_deref(), Some("undo"));
|
||||
// The Q key types ";", the W key the final sigma.
|
||||
assert_eq!(latin_keys(";ΣΑΩΕ").as_deref(), Some("qsave"));
|
||||
assert_eq!(latin_keys("ςΒΛΟΨΚ").as_deref(), Some("wblock"));
|
||||
// Accented letters (the layout's dead key) read as the plain letter.
|
||||
assert_eq!(latin_keys("ΛΊΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΆΡΡΑΥ").as_deref(), Some("array"));
|
||||
// The same accents written as a combining mark after the letter (decomposed text).
|
||||
assert_eq!(latin_keys("ΛΙ\u{301}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙΝΕ\u{301}").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}ΝΕ").as_deref(), Some("line"));
|
||||
assert_eq!(latin_keys("ΛΙ\u{308}\u{301}ΝΕ").as_deref(), Some("line"), "dialytika and tonos on one letter");
|
||||
// Prefixes and digits are kept.
|
||||
assert_eq!(latin_keys("-ΛΑΥΕΡ").as_deref(), Some("-layer"));
|
||||
assert_eq!(latin_keys("_ΛΙΝΕ").as_deref(), Some("_line"));
|
||||
}
|
||||
|
||||
/// A word with no Greek letter, or with a character the layout does not type, is not read.
|
||||
#[test]
|
||||
fn words_that_are_not_greek_layout_typing_are_left_alone() {
|
||||
assert_eq!(latin_keys("line"), None, "no Greek letter");
|
||||
assert_eq!(latin_keys(""), None);
|
||||
assert_eq!(latin_keys(";"), None, "a semicolon alone is not a word");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ Ж"), None, "a letter the layout does not type");
|
||||
assert_eq!(latin_keys("ΛΙΝΕ 日本"), None);
|
||||
assert_eq!(latin_keys("\u{387}"), None, "the ano teleia is punctuation, not a letter");
|
||||
}
|
||||
|
||||
/// A combining tonos or dialytika counts only straight after a Greek letter, and once each.
|
||||
#[test]
|
||||
fn a_combining_mark_must_follow_the_greek_letter_it_accents() {
|
||||
assert_eq!(latin_keys("\u{301}ΛΙΝΕ"), None, "no letter before the mark");
|
||||
assert_eq!(latin_keys("\u{308}ΛΙΝΕ"), None);
|
||||
assert_eq!(latin_keys("ΛΙΝΕ-\u{301}"), None, "after punctuation");
|
||||
assert_eq!(latin_keys("a\u{301}ΛΙΝΕ"), None, "after a Latin letter");
|
||||
assert_eq!(latin_keys("Λ\u{301}\u{301}ΙΝΕ"), None, "a second tonos on one letter");
|
||||
assert_eq!(latin_keys("Λ\u{308}\u{308}ΙΝΕ"), None, "a second dialytika on one letter");
|
||||
}
|
||||
|
||||
/// A 100 000-letter word and odd code points are read or refused; nothing panics.
|
||||
#[test]
|
||||
fn hostile_text_never_panics() {
|
||||
let long = "Ω".repeat(100_000);
|
||||
assert_eq!(latin_keys(&long).map(|s| s.len()), Some(100_000));
|
||||
for s in ["\u{0}", "\u{301}", "ΛΙΝΕ\u{301}", "\u{10ffff}", "ς\u{3c2}\u{3a3}"] {
|
||||
let _ = latin_keys(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ mod finite;
|
||||
pub mod grip_dims;
|
||||
pub mod grips;
|
||||
mod guard;
|
||||
pub mod keyboard;
|
||||
pub mod lisp;
|
||||
pub mod mtext_edit;
|
||||
pub mod pointmod;
|
||||
@@ -237,25 +236,11 @@ impl Session {
|
||||
pub fn space(&self) -> Space {
|
||||
self.state().map(DocState::edit_space).unwrap_or_default()
|
||||
}
|
||||
/// The layers frozen in the active MSPACE viewport ([`DocState::viewport_frozen`]): what a
|
||||
/// pick or a selection in [`Session::space`] leaves out. Empty with no drawing open.
|
||||
pub fn viewport_frozen(&self) -> &[String] {
|
||||
self.state().map(DocState::viewport_frozen).unwrap_or_default()
|
||||
}
|
||||
/// The tab being displayed: Model or a layout, regardless of MSPACE.
|
||||
pub fn layout_space(&self) -> Space {
|
||||
self.state().map(|s| s.space.clone()).unwrap_or_default()
|
||||
}
|
||||
/// The selected objects as the view being edited has them ([`DocState::shown_selection`]):
|
||||
/// inside an MSPACE viewport, without the objects on layers frozen in that viewport. Commands,
|
||||
/// grips and palettes start from this; [`DocState::selection`] holds every selected object.
|
||||
pub fn selection(&self) -> Vec<Handle> {
|
||||
self.state().map(DocState::shown_selection).unwrap_or_default()
|
||||
}
|
||||
/// Every selected object ([`DocState::selection`]), also the ones the active MSPACE viewport
|
||||
/// does not draw, which [`Session::selection`] leaves out. Adding to the selection or removing
|
||||
/// from it starts from this, so that it keeps them.
|
||||
pub(crate) fn stored_selection(&self) -> Vec<Handle> {
|
||||
self.state().map(|s| s.selection.clone()).unwrap_or_default()
|
||||
}
|
||||
pub fn set_selection(&mut self, sel: Vec<Handle>) {
|
||||
|
||||
@@ -123,12 +123,6 @@ pub struct LispState {
|
||||
acad_loaded: bool,
|
||||
/// Memory caps (lowered by tests).
|
||||
pub(crate) limits: Limits,
|
||||
/// Interpreter steps all synchronous callbacks have run, nested ones included. A callback's
|
||||
/// allowance counts what ran since it started, so the callbacks it calls spend its steps.
|
||||
pub(crate) callback_used: u64,
|
||||
/// The `callback_used` value the callback running now (and so every one it calls) must not
|
||||
/// pass: the lowest of its own and its callers' allowances.
|
||||
pub(crate) callback_ceiling: Option<u64>,
|
||||
}
|
||||
|
||||
/// What U and REDO inside the running routine take back: the commands it ran, each as the drawing
|
||||
@@ -421,12 +415,9 @@ impl Session {
|
||||
|
||||
/// Move printed text to the command history: complete lines, and with `all` the rest too.
|
||||
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
|
||||
// One pass over the buffer, dropping the printed lines once at the end: draining each line
|
||||
// from the front shifted the rest every time, quadratic in newline-dense output.
|
||||
let mut done = 0;
|
||||
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
|
||||
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
|
||||
done += i + 1;
|
||||
while let Some(i) = env.out.find('\n') {
|
||||
let line: String = env.out.drain(..=i).collect();
|
||||
let line = line.trim_end_matches(['\n', '\r']).to_string();
|
||||
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
|
||||
// leaving an empty one; after LISP's own line it does print an empty line.
|
||||
if line.is_empty() && env.printed_at != Some(self.log.len()) {
|
||||
@@ -436,7 +427,6 @@ impl Session {
|
||||
self.echo(line);
|
||||
env.printed_at = Some(self.log.len());
|
||||
}
|
||||
env.out.drain(..done);
|
||||
if all && !env.out.is_empty() {
|
||||
let line = std::mem::take(&mut env.out);
|
||||
self.echo(line);
|
||||
|
||||
@@ -27,14 +27,8 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
|
||||
if !writing {
|
||||
match read_text_file(&path) {
|
||||
Some(t) => {
|
||||
// Each line has a String header and a separately allocated buffer. Charge a
|
||||
// conservative 64 bytes per line for both, including the allocator's minimum
|
||||
// allocation size, before collecting so many short lines cannot evade the budget.
|
||||
let count = t.lines().count();
|
||||
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
|
||||
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
|
||||
f.lines = std::collections::VecDeque::with_capacity(count);
|
||||
f.lines.extend(t.lines().map(str::to_string));
|
||||
charge_bytes(t.len())?;
|
||||
f.lines = t.lines().map(str::to_string).collect();
|
||||
}
|
||||
None => return Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
@@ -106,19 +100,8 @@ fn can_write(_path: &str) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_text_file(path: &str, text: &str, append: bool) {
|
||||
use std::io::Write;
|
||||
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
|
||||
// instead of stalling the interpreter; only a regular file is then truncated and written.
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.create(true).write(true).append(append);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
o.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
if let Ok(mut f) = o.open(path)
|
||||
&& f.metadata().is_ok_and(|m| m.is_file())
|
||||
&& (append || f.set_len(0).is_ok())
|
||||
{
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
|
||||
if let Ok(mut f) = f {
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -135,29 +118,3 @@ fn file_exists(path: &str) -> bool {
|
||||
fn file_exists(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
|
||||
#[test]
|
||||
fn write_to_a_fifo_without_a_reader_does_not_block() {
|
||||
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
write_text_file(fifo.to_str().unwrap(), "x", false);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
let f = dir.join("f.txt");
|
||||
std::fs::write(&f, "long old text").unwrap();
|
||||
write_text_file(f.to_str().unwrap(), "new", false);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
|
||||
write_text_file(f.to_str().unwrap(), "+", true);
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
|
||||
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
@@ -114,13 +114,7 @@ pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => {
|
||||
// The strings in the list it makes are new, each as long as its text at most: the
|
||||
// text is charged, though a list result isn't inspected by `charge_result`.
|
||||
let text = string(v)?;
|
||||
charge_bytes(text.len())?;
|
||||
read::read_one(&text)
|
||||
}
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Entity, Handle, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
use super::LispError;
|
||||
use super::builtins::args_err;
|
||||
@@ -179,12 +179,7 @@ fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_js
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => {
|
||||
// A string is copied whole each time it occurs: it spends the allowance by its length
|
||||
// (one unit per 16 bytes, so about 32 MB in all), shared or not.
|
||||
*budget = budget.checked_sub(s.len() as u64 / 16)?;
|
||||
serde_json::json!(&**s)
|
||||
}
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
@@ -253,20 +248,20 @@ fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
|
||||
continue;
|
||||
}
|
||||
let v = match t.value {
|
||||
craft_dxf::Value::Real(r) => r,
|
||||
craft_dxf::Value::Int(i) => i as f64,
|
||||
cadcraft_dxf::Value::Real(r) => r,
|
||||
cadcraft_dxf::Value::Int(i) => i as f64,
|
||||
_ => continue,
|
||||
};
|
||||
t.value = craft_dxf::Value::Real(f(v));
|
||||
t.value = cadcraft_dxf::Value::Real(f(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_value(t: &Tag) -> Value {
|
||||
match &t.value {
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => Value::str(s),
|
||||
craft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
craft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
craft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
|
||||
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,16 +296,12 @@ fn groups_to_list(tags: &[Tag]) -> Vec<Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The most string bytes an entity list may hold (counted per occurrence, shared or not).
|
||||
const MAX_GROUP_TEXT: usize = 32 << 20;
|
||||
|
||||
/// An association list back to DXF groups (points split into x, y, z groups). The type
|
||||
/// (`0`), the entity name (`-1`), the handle (`5`), the owner (`330`) and the layout (`410`) are
|
||||
/// left out: they aren't the entity's to set.
|
||||
fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
let mut kind = None;
|
||||
let mut tags = Vec::new();
|
||||
let mut text_left = MAX_GROUP_TEXT;
|
||||
for item in list.iter() {
|
||||
let Value::Cons(c) = &item else { return Err(LispError::bad_arg("listp", &item)) };
|
||||
let Value::Int(code) = c.car else { return Err(LispError::bad_arg("fixnump", &c.car)) };
|
||||
@@ -332,11 +323,7 @@ fn list_to_groups(list: &Value) -> Result<(String, Vec<Tag>), LispError> {
|
||||
_ => tags.push(match &v {
|
||||
Value::Int(i) => Tag::i(code, i64::from(*i)),
|
||||
Value::Real(r) => Tag::f(code, *r),
|
||||
Value::Str(s) => {
|
||||
// Each occurrence of a shared string becomes its own copy in the tag.
|
||||
text_left = text_left.checked_sub(s.len()).ok_or_else(|| LispError::new("out of memory: the entity list holds too much text"))?;
|
||||
Tag::s(code, &**s)
|
||||
}
|
||||
Value::Str(s) => Tag::s(code, &**s),
|
||||
Value::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
@@ -651,19 +638,15 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
Some("X") => all(s, true),
|
||||
Some("L") => {
|
||||
let order = handles_in_order(ctx);
|
||||
// Inside an MSPACE viewport, the objects that viewport does not draw are passed over.
|
||||
let st = ctx.s.state().ok();
|
||||
let drawn = |h: &Handle| st.is_none_or(|st| st.doc.entity(*h).is_none_or(|e| !st.hidden_in_viewport(e)));
|
||||
order.iter().rev().copied().find(drawn).into_iter().collect()
|
||||
order.last().copied().into_iter().collect()
|
||||
}
|
||||
Some("P") => ctx.s.state().map(|st| st.drawn_in_viewport(&st.previous_selection)).unwrap_or_default(),
|
||||
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
|
||||
Some("I") => ctx.s.selection(),
|
||||
Some("W") | Some("C") => {
|
||||
let (p1, p2) = (point(0)?, point(1)?);
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let bx = cadcraft_geom::Bounds2::from_points([p1, p2]);
|
||||
crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, mode.as_deref() == Some("C"))
|
||||
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
|
||||
}
|
||||
Some("F") | Some("WP") | Some("CP") => {
|
||||
let poly: Vec<Vec2> =
|
||||
@@ -671,8 +654,8 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
match mode.as_deref() {
|
||||
Some("F") => crate::select::select_fence_in(d, &space, s.viewport_frozen(), &poly),
|
||||
m => crate::select::select_polygon_in(d, &space, s.viewport_frozen(), &poly, m == Some("CP")),
|
||||
Some("F") => crate::select::select_fence(d, &space, &poly),
|
||||
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
|
||||
}
|
||||
}
|
||||
Some(":S") | Some("A") | None => {
|
||||
@@ -688,7 +671,7 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
let s = &*ctx.s;
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
crate::select::pick_in(d, &space, s.viewport_frozen(), p, ap).into_iter().collect()
|
||||
crate::select::pick(d, &space, p, ap).into_iter().collect()
|
||||
}
|
||||
}
|
||||
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
|
||||
@@ -1142,27 +1125,3 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
/// One 64 KB string occurring many times is charged by its length each time.
|
||||
#[test]
|
||||
fn shared_strings_spend_the_conversion_budgets() {
|
||||
let big = Value::str(&"x".repeat(64 << 10));
|
||||
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
|
||||
let list = |n: usize| {
|
||||
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
|
||||
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
|
||||
Value::list(items)
|
||||
};
|
||||
assert!(list_to_groups(&list(600)).is_err());
|
||||
assert!(list_to_groups(&list(100)).is_ok());
|
||||
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
|
||||
let mut budget = TO_JSON_BUDGET;
|
||||
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +201,6 @@ impl Machine {
|
||||
if n > budget {
|
||||
return Outcome::Wait(Need::Yield);
|
||||
}
|
||||
// Charge a callback's step as it runs, so callbacks it calls see what it already spent.
|
||||
if ctx.depth > 0 {
|
||||
ctx.s.lisp.callback_used = ctx.s.lisp.callback_used.saturating_add(1);
|
||||
}
|
||||
let r = match std::mem::replace(&mut self.state, State::Ret(Value::Nil)) {
|
||||
State::Eval(expr) => self.eval(ctx, expr),
|
||||
State::Call(f, args) => self.call(ctx, f, args),
|
||||
@@ -650,44 +646,29 @@ impl Ctx<'_> {
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let start = self.s.lisp.callback_used;
|
||||
// This callback may run until `callback_used` reaches `ceiling`: `cap` steps from now, or
|
||||
// sooner where a caller's allowance ends, so callbacks nested in callbacks share one
|
||||
// allowance instead of each taking a fresh one (an exponential tree otherwise).
|
||||
let outer = self.s.lisp.callback_ceiling;
|
||||
let ceiling = start.saturating_add(cap).min(outer.unwrap_or(u64::MAX));
|
||||
self.s.lisp.callback_ceiling = Some(ceiling);
|
||||
let r = self.drive_callback(f, args, ceiling);
|
||||
self.s.lisp.callback_ceiling = outer;
|
||||
r
|
||||
}
|
||||
|
||||
/// Run callback `f` in slices until it finishes or `callback_used` reaches `ceiling`.
|
||||
fn drive_callback(&mut self, f: &Value, args: Vec<Value>, ceiling: u64) -> Result<Value, LispError> {
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||
// (the step budget ran out) is retried until the ceiling, so a constant-memory infinite
|
||||
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
|
||||
// one calls are counted in `callback_used` too.
|
||||
// thread and bypassing the blocking execution cap (CWE-400).
|
||||
let mut spent: u64 = 0;
|
||||
loop {
|
||||
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
let outcome = m.run(&mut sub, slice);
|
||||
match outcome {
|
||||
// The steps were charged as they ran; finishing past the shared allowance is a failure too.
|
||||
Outcome::Done(_) if sub.s.lisp.callback_used > ceiling => {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
match m.run(&mut sub, slice) {
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {}
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ impl GetRequest {
|
||||
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
|
||||
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
|
||||
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
|
||||
match crate::select::pick_in(d, &space, s.viewport_frozen(), Vec2::new(p[0], p[1]), ap) {
|
||||
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
|
||||
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
|
||||
None => Err("Missed, try again.".into()),
|
||||
}
|
||||
|
||||
@@ -286,35 +286,6 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
|
||||
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
|
||||
}
|
||||
|
||||
/// Callbacks that call callbacks share one allowance: a branching routine can't multiply it.
|
||||
#[test]
|
||||
fn nested_callbacks_share_one_step_allowance() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000_000;
|
||||
// 4^14 leaf calls if every callback took a fresh allowance; the whole tree gets one.
|
||||
let started = std::time::Instant::now();
|
||||
let e = eval(&mut s, "(defun f (n) (if (> n 0) (vl-every '(lambda (x) (f (1- n)) t) '(1 2 3 4)) t)) (f 14)").unwrap_err();
|
||||
assert!(e.contains("callback stopped after 1000000 steps"), "{e}");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(60));
|
||||
// The next run starts afresh.
|
||||
assert_eq!(ok(&mut s, "(f 1)"), "T");
|
||||
}
|
||||
|
||||
/// An outer callback's own steps count against the allowance before the callbacks it calls run.
|
||||
#[test]
|
||||
fn an_outer_callbacks_steps_are_charged_before_a_nested_one() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
ok(&mut s, "(setq inner-steps 0)");
|
||||
// The outer callback spends ~600 steps, then a nested one tries to spend ~600 more.
|
||||
let r = eval(
|
||||
&mut s,
|
||||
"(vl-remove-if '(lambda (x) (repeat 150 (setq a 1)) (vl-remove-if '(lambda (y) (repeat 150 (setq inner-steps (1+ inner-steps)))) '(1)) nil) '(1))",
|
||||
);
|
||||
assert_eq!(r, Err("callback stopped after 1000 steps".into()));
|
||||
assert!(ok(&mut s, "inner-steps").parse::<i64>().unwrap() < 150);
|
||||
}
|
||||
|
||||
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
|
||||
#[test]
|
||||
fn setvar_rejects_an_oversized_shared_structure() {
|
||||
@@ -336,16 +307,6 @@ fn printing() {
|
||||
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
|
||||
}
|
||||
|
||||
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
|
||||
#[test]
|
||||
fn printing_many_lines_is_linear() {
|
||||
let mut s = session();
|
||||
let started = std::time::Instant::now();
|
||||
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_draws_and_one_routine_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
@@ -1016,9 +977,6 @@ fn strings_are_capped() {
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
let e = eval(&mut s, "(repeat 200000 (princ \"0123456789\"))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// READ makes new strings inside the list it returns: they count, though the result is a list.
|
||||
let e = eval(&mut s, "(setq a \"(\\\"0123456789\\\")\") (repeat 100000 (setq b (read a)))").unwrap_err();
|
||||
assert!(e.contains("too much text"), "{e}");
|
||||
// The budget is per run: the next one starts afresh.
|
||||
assert_eq!(ok(&mut s, "(strcat \"a\" \"b\")"), "\"ab\"");
|
||||
}
|
||||
|
||||
@@ -346,11 +346,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
|
||||
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
|
||||
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
|
||||
equal_within(a, b, fuzz, EQUAL_BUDGET)
|
||||
}
|
||||
|
||||
/// [`equal`] with its work allowance (node visits plus string bytes / 64) given.
|
||||
fn equal_within(a: &Value, b: &Value, fuzz: f64, mut budget: u64) -> bool {
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
@@ -377,19 +373,7 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => {
|
||||
// Comparing two strings costs their length, not one visit: a shared list of long
|
||||
// equal strings must spend the allowance by the bytes it compares (one unit per 64).
|
||||
if let (Value::Str(x), Value::Str(y)) = (&a, &b) {
|
||||
let cost = (x.len().min(y.len()) / 64) as u64;
|
||||
if cost >= *budget {
|
||||
*budget = 0;
|
||||
return false;
|
||||
}
|
||||
*budget -= cost;
|
||||
}
|
||||
return atom_equal(&a, &b, fuzz);
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,20 +540,6 @@ fn write_value(out: &mut String, v: &Value, escape: bool, depth: usize) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `equal` charges the bytes of the strings it compares: a list of long equal strings fits a
|
||||
/// big allowance but not a small one, though it has few nodes.
|
||||
#[test]
|
||||
fn equal_spends_its_allowance_on_string_bytes() {
|
||||
let long = Value::str(&"x".repeat(6_400));
|
||||
let other = Value::str(&"x".repeat(6_400));
|
||||
let list = |v: &Value| Value::list(vec![v.clone(); 100]);
|
||||
let (a, b) = (list(&long), list(&other));
|
||||
// 100 strings x 100 units each, plus about 100 node visits.
|
||||
assert!(equal_within(&a, &b, 0.0, 20_000));
|
||||
assert!(!equal_within(&a, &b, 0.0, 5_000));
|
||||
assert!(equal(&a, &b, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reals_print_with_six_significant_digits() {
|
||||
assert_eq!(real_text(1.0), "1.0");
|
||||
|
||||
@@ -102,10 +102,6 @@ pub struct Prompt {
|
||||
/// the routine writes, so it cannot be read for the word "Select" (#1598).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub picks_one: bool,
|
||||
/// The prompt takes a block's name as well as its keywords: a Greek word that is an existing
|
||||
/// block's name is that block, not a keyword read from its Latin keys.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub takes_block: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -140,11 +136,6 @@ impl Prompt {
|
||||
self.picker = Some(p);
|
||||
self
|
||||
}
|
||||
/// Takes a block's name besides its keywords (the `takes_block` field).
|
||||
pub fn takes_block(mut self) -> Self {
|
||||
self.takes_block = true;
|
||||
self
|
||||
}
|
||||
pub fn kw(mut self, k: &[&str]) -> Self {
|
||||
self.keywords = k.iter().map(ToString::to_string).collect();
|
||||
self
|
||||
@@ -224,24 +215,6 @@ impl Prompt {
|
||||
return None;
|
||||
}
|
||||
let tl = t.to_ascii_lowercase();
|
||||
// A keyword typed with the Greek keyboard layout active (ΘΝΔΟ for Undo) is its Latin keys, unless
|
||||
// the prompt offers that spelling itself (a Greek keyword in a LISP program's list): its word
|
||||
// or a prefix of it as typed (ΘΝ for ΘΝΔΟ, not UN for Undo) is read before the Latin keys.
|
||||
if let Some(latin) = crate::keyboard::latin_keys(t) {
|
||||
// Greek letters have case, accents and a final sigma, which the ASCII-only folding above does not see.
|
||||
let typed = crate::keyboard::greek_fold(t).unwrap_or_else(|| latin.clone());
|
||||
let greek = || self.keywords.iter().filter_map(|k| crate::keyboard::greek_fold(k).map(|fold| (k, fold)));
|
||||
return greek()
|
||||
.find(|(_, k)| *k == typed)
|
||||
.or_else(|| greek().find(|(_, k)| k.starts_with(&typed)))
|
||||
.map(|(k, _)| k.clone())
|
||||
.or_else(|| self.match_latin_keyword(&latin));
|
||||
}
|
||||
self.match_latin_keyword(&tl)
|
||||
}
|
||||
|
||||
/// [`Prompt::match_keyword`] for text already read as Latin letters, in lower case.
|
||||
fn match_latin_keyword(&self, tl: &str) -> Option<String> {
|
||||
let keys = self.keywords.iter().map(|k| {
|
||||
let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::<String>().to_ascii_lowercase();
|
||||
let shortcut =
|
||||
@@ -254,8 +227,8 @@ impl Prompt {
|
||||
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
|
||||
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
|
||||
keys.clone()
|
||||
.find(|(_, kl, sc)| tl == kl.as_str() || tl == sc.as_str())
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(tl)))
|
||||
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
|
||||
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
|
||||
.map(|(k, _, _)| k.clone())
|
||||
}
|
||||
}
|
||||
@@ -421,29 +394,6 @@ mod tests {
|
||||
assert_eq!(parse_point("1,2,3,4", last), None);
|
||||
}
|
||||
|
||||
/// A keyword the prompt offers in Greek is matched as spelled (any case, accent or final sigma,
|
||||
/// or a prefix) before the Latin reading of the same keys; without one, the keys answer the
|
||||
/// Latin keyword.
|
||||
#[test]
|
||||
fn a_greek_keyword_is_matched_as_spelled_before_its_latin_reading() {
|
||||
// ΘΝΔΟ is the keys U-N-D-O, so it answers Undo, unless the prompt offers the Greek word itself
|
||||
// (a LISP program's keyword list).
|
||||
let both = Prompt::new("", Accept::TEXT).kw(&["Undo", "ΘΝΔΟ"]);
|
||||
assert_eq!(both.match_keyword("ΘΝΔΟ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θνδο").as_deref(), Some("ΘΝΔΟ"), "Greek case is folded like Latin case");
|
||||
assert_eq!(both.match_keyword("Undo").as_deref(), Some("Undo"));
|
||||
// A prefix of the Greek word as typed (ΘΝ) is that keyword, not the Latin prefix UN of Undo.
|
||||
assert_eq!(both.match_keyword("ΘΝ").as_deref(), Some("ΘΝΔΟ"));
|
||||
assert_eq!(both.match_keyword("θν").as_deref(), Some("ΘΝΔΟ"), "in either case");
|
||||
assert_eq!(both.match_keyword("θνδό").as_deref(), Some("ΘΝΔΟ"), "with an accent");
|
||||
let sigma = Prompt::new("", Accept::TEXT).kw(&["Last", "ΛΑΣΤ"]);
|
||||
assert_eq!(sigma.match_keyword("λας").as_deref(), Some("ΛΑΣΤ"), "a final sigma folds too");
|
||||
let latin = Prompt::new("", Accept::TEXT).kw(&["Undo"]);
|
||||
assert_eq!(latin.match_keyword("ΘΝ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("ΘΝΔΟ").as_deref(), Some("Undo"));
|
||||
assert_eq!(latin.match_keyword("Θ").as_deref(), Some("Undo"), "a Greek-layout shortcut still works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords() {
|
||||
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
|
||||
|
||||
+18
-98
@@ -152,28 +152,8 @@ fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
|
||||
!b.is_empty() && b.contains(turn(p))
|
||||
}
|
||||
|
||||
/// Whether `e` is on one of the layers in `frozen`: the layers frozen in a layout viewport, which
|
||||
/// does not draw their objects. The names are compared as the renderer compares them.
|
||||
pub(crate) fn on_frozen_layer(frozen: &[String], e: &Entity) -> bool {
|
||||
frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer))
|
||||
}
|
||||
|
||||
/// The last object of `store`, in draw order, that is not on one of the layers in `frozen`, the
|
||||
/// layers frozen in the layout viewport the selection is made through: what the Last option
|
||||
/// takes there. With no such layers it is the last object, whether the drawing shows it or not.
|
||||
pub(crate) fn last_in(store: &cadcraft_doc::EntityStore, frozen: &[String]) -> Option<Handle> {
|
||||
if frozen.is_empty() {
|
||||
return store.last().map(|e| e.handle);
|
||||
}
|
||||
store.iter().filter(|e| !on_frozen_layer(frozen, e)).last().map(|e| e.handle)
|
||||
}
|
||||
|
||||
/// Whether a selection can take `e`: the drawing shows it ([`Drawing::is_visible`]) and it is not
|
||||
/// on one of the layers in `frozen`, the layers frozen in the layout viewport the selection is
|
||||
/// made through ([`crate::DocState::viewport_frozen`]; empty anywhere else). A viewport shows
|
||||
/// model space, so `frozen` goes with a selection in model space only.
|
||||
pub(crate) fn selectable(d: &Drawing, e: &Entity, frozen: &[String]) -> bool {
|
||||
d.is_visible(e) && !on_frozen_layer(frozen, e)
|
||||
fn selectable(d: &Drawing, e: &Entity) -> bool {
|
||||
d.is_visible(e)
|
||||
}
|
||||
|
||||
/// Entities to test, in the order to test them, each with its bounds when already known.
|
||||
@@ -216,28 +196,15 @@ pub(crate) fn candidates<'a>(
|
||||
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
|
||||
/// picked, and DRAWORDER changes which).
|
||||
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
pick_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
/// [`pick`] through a layout viewport: the objects on the layers in `frozen`, the layers frozen
|
||||
/// in that viewport, are not drawn there and are not picked.
|
||||
pub fn pick_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
pick_with(d, space, &ix, frozen, p, aperture)
|
||||
pick_with(d, space, &ix, p, aperture)
|
||||
}
|
||||
|
||||
pub(crate) fn pick_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
p: Vec2,
|
||||
aperture: f64,
|
||||
) -> Option<Handle> {
|
||||
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
// Candidates come topmost first: the first within the aperture is the one.
|
||||
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
|
||||
if !selectable(d, e, frozen) {
|
||||
if !selectable(d, e) {
|
||||
continue;
|
||||
}
|
||||
let infinite = is_infinite(e);
|
||||
@@ -254,16 +221,11 @@ pub(crate) fn pick_with(
|
||||
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
|
||||
/// the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
pick_all_in(d, space, &[], p, aperture)
|
||||
}
|
||||
|
||||
/// [`pick_all`] through a layout viewport that freezes the layers in `frozen` ([`pick_in`]).
|
||||
pub fn pick_all_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
cands
|
||||
.filter(|(e, known)| selectable(d, e, frozen) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
|
||||
.map(|(e, _)| e.handle)
|
||||
.collect()
|
||||
@@ -291,27 +253,14 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
|
||||
|
||||
/// Window selection (entirely inside) or crossing selection (inside or touching).
|
||||
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
select_window_in(d, space, &[], bx, crossing)
|
||||
}
|
||||
|
||||
/// [`select_window`] through a layout viewport: the objects on the layers in `frozen`, the layers
|
||||
/// frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_window_in(d: &Drawing, space: &Space, frozen: &[String], bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_window_with(d, space, &ix, frozen, bx, crossing)
|
||||
select_window_with(d, space, &ix, bx, crossing)
|
||||
}
|
||||
|
||||
pub(crate) fn select_window_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
bx: Bounds2,
|
||||
crossing: bool,
|
||||
) -> Vec<Handle> {
|
||||
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
|
||||
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
|
||||
let tol = window_tol(&bx);
|
||||
cands.filter(|(e, known)| selectable(d, e, frozen) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
|
||||
}
|
||||
|
||||
/// The tessellation tolerance window and crossing tests use for a box.
|
||||
@@ -357,23 +306,11 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bo
|
||||
|
||||
/// Fence selection: entities crossed by the fence polyline.
|
||||
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
|
||||
select_fence_in(d, space, &[], fence)
|
||||
}
|
||||
|
||||
/// [`select_fence`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_fence_in(d: &Drawing, space: &Space, frozen: &[String], fence: &[Vec2]) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
select_fence_with(d, space, &ix, frozen, fence)
|
||||
select_fence_with(d, space, &ix, fence)
|
||||
}
|
||||
|
||||
pub(crate) fn select_fence_with(
|
||||
d: &Drawing,
|
||||
space: &Space,
|
||||
ix: &Option<std::sync::Arc<SpatialIndex>>,
|
||||
frozen: &[String],
|
||||
fence: &[Vec2],
|
||||
) -> Vec<Handle> {
|
||||
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
|
||||
let fb = Bounds2::from_points(fence.iter().copied());
|
||||
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
|
||||
let probe = fb.expand(tol);
|
||||
@@ -381,7 +318,7 @@ pub(crate) fn select_fence_with(
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
// Infinite objects have no useful bounds; like window selection, test their geometry directly.
|
||||
if !selectable(d, e, frozen) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
|
||||
continue;
|
||||
}
|
||||
let polys = hit_polylines(d, e, tol);
|
||||
@@ -402,12 +339,6 @@ pub(crate) fn select_fence_with(
|
||||
/// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection
|
||||
/// (inside or crossing its edges). The polygon closes back to its first point.
|
||||
pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
select_polygon_in(d, space, &[], poly, crossing)
|
||||
}
|
||||
|
||||
/// [`select_polygon`] through a layout viewport that freezes the layers in `frozen`
|
||||
/// ([`select_window_in`]).
|
||||
pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[Vec2], crossing: bool) -> Vec<Handle> {
|
||||
let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() };
|
||||
let ix = spatial::index(d, space);
|
||||
let pb = Bounds2::from_points(poly.iter().copied());
|
||||
@@ -418,7 +349,7 @@ pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[
|
||||
let mut out = Vec::new();
|
||||
for (e, known) in cands {
|
||||
let infinite = is_infinite(e);
|
||||
if !selectable(d, e, frozen) || (infinite && !crossing) {
|
||||
if !selectable(d, e) || (infinite && !crossing) {
|
||||
continue;
|
||||
}
|
||||
let eb = bounds_of(d, e, known);
|
||||
@@ -447,15 +378,9 @@ pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[
|
||||
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
|
||||
/// drawing has no such group.
|
||||
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
|
||||
select_group_in(d, space, &[], name)
|
||||
}
|
||||
|
||||
/// [`select_group`] through a layout viewport that freezes the layers in `frozen`: the group's
|
||||
/// objects on those layers are left out.
|
||||
pub fn select_group_in(d: &Drawing, space: &Space, frozen: &[String], name: &str) -> Option<Vec<Handle>> {
|
||||
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
|
||||
let store = d.space(space)?;
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| selectable(d, e, frozen))).collect())
|
||||
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
|
||||
}
|
||||
|
||||
/// A selection mode typed at "Select objects", collecting its points before it selects.
|
||||
@@ -529,19 +454,14 @@ impl SelectMode {
|
||||
}
|
||||
/// The objects the mode selects, or `None` while it has too few points.
|
||||
pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {
|
||||
self.select_in(d, space, &[])
|
||||
}
|
||||
/// [`SelectMode::select`] through a layout viewport: the objects on the layers in `frozen`,
|
||||
/// the layers frozen in that viewport, are not drawn there and are not selected.
|
||||
pub fn select_in(&self, d: &Drawing, space: &Space, frozen: &[String]) -> Option<Vec<Handle>> {
|
||||
match (self.kind, self.pts.as_slice()) {
|
||||
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
|
||||
Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
|
||||
}
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence_in(d, space, frozen, pts)),
|
||||
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
|
||||
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
|
||||
Some(select_polygon_in(d, space, frozen, pts, self.kind == SelectKind::CPolygon))
|
||||
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -116,26 +116,9 @@ impl Session {
|
||||
};
|
||||
}
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`). A word typed with
|
||||
// the Greek keyboard layout active is read by its keys there too: Λ is Last at COUNT's prompt,
|
||||
// not List.
|
||||
let selection_mode = prompt.accept.select && {
|
||||
let word = tt.strip_prefix('_').unwrap_or(tt);
|
||||
let keys = crate::keyboard::latin_keys(word).unwrap_or_else(|| word.to_ascii_lowercase());
|
||||
matches!(keys.as_str(), "all" | "l" | "last" | "p" | "previous")
|
||||
};
|
||||
// A Greek word that is an existing name at a prompt that takes one is that name, in any case:
|
||||
// ΣΟΛΙΔ is also the keys of Solid.
|
||||
let existing = if matches!(prompt.picker, Some(crate::Picker::HatchPattern(_))) {
|
||||
crate::keyboard::greek_fold(tt).and_then(|_| cmd::hatch::pattern_named(tt))
|
||||
} else if prompt.takes_block {
|
||||
self.greek_block_name(tt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(name) = existing {
|
||||
return self.input(Input::Text(name));
|
||||
}
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select
|
||||
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
|
||||
@@ -8,39 +8,6 @@ use super::{
|
||||
impl Session {
|
||||
// ---------------- interactive commands ----------------
|
||||
|
||||
/// Whether `name`, as typed, is a command, an alias or a LISP command: the lookups
|
||||
/// [`Session::start`] dispatches by, so only ASCII is lower-cased and a Greek alias stays as stored.
|
||||
fn names_a_command(&self, name: &str) -> bool {
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
let dashed = name.trim_start_matches(['_', '.']);
|
||||
find_command(&cmd::resolve_alias(dashed)).is_some()
|
||||
|| find_command(&cmd::resolve_alias(&lower)).is_some()
|
||||
|| self.lisp_command(&lower).is_some()
|
||||
}
|
||||
|
||||
/// The alias or LISP command that is the Greek word `name` in another case or accent (λινε for
|
||||
/// c:ΛΙΝΕ): those lookups fold ASCII only.
|
||||
fn greek_command_name(&self, name: &str) -> Option<String> {
|
||||
let bare = name.trim_start_matches(['_', '.', '-']);
|
||||
let same = crate::keyboard::same_greek_word_as(bare)?;
|
||||
let lisp = || self.lisp_commands().into_iter().find(|c| same(c) && self.lisp_command(c).is_some());
|
||||
cmd::file::greek_alias(bare).or_else(lisp)
|
||||
}
|
||||
|
||||
/// Whether the Greek word `name` is a command of its own, which [`Session::start`] runs instead
|
||||
/// of the Latin name its keys type.
|
||||
pub fn greek_word_is_a_command(&self, name: &str) -> bool {
|
||||
self.names_a_command(name) || self.greek_command_name(name).is_some()
|
||||
}
|
||||
|
||||
/// The existing block that is the Greek word `word`: as spelled, else in another case or accent
|
||||
/// (μ for Μ).
|
||||
pub(super) fn greek_block_name(&self, word: &str) -> Option<String> {
|
||||
let same = crate::keyboard::same_greek_word_as(word)?;
|
||||
let blocks = &self.doc().ok()?.blocks;
|
||||
blocks.keys().find(|k| *k == word).or_else(|| blocks.keys().find(|k| same(k))).cloned()
|
||||
}
|
||||
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
@@ -50,12 +17,6 @@ impl Session {
|
||||
Some(n) => (true, n),
|
||||
None => (false, name),
|
||||
};
|
||||
// A name typed with the Greek keyboard layout active (ΛΙΝΕ for LINE) is the Latin name's keys,
|
||||
// unless it is a command, alias or LISP function of its own, in any case or accent.
|
||||
let typed = name;
|
||||
let latin =
|
||||
crate::keyboard::latin_keys(name).filter(|_| !self.names_a_command(name)).map(|latin| self.greek_command_name(name).unwrap_or(latin));
|
||||
let name = latin.as_deref().unwrap_or(name);
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
@@ -76,7 +37,7 @@ impl Session {
|
||||
self.start(if transparent { "'setvar" } else { "setvar" })?;
|
||||
return self.input(Input::Text(var));
|
||||
}
|
||||
return Err(EngineError::UnknownCommand(typed.to_string()));
|
||||
return Err(EngineError::UnknownCommand(name.to_string()));
|
||||
};
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
@@ -214,33 +175,17 @@ impl Session {
|
||||
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
|
||||
other => other,
|
||||
};
|
||||
// A selection word typed with the Greek keyboard layout active (ΑΛΛ for ALL) is looked up as its
|
||||
// Latin keys. Only the lookup is: text that is no selection word (a block name at BEDIT's prompt,
|
||||
// which also takes a selection) reaches the command as typed. The name of an existing block at
|
||||
// a prompt that takes a name reaches it as stored, in whatever case it was typed: a block
|
||||
// called Μ is that block there, not Multiple.
|
||||
let block = match &input {
|
||||
Input::Text(t) if prompt.accept.text => self.greek_block_name(t.trim()),
|
||||
_ => None,
|
||||
};
|
||||
let is_block = block.is_some();
|
||||
let input = block.map_or(input, Input::Text);
|
||||
let word = match &input {
|
||||
Input::Text(t) => Some(crate::keyboard::latin_keys(t.trim()).filter(|_| !is_block).unwrap_or_else(|| t.clone())),
|
||||
Input::Keyword(k) => Some(k.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(w) = &word
|
||||
&& let Some(m) = select::SelectMode::from_keyword(w)
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& let Some(m) = select::SelectMode::from_keyword(t)
|
||||
{
|
||||
self.pending_window = None;
|
||||
self.select_mode = Some(m);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(w) = &word
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(w.trim()))
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
|
||||
{
|
||||
match w.trim().to_ascii_lowercase().as_str() {
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"si" | "single" => {
|
||||
self.select_single = true;
|
||||
return Ok(None);
|
||||
@@ -257,10 +202,10 @@ impl Session {
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
|
||||
None => {
|
||||
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
|
||||
@@ -268,18 +213,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Text(_) | Input::Keyword(_) => {
|
||||
let tl = word.as_deref().unwrap_or_default().trim().to_ascii_lowercase();
|
||||
Input::Text(ref t) | Input::Keyword(ref t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let d = self.doc()?;
|
||||
let store = d.space(&space);
|
||||
let frozen = self.viewport_frozen();
|
||||
let picked = match tl.as_str() {
|
||||
"all" => store.map(|s| s.iter().filter(|e| select::selectable(d, e, frozen)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| select::last_in(s, frozen)).map(|h| vec![h]),
|
||||
"p" | "previous" => {
|
||||
let st = self.state()?;
|
||||
Some(st.drawn_in_viewport(&st.previous_selection))
|
||||
}
|
||||
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
|
||||
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
|
||||
_ => None,
|
||||
};
|
||||
match picked {
|
||||
@@ -300,9 +241,8 @@ impl Session {
|
||||
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
|
||||
if mode.kind == select::SelectKind::Group {
|
||||
let space = self.space();
|
||||
return match input {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group_in(self.doc()?, &space, self.viewport_frozen(), &name) {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => {
|
||||
self.echo(format!("No group named \"{}\".", name.trim()));
|
||||
@@ -322,14 +262,14 @@ impl Session {
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
if mode.push(p) {
|
||||
return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick));
|
||||
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
mode.pts.pop();
|
||||
}
|
||||
// Enter ends a fence or polygon; with too few points the mode is dropped.
|
||||
Input::Enter => return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick)),
|
||||
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
|
||||
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
|
||||
_ => self.echo("Invalid point."),
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = self.window_is_crossing(pw.corner, p);
|
||||
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = cmd::groups::expand(self, hs);
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
if shift && pickadd {
|
||||
sel.retain(|h| !hs.contains(h));
|
||||
} else {
|
||||
@@ -44,7 +44,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
@@ -56,7 +56,7 @@ impl Session {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
@@ -93,7 +93,7 @@ impl Session {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all_in(d, &self.space(), self.viewport_frozen(), p, ap)).unwrap_or_default()
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
@@ -155,8 +155,7 @@ impl Session {
|
||||
|
||||
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
|
||||
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
|
||||
/// object under `p` within `aperture`. Inside an MSPACE viewport a pick in model space finds
|
||||
/// what that viewport draws: not the objects on the layers frozen in it.
|
||||
/// object under `p` within `aperture`.
|
||||
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
|
||||
let d = self.doc()?;
|
||||
if let Some((h, q)) = self.pick_hint
|
||||
@@ -164,9 +163,7 @@ impl Session {
|
||||
{
|
||||
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
|
||||
}
|
||||
// The viewport shows model space only: its frozen layers hide nothing on the sheet.
|
||||
let frozen = if *space == Space::Model { self.viewport_frozen() } else { &[] };
|
||||
Ok(select::pick_in(d, space, frozen, p, aperture))
|
||||
Ok(select::pick(d, space, p, aperture))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,8 +424,8 @@ mod tests {
|
||||
for k in 0..400 {
|
||||
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
|
||||
let ap = [0.05, 0.3, 1.5][k % 3];
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, &[], p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, &[], p, ap);
|
||||
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
|
||||
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
|
||||
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
|
||||
hits += usize::from(a.is_some());
|
||||
}
|
||||
@@ -444,15 +444,12 @@ mod tests {
|
||||
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
|
||||
let bx = Bounds2::new(a, b);
|
||||
for crossing in [false, true] {
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, &[], bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, &[], bx, crossing);
|
||||
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
|
||||
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
|
||||
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
|
||||
}
|
||||
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
|
||||
assert_eq!(
|
||||
select::select_fence_with(&d, &Space::Model, &ix, &[], &fence),
|
||||
select::select_fence_with(&d, &Space::Model, &None, &[], &fence)
|
||||
);
|
||||
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,13 +484,10 @@ mod tests {
|
||||
for (p, ap) in
|
||||
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
|
||||
{
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, &[], p, ap), select::pick_with(&d, &Space::Model, &None, &[], p, ap));
|
||||
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
|
||||
}
|
||||
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
|
||||
assert_eq!(
|
||||
select::select_window_with(&d, &Space::Model, &ix, &[], bx, true),
|
||||
select::select_window_with(&d, &Space::Model, &None, &[], bx, true)
|
||||
);
|
||||
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1551,13 +1551,3 @@ fn setvar_rejects_non_finite_text_for_real_variables() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_tools_keeps_compatible_command_ids() {
|
||||
assert_eq!(crate::cmd::resolve_alias("-blockreplace"), "blockreplace");
|
||||
for id in ["blockreplace", "burst", "breakline", "mocoro", "txt2mtxt"] {
|
||||
let command = crate::cmd::find_command(id).expect("existing command id");
|
||||
assert!(command.menu.contains(&"Additional Tools"), "{id}: {:?}", command.menu);
|
||||
assert!(!command.menu.contains(&"Express Tools"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
//! Saving an edited block as a copy must retain its source's last saved definition.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
|
||||
/// source block keeps the definition of its own last save.
|
||||
#[test]
|
||||
fn save_as_preserves_the_last_saved_source_definition() {
|
||||
let mut s = Session::new();
|
||||
let mut handles = Vec::new();
|
||||
for y in [5, 9] {
|
||||
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
|
||||
handles.push(line["handles"][0].clone());
|
||||
}
|
||||
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 2);
|
||||
|
||||
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
|
||||
assert_eq!(saved_source.entities.len(), 3);
|
||||
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
|
||||
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
|
||||
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
|
||||
|
||||
// A later save of Copy must not reintroduce the editor table's older source definition.
|
||||
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
|
||||
s.execute("bsave", &json!({})).unwrap();
|
||||
s.execute("bclose", &json!({ "save": false })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
|
||||
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
|
||||
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), 3);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
//! A word typed with the Greek keyboard layout that is an alias of its own (ALIASEDIT) is that
|
||||
//! alias, not the Latin name its keys type. The aliases are process-wide, so this file keeps to
|
||||
//! one test at a time.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// The alias store is process-wide: each test holds this while it changes the aliases.
|
||||
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// A Greek word the user made an alias runs the alias's command, not the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_not_the_latin_keys_command() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
// ΛΙΝΕ types LINE, but the user made it an alias of CIRCLE: aliases are stored as typed (only
|
||||
// ASCII is lower-cased), and the alias wins.
|
||||
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran = s.script(
|
||||
"ΛΙΝΕ 0,0 5
|
||||
",
|
||||
);
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
ran.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (1, 0), "the alias drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// The alias is found when it is typed in lower case or with an accent too.
|
||||
#[test]
|
||||
fn a_greek_alias_starts_its_command_in_any_case_or_accent() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
|
||||
let ran: Vec<_> = ["λινε", "λίνε"].iter().map(|typed| s.script(&format!("{typed} 0,0 5\n"))).collect();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(ran.iter().all(Result::is_ok), "{ran:?}");
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
|
||||
assert_eq!((circles, lines), (2, 0), "the alias drew both circles, LINE was not started");
|
||||
}
|
||||
|
||||
/// 5 000 Greek aliases and a pasted word of a million letters that is none of them: looking it up
|
||||
/// costs the word's length once, not once for every alias.
|
||||
#[test]
|
||||
fn a_long_greek_word_is_folded_once_however_many_greek_aliases_there_are() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
let set: serde_json::Map<String, serde_json::Value> = (0..5_000).map(|i| (format!("ΞΞ{i}"), json!("line"))).collect();
|
||||
s.execute("aliasedit", &json!({ "set": set })).unwrap();
|
||||
let word = "Ω".repeat(1_000_000);
|
||||
let t = std::time::Instant::now();
|
||||
let started = s.start(&word);
|
||||
let took = t.elapsed();
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert!(started.is_err(), "no command has those keys");
|
||||
assert!(took.as_secs() < 5, "{took:?}");
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! A command or keyword typed with the Greek keyboard layout active (ΛΙΝΕ for LINE, ΘΝΔΟ for
|
||||
//! Undo) works as its Latin name: the layout types each Greek letter on the Latin key it sounds
|
||||
//! like.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use serde_json::json;
|
||||
|
||||
/// How many lines the drawing's model space holds.
|
||||
fn lines(s: &Session) -> usize {
|
||||
s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count()
|
||||
}
|
||||
|
||||
/// ΛΙΝΕ draws a line like LINE, in lower case and with accents too; `;ΣΑΩΕ` starts QSAVE; a
|
||||
/// Greek word that is no command's keys is reported as typed.
|
||||
#[test]
|
||||
fn greek_layout_commands_start_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.script("ΛΙΝΕ 0,0 10,0\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 1, "{}", s.prompt_text());
|
||||
// Accents the layout's dead key adds are ignored; lower case works too.
|
||||
s.script("λίνε 0,5 10,5\n\n").unwrap();
|
||||
assert_eq!(lines(&s), 2);
|
||||
// The Q key types a semicolon: QSAVE is ";ΣΑΩΕ", which only starts the command (no file needed
|
||||
// here: it reports, it never fails as an unknown command).
|
||||
let err = s.start(";ΣΑΩΕ").map(|_| String::new()).unwrap_or_else(|e| e.to_string());
|
||||
assert!(!err.contains("unknown command"), "{err}");
|
||||
// A Greek word that is no command's keys is reported as typed, not as the Latin letters read from it.
|
||||
let err = s.start("ΧΥΖ").unwrap_err().to_string();
|
||||
assert!(err.contains("ΧΥΖ") && !err.contains("xyz"), "{err}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs as itself, not as the command its keys type.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_as_itself_not_as_the_latin_keys_command() {
|
||||
let mut s = Session::new();
|
||||
// ΛΙΝΕ types LINE, but the routine defines a command of that Greek name: it is the command typed.
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline("ΛΙΝΕ").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let doc = s.doc().unwrap();
|
||||
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "the routine drew the circle, LINE was not started");
|
||||
}
|
||||
|
||||
/// ΑΛΛ at "Select objects" selects everything, as ALL does.
|
||||
#[test]
|
||||
fn greek_layout_selection_words_select_like_the_latin_ones() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.execute("circle", &json!({ "center": [20, 0], "radius": 5 })).unwrap();
|
||||
// ΑΛΛ is the keys A-L-L: ALL at the "Select objects" prompt.
|
||||
s.script(
|
||||
"ERASE ΑΛΛ
|
||||
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(s.doc().unwrap().model.is_empty(), "ΑΛΛ selected nothing");
|
||||
}
|
||||
|
||||
/// Ν and Υ answer MIRROR's "Erase source objects?" as N and Y do.
|
||||
#[test]
|
||||
fn greek_layout_keywords_match_prompt_options() {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
s.script("ΜΙΡΡΟΡ ALL\n\n0,0 10,0\n").unwrap();
|
||||
// ... the last prompt asks whether to delete the sources: N (Greek layout: Ν) keeps them. Text
|
||||
// the prompt does not know keeps them too, so what Ν is read as is checked itself.
|
||||
let no = s.current_prompt().and_then(|p| p.match_keyword("Ν"));
|
||||
assert_eq!(no.as_deref(), Some("No"), "{}", s.prompt_text());
|
||||
s.script("Ν\n").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 2, "the circle and its mirror image");
|
||||
|
||||
// Υ (the Y key) answers Yes, which erases the source: unmatched Greek text would count as No, so
|
||||
// only a recognised keyword gives this result.
|
||||
let mut yes = Session::new();
|
||||
yes.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
|
||||
yes.script(
|
||||
"ΜΙΡΡΟΡ ALL
|
||||
|
||||
0,0 10,0
|
||||
Υ
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(yes.running.is_none(), "{}", yes.prompt_text());
|
||||
let circles = yes.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!(circles, 1, "Υ answered Yes and the source was erased");
|
||||
}
|
||||
|
||||
/// A Greek block name typed at BEDIT's "block name or select" opens that block: it is not read
|
||||
/// as a selection word.
|
||||
#[test]
|
||||
fn a_greek_block_name_typed_at_a_name_or_select_prompt_reaches_the_command_as_typed() {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "ΠΟΡΤΑ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
// BEDIT asks for a block name or a selected reference. ΠΟΡΤΑ is also the keys P-O-R-T-A, which a
|
||||
// selection word would be read from, but no selection word is meant here: the name is typed as it is.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline("ΠΟΡΤΑ").unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("ΠΟΡΤΑ"), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// Μ is the M key, Multiple at a selection prompt; once a block has that name, typing it at
|
||||
/// BEDIT opens the block.
|
||||
#[test]
|
||||
fn a_block_named_like_a_greek_selection_word_opens_at_a_name_or_select_prompt() {
|
||||
// The Greek capital Mu (U+039C) is the M key: Multiple at a selection prompt.
|
||||
let mu = "\u{39c}";
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
// With no block of that name it stays the selection word, which BEDIT's prompt waits through.
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
assert!(s.running.is_some(), "Multiple keeps the prompt: {}", s.prompt_text());
|
||||
assert!(s.state().unwrap().block_editor().is_none());
|
||||
s.cancel();
|
||||
// Once a block has that name, typing it opens that block.
|
||||
s.execute("block", &json!({ "name": mu, "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(mu).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some(mu), "{}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// ΣΟΛΙΔ is the keys of Solid at HATCH's pattern prompt; once a loaded pattern has that name,
|
||||
/// typing it in any case or accent chooses that pattern.
|
||||
#[test]
|
||||
fn a_loaded_pattern_named_like_the_greek_keys_of_solid_is_that_pattern_at_the_pattern_prompt() {
|
||||
// ΣΟΛΙΔ is the keys S-O-L-I-D: Solid at HATCH's pattern prompt, which takes no scale or angle.
|
||||
let mut s = Session::new();
|
||||
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline("ΣΟΛΙΔ").unwrap();
|
||||
assert!(!s.prompt_text().contains("scale"), "Solid: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
// A pattern file that defines a pattern of that name: typing the name chooses that pattern, which asks its scale.
|
||||
s.execute("hatch.patterns", &json!({ "text": "*ΣΟΛΙΔ, test\n0,0,0,0,1\n" })).unwrap();
|
||||
let asked = ["ΣΟΛΙΔ", "σολιδ", "σολίδ"].map(|typed| {
|
||||
s.start("hatch").unwrap();
|
||||
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
asked
|
||||
});
|
||||
s.execute("hatch.patterns", &json!({ "clear": true })).unwrap();
|
||||
assert!(asked.iter().all(|a| a.contains("scale")), "the loaded pattern, in any spelling: {asked:?}");
|
||||
}
|
||||
|
||||
/// A LISP command with a Greek name runs when typed in upper case, lower case or with an accent.
|
||||
#[test]
|
||||
fn a_greek_lisp_command_runs_in_any_case_or_accent() {
|
||||
for typed in ["ΛΙΝΕ", "λινε", "λίνε"] {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
|
||||
assert_eq!((circles, lines(&s)), (1, 0), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// A block called Μ opens at BEDIT when its name is typed in either case.
|
||||
#[test]
|
||||
fn a_block_opens_in_any_case_or_accent_at_a_prompt_that_also_takes_a_selection() {
|
||||
for typed in ["\u{39c}", "\u{3bc}"] {
|
||||
let mut s = Session::new();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "\u{39c}", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.start("BEDIT").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let open = s.state().unwrap().block_editor().map(str::to_string);
|
||||
assert_eq!(open.as_deref(), Some("\u{39c}"), "{typed}: {}", s.prompt_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// Σ is the S key, Select at BLOCKREPLACE; once a block has that name, typing it in either case
|
||||
/// names the block.
|
||||
#[test]
|
||||
fn a_block_named_like_the_keys_of_select_is_that_block_at_blockreplace() {
|
||||
let mut s = Session::new();
|
||||
// Σ is the S key: the Select option of BLOCKREPLACE, while no block has that name.
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline("Σ").unwrap();
|
||||
assert!(s.prompt_text().contains("Select a block reference"), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
|
||||
s.execute("block", &json!({ "name": "Σ", "base": [0, 0], "handles": [l] })).unwrap();
|
||||
s.execute("insert", &json!({ "name": "Σ", "at": [0, 0] })).unwrap();
|
||||
for typed in ["Σ", "σ"] {
|
||||
s.start("BLOCKREPLACE").unwrap();
|
||||
s.cmdline(typed).unwrap();
|
||||
let asked = s.prompt_text();
|
||||
s.cancel();
|
||||
assert!(asked.contains("replacement block for Σ"), "{typed}: {asked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Λ is the L key. At COUNT's "Select object to count or [List/Spaces]" the Latin L is the selection
|
||||
/// word Last, not the List option: the Greek key is read the same way, and so is the whole word.
|
||||
#[test]
|
||||
fn a_greek_selection_word_wins_over_a_keyword_on_the_same_key() {
|
||||
let answer = |typed: &str| {
|
||||
let mut s = Session::new();
|
||||
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
|
||||
s.start("COUNT").unwrap();
|
||||
let from = s.log.len();
|
||||
s.cmdline(typed).unwrap();
|
||||
s.log[from..].join("\n")
|
||||
};
|
||||
let last = answer("L");
|
||||
assert!(last.contains("like the selected one"), "L counts the objects like the last one: {last}");
|
||||
assert_ne!(last, answer("List"));
|
||||
for typed in ["Λ", "λ", "ΛΑΣΤ"] {
|
||||
assert_eq!(answer(typed), last, "{typed}");
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst`
|
||||
//! or a control call can restore it there, and the grips it shares with a visible object must not
|
||||
//! drag it along.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
/// Draws a line from `a` to `b` and returns its handle.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The two ends of the line `h`.
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two lines sharing the end (0, 0), the second one hidden, both in the selection.
|
||||
fn shared_corner() -> (Session, Handle, Handle) {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden");
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
(s, shown, hidden)
|
||||
}
|
||||
|
||||
/// The index of the grip of `h` at `p`.
|
||||
fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
/// Stretching the shared corner moves the visible line only; the hidden one stays where it was.
|
||||
#[test]
|
||||
fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The Move mode of a grip edit moves the visible selection only.
|
||||
#[test]
|
||||
fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(10.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text());
|
||||
s.cmdline("12,5").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// The grip of a hidden object is never made hot, so a grip edit cannot start on it.
|
||||
#[test]
|
||||
fn the_grip_of_a_hidden_object_cannot_be_made_hot() {
|
||||
let (mut s, _, hidden) = shared_corner();
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string();
|
||||
assert!(err.contains("no grip"), "{err}");
|
||||
assert!(s.running.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
|
||||
}
|
||||
|
||||
/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the
|
||||
/// first grip the drawing shows is the base grip, and only the visible line is stretched.
|
||||
#[test]
|
||||
fn a_hidden_grip_among_the_hot_grips_is_left_out() {
|
||||
for hidden_first in [false, true] {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))];
|
||||
if hidden_first {
|
||||
hot.reverse();
|
||||
}
|
||||
s.start_grip_edit(&hot).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(
|
||||
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
|
||||
"hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only what the drawing does not show is left out: a grip of an object that does not exist is
|
||||
/// still an error, also after a good one.
|
||||
#[test]
|
||||
fn a_missing_object_among_the_hot_grips_is_still_an_error() {
|
||||
let (mut s, shown, _) = shared_corner();
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string();
|
||||
assert!(err.contains("no such object"), "{err}");
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
/// A selected line on a layer that is off is not shown either, and is not stretched with a
|
||||
/// shared grip.
|
||||
#[test]
|
||||
fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap();
|
||||
let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
|
||||
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
|
||||
s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off");
|
||||
s.set_selection(vec![shown, off]);
|
||||
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, off);
|
||||
assert!(
|
||||
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
|
||||
"the line on the layer that is off moved: {start:?} {end:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared
|
||||
/// corner it makes only the visible line's grip hot.
|
||||
#[test]
|
||||
fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() {
|
||||
let (mut s, shown, hidden) = shared_corner();
|
||||
s.cmdline("grip").unwrap();
|
||||
assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text());
|
||||
// Only the hidden line has a grip at (0, 10): the command says so and asks again.
|
||||
s.cmdline("0,10").unwrap();
|
||||
assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log);
|
||||
s.cmdline("0,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text());
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let (start, end) = ends(&s, shown);
|
||||
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
|
||||
let (start, end) = ends(&s, hidden);
|
||||
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
|
||||
}
|
||||
@@ -23,10 +23,10 @@ fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, V
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let v = match t.value {
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => json!(s),
|
||||
craft_dxf::Value::Real(r) => json!(r),
|
||||
craft_dxf::Value::Int(i) => json!(i),
|
||||
craft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
|
||||
cadcraft_dxf::Value::Real(r) => json!(r),
|
||||
cadcraft_dxf::Value::Int(i) => json!(i),
|
||||
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
};
|
||||
(t.code, v)
|
||||
})
|
||||
|
||||
@@ -52,7 +52,6 @@ mod extend_polyline_arc_end;
|
||||
mod fillet_chamfer;
|
||||
mod fix_holds_grips;
|
||||
mod followups_626;
|
||||
mod greek_keyboard;
|
||||
mod grip_dim_input;
|
||||
mod grip_options;
|
||||
mod grip_selection;
|
||||
@@ -63,7 +62,6 @@ mod hatch_dialog_helpers;
|
||||
mod hatch_numeric_validation;
|
||||
mod hatch_options;
|
||||
mod helix;
|
||||
mod hidden_grip_targets;
|
||||
mod inquiry_keywords;
|
||||
mod inquiry_variables;
|
||||
mod inquiry_z;
|
||||
@@ -167,7 +165,6 @@ mod trim_extend_options;
|
||||
mod undo_count;
|
||||
mod undo_layout_tab;
|
||||
mod viewport_clip;
|
||||
mod viewport_frozen_selection;
|
||||
mod vplayer_prompts;
|
||||
mod vports;
|
||||
mod wipeout_grips;
|
||||
|
||||
@@ -233,17 +233,3 @@ fn options_on_objects_that_are_gone_record_nothing() {
|
||||
assert!(lines(&s).is_empty());
|
||||
assert_eq!(undo_len(&s), undo, "no empty undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_form_copies_stay_within_maxarray() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// 5,000 Copy options of 30 objects would be 150,000 copies, over the default MAXARRAY of 100,000.
|
||||
let others: Vec<String> = (0..29).map(|i| line(&mut s, [0.0, f64::from(i + 1)], [10.0, f64::from(i + 1)])).collect();
|
||||
let mut hs = vec![l];
|
||||
hs.extend(others);
|
||||
let ops: Vec<Value> = (0..5_000).map(|i| json!({"copy": [0, i]})).collect();
|
||||
let e = s.execute("mocoro", &json!({"handles": hs, "base": [0, 0], "ops": ops})).unwrap_err();
|
||||
assert!(e.to_string().contains("MAXARRAY"), "{e}");
|
||||
assert_eq!(lines(&s).len(), 30, "nothing was copied");
|
||||
}
|
||||
|
||||
@@ -1,783 +0,0 @@
|
||||
//! Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, "VP Freeze")
|
||||
//! is not drawn there, so the selections made through that viewport leave it out: picks, windows,
|
||||
//! the typed selection modes, the commands that select for themselves, and, for an object that is
|
||||
//! already selected, its grips and the commands that start from the selection. In a viewport that
|
||||
//! does not freeze the layer, in paper space and on the Model tab everything is as before.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::{EntityKind, Handle, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Draws a line from `a` to `b` on the current layer and returns its handle.
|
||||
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
|
||||
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
|
||||
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Puts `h` on the layer "Hidden", which [`viewports`] freezes in its first viewport.
|
||||
fn hide(s: &mut Session, h: Handle) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
|
||||
}
|
||||
|
||||
/// Two viewports on Layout1: the first freezes the layer "Hidden", the second freezes nothing.
|
||||
/// The session is left in paper space.
|
||||
fn viewports(s: &mut Session) -> (String, String) {
|
||||
if s.doc().unwrap().layer("Hidden").is_none() {
|
||||
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
|
||||
}
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
|
||||
let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string());
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Makes `viewport` the active MSPACE viewport.
|
||||
fn enter(s: &mut Session, viewport: &str) {
|
||||
s.execute("mspace", &json!({ "handle": viewport })).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "inside a viewport the edit space is model space");
|
||||
}
|
||||
|
||||
/// What most tests draw: `shown` on layer 0 and, drawn after it and so on top of it, `hidden` on
|
||||
/// the layer "Hidden", both from (0, 0) to (10, 0); and the two viewports of [`viewports`].
|
||||
struct Sheet {
|
||||
s: Session,
|
||||
shown: Handle,
|
||||
hidden: Handle,
|
||||
/// The viewport that freezes "Hidden".
|
||||
a: String,
|
||||
/// The viewport that freezes nothing.
|
||||
b: String,
|
||||
}
|
||||
|
||||
/// The drawing of [`Sheet`], in paper space.
|
||||
fn sheet() -> Sheet {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
hide(&mut s, hidden);
|
||||
assert_eq!(s.doc().unwrap().entity(shown).unwrap().common.layer, "0");
|
||||
let (a, b) = viewports(&mut s);
|
||||
Sheet { s, shown, hidden, a, b }
|
||||
}
|
||||
|
||||
/// The two ends of the line `h`.
|
||||
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
k => panic!("not a line: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the line `h` still runs from (0, 0) to (10, 0).
|
||||
fn untouched(s: &Session, h: Handle) -> bool {
|
||||
let (a, b) = ends(s, h);
|
||||
a.near(Vec2::new(0.0, 0.0), 1e-9) && b.near(Vec2::new(10.0, 0.0), 1e-9)
|
||||
}
|
||||
|
||||
/// Whether the drawing still has the object `h`.
|
||||
fn exists(s: &Session, h: Handle) -> bool {
|
||||
s.doc().unwrap().entity(h).is_some()
|
||||
}
|
||||
|
||||
/// Every selected object, in handle order: the stored selection, which also holds the objects
|
||||
/// the active viewport does not draw ([`Session::selection`] leaves those out).
|
||||
fn selected(s: &Session) -> Vec<Handle> {
|
||||
let mut v = s.state().unwrap().selection.clone();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Whether the line `h` was erased or no longer runs from (0, 0) to (10, 0).
|
||||
fn edited(s: &Session, h: Handle) -> bool {
|
||||
!exists(s, h) || !untouched(s, h)
|
||||
}
|
||||
|
||||
/// The index of the grip of `h` at the origin.
|
||||
fn origin_grip(s: &Session, h: Handle) -> usize {
|
||||
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(0.0, 0.0), 1e-9)).unwrap()
|
||||
}
|
||||
|
||||
/// `hs` in handle order.
|
||||
fn sorted(mut hs: Vec<Handle>) -> Vec<Handle> {
|
||||
hs.sort();
|
||||
hs
|
||||
}
|
||||
|
||||
/// Runs `text` as a script and ends whatever prompt it leaves open with Enter.
|
||||
fn run(s: &mut Session, text: &str) {
|
||||
s.script(text).unwrap();
|
||||
for _ in 0..4 {
|
||||
if s.running.is_none() {
|
||||
break;
|
||||
}
|
||||
s.cmdline("").unwrap();
|
||||
}
|
||||
assert!(s.running.is_none(), "`{text}` left a prompt open: {}", s.prompt_text());
|
||||
}
|
||||
|
||||
/// The JSON `select` command replaces the selection with what `how` finds.
|
||||
fn select(s: &mut Session, how: Value) -> Vec<Handle> {
|
||||
s.set_selection(Vec::new());
|
||||
s.execute("select", &how).unwrap();
|
||||
selected(s)
|
||||
}
|
||||
|
||||
/// A pick takes the topmost object the viewport draws: the frozen line lies on top of the other
|
||||
/// one, and a pick at the same point finds the line under it. Where the layer is not frozen the
|
||||
/// pick finds the line on top, as it always did.
|
||||
#[test]
|
||||
fn a_pick_finds_the_topmost_object_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let at = json!({ "at": [5, 0], "aperture": 0.5 });
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, at.clone()), [shown], "the viewport freezes the layer of the line on top");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, at.clone()), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert_eq!(select(&mut s, at), [hidden], "the Model tab draws both");
|
||||
}
|
||||
|
||||
/// Window, crossing and fence selection through the viewport leave the frozen line out, and take
|
||||
/// both lines through the viewport that draws both.
|
||||
#[test]
|
||||
fn a_window_a_crossing_and_a_fence_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let ways =
|
||||
[json!({ "window": [[-1, -1], [11, 1]] }), json!({ "window": [[4, -1], [6, 1]], "crossing": true }), json!({ "fence": [[5, -1], [5, 1]] })];
|
||||
for how in &ways {
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(select(&mut s, how.clone()), [shown], "{how}");
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(select(&mut s, how.clone()), sorted(vec![shown, hidden]), "{how}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks with no command running: a click on the two lines selects the one the viewport draws,
|
||||
/// and a window dragged around them takes only that one.
|
||||
#[test]
|
||||
fn idle_clicks_and_windows_select_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.set_selection(Vec::new());
|
||||
// A click on nothing opens a window; the next click closes it (left to right: a window).
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "this viewport draws both: the one on top is picked");
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
|
||||
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Selection cycling lists the objects under the pick box that the viewport draws.
|
||||
#[test]
|
||||
fn selection_cycling_lists_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [shown]);
|
||||
enter(&mut s, &b);
|
||||
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [hidden, shown], "topmost first");
|
||||
}
|
||||
|
||||
/// Every way of answering "Select objects" leaves the frozen line out: ERASE through the
|
||||
/// viewport that freezes its layer erases the other line only, and through the other viewport
|
||||
/// both.
|
||||
#[test]
|
||||
fn the_answers_to_select_objects_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
// Group selection off: a pick takes the object alone, so each answer is tested by itself.
|
||||
s.settings.pickstyle = 0;
|
||||
let answers = [
|
||||
"5,0",
|
||||
"-1,-1 11,1",
|
||||
"ALL",
|
||||
"W -1,-1 11,1",
|
||||
"C 6,1 4,-1",
|
||||
"BOX 6,1 4,-1",
|
||||
"F 5,-1 5,1\n",
|
||||
"WP -1,-1 11,-1 11,1 -1,1\n",
|
||||
"CP 4,-1 6,-1 5,1\n",
|
||||
"G PAIR",
|
||||
];
|
||||
for answer in answers {
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
assert!(!exists(&s, shown), "`{answer}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{answer}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, &format!("ERASE {answer}\n"));
|
||||
// A pick takes one object, the one on top; every other answer takes both.
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (answer == "5,0", false), "`{answer}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// With group selection on, a pick brings the members of its group that the viewport draws.
|
||||
#[test]
|
||||
fn a_pick_brings_the_group_members_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
|
||||
s.settings.pickstyle = 1;
|
||||
s.set_selection(Vec::new());
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// Select All and Select Similar take what the viewport draws.
|
||||
#[test]
|
||||
fn select_all_and_select_similar_take_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// Mode 0: any object of the same type is similar, whatever its layer.
|
||||
let similar = json!({ "handles": [shown.hex()], "mode": 0 });
|
||||
enter(&mut s, &a);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), [shown]);
|
||||
enter(&mut s, &b);
|
||||
s.execute("selectall", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
s.execute("selectsimilar", &similar).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
|
||||
}
|
||||
|
||||
/// STRETCH selects for itself: its crossing window, polygon and fence, called with JSON or
|
||||
/// typed, and its typed All, Last and Previous, move the line the viewport draws and leave the
|
||||
/// frozen one where it was.
|
||||
#[test]
|
||||
fn stretch_moves_what_the_viewport_draws() {
|
||||
let calls = [
|
||||
json!({ "window": [[9, -1], [11, 1]], "delta": [0, 5] }),
|
||||
json!({ "polygon": [[9, -1], [11, -1], [11, 1], [9, 1]], "delta": [0, 5] }),
|
||||
json!({ "fence": [[5, -1], [5, 1]], "delta": [0, 5] }),
|
||||
];
|
||||
for call in &calls {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both move");
|
||||
}
|
||||
// Typed: a crossing window, named and implied, All, a pick, Last, and Previous (both lines).
|
||||
for objects in ["C 11,1 9,-1", "11,1 9,-1", "ALL", "5,0", "L", "P"] {
|
||||
let typed = format!("STRETCH {objects}\n\n0,0 0,5\n");
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, shown), "{typed:?}: the line the viewport draws must move");
|
||||
assert!(untouched(&s, hidden), "{typed:?}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &typed);
|
||||
assert!(!untouched(&s, hidden), "{typed:?}: where both are drawn the one on top moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline whose control vertex is inside STRETCH's crossing window is stretched although
|
||||
/// the window misses its drawn lines, but not when the viewport does not draw it.
|
||||
#[test]
|
||||
fn stretch_leaves_a_multiline_the_viewport_does_not_draw() {
|
||||
/// The control vertices of the multiline `h`.
|
||||
fn vertices(s: &Session, h: Handle) -> Vec<Vec2> {
|
||||
match &s.doc().unwrap().entity(h).unwrap().kind {
|
||||
EntityKind::MLine(m) => m.vertices.iter().map(|v| v.p.xy()).collect(),
|
||||
k => panic!("not a multiline: {k:?}"),
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
// Zero justification at scale 2: the element lines run one unit either side of the vertices.
|
||||
let r = s.execute("mline", &json!({ "points": [[0, 0], [10, 0]], "scale": 2, "justification": "zero" })).unwrap();
|
||||
let mline = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
|
||||
hide(&mut s, mline);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let call = json!({ "window": [[9.9, -0.1], [10.1, 0.1]], "delta": [0, 5] });
|
||||
enter(&mut s, &a);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 0.0), 1e-9), "a multiline the viewport does not draw was stretched");
|
||||
enter(&mut s, &b);
|
||||
s.execute("stretch", &call).unwrap();
|
||||
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 5.0), 1e-9), "{:?}", vertices(&s, mline));
|
||||
}
|
||||
|
||||
/// TRIM's fence and crossing window trim the line the viewport draws and leave the frozen one.
|
||||
#[test]
|
||||
fn trim_by_fence_and_crossing_leaves_what_the_viewport_does_not_draw() {
|
||||
for call in [json!({ "fence": [[8, -1], [8, 1]] }), json!({ "crossing": [[7.5, -1], [8.5, 1]] })] {
|
||||
let mut s = Session::new();
|
||||
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
||||
// The cutting edge, on a layer every viewport draws.
|
||||
line(&mut s, [5.0, -5.0], [5.0, 5.0]);
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
enter(&mut s, &a);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must be trimmed");
|
||||
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw was trimmed: {:?}", ends(&s, hidden));
|
||||
enter(&mut s, &b);
|
||||
s.execute("trim", &call).unwrap();
|
||||
assert!(!untouched(&s, hidden), "{call}: where both are drawn both are trimmed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A prompt that picks one object (BREAK's "Select object") finds the one the viewport draws.
|
||||
#[test]
|
||||
fn a_prompt_for_one_object_picks_what_the_viewport_draws() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw was broken: {:?}", ends(&s, hidden));
|
||||
assert!(!untouched(&s, shown), "the line the viewport draws must be broken");
|
||||
enter(&mut s, &b);
|
||||
run(&mut s, "BREAK 4,0 6,0\n");
|
||||
assert!(!untouched(&s, hidden), "where both are drawn the one on top is broken");
|
||||
}
|
||||
|
||||
/// LEADER's Copy option picks the text to copy: at a point where two texts lie, the one the
|
||||
/// viewport draws.
|
||||
#[test]
|
||||
fn leader_copies_the_text_the_viewport_draws() {
|
||||
/// The contents of the multiline texts in model space: LEADER's copies.
|
||||
fn copies(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect()
|
||||
}
|
||||
let mut s = Session::new();
|
||||
for text in ["SHOWN", "HIDDEN"] {
|
||||
s.execute("text", &json!({ "at": [0, 0], "text": text, "height": 2.5 })).unwrap();
|
||||
}
|
||||
// The text drawn last lies on top.
|
||||
let hidden = *s.doc().unwrap().model.handles().last().unwrap();
|
||||
hide(&mut s, hidden);
|
||||
let (a, b) = viewports(&mut s);
|
||||
let leader = "(command \"_.LEADER\" '(20.0 20.0) '(30.0 25.0) \"\" \"\" \"_C\" '(1.0 1.0))";
|
||||
enter(&mut s, &a);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["SHOWN"], "the viewport freezes the layer of the text on top");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(copies(&s).is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.cmdline(leader).unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert_eq!(copies(&s), ["HIDDEN"], "this viewport draws both: the one on top is picked");
|
||||
}
|
||||
|
||||
/// AutoLISP selects through the viewport too: `ssget` with a window, a crossing window, a fence,
|
||||
/// a polygon, a crossing polygon or a point leaves the frozen line out, and `entsel` picks the
|
||||
/// line the viewport draws. The sets made earlier (the implied selection, `ssgetfirst`, Previous)
|
||||
/// come without the frozen line, and Last is the last object the viewport draws.
|
||||
#[test]
|
||||
fn lisp_selects_what_the_viewport_draws() {
|
||||
use cadcraft_engine::lisp::value::Value as Lisp;
|
||||
/// The objects of the selection set that `code` returns (none for nil), in handle order.
|
||||
fn set(s: &mut Session, code: &str) -> Vec<Handle> {
|
||||
match s.lisp_eval(code, false, true, Vec::new()).unwrap() {
|
||||
Some(Ok(Lisp::PickSet(set))) => sorted(set.items.lock().unwrap().clone()),
|
||||
Some(Ok(Lisp::Nil)) => Vec::new(),
|
||||
other => panic!("`{code}` returned {other:?}"),
|
||||
}
|
||||
}
|
||||
/// The object `entsel` returns for a pick at (5, 0).
|
||||
fn entsel(s: &mut Session) -> Handle {
|
||||
s.cmdline("(setq picked (car (entsel)))").unwrap();
|
||||
s.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 0.0))).unwrap();
|
||||
match s.lisp_var("picked") {
|
||||
Lisp::Ename(h) => h,
|
||||
other => panic!("entsel returned {other:?}: {}", s.prompt_text()),
|
||||
}
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
let areas = [
|
||||
"(ssget \"W\" '(-1 -1) '(11 1))",
|
||||
"(ssget \"C\" '(4 -1) '(6 1))",
|
||||
"(ssget \"F\" '((5 -1) (5 1)))",
|
||||
"(ssget \"WP\" '((-1 -1) (11 -1) (11 1) (-1 1)))",
|
||||
"(ssget \"CP\" '((4 -1) (6 -1) (5 1)))",
|
||||
];
|
||||
enter(&mut s, &a);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), [shown], "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [shown], "a point picks the object the viewport draws");
|
||||
assert_eq!(entsel(&mut s), shown);
|
||||
enter(&mut s, &b);
|
||||
for code in areas {
|
||||
assert_eq!(set(&mut s, code), sorted(vec![shown, hidden]), "{code}");
|
||||
}
|
||||
assert_eq!(set(&mut s, "(ssget '(5 0))"), [hidden], "this viewport draws both: the one on top is picked");
|
||||
assert_eq!(entsel(&mut s), hidden);
|
||||
|
||||
let earlier = ["(ssget \"I\")", "(ssget)", "(cadr (ssgetfirst))", "(ssget \"P\")"];
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
for code in earlier {
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
assert_eq!(set(&mut s, code), expected, "{code}");
|
||||
}
|
||||
}
|
||||
// Last: of two lines made after everything else, the later one is on the layer the first
|
||||
// viewport freezes, so through that viewport Last is the earlier one.
|
||||
s.set_selection(Vec::new());
|
||||
enter(&mut s, &b);
|
||||
let drawn = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
|
||||
let last = line(&mut s, [0.0, 3.0], [10.0, 3.0]);
|
||||
hide(&mut s, last);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [last]);
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(set(&mut s, "(ssget \"L\")"), [drawn], "Last must pass over an object the viewport does not draw");
|
||||
}
|
||||
|
||||
/// The grips of a selected object the viewport does not draw take no part in a grip edit: a
|
||||
/// shared grip stretches only the line that is drawn. Through the other viewport both stretch.
|
||||
#[test]
|
||||
fn a_selected_line_the_viewport_does_not_draw_is_not_stretched_with_a_shared_grip() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(untouched(&s, hidden), "a line the viewport does not draw moved: {:?}", ends(&s, hidden));
|
||||
// Its own grip starts no edit there.
|
||||
let before = s.state().unwrap().doc.clone();
|
||||
let j = origin_grip(&s, hidden);
|
||||
assert!(s.start_grip_edit(&[(hidden, j)]).is_err());
|
||||
assert!(s.running.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
|
||||
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.start_grip_edit(&[(shown, i)]).unwrap();
|
||||
s.cmdline("3,4").unwrap();
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
|
||||
assert!(ends(&s, hidden).0.near(Vec2::new(3.0, 4.0), 1e-9), "where both are drawn both stretch: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
/// A layer frozen in the viewport while its object is selected: from then on the object's grips
|
||||
/// are not there, and thawing the layer in the viewport brings them back.
|
||||
#[test]
|
||||
fn freezing_a_layer_in_the_viewport_removes_the_grips_of_a_selected_object() {
|
||||
let Sheet { mut s, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
// The typed GRIP command finds a grip of the selected line at its start.
|
||||
let grip_there = |s: &mut Session| {
|
||||
s.cmdline("grip").unwrap();
|
||||
s.cmdline("0,0").unwrap();
|
||||
let none = s.log.iter().any(|l| l.contains("No grip of a selected object there"));
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
s.log.clear();
|
||||
!none
|
||||
};
|
||||
assert!(grip_there(&mut s), "the viewport draws the line: its grip is there");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(!grip_there(&mut s), "the viewport no longer draws the line");
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(grip_there(&mut s), "thawed in the viewport: the grip is back");
|
||||
}
|
||||
|
||||
/// What the edited view shows: the frozen layer hides its model-space objects inside that
|
||||
/// viewport only, and never an object on the sheet.
|
||||
#[test]
|
||||
fn only_the_viewport_that_freezes_a_layer_hides_its_model_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert_eq!(s.doc().unwrap().space_of(on_sheet), Some(Space::Paper("Layout1".into())));
|
||||
let shows = |s: &Session, h: Handle| {
|
||||
let st = s.state().unwrap();
|
||||
st.shows(st.doc.entity(h).unwrap())
|
||||
};
|
||||
assert!(s.viewport_frozen().is_empty(), "paper space: no viewport is active");
|
||||
assert!(shows(&s, hidden) && shows(&s, shown) && shows(&s, on_sheet));
|
||||
enter(&mut s, &a);
|
||||
assert_eq!(s.viewport_frozen(), ["Hidden".to_string()]);
|
||||
assert!(!shows(&s, hidden), "frozen in the active viewport");
|
||||
assert!(shows(&s, shown));
|
||||
assert!(shows(&s, on_sheet), "the viewport draws model space: its frozen layers hide nothing on the sheet");
|
||||
// A prompt that picks one object on the sheet while the viewport is active finds it.
|
||||
let sheet_space = Space::Paper("Layout1".into());
|
||||
assert_eq!(s.pick_object(&sheet_space, Vec2::new(1.5, 1.0), 0.5).unwrap(), Some(on_sheet));
|
||||
assert_eq!(s.pick_object(&Space::Model, Vec2::new(5.0, 0.0), 0.5).unwrap(), Some(shown), "in model space the frozen line is left out");
|
||||
enter(&mut s, &b);
|
||||
assert!(s.viewport_frozen().is_empty());
|
||||
assert!(shows(&s, hidden));
|
||||
// Layer names are not case sensitive: frozen under another spelling, the line is hidden too.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["HIDDEN"] })).unwrap();
|
||||
assert_eq!(s.viewport_frozen(), ["HIDDEN".to_string()]);
|
||||
assert!(!shows(&s, hidden));
|
||||
assert_eq!(select(&mut s, json!({ "at": [5, 0], "aperture": 0.5 })), [shown]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["hidden"] })).unwrap();
|
||||
assert!(shows(&s, hidden));
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
assert!(s.viewport_frozen().is_empty(), "the Model tab has no layout viewport");
|
||||
assert!(shows(&s, hidden));
|
||||
}
|
||||
|
||||
/// An object that is selected when its layer is frozen in the active viewport stays selected, but
|
||||
/// nothing that starts from the selection takes it there: a typed command with the selection
|
||||
/// made before it (PICKFIRST), a JSON command that names no objects, and Delete.
|
||||
#[test]
|
||||
fn a_command_leaves_out_a_selected_object_the_viewport_does_not_draw() {
|
||||
/// One way of editing the selection.
|
||||
type Edit = fn(&mut Session);
|
||||
let edits: [(&str, Edit); 5] = [
|
||||
("typed ERASE", |s: &mut Session| run(s, "ERASE\n")),
|
||||
("typed MOVE", |s: &mut Session| run(s, "MOVE 0,0 0,5\n")),
|
||||
("JSON erase", |s: &mut Session| drop(s.execute("erase", &json!({})).unwrap())),
|
||||
("JSON move", |s: &mut Session| drop(s.execute("move", &json!({ "delta": [0, 5] })).unwrap())),
|
||||
("Delete", |s: &mut Session| drop(s.execute("erase.selection", &json!({})).unwrap())),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
// Where both lines are drawn, both are edited.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown) && edited(&s, hidden), "{name}: where both are drawn both are edited");
|
||||
|
||||
// The layer is frozen in the active viewport while both lines are selected.
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "{name}: both lines must still be selected");
|
||||
assert_eq!(s.selection(), [shown], "{name}: the selection as the viewport has it");
|
||||
edit(&mut s);
|
||||
assert!(edited(&s, shown), "{name}: the line the viewport draws must be edited");
|
||||
assert!(!edited(&s, hidden), "{name}: a line the viewport does not draw was edited: {:?}", ends(&s, hidden));
|
||||
}
|
||||
|
||||
// With only that line selected there is nothing to start from.
|
||||
let Sheet { mut s, hidden, a, b, .. } = sheet();
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![hidden]);
|
||||
assert!(s.selection().is_empty());
|
||||
assert!(s.execute("erase.selection", &json!({})).is_err(), "Delete must find nothing selected");
|
||||
s.script("ERASE\n").unwrap();
|
||||
assert!(s.running.is_some() && s.prompt_text().contains("Select objects"), "ERASE must ask for objects: {}", s.prompt_text());
|
||||
s.input(cadcraft_engine::Input::Cancel).unwrap();
|
||||
assert!(!edited(&s, hidden));
|
||||
// The line is still selected, and a viewport that draws it has it again.
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [a], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(s.selection(), [hidden], "thawed in the viewport: selected as before");
|
||||
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
|
||||
assert!(s.selection().is_empty());
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![hidden]);
|
||||
run(&mut s, "ERASE\n");
|
||||
assert!(!exists(&s, hidden), "where the line is drawn the selection made before ERASE is erased");
|
||||
}
|
||||
|
||||
/// The Previous and Last options of "Select objects" take what the viewport draws: of the set a
|
||||
/// command used before, and the last object that is not frozen there.
|
||||
#[test]
|
||||
fn previous_and_last_leave_out_what_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for option in ["P", "L"] {
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
assert!(!exists(&s, shown), "`{option}` must erase the line the viewport draws");
|
||||
assert!(exists(&s, hidden), "`{option}` erased a line the viewport does not draw");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(Vec::new());
|
||||
s.remember_selection(&[shown, hidden]);
|
||||
run(&mut s, &format!("ERASE {option}\n"));
|
||||
// Previous is both lines; Last is the one made last, the line on the layer "Hidden".
|
||||
assert_eq!((exists(&s, shown), exists(&s, hidden)), (option == "L", false), "`{option}` where both are drawn");
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
assert!(exists(&s, shown) && exists(&s, hidden));
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON grip edits that cover the selection (`grip.stretch` with hot grips, and
|
||||
/// [`Session::grip_edit`]) drag the grips of the other selected objects on the same point, but
|
||||
/// not the grip of a selected line the viewport does not draw.
|
||||
#[test]
|
||||
fn the_grip_edits_that_cover_the_selection_leave_out_what_the_viewport_does_not_draw() {
|
||||
use cadcraft_engine::grips::GripMode;
|
||||
/// Drags grip `i` of the line `h` to (3, 4).
|
||||
type Edit = fn(&mut Session, Handle, usize);
|
||||
let edits: [(&str, Edit); 2] = [
|
||||
("grip.stretch", |s: &mut Session, h: Handle, i: usize| {
|
||||
drop(s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i]], "to": [3, 4] })).unwrap())
|
||||
}),
|
||||
("grip_edit", |s: &mut Session, h: Handle, i: usize| s.grip_edit(h, i, Vec2::new(3.0, 4.0), GripMode::Stretch).unwrap()),
|
||||
];
|
||||
for (name, edit) in edits {
|
||||
for frozen in [true, false] {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
enter(&mut s, if frozen { &a } else { &b });
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
let i = origin_grip(&s, shown);
|
||||
edit(&mut s, shown, i);
|
||||
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{name}: {:?}", ends(&s, shown));
|
||||
assert_eq!(untouched(&s, hidden), frozen, "{name}, layer frozen in the viewport: {frozen}: {:?}", ends(&s, hidden));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session reports as the selection is the selection as the viewport has it, while
|
||||
/// every selected object stays in the stored selection.
|
||||
#[test]
|
||||
fn the_reported_selection_leaves_out_what_the_viewport_does_not_draw() {
|
||||
/// The handles in `list`, a JSON array of hex strings, in handle order.
|
||||
fn handles(list: &Value) -> Vec<Handle> {
|
||||
sorted(list.as_array().unwrap().iter().map(|h| Handle::parse_hex(h.as_str().unwrap()).unwrap()).collect())
|
||||
}
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
|
||||
enter(&mut s, viewport);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "both lines are selected");
|
||||
assert_eq!(sorted(s.selection()), expected);
|
||||
assert_eq!(handles(&s.summary()["selection"]), expected);
|
||||
let inspect = s.execute("drawing.inspect", &json!({})).unwrap();
|
||||
assert_eq!(handles(&inspect["selection"]), expected);
|
||||
}
|
||||
// A selected object on the sheet is never left out, whatever its layer.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, on_sheet]));
|
||||
// Where no viewport is active nothing is left out: in paper space and on the Model tab.
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
s.set_selection(vec![shown, hidden, on_sheet]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, on_sheet]), "paper space");
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden]), "the Model tab");
|
||||
}
|
||||
|
||||
/// Adding to the selection or removing from it inside the viewport keeps a selected object the
|
||||
/// viewport does not draw: nothing drops it behind the user's back, and it counts again once its
|
||||
/// layer is thawed there.
|
||||
#[test]
|
||||
fn changing_the_selection_keeps_a_selected_object_the_viewport_does_not_draw() {
|
||||
let Sheet { mut s, shown, hidden, b, .. } = sheet();
|
||||
enter(&mut s, &b);
|
||||
// A third line, on layer 0 like `shown`, away from the other two.
|
||||
let other = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
|
||||
// Selects `hidden` where it is drawn, then freezes its layer in the active viewport.
|
||||
let freeze = |s: &mut Session| {
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
s.set_selection(vec![hidden]);
|
||||
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
|
||||
assert_eq!(selected(s), [hidden], "the line must still be selected");
|
||||
assert!(s.selection().is_empty(), "the viewport does not draw it");
|
||||
};
|
||||
|
||||
// A click adds (PICKADD), Shift+click removes.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a click dropped the selected line the viewport does not draw");
|
||||
assert_eq!(s.selection(), [other]);
|
||||
s.idle_click(Vec2::new(5.0, 5.0), true).unwrap();
|
||||
assert_eq!(selected(&s), [hidden], "Shift+click must remove only the line clicked");
|
||||
|
||||
// A window adds.
|
||||
freeze(&mut s);
|
||||
s.idle_click(Vec2::new(-1.0, 4.0), false).unwrap();
|
||||
assert!(s.pending_window.is_some(), "the first click must be on nothing");
|
||||
s.idle_click(Vec2::new(11.0, 6.0), false).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a window dropped the selected line the viewport does not draw");
|
||||
|
||||
// The JSON `select` with `add`, and QSELECT appending the objects on layer 0.
|
||||
freeze(&mut s);
|
||||
s.execute("select", &json!({ "at": [5, 5], "aperture": 0.5, "add": true })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with `add`");
|
||||
// Given nothing to select with, `select` leaves the selection as it is.
|
||||
s.execute("select", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with nothing to select with");
|
||||
freeze(&mut s);
|
||||
s.execute("qselect", &json!({ "layer": "0", "mode": "append" })).unwrap();
|
||||
assert_eq!(selected(&s), sorted(vec![shown, hidden, other]), "QSELECT appending");
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, other]));
|
||||
|
||||
// Thawed in the viewport, the line counts as selected again.
|
||||
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
|
||||
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, other]));
|
||||
}
|
||||
|
||||
/// [`Session::selection`] looks a few selected objects up one by one and, when there are many,
|
||||
/// passes over model space once. Both leave out the same objects and keep the order of the
|
||||
/// selection: in a drawing of some hundreds of lines whose draw order is not the order of their
|
||||
/// handles, a selection of a few lines and the selection of every line.
|
||||
#[test]
|
||||
fn a_small_and_a_large_selection_leave_out_the_same_objects() {
|
||||
let Sheet { mut s, shown, hidden, a, b } = sheet();
|
||||
// A line on the sheet, on the layer the first viewport freezes: no viewport hides it.
|
||||
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
|
||||
hide(&mut s, on_sheet);
|
||||
assert!(!s.doc().unwrap().model.contains(on_sheet), "the line must be on the sheet");
|
||||
enter(&mut s, &b);
|
||||
let more: Vec<Handle> = (0..400).map(|i| line(&mut s, [f64::from(i), 5.0], [f64::from(i), 6.0])).collect();
|
||||
// Every second one of them goes on the layer the first viewport freezes.
|
||||
let frozen: Vec<String> = more.iter().step_by(2).map(|h| h.hex()).collect();
|
||||
s.execute("properties.set", &json!({ "handles": frozen, "layer": "Hidden" })).unwrap();
|
||||
// The frozen line made last goes to the back: it is now the first object in draw order.
|
||||
s.execute("draworder", &json!({ "handles": [more[398].hex()], "order": "back" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.iter().next().map(|e| e.handle), Some(more[398]));
|
||||
let all: Vec<Handle> = [shown, hidden].into_iter().chain(more.iter().copied()).collect();
|
||||
let drawn: Vec<Handle> = std::iter::once(shown).chain(more.iter().copied().skip(1).step_by(2)).collect();
|
||||
assert_eq!(s.doc().unwrap().model.len(), all.len());
|
||||
|
||||
enter(&mut s, &a);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown], "two selected lines of some hundreds");
|
||||
s.set_selection(vec![hidden, on_sheet, more[0], more[1]]);
|
||||
assert_eq!(s.selection(), [on_sheet, more[1]], "a line on the sheet is never left out");
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), drawn, "every line selected");
|
||||
assert_eq!(s.state().unwrap().selection, all, "every line must still be selected");
|
||||
|
||||
// The viewport that freezes nothing has them all, few or many.
|
||||
enter(&mut s, &b);
|
||||
s.set_selection(vec![shown, hidden]);
|
||||
assert_eq!(s.selection(), [shown, hidden]);
|
||||
s.set_selection(all.clone());
|
||||
assert_eq!(s.selection(), all);
|
||||
}
|
||||
@@ -190,28 +190,6 @@ fn reload_unload_path_and_detach() {
|
||||
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
|
||||
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
|
||||
|
||||
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
|
||||
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
|
||||
|
||||
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
|
||||
// and reload-all loads one of them once.
|
||||
let mut twin = Block::new("SITE");
|
||||
twin.xref_path = Some("site.dxf".into());
|
||||
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
|
||||
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
|
||||
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
|
||||
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
|
||||
// Detaching one of them, or giving both a new path, is refused before anything changes.
|
||||
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
|
||||
assert!(s.doc().unwrap().block("site").is_some());
|
||||
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
|
||||
for k in ["site", "SITE"] {
|
||||
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
|
||||
}
|
||||
s.doc_mut().unwrap().blocks.remove("SITE");
|
||||
|
||||
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
|
||||
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
|
||||
assert!(s.doc().unwrap().block("site|Tree").is_none());
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user