mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-12 00:45:16 -06:00
Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910278bf4d | ||
|
|
886f6b66b4 | ||
|
|
b15c5a023e | ||
|
|
dea9f2d048 | ||
|
|
d6d16bd474 | ||
|
|
2410a1fd07 | ||
|
|
a1574780c2 | ||
|
|
ea2637ae73 | ||
|
|
7e780548be | ||
|
|
6d744887f4 | ||
|
|
2c25c2d700 | ||
|
|
95fdf8d11a | ||
|
|
0843770389 | ||
|
|
36f5ad64f9 | ||
|
|
7804e94ee0 | ||
|
|
bc9ad6b059 | ||
|
|
2f8f186188 | ||
|
|
6f0f1fefa8 | ||
|
|
a3d9348f63 | ||
|
|
3074f18960 | ||
|
|
10ffb7c7bd | ||
|
|
4744b4cffb | ||
|
|
d9e62df1dd | ||
|
|
9bd7f8f8f1 | ||
|
|
d847eac134 | ||
|
|
cd71385da9 | ||
|
|
004cbecd48 | ||
|
|
0e7e0f59f0 | ||
|
|
6cb8e9b1a0 | ||
|
|
3a9aebc210 | ||
|
|
909af5e389 | ||
|
|
78810069c7 | ||
|
|
bd4a47491a | ||
|
|
54c973c919 | ||
|
|
53962a5cd5 | ||
|
|
1434d1017b | ||
|
|
baf2424164 | ||
|
|
ec09c87c43 | ||
|
|
a0dfe07f5a | ||
|
|
9ec5728159 | ||
|
|
42490afe8e | ||
|
|
adf02f9f04 | ||
|
|
e56cc4beeb | ||
|
|
7a9ec9bae3 | ||
|
|
1363afb883 | ||
|
|
3f9e45f726 | ||
|
|
5ece420543 | ||
|
|
bd37c89ca1 | ||
|
|
62caaa30d9 | ||
|
|
486d558a78 | ||
|
|
6304abd8f0 | ||
|
|
07b6b8c53a | ||
|
|
14a6cc3cdc | ||
|
|
85b35fc5b9 | ||
|
|
865fa9afb0 | ||
|
|
1452ab6f9f | ||
|
|
0140e275f3 | ||
|
|
098fa7fd80 | ||
|
|
32db96f5e3 | ||
|
|
4e36021812 | ||
|
|
fafc9e1910 | ||
|
|
a42760af80 | ||
|
|
b1263ed3a4 | ||
|
|
141a1ac8cd | ||
|
|
42c1dca89d | ||
|
|
b36d7aea40 | ||
|
|
a655cee0c1 | ||
|
|
968b5bd648 | ||
|
|
35545af16d | ||
|
|
876e103488 | ||
|
|
01e28fc0d4 | ||
|
|
bd639d3204 | ||
|
|
cce67d27c7 | ||
|
|
d23c5f0182 | ||
|
|
789c4e9542 | ||
|
|
5aa0e2da9e | ||
|
|
cdbc32ac8f | ||
|
|
7e141247dd | ||
|
|
2cbd67f963 | ||
|
|
56dc397680 | ||
|
|
d2052eb4c7 | ||
|
|
0722afc5bd | ||
|
|
5ca4b220aa | ||
|
|
b254a28a5e | ||
|
|
168f4a699a | ||
|
|
ff6b0e375b | ||
|
|
f977b6e7cc | ||
|
|
c9082d974d | ||
|
|
fd3a0b9e15 | ||
|
|
b8efe39acc | ||
|
|
b1bab3043c | ||
|
|
0e949bffe1 | ||
|
|
50bc6c1158 | ||
|
|
f8f0504b9a | ||
|
|
6ecabc3451 | ||
|
|
c223550cb3 | ||
|
|
ef167c3140 | ||
|
|
08ea23ec2d | ||
|
|
98b47c1984 | ||
|
|
1f75a9fb25 | ||
|
|
c2175377fd | ||
|
|
c1d7e4415b | ||
|
|
1e02e6f263 | ||
|
|
d0ab457f31 | ||
|
|
eae1fb98bf | ||
|
|
c35d18e803 | ||
|
|
811bd3cb4e | ||
|
|
603e1acaf9 | ||
|
|
5585298128 | ||
|
|
e67de34cf0 | ||
|
|
ec37f02f46 | ||
|
|
f02ac726ba | ||
|
|
f892a6f60d | ||
|
|
0934a629e6 | ||
|
|
22c6b398fd | ||
|
|
609148a444 | ||
|
|
2830c7c176 | ||
|
|
a841f225cf | ||
|
|
6549b97b6c | ||
|
|
1d29b626e6 | ||
|
|
cab962a7cb | ||
|
|
101397d9c5 | ||
|
|
c32c0f5e30 | ||
|
|
950453c7fd | ||
|
|
7003c5b930 |
@@ -18,6 +18,12 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
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
|
||||
@@ -58,7 +64,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
@@ -70,6 +76,7 @@ jobs:
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+34
-31
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -631,6 +631,7 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -644,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -660,14 +661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -677,7 +678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -688,7 +689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
@@ -696,7 +697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -705,7 +706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -726,8 +727,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
@@ -738,14 +740,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -767,13 +769,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"clap",
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"serde_json",
|
||||
"tiny-skia",
|
||||
@@ -781,7 +784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -795,7 +798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -822,15 +825,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-io",
|
||||
"cadcraft-ui-egui",
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
@@ -1209,7 +1214,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1232,10 +1237,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -5977,7 +5993,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
@@ -6210,7 +6226,6 @@ dependencies = [
|
||||
"nix 0.31.3",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6225,18 +6240,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -71,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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 }
|
||||
|
||||
|
||||
+27
-24
@@ -2,16 +2,18 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (re-audit against main 41d9b5e95: language table, CI gate, open-PR count; previously: progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
Precise drafting still has approximate ellipse and spline intersections and a few stubbed prompt options
|
||||
(object snap tracking, TRIM/EXTEND/OFFSET options and typed snap overrides have landed); dynamic blocks can't be
|
||||
authored and xrefs can't be edited in place (the Block Editor, REFEDIT, dynamic block editing and XCLIP have landed);
|
||||
plots go only to PDF, PNG and SVG files (plot styles and the Plot dialog have landed); and a DWG saved by
|
||||
CADCraft drops what acadrust doesn't model and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
|
||||
@@ -19,7 +21,7 @@ is ≈ 600–930 h away.
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491 at the 2026-10-10 re-measure, including UI-only commands; not re-measured since; the generated checklist counts engine commands only and now reads 298 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) |
|
||||
| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) |
|
||||
| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -80,40 +82,40 @@ Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build:
|
||||
|
||||
## Languages
|
||||
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus partial
|
||||
Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus eighteen catalogs
|
||||
(Ukrainian, Brazilian Portuguese, German, Spanish, Czech, Russian, French, Simplified Chinese, Italian, Korean, Swedish, Arabic, Greek, Hungarian, Turkish, Bosnian, Georgian and Vietnamese). Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Status | UI translated |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | none | 0% |
|
||||
| Spanish | none | 0% |
|
||||
| Simplified Chinese | partial | 1,337 catalog rows |
|
||||
| Spanish | partial | 1,243 catalog rows |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | none | 0% |
|
||||
| Portuguese | none | 0% |
|
||||
| Arabic | partial | 1,229 catalog rows |
|
||||
| French | partial | 1,004 catalog rows |
|
||||
| Portuguese (Brazilian) | partial | 1,243 catalog rows |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
| Greek | partial | 1,330 catalog rows; independent proofreading pending |
|
||||
| German | partial | 1,031 catalog rows |
|
||||
| Korean | partial | 1,235 catalog rows |
|
||||
| Vietnamese | partial | 1,460 catalog rows (2026-11-07); native review pending |
|
||||
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
Other languages shipped: Ukrainian (1,357 rows, the reference catalog, #36), Czech (1,215), Russian (1,005), Italian (1,352), Swedish (1,382), Hungarian (1,326) and Turkish (1,330), counted 2026-10-24; engine messages stay English. Every catalog but Ukrainian's has strings added since it landed that still fall back to English: 6 (Italian, Swedish) to 296 (French) per `cargo xtask i18n` ([localization-parity.md](docs/localization-parity.md)).
|
||||
|
||||
## Upcoming
|
||||
|
||||
Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h).
|
||||
2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h).
|
||||
1. Gate and land: CI runs `cargo xtask ci` on every PR since #1987 (fmt on main is fixed); land the 450 open PRs (2026-10-24) (15–25 h).
|
||||
2. Stop losing data on save (DXF done, #528; native MULTILEADER, #459, and the save version, #421, done; through DWG, acadrust still drops some content) (30–45 h).
|
||||
3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner).
|
||||
4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input (25–35 h).
|
||||
5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h).
|
||||
6. Autosave, recovery, AUDIT, RECOVER (10–15 h).
|
||||
4. ~~Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides,
|
||||
editable dynamic input.~~ Done (#379, #441, #60, #519; `snap/tracking.rs`, `pointmod.rs`, `dyninput.rs`).
|
||||
5. ~~Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER.~~ Done (#397, #408, #547); the stubs left are in gaps F1.
|
||||
6. ~~Autosave, recovery, AUDIT, RECOVER.~~ Done on the desktop (#406, #416); the web build has no autosave (gaps F4).
|
||||
7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h).
|
||||
8. Xrefs, block editor, groups, then dynamic blocks (80–120 h): xrefs (#525), groups (#417), the Block Editor (#824, #833, #885), REFEDIT (#818), XCLIP and XCLIPFRAME (#2354) and dynamic block references (#838–#883) landed; left: dynamic-block authoring, editing an xref in place.
|
||||
|
||||
## Progress log
|
||||
|
||||
@@ -136,6 +138,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Re-audit against main 41d9b5e95: language table, CI gate and open-PR count re-checked against main 41d9b5e95 |
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
|
||||
@@ -20,7 +20,9 @@ cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.cadcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -33,4 +35,5 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.cadcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
|
||||
//! own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is CADCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on drops its [`PendingReply`], which cancels a request the app has
|
||||
//! not started, so the app skips it instead of running it late.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use cadcraft_ui_egui::{ControlRequest, PendingReply};
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "cadcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.cadcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `CadApp::with_control`). An existing `window.cadcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame. Dropping this future (the page's timeout) drops
|
||||
/// `reply`, which cancels the request if the app has not started it.
|
||||
async fn wait(reply: PendingReply) -> Value {
|
||||
loop {
|
||||
if let Some(reply) = reply.try_reply() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -160,7 +162,8 @@ mod web {
|
||||
Box::new(move |cc| {
|
||||
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
|
||||
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
let mut app = CadApp::new(session, services());
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = CadApp::new(session, services()).with_control(control);
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
|
||||
|
||||
@@ -55,7 +55,11 @@ 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.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# `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"
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -250,17 +250,18 @@ 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 Some(obj) = msg.as_object() else {
|
||||
let Value::Object(mut obj) = msg else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
let Some(Value::String(method)) = obj.remove("method") 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.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),
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,20 @@ 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);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// 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")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -441,8 +454,14 @@ 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; 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"));
|
||||
// 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}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+42
-21
@@ -23,13 +23,15 @@ 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, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -159,28 +161,41 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
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())
|
||||
})),
|
||||
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,
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).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())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +368,12 @@ 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;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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);
|
||||
})
|
||||
}
|
||||
@@ -37,7 +37,9 @@ display_name = "Tasos Sangiotis"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
|
||||
@@ -232,6 +232,10 @@ 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;
|
||||
}
|
||||
@@ -267,20 +271,22 @@ 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()));
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && 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);
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
// The duplicate search scans every candidate, so it comes after the cheap tests.
|
||||
if tee
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ 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)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
// 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 mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
|
||||
+31
-1
@@ -204,6 +204,23 @@ 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).
|
||||
@@ -217,9 +234,10 @@ 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: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
block: 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,
|
||||
@@ -301,6 +319,18 @@ 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,6 +674,10 @@ 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()
|
||||
@@ -689,7 +693,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ impl ForeignExtra {
|
||||
pub struct Foreign {
|
||||
/// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types.
|
||||
pub classes: Vec<Vec<RawTag>>,
|
||||
/// Classes retain every source position, including duplicate names.
|
||||
pub complete_class_table: bool,
|
||||
/// Kept objects, in file order.
|
||||
pub objects: Vec<ForeignObject>,
|
||||
/// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object).
|
||||
|
||||
@@ -486,16 +486,24 @@ 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.
|
||||
/// had unusable values. When several spellings name one field, the last one wins and the
|
||||
/// superseded ones are not validated.
|
||||
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,3 +714,14 @@ 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);
|
||||
}
|
||||
|
||||
+378
-21
@@ -66,6 +66,8 @@ fn mb(n: u64) -> String {
|
||||
pub mod worker;
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod proxy_transport;
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
mod native {
|
||||
@@ -107,6 +109,13 @@ mod native {
|
||||
}
|
||||
let mut notes = read_notes(&outcome.stats);
|
||||
let mut doc = outcome.document;
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| application_objects(&mut doc))).map_err(|_| "the DWG application object conversion failed".to_string())?,
|
||||
);
|
||||
// After application_objects, so the proxy objects it makes are numbered as the DXF's.
|
||||
notes.extend(
|
||||
guarded(AssertUnwindSafe(|| super::proxy_transport::prepare(&mut doc))).map_err(|_| "the DWG proxy conversion failed".to_string())?,
|
||||
);
|
||||
let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?;
|
||||
if orphans > 0 {
|
||||
let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") };
|
||||
@@ -390,6 +399,214 @@ mod native {
|
||||
std::panic::catch_unwind(f).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// The version a proxy record of a DWG of `version` names its format by (Open Design
|
||||
/// Specification for .dwg, proxy objects): 19 for R13, 21 for R14, 23 for 2000, 25 for 2004,
|
||||
/// 27 for 2007, 29 for 2010, 31 for 2013 and 33 for 2018; `None` for any other version.
|
||||
pub(super) fn proxy_dwg_version(version: acadrust::DxfVersion) -> Option<i32> {
|
||||
Some(match version.version_code() {
|
||||
1012 => 19,
|
||||
1014 => 21,
|
||||
1015 => 23,
|
||||
1018 => 25,
|
||||
1021 => 27,
|
||||
1024 => 29,
|
||||
1027 => 31,
|
||||
1032 => 33,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects of another application's class that acadrust keeps only as their DWG record
|
||||
/// (`ObjectType::Unknown`, which has no DXF form): its DXF writer left them out, and the
|
||||
/// references to them went with them (a dimension's extension dictionary entry naming its
|
||||
/// application data, say), with no note. Each is carried as a proxy object of its class instead
|
||||
/// (DXF Reference, ACAD_PROXY_OBJECT), so the DXF keeps it and a DWG written from that DXF has it
|
||||
/// again: its common object data (owner, reactors, extension dictionary) and the streams after
|
||||
/// it (data bits, R2007+ strings, object references), as the Open Design Specification for .dwg
|
||||
/// splits an object record. Objects of the drawing database's own classes are not turned into
|
||||
/// another application's proxies. What is still without a DXF form is named in the notes, so a
|
||||
/// save over the original is not mistaken for a complete one.
|
||||
fn application_objects(doc: &mut acadrust::CadDocument) -> Vec<String> {
|
||||
use acadrust::objects::{ObjectType, ProxyObject};
|
||||
let classes: Vec<acadrust::classes::DxfClass> = doc.classes.iter().cloned().collect();
|
||||
let source = doc.dwg_source_version.unwrap_or(doc.version);
|
||||
if let Some(dwg_version) = proxy_dwg_version(source) {
|
||||
let maintenance = i32::from(doc.maintenance_version);
|
||||
for object in doc.objects.values_mut() {
|
||||
let ObjectType::Unknown { handle, raw_dwg_data: Some(data), raw_dwg_handle_bits, raw_dwg_version: Some(version), type_name, .. } =
|
||||
&*object
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !application_class(&classes, type_name) {
|
||||
continue;
|
||||
}
|
||||
let raw = acadrust::entities::RawRecord { data: data.clone(), handle_bits: *raw_dwg_handle_bits, version: *version };
|
||||
let handle = *handle;
|
||||
// A record that doesn't split stays as it was, and is named below.
|
||||
let Ok(Ok(record)) = std::panic::catch_unwind(AssertUnwindSafe(|| object_record(&raw, handle.value()))) else { continue };
|
||||
let Some(class) = classes.iter().find(|c| c.class_number == record.kind && !c.is_an_entity) else { continue };
|
||||
*object = ObjectType::ProxyObject(ProxyObject {
|
||||
handle,
|
||||
owner: record.owner,
|
||||
reactors: record.reactors,
|
||||
xdictionary_handle: record.xdictionary,
|
||||
proxy_id: 499,
|
||||
class_id: i32::from(class.class_number),
|
||||
dwg_version,
|
||||
maintenance_version: maintenance,
|
||||
version: (maintenance << 16) | dwg_version,
|
||||
payload: record.payload,
|
||||
text_payload: record.text_payload,
|
||||
object_ids: record.object_ids,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut left: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for object in doc.objects.values() {
|
||||
if let ObjectType::Unknown { type_name, raw_dxf_codes: None, .. } = object {
|
||||
let n = left.entry(type_name.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if left.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let total = left.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
let mut list: Vec<String> = left.iter().take(NOTE_TYPES).map(|(t, n)| format!("{t:.60} × {n}")).collect();
|
||||
if left.len() > NOTE_TYPES {
|
||||
list.push(format!("{} more types", left.len() - NOTE_TYPES));
|
||||
}
|
||||
vec![format!(
|
||||
"DWG: {total} objects of types CADCraft doesn't keep were left out ({}); saving over the original file would lose them",
|
||||
list.join(", ")
|
||||
)]
|
||||
}
|
||||
|
||||
/// Whether `name` is an object class of another application, which only that application
|
||||
/// rebuilds: not one of the drawing database's own classes (`AcDb…`/`ACDB…`/`ACAD_…`), whose
|
||||
/// leftovers the database regenerates itself.
|
||||
fn application_class(classes: &[acadrust::classes::DxfClass], name: &str) -> bool {
|
||||
classes.iter().any(|c| {
|
||||
!c.is_an_entity
|
||||
&& c.dxf_name.eq_ignore_ascii_case(name)
|
||||
&& !c.cpp_class_name.starts_with("AcDb")
|
||||
&& !["ACDB", "ACAD_", "ACAD"].iter().any(|p| c.dxf_name.to_ascii_uppercase().starts_with(p))
|
||||
})
|
||||
}
|
||||
|
||||
/// An application object acadrust kept only as its record: the parts a proxy object carries.
|
||||
struct ObjectRecord {
|
||||
kind: i16,
|
||||
owner: acadrust::types::Handle,
|
||||
reactors: Vec<acadrust::types::Handle>,
|
||||
xdictionary: Option<acadrust::types::Handle>,
|
||||
payload: acadrust::objects::ProxyPayload,
|
||||
text_payload: acadrust::objects::ProxyPayload,
|
||||
object_ids: Vec<acadrust::objects::ProxyObjectReference>,
|
||||
}
|
||||
|
||||
/// Split an object record into its common object data and the uninterpreted streams after it
|
||||
/// (data bits, R2007+ strings and object references), as an application object's proxy holds
|
||||
/// them (Open Design Specification for .dwg, common object data and proxy objects). The record
|
||||
/// is hostile: sizes are checked against it, and acadrust's checked stream reader does the rest.
|
||||
fn object_record(raw: &acadrust::entities::RawRecord, handle: u64) -> Result<ObjectRecord, String> {
|
||||
use acadrust::io::dwg::{
|
||||
DwgVersion, dwg_reference_type::DwgReferenceType, dwg_stream_readers::object_reader::DwgObjectReader,
|
||||
dwg_stream_writers::bit_writer::DwgBitWriter,
|
||||
};
|
||||
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
if raw.data.len() > 32 << 20 {
|
||||
return Err("object record exceeds the transport limit".into());
|
||||
}
|
||||
let handle_bits = usize::try_from(raw.handle_bits).map_err(|_| "object has a negative handle stream size")?;
|
||||
let total_bits = raw.data.len().checked_mul(8).ok_or("object record is too large")?;
|
||||
if handle_bits > total_bits {
|
||||
return Err("object handle stream exceeds its record".into());
|
||||
}
|
||||
let version = DwgVersion::from_dxf_version(raw.version).map_err(|e| e.to_string())?;
|
||||
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, std::collections::HashMap::new()).map_err(|e| e.to_string())?;
|
||||
let (kind, mut streams) = reader.read_record_at(0).map_err(|e| e.to_string())?;
|
||||
if kind < 500 {
|
||||
return Err("object has no application class".into());
|
||||
}
|
||||
let common = reader.read_common_non_entity_data(&mut streams, kind);
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
if common.common.handle != handle {
|
||||
return Err("object record names another handle".into());
|
||||
}
|
||||
let read_bits = |count: usize, bit: &mut dyn FnMut() -> bool| -> Result<ProxyPayload, String> {
|
||||
if count > total_bits {
|
||||
return Err("object stream exceeds its record".into());
|
||||
}
|
||||
let n = u32::try_from(count).map_err(|_| "object stream is too large")?;
|
||||
let mut bytes = vec![0u8; count.div_ceil(8)];
|
||||
for index in 0..count {
|
||||
if bit()
|
||||
&& let Some(b) = bytes.get_mut(index / 8)
|
||||
{
|
||||
*b |= 0x80 >> (index % 8);
|
||||
}
|
||||
}
|
||||
Ok(ProxyPayload::from_bits(&bytes, n))
|
||||
};
|
||||
let main = usize::try_from(streams.main_remaining_bits()).map_err(|_| "object body has an invalid size")?;
|
||||
let payload = read_bits(main, &mut || streams.read_bit())?;
|
||||
let text_payload = if raw.version >= DxfVersion::AC1021 {
|
||||
let bits = usize::try_from(streams.text_remaining_bits()).map_err(|_| "object has a negative string stream size")?;
|
||||
read_bits(bits, &mut || streams.read_text_bit())?
|
||||
} else {
|
||||
ProxyPayload::default()
|
||||
};
|
||||
// Every reference to the end of the handle stream, null ones too: an object keeps a null pointer in its
|
||||
// field's place, and the references after it are the next fields' (as acadrust reads a proxy's own). R2010+
|
||||
// records give the stream's exact length; the bits of its last byte past it are padding.
|
||||
let mut object_ids = Vec::new();
|
||||
let mut left = if raw.version >= DxfVersion::AC1024 {
|
||||
streams.get_handle_bits().max(0).min(streams.handle_remaining_bits())
|
||||
} else {
|
||||
streams.handle_remaining_bits()
|
||||
};
|
||||
while left >= 8 {
|
||||
if object_ids.len() >= 100_000 {
|
||||
return Err("object has too many references".into());
|
||||
}
|
||||
let before = streams.handle_remaining_bits();
|
||||
let (target, reference) = streams.read_handle_reference(handle);
|
||||
let used = before.saturating_sub(streams.handle_remaining_bits());
|
||||
if used <= 0 || used > left {
|
||||
break;
|
||||
}
|
||||
left -= used;
|
||||
let kind = match reference {
|
||||
DwgReferenceType::SoftOwnership => ProxyReferenceKind::SoftOwnership,
|
||||
DwgReferenceType::HardOwnership => ProxyReferenceKind::HardOwnership,
|
||||
DwgReferenceType::SoftPointer => ProxyReferenceKind::SoftPointer,
|
||||
DwgReferenceType::HardPointer => ProxyReferenceKind::HardPointer,
|
||||
DwgReferenceType::Undefined => ProxyReferenceKind::Undefined,
|
||||
};
|
||||
object_ids.push(ProxyObjectReference { handle: Handle::from(target), kind });
|
||||
}
|
||||
streams.check_error().map_err(|e| e.to_string())?;
|
||||
Ok(ObjectRecord {
|
||||
kind,
|
||||
owner: Handle::from(common.owner_handle),
|
||||
reactors: common.reactors.into_iter().map(Handle::from).collect(),
|
||||
xdictionary: common.xdictionary_handle.map(Handle::from),
|
||||
payload,
|
||||
text_payload,
|
||||
object_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is
|
||||
/// left unmerged. Returns the cells visited.
|
||||
fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize {
|
||||
@@ -746,7 +963,7 @@ mod native {
|
||||
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
|
||||
/// of the HEADER section: a large input without one costs time, not memory (a line index of
|
||||
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
|
||||
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
|
||||
pub(super) fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
|
||||
let mut offset = 0usize;
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
|
||||
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
|
||||
@@ -806,32 +1023,32 @@ mod native {
|
||||
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
|
||||
/// conversion functions comes here.
|
||||
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
|
||||
let pairs = super::proxy_transport::text_pairs(dxf)?;
|
||||
let mut names = Vec::new();
|
||||
if dxf.starts_with(b"AutoCAD Binary DXF") {
|
||||
return None;
|
||||
}
|
||||
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
|
||||
let (mut in_classes, mut in_class) = (false, false);
|
||||
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
|
||||
match (code, value) {
|
||||
(b"0", b"ENDSEC") if in_classes => break,
|
||||
(b"2", b"CLASSES") => in_classes = true,
|
||||
(b"0", v) if in_classes => {
|
||||
in_class = v == b"CLASS";
|
||||
// Every record keeps its place, named or not: a proxy numbers its class by
|
||||
// place, so a record without a name must not move the ones after it. An empty
|
||||
// name matches no class, and such a proxy is left out.
|
||||
let (mut section, mut in_classes, mut in_class) = (false, 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 => {
|
||||
in_class = kind == "CLASS";
|
||||
// Even an unnamed record keeps its place in the source class table.
|
||||
if in_class {
|
||||
names.push(String::new());
|
||||
}
|
||||
}
|
||||
(b"1", v) if in_class => {
|
||||
(1, value) if in_class => {
|
||||
if let Some(last) = names.last_mut() {
|
||||
*last = String::from_utf8_lossy(v).into_owned();
|
||||
*last = value.to_string();
|
||||
}
|
||||
in_class = false;
|
||||
}
|
||||
_ => {}
|
||||
_ => section = false,
|
||||
}
|
||||
}
|
||||
Some(names)
|
||||
@@ -877,8 +1094,17 @@ mod native {
|
||||
return left_out(objects.len(), entities.len());
|
||||
}
|
||||
};
|
||||
// Name matching cannot choose between different source classes with the same name.
|
||||
// Count those proxies as unwritten rather than silently changing their class identity.
|
||||
let mut unique = std::collections::HashMap::new();
|
||||
for name in classes {
|
||||
unique.entry(name.to_uppercase()).and_modify(|value| *value = false).or_insert(true);
|
||||
}
|
||||
let number = |id: i32| -> Option<i32> {
|
||||
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
|
||||
if unique.get(&name.to_uppercase()) != Some(&true) {
|
||||
return None;
|
||||
}
|
||||
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
|
||||
};
|
||||
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
|
||||
@@ -971,7 +1197,8 @@ mod native {
|
||||
Some(d) => d,
|
||||
None => dxf.to_vec(),
|
||||
};
|
||||
let classes = dxf_class_names(&data);
|
||||
let (classes, source) = guarded(AssertUnwindSafe(|| (dxf_class_names(&data), super::proxy_transport::source_classes(&data))))
|
||||
.map_err(|_| "the DXF proxy class conversion failed".to_string())?;
|
||||
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
|
||||
.map_err(|e| format!("DXF: {e}"))?;
|
||||
@@ -986,7 +1213,17 @@ mod native {
|
||||
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
|
||||
let unwritten = guarded(AssertUnwindSafe(|| {
|
||||
super::fail_in_test("convert");
|
||||
let proxies = proxy_classes(&mut doc, classes.as_deref());
|
||||
// From 2013 the DWG takes the source classes as they are, duplicates and metadata
|
||||
// included; otherwise (before 2013, a binary DXF, classes that can't be written back)
|
||||
// proxies are matched to classes by name or left out.
|
||||
let restored = match source {
|
||||
Some(source) if doc.version >= acadrust::DxfVersion::AC1027 => super::proxy_transport::restore_classes(&mut doc, source),
|
||||
_ => None,
|
||||
};
|
||||
let proxies = match restored {
|
||||
Some((objects, entities)) => left_out(objects, entities),
|
||||
None => proxy_classes(&mut doc, classes.as_deref()),
|
||||
};
|
||||
other_layout_owners(&mut doc);
|
||||
table_style_rows(&mut doc, true);
|
||||
dimension_flags(&mut doc);
|
||||
@@ -1159,6 +1396,126 @@ mod native {
|
||||
assert_eq!(guarded(|| 3), Ok(3));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod application_object_tests {
|
||||
use super::*;
|
||||
use acadrust::CadDocument;
|
||||
use acadrust::classes::DxfClass;
|
||||
use acadrust::objects::{ObjectType, ProxyObjectReference, ProxyPayload, ProxyReferenceKind, RegisteredClassObject};
|
||||
use acadrust::types::{DxfVersion, Handle};
|
||||
|
||||
/// An object of another application's class that acadrust keeps only as its record (a
|
||||
/// vertical product's dimension data, say) is carried as that class's proxy object, with its
|
||||
/// data bits, references, owner and extension dictionary, through DXF and back to DWG, twice.
|
||||
#[test]
|
||||
fn undecoded_application_object_is_kept_as_its_proxy() {
|
||||
for version in [DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut source = CadDocument::new();
|
||||
source.version = version;
|
||||
source.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x2Bu64);
|
||||
let target = Handle::from(0x2Cu64);
|
||||
let payload = ProxyPayload::from_bits(&[0x5A, 0x3C, 0xC0], 18);
|
||||
// A null pointer between two references: it keeps its place, the one after it is not lost.
|
||||
let references = vec![
|
||||
ProxyObjectReference { handle: target, kind: ProxyReferenceKind::HardPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0u64), kind: ProxyReferenceKind::SoftPointer },
|
||||
ProxyObjectReference { handle: Handle::from(0x0Cu64), kind: ProxyReferenceKind::HardPointer },
|
||||
];
|
||||
source.objects.insert(
|
||||
handle,
|
||||
ObjectType::RegisteredClass(RegisteredClassObject {
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
dxf_name: "TESTCONTAINER".into(),
|
||||
cpp_class_name: "xyzDbTestContainer".into(),
|
||||
payload: payload.clone(),
|
||||
object_ids: references.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let mut dwg = acadrust::DwgWriter::write_to_vec(&source).unwrap();
|
||||
// acadrust has no model of it: read back, it is only its record.
|
||||
let decoded = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
assert!(matches!(decoded.objects.get(&handle), Some(ObjectType::Unknown { .. })), "{:?}", decoded.objects.get(&handle));
|
||||
for generation in 0..2 {
|
||||
let (dxf, notes) = dwg_to_dxf_noted(&dwg, Limits::DEFAULT).unwrap();
|
||||
assert!(notes.is_empty(), "{version:?} {generation}: {notes:?}");
|
||||
// The DXF has the object, by its handle, as a proxy record.
|
||||
let text = String::from_utf8_lossy(&dxf);
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let record = lines.windows(4).any(|w| w == ["0", "ACAD_PROXY_OBJECT", "5", "2B"]);
|
||||
assert!(record, "{version:?} {generation}: no proxy record for the object");
|
||||
dwg = dxf_to_dwg_as(&dxf, "AC1032").unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.clone())).read().unwrap();
|
||||
let Some(ObjectType::ProxyObject(proxy)) = doc.objects.get(&handle) else { panic!("{version:?} {generation}: proxy object") };
|
||||
assert_eq!(proxy.payload.bit_count, payload.bit_count);
|
||||
assert_eq!(proxy.payload.data(), payload.data());
|
||||
assert_eq!(proxy.object_ids, references);
|
||||
let class = doc.classes.iter().find(|c| i32::from(c.class_number) == proxy.class_id).unwrap();
|
||||
assert_eq!(class.dxf_name, "TESTCONTAINER");
|
||||
assert!(!class.is_an_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The database's own classes are not turned into another application's proxies; what is
|
||||
/// left without a DXF form is said, not dropped silently.
|
||||
#[test]
|
||||
fn database_objects_without_a_form_are_reported() {
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("BLOCKPROPERTIESTABLE", "AcDbBlockPropertiesTable"));
|
||||
let handle = Handle::from(0x40u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "BLOCKPROPERTIESTABLE".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0; 8]),
|
||||
raw_dwg_handle_bits: 0,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert_eq!(
|
||||
notes,
|
||||
[
|
||||
"DWG: 1 objects of types CADCraft doesn't keep were left out (BLOCKPROPERTIESTABLE × 1); saving over the original file would lose them"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A record that doesn't split stays as it was and is named, never guessed at.
|
||||
#[test]
|
||||
fn invalid_object_records_are_rejected() {
|
||||
for handle_bits in [-1, 1000] {
|
||||
let raw = acadrust::entities::RawRecord { data: vec![0; 8], handle_bits, version: DxfVersion::AC1032 };
|
||||
assert!(object_record(&raw, 42).is_err());
|
||||
}
|
||||
let mut doc = CadDocument::new();
|
||||
doc.classes.add_or_update(DxfClass::new("TESTCONTAINER", "xyzDbTestContainer"));
|
||||
let handle = Handle::from(0x41u64);
|
||||
doc.objects.insert(
|
||||
handle,
|
||||
ObjectType::Unknown {
|
||||
type_name: "TESTCONTAINER".into(),
|
||||
handle,
|
||||
owner: Handle::from(0x0Cu64),
|
||||
raw_dxf_codes: None,
|
||||
raw_dwg_data: Some(vec![0xFF; 4]),
|
||||
raw_dwg_handle_bits: 1000,
|
||||
raw_dwg_version: Some(DxfVersion::AC1032),
|
||||
},
|
||||
);
|
||||
let notes = application_objects(&mut doc);
|
||||
assert!(matches!(doc.objects.get(&handle), Some(ObjectType::Unknown { .. })));
|
||||
assert!(notes.iter().any(|n| n.contains("(TESTCONTAINER × 1)")), "{notes:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
@@ -1646,7 +2003,7 @@ mod tests {
|
||||
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
|
||||
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
|
||||
/// from DXF is written whole, with the byte AutoCAD writes (0).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(no_dwg_bridge))]
|
||||
#[test]
|
||||
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
|
||||
use acadrust::objects::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
|
||||
/// 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.
|
||||
fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -178,14 +178,34 @@ 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());
|
||||
let mut out = Vec::new();
|
||||
// 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);
|
||||
for item in blk.entities.iter() {
|
||||
let item = placed(e, item, &m);
|
||||
let source = match &item.kind {
|
||||
@@ -193,7 +213,9 @@ 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,8 +188,9 @@ 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 = p.block_for(it.i, it.j, it.level);
|
||||
let name = 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>> {
|
||||
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -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;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use cadcraft_doc::{EntityKind, Insert};
|
||||
use cadcraft_doc::{Drawing, EntityKind, Insert};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -19,6 +19,21 @@ const MAX_TABLE_ROWS: usize = 2000;
|
||||
const MAX_TABLE_COLS: usize = 200;
|
||||
/// Attribute columns kept at most.
|
||||
const MAX_TAGS: usize = 190;
|
||||
/// Attribute text one extraction may hold across all its references (each row owns a copy of its
|
||||
/// values, constants included). Far above any real schedule; an error, never a silent cut.
|
||||
const MAX_VALUE_BYTES: usize = 1 << 30;
|
||||
|
||||
/// Add `add` bytes to `used`; an error past `limit`.
|
||||
fn charge(used: &mut usize, add: usize, limit: usize) -> Result<()> {
|
||||
*used = used.saturating_add(add);
|
||||
if *used > limit {
|
||||
return Err(bad(
|
||||
"dataextraction",
|
||||
format!("the attribute text of the block references passes {} MiB; narrow `blocks`, `tags` or `handles`", limit >> 20),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PARAMS: &str = "{blocks?: \"A*,B\" (block names, wildcards, default *), tags?: attribute tags to include (wildcards, default *), handles? (default: the block references in model space and every layout), combine?: bool (default true: identical rows once, with a Count column; false: one row per reference with handle, layer, position and rotation), file?: path (.csv; .txt is tab-separated), at?: [x,y] (insert a table there), title?: table title} → {columns, rows, file?, table?}";
|
||||
|
||||
@@ -88,22 +103,75 @@ fn matches(spec: &str, text: &str) -> bool {
|
||||
spec.split(',').any(|p| wildcard(p.trim(), text))
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
}
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
/// The block references to extract from: `handles` when given, else model space and the layouts.
|
||||
fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
let d = s.doc()?;
|
||||
@@ -112,7 +180,11 @@ 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 used = 0usize;
|
||||
let mut const_sizes: HashMap<String, Vec<(String, usize)>> = HashMap::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);
|
||||
@@ -120,13 +192,47 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
continue;
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
// Charged from the lengths, before any text is copied.
|
||||
let selected_bytes: usize =
|
||||
ins.attribs.iter().filter(|a| selected.contains(&a.tag.to_ascii_uppercase())).map(|a| a.text.value.len() + a.tag.len()).sum();
|
||||
// The constants this row copies (those its own attributes do not override), from the lengths.
|
||||
let first_of_block = !const_sizes.contains_key(&block);
|
||||
let consts_sizes = const_sizes.entry(block.clone()).or_insert_with(|| {
|
||||
d.block(&ins.block).map_or_else(Vec::new, |b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if a.constant && selected.iter().any(|t| t == &a.tag.to_ascii_uppercase()) => {
|
||||
Some((a.tag.to_ascii_uppercase(), a.tag.len() + a.text.value.len()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
if first_of_block {
|
||||
// The per-block cache of constants is one more copy of them, made once.
|
||||
charge(&mut used, consts_sizes.iter().map(|(_, n)| *n).fold(0, usize::saturating_add), MAX_VALUE_BYTES)?;
|
||||
}
|
||||
let const_bytes: usize = consts_sizes
|
||||
.iter()
|
||||
.filter(|(tag, _)| !ins.attribs.iter().any(|a| a.tag.eq_ignore_ascii_case(tag)))
|
||||
.map(|(_, n)| *n)
|
||||
.fold(0, usize::saturating_add);
|
||||
charge(&mut used, selected_bytes.saturating_add(const_bytes), MAX_VALUE_BYTES)?;
|
||||
let 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,
|
||||
);
|
||||
out.push(Found {
|
||||
block,
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
values: values(d, ins),
|
||||
block: block.clone(),
|
||||
values,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -389,6 +495,15 @@ impl Interactive for ExtractM {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn attribute_text_is_charged_against_one_budget() {
|
||||
let mut used = 0;
|
||||
assert!(super::charge(&mut used, 60, 100).is_ok());
|
||||
assert!(super::charge(&mut used, 40, 100).is_ok());
|
||||
assert!(super::charge(&mut used, 1, 100).is_err());
|
||||
assert!(super::charge(&mut used, usize::MAX, 100).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,7 +85,8 @@ fn read_drawing(path: &str) -> Result<Drawing> {
|
||||
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("insert", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("insert", "paths are not available on the web"))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! of nested xrefs (`xref|nested|name`) bind with every `|` written `$0$`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
@@ -20,6 +20,16 @@ fn split(name: &str) -> Option<(&str, &str)> {
|
||||
|
||||
/// Whether table `sym` of `d` has a symbol called `name`.
|
||||
fn taken(d: &Drawing, sym: Sym, name: &str) -> bool {
|
||||
taken_with(d, sym, name, None)
|
||||
}
|
||||
|
||||
/// [`taken`], answering for blocks from `blocks` (the upper-cased names of `d`'s blocks, kept
|
||||
/// current by the caller) when given: a lookup that misses scans every block, which a bind
|
||||
/// naming thousands of blocks would repeat for each.
|
||||
fn taken_with(d: &Drawing, sym: Sym, name: &str, blocks: Option<&HashSet<String>>) -> bool {
|
||||
if let (Sym::Block, Some(set)) = (sym, blocks) {
|
||||
return set.contains(&name.to_ascii_uppercase());
|
||||
}
|
||||
match sym {
|
||||
Sym::Layer => d.layer(name).is_some(),
|
||||
Sym::Linetype => d.linetype(name).is_some(),
|
||||
@@ -32,12 +42,12 @@ fn taken(d: &Drawing, sym: Sym, name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// The bound name of `key|rest`: `key$n$rest` with the first free `n`, or `rest` (Insert type).
|
||||
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool) -> String {
|
||||
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool, blocks: Option<&HashSet<String>>) -> String {
|
||||
let rest = rest.replace('|', "$0$");
|
||||
if insert {
|
||||
return rest;
|
||||
}
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken(d, sym, c)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken_with(d, sym, c, blocks)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
}
|
||||
|
||||
/// Every table's symbol names, for one table at a time.
|
||||
@@ -53,9 +63,8 @@ fn names(d: &Drawing, sym: Sym) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename symbol `old` of table `sym` to `new`, or drop it (`new` already exists: Insert type).
|
||||
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str) {
|
||||
let merge = !new.eq_ignore_ascii_case(old) && taken(d, sym, new);
|
||||
/// Rename symbol `old` of table `sym` to `new`, or drop it (`merge`: `new` already exists, Insert type).
|
||||
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str, merge: bool) {
|
||||
macro_rules! table {
|
||||
($v:expr) => {{
|
||||
if merge {
|
||||
@@ -101,12 +110,17 @@ pub(crate) fn bind(d: &mut Drawing, key: &str, insert: bool) -> Result<(), Strin
|
||||
let mut map: HashMap<(Sym, String), String> = HashMap::new();
|
||||
let mut bound_blocks = vec![key.clone()];
|
||||
for sym in TABLES {
|
||||
let mut blocks: Option<HashSet<String>> = (sym == Sym::Block).then(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
for old in names(d, sym).into_iter().filter(|n| has_prefix(n, &prefix)) {
|
||||
let rest = old.get(prefix.len()..).unwrap_or_default();
|
||||
let new = bound_name(d, sym, &key, rest, insert);
|
||||
let new = bound_name(d, sym, &key, rest, insert, blocks.as_ref());
|
||||
// Insert type: a host symbol of the bound name is used instead of the xref's.
|
||||
let merged = taken(d, sym, &new);
|
||||
rename_symbol(d, sym, &old, &new);
|
||||
let merged = taken_with(d, sym, &new, blocks.as_ref());
|
||||
rename_symbol(d, sym, &old, &new, !new.eq_ignore_ascii_case(&old) && merged);
|
||||
if let Some(set) = &mut blocks {
|
||||
set.remove(&old.to_ascii_uppercase());
|
||||
set.insert(new.to_ascii_uppercase());
|
||||
}
|
||||
if sym == Sym::Block && !merged {
|
||||
bound_blocks.push(new.clone());
|
||||
}
|
||||
@@ -242,14 +256,27 @@ fn xbind_in(d: &mut Drawing, sym: Sym, name: &str, depth: usize) -> Result<Strin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_block_name_set_answers_as_the_scan_does() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for n in ["SITE$0$Plan", "Other"] {
|
||||
d.blocks.insert(n.into(), Arc::new(cadcraft_doc::Block::new(n)));
|
||||
}
|
||||
let set: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for n in ["site$0$plan", "SITE$0$Plan", "OTHER", "SITE$1$Plan", "nothing"] {
|
||||
assert_eq!(taken_with(&d, Sym::Block, n, Some(&set)), taken(&d, Sym::Block, n), "{n}");
|
||||
}
|
||||
assert_eq!(bound_name(&d, Sym::Block, "SITE", "plan", false, Some(&set)), "SITE$1$plan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_names() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false), "SITE$0$Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false, None), "SITE$0$Walls");
|
||||
d.ensure_layer("SITE$0$Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false), "SITE$1$walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true), "Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false), "SITE$0$TREES$0$Oak");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false, None), "SITE$1$walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true, None), "Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false, None), "SITE$0$TREES$0$Oak");
|
||||
assert_eq!(split("A|b|c"), Some(("A", "b|c")));
|
||||
assert_eq!(split("|b"), None);
|
||||
}
|
||||
|
||||
@@ -453,14 +453,32 @@ impl Chain {
|
||||
}
|
||||
/// Point and unit tangent at arc length `d` from the start (clamped).
|
||||
pub fn at_length(&self, d: f64) -> Option<(Vec2, Vec2)> {
|
||||
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.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)));
|
||||
}
|
||||
self.segs.last().map(|s| (s.end(), s.tangent(1.0)))
|
||||
}
|
||||
@@ -640,6 +658,34 @@ 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();
|
||||
|
||||
@@ -36,16 +36,18 @@ 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 };
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
let ends = c.end_lengths();
|
||||
range.filter_map(|i| c.at_length_in(&ends, 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(d));
|
||||
out.extend(c.at_length_in(&ends, d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
|
||||
@@ -58,8 +58,9 @@ 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(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
out.extend(chain.at_length_in(&ends, perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
|
||||
@@ -204,7 +204,8 @@ 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"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
|
||||
.map_err(|e| bad("import", format!("{_path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad("import", "paths are not available on the web; pass `data`"))
|
||||
@@ -496,6 +497,30 @@ fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, u
|
||||
(books, skipped)
|
||||
}
|
||||
|
||||
/// Most page-by-entity renderings one PDFBOOK may ask for: every page draws the whole model through
|
||||
/// its window, so the work is the frames times the model's objects (1,000 sheets of a 500,000-object
|
||||
/// drawing).
|
||||
const MAX_BOOK_WORK: u64 = 500_000_000;
|
||||
|
||||
/// Objects one page draws: the model's objects, and the contents of each block reference (once per
|
||||
/// array cell, and the reference's own attributes; nested blocks count as their own entries).
|
||||
fn model_visits(d: &Drawing) -> usize {
|
||||
d.model
|
||||
.iter()
|
||||
.map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
d.block(&i.block).map_or(1, |b| 1 + b.entities.len().saturating_mul((i.rows as usize).saturating_mul(i.cols as usize)))
|
||||
}
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0usize, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Whether `frames` pages of a model with `entities` objects stay within [`MAX_BOOK_WORK`].
|
||||
fn book_work_fits(frames: usize, entities: usize) -> bool {
|
||||
(frames as u64).saturating_mul(entities as u64) <= MAX_BOOK_WORK
|
||||
}
|
||||
|
||||
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return Err(bad("pdfbook", "plotting is not available in this build"));
|
||||
@@ -506,6 +531,13 @@ fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if books.is_empty() {
|
||||
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
|
||||
}
|
||||
let sheets: usize = books.iter().map(Vec::len).sum();
|
||||
if !book_work_fits(sheets, model_visits(s.doc()?)) {
|
||||
return Err(bad(
|
||||
"pdfbook",
|
||||
format!("{sheets} sheets of a model this large are too much work for one command; plot part of the drawing at a time"),
|
||||
));
|
||||
}
|
||||
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
|
||||
let pb = target.as_deref().map(std::path::Path::new);
|
||||
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
@@ -560,6 +592,13 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_is_bounded_by_pages_times_objects() {
|
||||
assert!(book_work_fits(1_000, 500_000));
|
||||
assert!(!book_work_fits(100_000, 100_000));
|
||||
assert!(!book_work_fits(usize::MAX, usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
||||
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_co
|
||||
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
||||
|
||||
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
||||
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
pub(crate) 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;
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds};
|
||||
use cadcraft_doc::{BlockBoundsCache, Drawing, Entity, EntityKind, Handle, MAX_BLOCK_DEPTH, Space, entity_bounds_cached};
|
||||
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"))]
|
||||
{
|
||||
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).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,16 +375,20 @@ 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();
|
||||
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()) {
|
||||
// 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())
|
||||
{
|
||||
boxes.push(b);
|
||||
}
|
||||
};
|
||||
added.iter().for_each(|h| bound(&cur, *h));
|
||||
removed.iter().for_each(|h| bound(&other, *h));
|
||||
}
|
||||
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));
|
||||
for (a, b, _, _) in &changed {
|
||||
bound(&cur, *a);
|
||||
bound(&other, *b);
|
||||
bound(&mut boxes, &cur, *a, &mut cur_cache);
|
||||
bound(&mut boxes, &other, *b, &mut other_cache);
|
||||
}
|
||||
let regions = regions(&boxes);
|
||||
let ch: Vec<Value> = changed
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! The user's command alias file (the publicly documented `.pgp` format: `ALIAS, *COMMAND` lines,
|
||||
//! `;` comments): ALIASEDIT and REINIT.
|
||||
//!
|
||||
//! A loaded file adds aliases or overrides the built-in ones for the rest of the session, for
|
||||
//! every drawing; command names themselves always win. Lines that define external commands
|
||||
//! A loaded file replaces the user aliases, which add to or override the built-in ones for every
|
||||
//! drawing; command names themselves always win. Lines that define external commands
|
||||
//! (programs the file would have the application start) are never run: they are skipped with a
|
||||
//! note. CADCraft ships no alias file; the aliases built into the command catalog stay the
|
||||
//! defaults.
|
||||
//!
|
||||
//! The aliases are user settings, never part of a drawing: [`alias_profile`] gives them (and the
|
||||
//! file REINIT reads) to the host's user profile, and [`restore_aliases`] brings them back on the
|
||||
//! next start. The desktop and web hosts keep that profile; the headless CLI has no profile, so
|
||||
//! aliases set in a script last for that session only.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
|
||||
@@ -20,10 +25,21 @@ use super::{CommandSpec, Result, Session, bad, bool_or, find_command};
|
||||
pub const MAX_ALIASES: usize = 10_000;
|
||||
/// Longest alias name.
|
||||
const MAX_ALIAS_CHARS: usize = 64;
|
||||
/// Longest command id (the catalog's longest is `texttofront.dimensions`; a test keeps it true).
|
||||
const MAX_COMMAND_ID_CHARS: usize = 32;
|
||||
/// Longest line read.
|
||||
const MAX_LINE_CHARS: usize = 4096;
|
||||
/// Most warnings reported.
|
||||
const MAX_WARNINGS: usize = 100;
|
||||
/// Longest alias file path kept in the user profile.
|
||||
const MAX_ALIAS_FILE_CHARS: usize = 1024;
|
||||
|
||||
/// The largest alias section [`alias_profile`] can produce: [`MAX_ALIASES`] entries, each a
|
||||
/// [`MAX_ALIAS_CHARS`]-character name (up to four UTF-8 bytes per character, and JSON adds its
|
||||
/// quotes) mapped to a command id, plus the alias file path and the section's keys. A host that
|
||||
/// keeps the section in its user profile allows at least this much, so a full set always comes
|
||||
/// back after a restart.
|
||||
pub const MAX_ALIAS_PROFILE_BYTES: usize = MAX_ALIASES * (MAX_ALIAS_CHARS * 4 + MAX_COMMAND_ID_CHARS + 6) + MAX_ALIAS_FILE_CHARS * 4 + 64;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aliases {
|
||||
@@ -43,6 +59,49 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
|
||||
store().map.get(name).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 {
|
||||
let st = store();
|
||||
// A path [`restore_aliases`] would refuse is left out, so it can't grow the profile either.
|
||||
json!({ "aliases": st.map, "file": st.file.as_deref().filter(|f| keepable_path(f)) })
|
||||
}
|
||||
|
||||
/// Whether the profile keeps an alias file path: short enough, and no control characters.
|
||||
fn keepable_path(f: &str) -> bool {
|
||||
f.chars().count() <= MAX_ALIAS_FILE_CHARS && !f.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Restore aliases saved by [`alias_profile`], replacing the user aliases and the file.
|
||||
///
|
||||
/// The saved set is untrusted (a hand-edited or older profile): entries that are not aliases of
|
||||
/// real commands are skipped, command names are never aliased, at most [`MAX_ALIASES`] are kept,
|
||||
/// and a malformed section changes nothing.
|
||||
pub fn restore_aliases(saved: &Value) {
|
||||
let Some(obj) = saved.as_object() else { return };
|
||||
let file = match obj.get("file") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(f)) if keepable_path(f) => Some(f.clone()),
|
||||
Some(Value::String(_)) => None,
|
||||
// A file entry that is not a path makes the section malformed: keep the aliases as they are.
|
||||
Some(_) => return,
|
||||
};
|
||||
let Some(map) = obj.get("aliases").and_then(Value::as_object) else { return };
|
||||
let mut aliases = BTreeMap::new();
|
||||
// Entries that are skipped don't use up the cap.
|
||||
for (alias, target) in map {
|
||||
if aliases.len() >= MAX_ALIASES {
|
||||
break;
|
||||
}
|
||||
let Ok(name) = alias_name(alias) else { continue };
|
||||
let Some(id) = target.as_str().and_then(command_id) else { continue };
|
||||
aliases.insert(name, id);
|
||||
}
|
||||
let mut st = store();
|
||||
st.map = aliases;
|
||||
st.file = file;
|
||||
}
|
||||
|
||||
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
|
||||
pub fn command_id(target: &str) -> Option<String> {
|
||||
let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase();
|
||||
@@ -201,3 +260,25 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("reinit", "Reinitialize", run_reinit).params("{file? | text?} (default: the alias file loaded last)").enabled(always).noundo(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_COMMAND_ID_CHARS;
|
||||
|
||||
/// [`super::MAX_ALIAS_PROFILE_BYTES`] bounds a saved alias's command id by the longest id in
|
||||
/// the catalog: keep that true as commands are added.
|
||||
#[test]
|
||||
fn every_command_id_fits_the_saved_alias_bound() {
|
||||
for spec in crate::cmd::command_specs() {
|
||||
assert!(spec.id.chars().count() <= MAX_COMMAND_ID_CHARS, "`{}` exceeds the alias profile bound", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// An alias file path the profile couldn't restore isn't saved in it.
|
||||
#[test]
|
||||
fn only_paths_the_profile_restores_are_saved() {
|
||||
assert!(super::keepable_path("C:\\support\\mine.pgp"));
|
||||
assert!(!super::keepable_path(&"x".repeat(super::MAX_ALIAS_FILE_CHARS + 1)));
|
||||
assert!(!super::keepable_path("mine\n.pgp"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bytes(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -303,11 +303,9 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
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}")))?
|
||||
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}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
|
||||
@@ -117,6 +117,12 @@ 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)?);
|
||||
@@ -150,6 +156,12 @@ 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)?;
|
||||
|
||||
@@ -7,16 +7,32 @@ use super::{
|
||||
// NCOPY
|
||||
// =====================================================================================
|
||||
|
||||
/// The leaf objects of a block reference in world coordinates (nested references expanded).
|
||||
fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) {
|
||||
/// Block entities one NCOPY may visit in all, leaves or not: references to shared, empty or
|
||||
/// repeated sub-blocks multiply the visits (not the leaves) with each level of nesting.
|
||||
const MAX_VISITS: usize = 1_000_000;
|
||||
|
||||
/// The leaf objects of a block reference in world coordinates (nested references expanded). An
|
||||
/// error when the nesting needs more than [`MAX_VISITS`] visits.
|
||||
fn nested_leaves(
|
||||
d: &cadcraft_doc::Drawing,
|
||||
ins: &cadcraft_doc::Insert,
|
||||
m: &Mat3,
|
||||
depth: usize,
|
||||
visits: &mut usize,
|
||||
out: &mut Vec<(Common, EntityKind)>,
|
||||
) -> Result<()> {
|
||||
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(b) = d.block(&ins.block) else { return };
|
||||
let Some(b) = d.block(&ins.block) else { return Ok(()) };
|
||||
let mm = m.then_before(ins.transform(b.base.xy()));
|
||||
for e in b.entities.iter() {
|
||||
*visits += 1;
|
||||
if *visits > MAX_VISITS {
|
||||
return Err(other("The block reference nests too many objects to copy."));
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, visits, out)?,
|
||||
EntityKind::AttDef(_) => {}
|
||||
k => {
|
||||
let mut k = k.clone();
|
||||
@@ -25,13 +41,14 @@ fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leaves_of(s: &Session, h: Handle) -> Result<Vec<(Common, EntityKind)>> {
|
||||
fn leaves_of(s: &Session, h: Handle, visits: &mut usize) -> Result<Vec<(Common, EntityKind)>> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) };
|
||||
let mut out = Vec::new();
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, visits, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -53,7 +70,7 @@ fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) ->
|
||||
|
||||
pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?;
|
||||
let leaves = leaves_of(s, h)?;
|
||||
let leaves = leaves_of(s, h, &mut 0)?;
|
||||
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
|
||||
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
|
||||
None => leaves,
|
||||
@@ -74,6 +91,8 @@ pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) struct NcopyM {
|
||||
items: Vec<(Common, EntityKind)>,
|
||||
selecting_done: bool,
|
||||
/// Block entities visited by every selection so far: one allowance for the whole command.
|
||||
visits: usize,
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
@@ -97,7 +116,7 @@ impl Interactive for NcopyM {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h, &mut self.visits)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
s.echo(format!("Nested object: {}", leaf.1.type_name()));
|
||||
if self.items.len() < MAX_GEN {
|
||||
self.items.push(leaf);
|
||||
|
||||
@@ -34,10 +34,12 @@ 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())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
// 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 mut mats = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let Some((p, t)) = c.at_length_in(&ends, 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);
|
||||
|
||||
@@ -777,3 +777,25 @@ fn offset_elliptical_arc_follows_picked_side() {
|
||||
let outer = max_x(json!([20, 20]));
|
||||
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
|
||||
}
|
||||
|
||||
/// Nested references multiply visits even when no leaf results; NCOPY refuses past its visit budget.
|
||||
#[test]
|
||||
fn ncopy_charges_every_nested_visit() {
|
||||
use cadcraft_doc::{Block, Common, Entity, Insert};
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut n = 1;
|
||||
for level in 0..15 {
|
||||
let mut b = Block::new(&format!("B{level}"));
|
||||
for _ in 0..100 {
|
||||
let ins = Insert { block: format!("B{}", level + 1), ..Insert::default() };
|
||||
b.entities.push(Entity::new(Handle(n), EntityKind::Insert(ins)));
|
||||
n += 1;
|
||||
}
|
||||
d.blocks.insert(b.name.clone(), std::sync::Arc::new(b));
|
||||
}
|
||||
d.blocks.insert("B15".into(), std::sync::Arc::new(Block::new("B15")));
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { block: "B0".into(), ..Insert::default() })).unwrap();
|
||||
let err = s.execute("ncopy", &json!({ "handle": h.hex() })).unwrap_err().to_string();
|
||||
assert!(err.contains("nests too many objects"), "{err}");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ fn text_of(k: &EntityKind) -> Option<String> {
|
||||
EntityKind::Text(t) => Some(t.value.clone()),
|
||||
EntityKind::AttDef(a) => Some(a.text.value.clone()),
|
||||
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
|
||||
// A multileader's text (one with a block instead has none, #1081).
|
||||
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),
|
||||
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
|
||||
_ => None,
|
||||
}
|
||||
@@ -25,11 +27,16 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
|
||||
EntityKind::Text(t) => t.value = text.to_string(),
|
||||
EntityKind::AttDef(a) => a.text.value = text.to_string(),
|
||||
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
|
||||
EntityKind::MLeader(m) if m.text.is_some() => {
|
||||
if let Some(t) = &mut m.text {
|
||||
t.contents = text.replace('\n', "\\P");
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
d.text = if text == "<>" { String::new() } else { text.to_string() };
|
||||
d.block = None;
|
||||
}
|
||||
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
|
||||
_ => return Err(other("Select a text, mtext, multileader, attribute definition or dimension.")),
|
||||
}
|
||||
set_kind(s, h, k)
|
||||
}
|
||||
@@ -53,12 +60,18 @@ pub(super) struct TextEditM {
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
/// The MTEXT being edited, for the in-place editor (#842).
|
||||
mtext: Option<cadcraft_doc::MText>,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.h.as_ref().and(self.mtext.clone())
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
@@ -83,7 +96,24 @@ impl Interactive for TextEditM {
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
|
||||
// Refused when picked, not after the new text has been typed.
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let kind = curves::entity(s, h)?.kind;
|
||||
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?;
|
||||
self.mtext = match kind {
|
||||
EntityKind::MText(m) => Some(m),
|
||||
// A multileader's text opens in the same editor (#1081).
|
||||
EntityKind::MLeader(m) => m.text,
|
||||
_ => None,
|
||||
};
|
||||
// The editor would drop part of a text this long, and saving would lose it: the
|
||||
// command line asks for it instead.
|
||||
if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) {
|
||||
self.mtext = None;
|
||||
s.echo("This text is too long for the editor; enter the new text at the command line.");
|
||||
}
|
||||
self.h = Some((h, old));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
@@ -93,10 +123,12 @@ impl Interactive for TextEditM {
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
self.mtext = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
|
||||
@@ -402,6 +402,17 @@ fn apply_kind_properties(
|
||||
t.rotation = x.to_radians();
|
||||
}
|
||||
}
|
||||
// A multileader's text: its contents (#1081) and height.
|
||||
EntityKind::MLeader(m) => {
|
||||
if let Some(t) = &mut m.text {
|
||||
if let Some(v) = str_param(params, "text") {
|
||||
t.contents = v.replace('\n', "\\P");
|
||||
}
|
||||
if let Some(x) = num("height").filter(|x| *x > 0.0) {
|
||||
t.height = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
if let Some(x) = num("width").filter(|x| *x >= 0.0) {
|
||||
pl.const_width = x;
|
||||
|
||||
@@ -175,8 +175,14 @@ 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`.
|
||||
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
|
||||
// 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)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
@@ -400,3 +406,26 @@ 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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: u64 = 8 << 20;
|
||||
const MAX_LINK_BYTES: usize = 8 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -238,11 +238,9 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
|
||||
let path = file.display();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
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}")))?;
|
||||
// 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 text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
|
||||
}
|
||||
@@ -370,6 +368,16 @@ 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"]]);
|
||||
|
||||
@@ -123,6 +123,12 @@ 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
|
||||
@@ -415,9 +421,12 @@ 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) {
|
||||
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();
|
||||
// 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;
|
||||
// `(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()) {
|
||||
@@ -427,6 +436,7 @@ 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);
|
||||
@@ -687,7 +697,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) use drawing::from_json as drawing_from_json;
|
||||
pub(crate) use drawing::load_source;
|
||||
pub(crate) use drawing::{load_source, support_file};
|
||||
|
||||
impl Session {
|
||||
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
|
||||
|
||||
@@ -235,7 +235,8 @@ pub(super) fn autoload(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispErr
|
||||
return Err(LispError::bad_arg("command name", &cmd));
|
||||
}
|
||||
// The stub forgets itself, loads the file and calls what it defined.
|
||||
let file_lit = to_text(&Value::Str(file.clone()), true);
|
||||
// A file in the support folder is loaded from there, never from a same-named file of the working folder.
|
||||
let file_lit = to_text(&Value::Str(crate::lisp::support_file(ctx.s, &file).map_or_else(|| file.clone(), |p| p.into())), true);
|
||||
let src = format!(
|
||||
"(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))",
|
||||
file.replace(['"', '\\'], "/")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::{LispError, OsStr, Value, check_string_len, read, to_text};
|
||||
use super::{LispError, OsStr, Value, charge_bytes, check_string_len, read, to_text};
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
@@ -114,7 +114,13 @@ pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,12 @@ 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) => serde_json::json!(&**s),
|
||||
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::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
@@ -296,12 +301,16 @@ 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)) };
|
||||
@@ -323,7 +332,11 @@ 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) => Tag::s(code, &**s),
|
||||
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::Ename(h) => Tag::s(code, h.hex()),
|
||||
other => return Err(LispError::bad_arg("DXF group value", other)),
|
||||
}),
|
||||
@@ -1115,7 +1128,9 @@ pub fn trans(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
/// What `(load name)` reads: a file loaded through APPLOAD (the web), or one on disk, with
|
||||
/// `.lsp` added when the name has no extension.
|
||||
pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
if let Some(t) = s.lisp_file(name) {
|
||||
// An absolute path names a file, not an APPLOAD entry that happens to share its base name.
|
||||
let absolute = std::path::Path::new(name).is_absolute();
|
||||
if !absolute && let Some(t) = s.lisp_file(name) {
|
||||
return Some(t);
|
||||
}
|
||||
let candidates =
|
||||
@@ -1125,3 +1140,54 @@ 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)
|
||||
}
|
||||
|
||||
/// The file `name` names in the support folder, when it is there: what an autoload registration
|
||||
/// loads, so a same-named file in the working folder cannot take its place. `None` for an absolute
|
||||
/// path, no support folder or no such file there (the general search then applies, as before).
|
||||
pub fn support_file(s: &Session, name: &str) -> Option<String> {
|
||||
if std::path::Path::new(name).is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let dir = s.lisp_support_dir()?;
|
||||
let candidates =
|
||||
if std::path::Path::new(name).extension().is_none() { vec![format!("{name}.lsp"), name.to_string()] } else { vec![name.to_string()] };
|
||||
candidates.iter().map(|c| dir.join(c)).find(|p| p.is_file()).map(|p| std::path::absolute(&p).unwrap_or(p).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn autoload_prefers_the_support_folder_file() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lisp-support-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("lazy.lsp"), "").unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(support_file(&s, "lazy"), None);
|
||||
s.set_lisp_support_dir(Some(dir.clone()));
|
||||
assert_eq!(support_file(&s, "lazy"), Some(dir.join("lazy.lsp").to_string_lossy().into_owned()));
|
||||
assert_eq!(support_file(&s, "other"), None);
|
||||
assert_eq!(support_file(&s, dir.join("lazy.lsp").to_str().unwrap()), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 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,6 +201,10 @@ 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),
|
||||
@@ -646,29 +650,44 @@ 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 a hard cap, so a constant-memory infinite
|
||||
// (the step budget ran out) is retried until the ceiling, 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).
|
||||
let mut spent: u64 = 0;
|
||||
// thread and bypassing the blocking execution cap (CWE-400). The steps of callbacks this
|
||||
// one calls are counted in `callback_used` too.
|
||||
loop {
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
let remaining = ceiling.saturating_sub(sub.s.lisp.callback_used);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
match m.run(&mut sub, 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")));
|
||||
}
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(Need::Yield) => {}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,35 @@ 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() {
|
||||
@@ -307,6 +336,16 @@ 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();
|
||||
@@ -977,6 +1016,9 @@ 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,7 +346,11 @@ 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.
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
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 {
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
@@ -373,7 +377,19 @@ fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64)
|
||||
a = na;
|
||||
b = nb;
|
||||
}
|
||||
_ => return atom_equal(&a, &b, fuzz),
|
||||
_ => {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,6 +556,20 @@ 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");
|
||||
|
||||
+112
-11
@@ -33,6 +33,9 @@ pub struct CharFmt {
|
||||
pub font: Option<String>,
|
||||
/// Absolute character height set inline (`\H`); `None`: the text's.
|
||||
pub height: Option<f64>,
|
||||
/// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written
|
||||
/// back as it was so the part keeps scaling with the text's height.
|
||||
pub height_rel: Option<f64>,
|
||||
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
|
||||
pub color: Option<MTextColor>,
|
||||
}
|
||||
@@ -99,26 +102,60 @@ fn arg_end(chars: &[char], from: usize) -> usize {
|
||||
impl Formatted {
|
||||
/// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped.
|
||||
pub fn from_contents(contents: &str) -> Formatted {
|
||||
Self::from_contents_with_height(contents, None)
|
||||
}
|
||||
|
||||
/// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no
|
||||
/// inline height before it scales that one instead of being dropped.
|
||||
pub fn from_contents_with_height(contents: &str, height: Option<f64>) -> Formatted {
|
||||
Self::parse(contents, height).0
|
||||
}
|
||||
|
||||
/// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters, [`MAX_PARAS`]
|
||||
/// paragraphs or [`MAX_ATOMS`] kept codes the rest would be dropped, and saving would lose it.
|
||||
pub fn fits(contents: &str) -> bool {
|
||||
Self::parse(contents, None).1
|
||||
}
|
||||
|
||||
/// Whether `edited`, what the editor gives back for `original` (a text `height` high), is the
|
||||
/// text as it was: the same, or what opening it and pressing OK without an edit gives (a
|
||||
/// non-canonical `{\fArial|b1;first}` comes back written another way).
|
||||
pub fn leaves_unchanged(original: &str, height: Option<f64>, edited: &str) -> bool {
|
||||
edited == original || edited == Self::from_contents_with_height(original, height).to_contents()
|
||||
}
|
||||
|
||||
/// The parse, and whether it took in all of `contents`.
|
||||
fn parse(contents: &str, height: Option<f64>) -> (Formatted, bool) {
|
||||
let mut f = Formatted::default();
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut stack: Vec<CharFmt> = Vec::new();
|
||||
let mut cur = CharFmt::default();
|
||||
let mut props = ParaProps::default();
|
||||
f.paras = vec![props.clone()];
|
||||
// Cleared when a paragraph, a kept code or a nesting level doesn't fit.
|
||||
let mut whole = true;
|
||||
// Groups opened past the 64 levels kept: their closing braces pop nothing.
|
||||
let mut unkept = 0usize;
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if f.fmt.len() >= MAX_CHARS {
|
||||
break;
|
||||
// Braces after the last character the editor holds add no text.
|
||||
if f.fmt.len() >= MAX_CHARS && !matches!(c, '{' | '}' | '\r') {
|
||||
return (f, false);
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
if stack.len() < 64 {
|
||||
stack.push(cur.clone());
|
||||
} else {
|
||||
unkept = unkept.saturating_add(1);
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
if let Some(p) = stack.pop() {
|
||||
if unkept > 0 {
|
||||
unkept -= 1;
|
||||
} else if let Some(p) = stack.pop() {
|
||||
cur = p;
|
||||
}
|
||||
i += 1;
|
||||
@@ -128,6 +165,8 @@ impl Formatted {
|
||||
if f.paras.len() < MAX_PARAS {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
} else {
|
||||
whole = false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -140,6 +179,7 @@ impl Formatted {
|
||||
f.push_char('\n', &cur);
|
||||
f.paras.push(props.clone());
|
||||
}
|
||||
Some('P') => whole = false,
|
||||
Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur),
|
||||
Some('L') => cur.underline = true,
|
||||
Some('l') => cur.underline = false,
|
||||
@@ -151,7 +191,7 @@ impl Formatted {
|
||||
i = end + 1;
|
||||
match k {
|
||||
'f' | 'F' => apply_font(&mut cur, &a),
|
||||
'H' => cur.height = parse_height(&a, cur.height),
|
||||
'H' => apply_height(&mut cur, &a, height),
|
||||
'C' => {
|
||||
cur.color = a.trim().parse::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
|
||||
}
|
||||
@@ -177,6 +217,7 @@ impl Formatted {
|
||||
code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter());
|
||||
i = end + 1;
|
||||
}
|
||||
whole &= f.atoms.len() < MAX_ATOMS;
|
||||
f.push_atom(code, &cur);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +228,7 @@ impl Formatted {
|
||||
}
|
||||
}
|
||||
}
|
||||
f
|
||||
(f, whole)
|
||||
}
|
||||
|
||||
fn push_char(&mut self, c: char, fmt: &CharFmt) {
|
||||
@@ -445,7 +486,9 @@ impl Formatted {
|
||||
let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect();
|
||||
s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic)));
|
||||
}
|
||||
if let Some(h) = f.height {
|
||||
if let Some(m) = f.height_rel {
|
||||
s.push_str(&format!("\\H{}x;", trim(m)));
|
||||
} else if let Some(h) = f.height {
|
||||
s.push_str(&format!("\\H{};", trim(h)));
|
||||
}
|
||||
match f.color {
|
||||
@@ -462,12 +505,32 @@ fn trim(v: f64) -> String {
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn parse_height(a: &str, cur: Option<f64>) -> Option<f64> {
|
||||
/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no
|
||||
/// inline height before it, or a relative one) stays relative; over an inline absolute height it
|
||||
/// becomes absolute.
|
||||
fn apply_height(cur: &mut CharFmt, a: &str, base: Option<f64>) {
|
||||
let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h);
|
||||
let a = a.trim();
|
||||
match a.strip_suffix(['x', 'X']) {
|
||||
// A relative height needs the current one; without it the code is dropped.
|
||||
Some(r) => r.trim().parse::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
|
||||
None => a.parse::<f64>().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur),
|
||||
let Some(r) = a.strip_suffix(['x', 'X']) else {
|
||||
if let Some(h) = a.parse::<f64>().ok().and_then(valid) {
|
||||
cur.height = Some(h);
|
||||
cur.height_rel = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(m) = r.trim().parse::<f64>().ok().and_then(valid) else { return };
|
||||
match (cur.height, cur.height_rel) {
|
||||
(_, Some(r0)) => {
|
||||
if let Some(m) = valid(r0 * m) {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
cur.height_rel = Some(m);
|
||||
cur.height = base.and_then(|b| valid(b * m));
|
||||
}
|
||||
(Some(h), None) => cur.height = valid(h * m).or(cur.height),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +556,22 @@ fn apply_font(cur: &mut CharFmt, a: &str) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_relative_height_is_written_back_relative() {
|
||||
let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0));
|
||||
assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height");
|
||||
let out = f.to_contents();
|
||||
assert!(out.contains("\\H2x;big"), "{out}");
|
||||
assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}");
|
||||
// Opened again for a taller text, the part scales with it.
|
||||
assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0));
|
||||
// Relative over relative multiplies; over an absolute one it is absolute.
|
||||
let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents();
|
||||
assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}");
|
||||
// With no height known it is still kept.
|
||||
assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_formats_and_atoms() {
|
||||
for src in [
|
||||
@@ -563,4 +642,26 @@ mod tests {
|
||||
let _ = f.fmt_at(usize::MAX);
|
||||
let _ = Formatted::from_contents(&"{".repeat(100_000));
|
||||
}
|
||||
|
||||
/// A text with more paragraphs or kept codes than the editor holds doesn't fit, so TEXTEDIT
|
||||
/// leaves it to the command line instead of dropping the rest on save.
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_codes_do_not_fit() {
|
||||
assert!(Formatted::fits("one\\Ptwo\\A1;three"));
|
||||
assert!(!Formatted::fits(&"a\\P".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"a\n".repeat(MAX_PARAS)));
|
||||
assert!(!Formatted::fits(&"\\A1;".repeat(MAX_ATOMS + 1)));
|
||||
assert!(Formatted::fits(&"\\A1;".repeat(MAX_ATOMS)));
|
||||
// A full formatted run's closing brace adds no text: it still fits.
|
||||
assert!(Formatted::fits(&format!("{{\\L{}}}", "x".repeat(MAX_CHARS))));
|
||||
assert!(!Formatted::fits(&format!("{{\\L{}}}y", "x".repeat(MAX_CHARS))));
|
||||
// OK without an edit leaves a text alone, also one the editor writes another way.
|
||||
let odd = "{\\fArial|b1;first}";
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), &Formatted::from_contents_with_height(odd, Some(2.5)).to_contents()));
|
||||
assert!(Formatted::leaves_unchanged(odd, Some(2.5), odd));
|
||||
assert!(!Formatted::leaves_unchanged(odd, Some(2.5), "second"));
|
||||
// Nesting past the 64 levels the editor keeps doesn't fit.
|
||||
assert!(Formatted::fits(&format!("{}a{}", "{".repeat(64), "}".repeat(64))));
|
||||
assert!(!Formatted::fits(&format!("{}a{}", "{".repeat(65), "}".repeat(65))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +286,9 @@ pub trait Interactive: Send {
|
||||
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
|
||||
None
|
||||
}
|
||||
/// The multiline text being typed (MTEXT, once its box is known) with empty contents: where
|
||||
/// the UI places its in-place editor and with which height, width and justification.
|
||||
/// The multiline text being typed (MTEXT, once its box is known, with empty contents) or edited
|
||||
/// (TEXTEDIT on an MTEXT or a multileader's text, with its contents): where the UI places its
|
||||
/// in-place editor and with which height, width, justification and starting text.
|
||||
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -75,6 +75,57 @@ fn reinit_reads_the_alias_file_again() {
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_alias_profile_restores_without_overriding_commands() {
|
||||
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": { "zzp": "qsave", "zzcmd": "line" } })).unwrap();
|
||||
let saved = cadcraft_engine::cmd::file::alias_profile();
|
||||
assert_eq!(saved["aliases"]["zzp"], json!("qsave"));
|
||||
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zzp"), "zzp");
|
||||
cadcraft_engine::cmd::file::restore_aliases(&saved);
|
||||
assert_eq!(resolve_alias("zzp"), "qsave");
|
||||
assert_eq!(resolve_alias("zzcmd"), "line");
|
||||
assert_eq!(resolve_alias("line"), "line", "command names win");
|
||||
assert_eq!(resolve_alias("c"), "circle", "built-in aliases still resolve");
|
||||
|
||||
// Hostile saved entries are skipped, and an oversized set is capped without panicking.
|
||||
let mut hostile = serde_json::Map::new();
|
||||
for i in 0..12_000 {
|
||||
hostile.insert(format!("ZZ{i}"), json!("line"));
|
||||
}
|
||||
hostile.insert("LINE".into(), json!("circle"));
|
||||
hostile.insert("ZZLONG".into(), json!("x".repeat(5000)));
|
||||
hostile.insert("ZZNUM".into(), json!(3));
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": hostile, "file": "mine.pgp" }));
|
||||
assert_eq!(resolve_alias("line"), "line", "a restored alias never overrides a command name");
|
||||
assert_eq!(resolve_alias("zzlong"), "zzlong");
|
||||
assert_eq!(resolve_alias("zzn"), "zzn");
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
let restored = listing["aliases"].as_object().map_or(0, serde_json::Map::len);
|
||||
assert!(restored <= cadcraft_engine::cmd::file::MAX_ALIASES, "the restored set is capped, not {restored}");
|
||||
assert!(restored > 9_000, "the valid entries restore, not just {restored}");
|
||||
assert_eq!(listing["file"], json!("mine.pgp"));
|
||||
|
||||
// Skipped entries don't use up the cap: invalid ones sorted first leave a full valid set.
|
||||
let mut first_bad = serde_json::Map::new();
|
||||
for i in 0..cadcraft_engine::cmd::file::MAX_ALIASES {
|
||||
first_bad.insert(format!("A{i:05}"), json!("nosuchcommand"));
|
||||
first_bad.insert(format!("B{i:05}"), json!("line"));
|
||||
}
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": first_bad, "file": null }));
|
||||
let listing = s.execute("aliasedit", &json!({})).unwrap();
|
||||
assert_eq!(listing["aliases"].as_object().map_or(0, serde_json::Map::len), cadcraft_engine::cmd::file::MAX_ALIASES);
|
||||
// A malformed section is skipped without changing what ALIASEDIT holds.
|
||||
cadcraft_engine::cmd::file::restore_aliases(&json!({ "aliases": { "zzchange": "circle" }, "file": 7 }));
|
||||
assert_eq!(resolve_alias("zzchange"), "zzchange", "a malformed section restores nothing");
|
||||
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
|
||||
assert_eq!(resolve_alias("zz0"), "zz0", "clear still empties the restored set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_alias_files_never_panic() {
|
||||
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -86,6 +86,7 @@ mod minsert;
|
||||
mod mirror_degenerate_line;
|
||||
mod mleader_content;
|
||||
mod mleader_remember;
|
||||
mod mleader_text_edit;
|
||||
mod mline;
|
||||
mod mocoro;
|
||||
mod modify_curves;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and
|
||||
//! hands it to the in-place editor, and the Properties palette's Contents field changes it.
|
||||
|
||||
use cadcraft_engine::doc::EntityKind;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Input, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_of(s: &Session) -> String {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None })
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textedit_and_properties_change_a_multileaders_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
|
||||
// TEXTEDIT on the text: the current contents are the default, and the in-place editor gets
|
||||
// the multileader's MTEXT.
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
let p = s.current_prompt().unwrap();
|
||||
assert_eq!(p.message, "Enter new text");
|
||||
assert_eq!(p.default.as_deref(), Some("example text"));
|
||||
assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string()));
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "edited\\Psecond line");
|
||||
s.cancel();
|
||||
|
||||
// The Properties palette's Contents field.
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!(text_of(&s), "from properties");
|
||||
}
|
||||
|
||||
/// An edited multileader text is undone in one step and is what a DXF save keeps.
|
||||
#[test]
|
||||
fn an_edited_multileader_text_undoes_and_survives_a_dxf_save() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let at = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None })
|
||||
.unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("edited\nsecond line".into())).unwrap();
|
||||
s.cancel();
|
||||
// 2007 DXF: older versions keep a multileader as a LEADER and an MTEXT.
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2007), binary: false };
|
||||
let reopened = cadcraft_io::read_dxf(&cadcraft_io::write_with(s.doc().unwrap(), "x.dxf", &opts).unwrap()).unwrap();
|
||||
let saved =
|
||||
reopened.model.iter().find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None });
|
||||
assert_eq!(saved.as_deref(), Some("edited\\Psecond line"));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
/// A multileader on a locked layer is refused when it is picked, before the editor opens, and
|
||||
/// Properties leaves it alone.
|
||||
#[test]
|
||||
fn a_multileader_on_a_locked_layer_is_refused_when_picked() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("The object is on a locked layer."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Select an annotation object");
|
||||
s.cancel();
|
||||
let r = s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap();
|
||||
assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(text_of(&s), "example text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_too_long_for_the_editor_is_asked_for_at_the_command_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap();
|
||||
let (h, at) = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None })
|
||||
.unwrap();
|
||||
let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS));
|
||||
s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap();
|
||||
assert_eq!(text_of(&s), long);
|
||||
s.cmdline("TEXTEDIT").unwrap();
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
assert_eq!(s.log.last().map(String::as_str), Some("This text is too long for the editor; enter the new text at the command line."));
|
||||
assert!(s.editing_mtext().is_none(), "the editor doesn't open");
|
||||
assert_eq!(s.current_prompt().unwrap().message, "Enter new text");
|
||||
// Enter keeps it whole; a typed answer still replaces it, as for any other text.
|
||||
s.input(Input::Enter).unwrap();
|
||||
assert_eq!(text_of(&s), long, "the text is not shortened");
|
||||
s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap();
|
||||
s.input(Input::Text("short".into())).unwrap();
|
||||
assert_eq!(text_of(&s), "short");
|
||||
s.cancel();
|
||||
}
|
||||
@@ -233,3 +233,17 @@ 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");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
@@ -33,13 +33,21 @@ pub(crate) fn is_mark(c: char) -> bool {
|
||||
}
|
||||
|
||||
/// Base direction of a paragraph from its first strong character (rules P2/P3): `Some(true)`
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character.
|
||||
/// right-to-left, `Some(false)` left-to-right, `None` when it has no strong character. As rule
|
||||
/// P2 requires, characters between an isolate initiator and its matching PDI (or the end of the
|
||||
/// paragraph, when unmatched) are skipped.
|
||||
pub fn base_rtl(s: &str) -> Option<bool> {
|
||||
s.chars().find_map(|c| match bidi_class(c) {
|
||||
BidiClass::L => Some(false),
|
||||
BidiClass::R | BidiClass::AL => Some(true),
|
||||
_ => None,
|
||||
})
|
||||
let mut isolate_depth: usize = 0;
|
||||
for c in s.chars() {
|
||||
match bidi_class(c) {
|
||||
BidiClass::LRI | BidiClass::RLI | BidiClass::FSI => isolate_depth = isolate_depth.saturating_add(1),
|
||||
BidiClass::PDI => isolate_depth = isolate_depth.saturating_sub(1),
|
||||
BidiClass::L if isolate_depth == 0 => return Some(false),
|
||||
BidiClass::R | BidiClass::AL if isolate_depth == 0 => return Some(true),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn para_level(base: Option<bool>) -> Option<Level> {
|
||||
@@ -349,14 +357,24 @@ pub fn presentation_forms(chars: &[char], keep: impl Fn(char) -> bool) -> Vec<(c
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let form = forms.get(i).copied().flatten();
|
||||
// Combining marks (harakat) between lam and alef are transparent for joining, so they
|
||||
// don't block the ligature; they stay after it, in logical order.
|
||||
let alef_at =
|
||||
if c == '\u{0644}' { chars.get(i + 1..).and_then(|rest| rest.iter().position(|ch| !is_mark(*ch))).map(|p| i + 1 + p) } else { None };
|
||||
if c == '\u{0644}'
|
||||
&& let Some(f) = form
|
||||
&& let Some(&a) = chars.get(i + 1)
|
||||
&& let Some(j) = alef_at
|
||||
&& let Some(&a) = chars.get(j)
|
||||
&& let Some(lig) = lam_alef(a, matches!(f, Form::Medial | Form::Final))
|
||||
&& keep(lig)
|
||||
{
|
||||
out.push((lig, i));
|
||||
i += 2;
|
||||
for k in i + 1..j {
|
||||
if let Some(&m) = chars.get(k) {
|
||||
out.push((m, k));
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
let shaped = form.and_then(|f| presentation_form(c, f)).filter(|p| keep(*p)).unwrap_or(c);
|
||||
@@ -442,6 +460,48 @@ mod tests {
|
||||
assert!(visual_runs(&[], None).is_empty() && char_levels("", None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_direction_skips_isolate_contents() {
|
||||
// RLI שלום PDI abc: the Hebrew sits inside the isolate, so the first strong character is "a".
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069} abc"), Some(false));
|
||||
assert_eq!(base_rtl("\u{2066}abc\u{2069} שלום"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2068}שלום\u{2069}abc"), Some(false));
|
||||
// Nested isolates: only the outermost PDI closes the outer one.
|
||||
assert_eq!(base_rtl("\u{2066}a\u{2067}ב\u{2069}a\u{2069}ב"), Some(true));
|
||||
assert_eq!(base_rtl("\u{2067}\u{2066}a\u{2069}ב\u{2069}a"), Some(false));
|
||||
// An unmatched initiator isolates the rest of the paragraph.
|
||||
assert_eq!(base_rtl("\u{2067}שלום"), None);
|
||||
assert_eq!(base_rtl("\u{2067}שלום\u{2069}"), None);
|
||||
// A stray PDI does not close anything and does not underflow.
|
||||
assert_eq!(base_rtl("\u{2069}\u{2069}שלום"), Some(true));
|
||||
// No strong character outside the isolates.
|
||||
assert_eq!(base_rtl("12 \u{2066}abc\u{2069} 34"), None);
|
||||
// Without isolates nothing changes.
|
||||
assert_eq!(base_rtl(" 123 سلام abc"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harakat_between_lam_and_alef_keep_the_ligature() {
|
||||
let run = |s: &str| -> Vec<(char, usize)> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
presentation_forms(&chars, |_| true)
|
||||
};
|
||||
// Lam + fatha + alef: the ligature, then the fatha (logical order kept).
|
||||
assert_eq!(run("ل\u{064E}ا"), vec![('\u{FEFB}', 0), ('\u{064E}', 1)]);
|
||||
// Word-medial: س + lam + fatha + shadda + alef gives the final ligature, marks after it.
|
||||
assert_eq!(run("سل\u{064E}\u{0651}ا"), vec![('\u{FEB3}', 0), ('\u{FEFC}', 1), ('\u{064E}', 2), ('\u{0651}', 3)]);
|
||||
// Plain lam + alef is unchanged.
|
||||
assert_eq!(run("لا"), vec![('\u{FEFB}', 0)]);
|
||||
// Mark after the alef is not between them: unchanged behaviour.
|
||||
assert_eq!(run("لا\u{064E}"), vec![('\u{FEFB}', 0), ('\u{064E}', 2)]);
|
||||
// Lam + mark + a non-alef letter: no ligature, the lam joins forward as before.
|
||||
assert_eq!(run("ل\u{064E}م"), vec![('\u{FEDF}', 0), ('\u{064E}', 1), ('\u{FEE2}', 2)]);
|
||||
// Lam + mark at the end: no alef, nothing breaks.
|
||||
assert_eq!(run("ل\u{064E}"), vec![('\u{FEDD}', 0), ('\u{064E}', 1)]);
|
||||
// A non-mark between lam and alef blocks the ligature.
|
||||
assert_eq!(run("ل-ا").len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for s in [
|
||||
|
||||
+215
-10
@@ -9,6 +9,8 @@ pub(crate) mod para;
|
||||
/// Strip MTEXT formatting codes to plain text with `\n` paragraph breaks.
|
||||
/// Stacked fractions `\S1/2;` become `1/2`; `\~` is a space; braces are dropped.
|
||||
pub fn plain_mtext(s: &str) -> String {
|
||||
let s = unescape_codes(s);
|
||||
let s = s.as_ref();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -63,7 +65,7 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
i += 7;
|
||||
}
|
||||
// Codes with an argument terminated by ';' (\f, \F, \H, \W, \Q, \T, \A, \C, \c, \p).
|
||||
_ => {
|
||||
_ if takes_arg(code) => {
|
||||
let mut j = i + 2;
|
||||
while let Some(&ch) = chars.get(j) {
|
||||
if ch == ';' {
|
||||
@@ -73,6 +75,8 @@ pub fn plain_mtext(s: &str) -> String {
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
// An unknown code has no argument: drop the backslash and the code only.
|
||||
_ => i += 2,
|
||||
}
|
||||
}
|
||||
'{' | '}' => i += 1,
|
||||
@@ -180,11 +184,96 @@ enum Atom {
|
||||
Para(usize),
|
||||
}
|
||||
|
||||
/// Upper bound on parsed atoms (hostile contents).
|
||||
/// Upper bound on parsed atoms (words, spaces and paragraph breaks) per MTEXT. Hostile contents
|
||||
/// are cut here: once more than this many atoms are parsed, `parse` stops reading and the rest
|
||||
/// of the contents is silently dropped from the layout. There is no warning to the caller.
|
||||
const MAX_ATOMS: usize = 200_000;
|
||||
/// Scale of stacked text relative to the surrounding text.
|
||||
const STACK_SCALE: f64 = 0.7;
|
||||
|
||||
/// Codes that take an argument terminated by `;` (besides `\S`, which has its own scan). Any
|
||||
/// other code stands alone, so a missing `;` must not swallow the text after it.
|
||||
pub(crate) fn takes_arg(code: char) -> bool {
|
||||
matches!(code, 'f' | 'F' | 'H' | 'W' | 'Q' | 'T' | 'A' | 'C' | 'c' | 'p')
|
||||
}
|
||||
|
||||
/// The line-spacing factor to lay out with: AutoCAD's 0.25..=4 range, 1 for a missing or
|
||||
/// non-finite value.
|
||||
pub(crate) fn sane_line_spacing(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.clamp(0.25, 4.0) } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Four hex digits at `chars[at..at + 4]`.
|
||||
fn hex4(chars: &[char], at: usize) -> Option<u32> {
|
||||
let digits = chars.get(at..at.checked_add(4)?)?;
|
||||
digits.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// Push a decoded character so that the tokenizers read it as text, never as markup.
|
||||
fn push_literal(out: &mut String, c: char) {
|
||||
if matches!(c, '\\' | '{' | '}') {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
/// Decode `\U+hhhh` (one UTF-16 unit; a surrogate pair gives one character, a lone surrogate
|
||||
/// U+FFFD) and `\M+nhhhh` (double-byte East Asian characters) to the characters they stand
|
||||
/// for. Malformed escapes are left for the tokenizers, which drop the backslash and the code.
|
||||
/// An escaped backslash (`\\U+0041`) is not an escape.
|
||||
pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return std::borrow::Cow::Borrowed(s);
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (n, plus) = (chars.get(i + 1).copied(), chars.get(i + 2).copied());
|
||||
if n == Some('U') && plus == Some('+') {
|
||||
if let Some(unit) = hex4(&chars, i + 3) {
|
||||
i += 7;
|
||||
let low = |at: usize| {
|
||||
(chars.get(at) == Some(&'\\') && chars.get(at + 1) == Some(&'U') && chars.get(at + 2) == Some(&'+'))
|
||||
.then(|| hex4(&chars, at + 3))
|
||||
.flatten()
|
||||
.filter(|u| (0xDC00..0xE000).contains(u))
|
||||
};
|
||||
let ch = if (0xD800..0xDC00).contains(&unit) {
|
||||
match low(i) {
|
||||
Some(lo) => {
|
||||
i += 7;
|
||||
char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (lo - 0xDC00))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
char::from_u32(unit)
|
||||
};
|
||||
push_literal(&mut out, ch.unwrap_or('\u{fffd}'));
|
||||
continue;
|
||||
}
|
||||
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
|
||||
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
// Any other backslash pair stays as it is (and an escaped backslash stays one).
|
||||
out.push('\\');
|
||||
i += 1;
|
||||
if let Some(&next) = chars.get(i) {
|
||||
out.push(next);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn arg(chars: &[char], from: usize) -> (String, usize) {
|
||||
let mut j = from;
|
||||
let mut a = String::new();
|
||||
@@ -236,6 +325,8 @@ fn font_switch(a: &str) -> Option<TextFont> {
|
||||
/// Parse MTEXT contents into words, spaces and paragraph breaks with their formats.
|
||||
fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let h_min = base.height * 1e-3;
|
||||
let h_max = if (base.height * 1e3).is_finite() { base.height * 1e3 } else { f64::MAX };
|
||||
let mut fmts = vec![base];
|
||||
let mut cur = 0usize;
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
@@ -342,14 +433,22 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.0 && *v < 1e6) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { f.height * v } else { v });
|
||||
// Relative heights compound; keep the result within 1e-3..1e3 times the
|
||||
// MTEXT's own height, never infinite.
|
||||
derive(&mut fmts, &mut cur, &|f| f.height = if rel { (f.height * v).min(h_max).max(h_min) } else { v });
|
||||
}
|
||||
}
|
||||
'W' => {
|
||||
let (a, n) = arg(&chars, i);
|
||||
i = n;
|
||||
if let Some(v) = num(a.trim_end_matches(['x', 'X'])).filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = v);
|
||||
// `\W2x;` multiplies the current factor (like `\H2x;`); `\W2;` sets it.
|
||||
let (rel, v) = match a.trim().strip_suffix(['x', 'X']) {
|
||||
Some(r) => (true, num(r)),
|
||||
None => (false, num(&a)),
|
||||
};
|
||||
if let Some(v) = v.filter(|v| *v > 0.01 && *v < 100.0) {
|
||||
// Relative factors compound; keep the result within the absolute range.
|
||||
derive(&mut fmts, &mut cur, &|f| f.wf = if rel { (f.wf * v).clamp(0.01, 100.0) } else { v });
|
||||
}
|
||||
}
|
||||
'Q' => {
|
||||
@@ -381,11 +480,13 @@ fn parse(contents: &str, base: Fmt) -> (Vec<Atom>, Vec<Fmt>) {
|
||||
derive(&mut fmts, &mut cur, &|f| f.valign = v);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// \T, \p and unknown codes: argument up to ';'.
|
||||
_ if takes_arg(code) => {
|
||||
// \T and \p: argument up to ';'.
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code are dropped.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'{' => {
|
||||
@@ -692,11 +793,14 @@ fn plain_lines(atoms: &[Atom], fmts: &[Fmt], base: &Fmt, width: f64) -> Vec<Line
|
||||
/// Lay out MTEXT: inline formatting runs, stacked fractions, paragraphs, word wrap to the
|
||||
/// column width and the attachment point.
|
||||
pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
let contents = unescape_codes(contents);
|
||||
let contents = contents.as_ref();
|
||||
let h = if p.height > 0.0 && p.height.is_finite() { p.height } else { 1.0 };
|
||||
let wf = if p.width_factor.is_finite() && p.width_factor > 0.0 { p.width_factor } else { 1.0 };
|
||||
let width = if p.width.is_finite() && p.width > 0.0 { p.width } else { 0.0 };
|
||||
let rotation = if p.rotation.is_finite() { p.rotation } else { 0.0 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique: p.oblique, valign: 1 };
|
||||
let oblique = if p.oblique.is_finite() { p.oblique } else { 0.0 };
|
||||
let base = Fmt { font: p.font.clone(), height: h, color: None, under: false, over: false, wf, oblique, valign: 1 };
|
||||
if para::needed(contents, p) {
|
||||
return para::layout(contents, p, &base);
|
||||
}
|
||||
@@ -719,7 +823,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
for l in &mut lines {
|
||||
l.reorder_bidi(bases.get(l.para).copied().flatten());
|
||||
}
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
let ls = sane_line_spacing(p.line_spacing);
|
||||
// Baselines below the top of the first line.
|
||||
let mut baselines = Vec::with_capacity(lines.len());
|
||||
let mut y = 0.0;
|
||||
@@ -800,7 +904,108 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
|
||||
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
|
||||
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
assert_eq!(plain_mtext("\\U+12"), "+12", "a short escape loses only the backslash and the code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_are_decoded_in_every_parser() {
|
||||
// The escape has no `;`, so it must not swallow the rest of the text.
|
||||
assert_eq!(plain_mtext("A\\U+00B0 B C;D"), "A\u{b0} B C;D");
|
||||
assert_eq!(lay("A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
// The paragraph path (`\p`) and the plain path agree.
|
||||
assert_eq!(lay("\\pxql;A\\U+00B0 B C;D", 0.0).lines, vec!["A\u{b0} B C;D"]);
|
||||
let scanned = para::Paragraphs::scan("\\U+00B0\\pi1;x\\U+0041 y;z");
|
||||
assert!(scanned.formatted());
|
||||
// A surrogate pair is one character; a lone surrogate is U+FFFD.
|
||||
assert_eq!(plain_mtext("\\U+D83D\\U+DE00!"), "\u{1f600}!");
|
||||
assert_eq!(lay("\\U+D83D\\U+DE00!", 0.0).lines, vec!["\u{1f600}!"]);
|
||||
assert_eq!(plain_mtext("a\\U+D83Db"), "a\u{fffd}b");
|
||||
assert_eq!(plain_mtext("a\\U+DE00b\\U+D83D"), "a\u{fffd}b\u{fffd}");
|
||||
// A decoded backslash or brace is text, not markup; an escaped backslash is not an escape.
|
||||
assert_eq!(plain_mtext("\\U+005CH2;x"), "\\H2;x");
|
||||
assert_eq!(plain_mtext("\\\\U+0041"), "\\U+0041");
|
||||
// `\M+n` double-byte characters, decoded like the DXF reader does.
|
||||
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
|
||||
// Malformed escapes lose only the backslash and the code.
|
||||
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
|
||||
assert_eq!(lay("a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(lay("\\pxql;a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_code_without_semicolon_keeps_the_text() {
|
||||
assert_eq!(plain_mtext("A\\Zbc d"), "Abc d");
|
||||
assert_eq!(lay("A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
assert_eq!(lay("\\pxql;A\\Zbc d", 0.0).lines, vec!["Abc d"]);
|
||||
// A trailing lone backslash is dropped.
|
||||
assert_eq!(plain_mtext("A\\"), "A");
|
||||
// Codes with an argument still take it up to the `;`, or to the end.
|
||||
assert_eq!(plain_mtext("A\\H2;B"), "AB");
|
||||
assert_eq!(plain_mtext("A\\H2 B"), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_oblique_and_line_spacing_are_repaired() {
|
||||
let run = |oblique: f64, spacing: f64| {
|
||||
let p = MTextParams { oblique, line_spacing: spacing, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
layout_mtext_with("AB\\PCD", &p)
|
||||
};
|
||||
let finite =
|
||||
|l: &MTextLayout| l.bounds.min.x.is_finite() && l.bounds.max.x.is_finite() && l.bounds.min.y.is_finite() && l.bounds.max.y.is_finite();
|
||||
let reference = run(0.0, 1.0);
|
||||
for o in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let l = run(o, 1.0);
|
||||
assert!(finite(&l), "oblique {o}");
|
||||
assert_eq!(l.bounds, reference.bounds, "oblique {o}");
|
||||
}
|
||||
for s in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, -2.0] {
|
||||
let l = run(0.0, s);
|
||||
assert!(finite(&l), "spacing {s}");
|
||||
assert_eq!(l.bounds, reference.bounds, "spacing {s}");
|
||||
}
|
||||
// Spacing is clamped to AutoCAD's 0.25..=4 range.
|
||||
assert_eq!(run(0.0, 1e9).bounds, run(0.0, 4.0).bounds);
|
||||
assert_eq!(run(0.0, 1e-9).bounds, run(0.0, 0.25).bounds);
|
||||
assert!(run(0.0, 4.0).bounds.height() > reference.bounds.height());
|
||||
// The paragraph layout takes the same path.
|
||||
let p = MTextParams { oblique: f64::NAN, line_spacing: f64::NAN, ..MTextParams::new(Vec2::ZERO, 1.0) };
|
||||
assert!(finite(&layout_mtext_with("\\pxql;AB\\PCD", &p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_heights_do_not_run_away() {
|
||||
let src = format!("{}A", "\\H999999x;".repeat(60));
|
||||
let l = lay(&src, 0.0);
|
||||
assert!(l.bounds.height().is_finite() && l.bounds.width().is_finite());
|
||||
assert!(l.bounds.height() <= 1e3 * 1.5, "{}", l.bounds.height());
|
||||
assert!(l.bounds.height() > 100.0, "the glyph must not vanish: {}", l.bounds.height());
|
||||
// Shrinking is bounded too.
|
||||
let tiny = lay(&format!("{}A", "\\H0.000001x;".repeat(60)), 0.0);
|
||||
assert!(tiny.bounds.height() > 0.0);
|
||||
}
|
||||
|
||||
/// The width factor of the last format `parse` derived.
|
||||
fn last_wf(contents: &str, base_wf: f64) -> f64 {
|
||||
let base = Fmt { font: TextFont::Stroke, height: 1.0, color: None, under: false, over: false, wf: base_wf, oblique: 0.0, valign: 1 };
|
||||
parse(contents, base).1.last().map_or(f64::NAN, |f| f.wf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_factor_x_is_relative() {
|
||||
assert_eq!(last_wf("\\W2;a", 1.5), 2.0, "absolute sets the factor");
|
||||
assert_eq!(last_wf("\\W2x;a", 1.5), 3.0, "x multiplies the current factor");
|
||||
assert_eq!(last_wf("\\W2;\\W2x;a", 1.0), 4.0, "relative builds on the last absolute");
|
||||
assert_eq!(last_wf("\\W0.5X;a", 1.0), 0.5, "capital X too");
|
||||
// Hostile: a long relative chain stays inside the absolute range.
|
||||
let chain = "\\W99x;".repeat(50);
|
||||
assert_eq!(last_wf(&chain, 1.0), 100.0);
|
||||
let down = "\\W0.02x;".repeat(50);
|
||||
assert_eq!(last_wf(&down, 1.0), 0.01);
|
||||
// Out of range or not a number: ignored.
|
||||
assert_eq!(last_wf("\\W1e999x;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\WNaNx;a", 1.5), 1.5);
|
||||
assert_eq!(last_wf("\\Wx;a", 1.5), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -264,6 +264,7 @@ pub struct Paragraphs {
|
||||
|
||||
impl Paragraphs {
|
||||
pub fn scan(contents: &str) -> Paragraphs {
|
||||
let contents = super::unescape_codes(contents);
|
||||
let chars: Vec<char> = contents.chars().collect();
|
||||
let mut out = Paragraphs { states: vec![ParaProps::default()], ..Default::default() };
|
||||
let mut cur = 0usize;
|
||||
@@ -303,10 +304,12 @@ impl Paragraphs {
|
||||
cur = out.states.len() - 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
_ if super::takes_arg(code) => {
|
||||
let (_, n) = arg(&chars, i);
|
||||
i = n;
|
||||
}
|
||||
// An unknown code has no argument: only the backslash and the code go.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
'\n' => {
|
||||
@@ -604,7 +607,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
|
||||
let lines = LineBuilder::new(¶s, &fmts, base, width, h).build(&atoms);
|
||||
|
||||
// Vertical placement: baselines below the top of each line's column.
|
||||
let ls = if p.line_spacing > 0.0 && p.line_spacing.is_finite() { p.line_spacing } else { 1.0 };
|
||||
let ls = super::sane_line_spacing(p.line_spacing);
|
||||
let cols = p.columns.as_ref().filter(|_| width > 0.0);
|
||||
let max_cols = match cols {
|
||||
Some(c) if c.dynamic => MAX_COLUMNS,
|
||||
|
||||
@@ -103,7 +103,11 @@ impl Face {
|
||||
let Some(f) = self.font() else { return Vec::new() };
|
||||
[skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
|
||||
.into_iter()
|
||||
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
|
||||
// A name table can hold many records that point at one big string: at most
|
||||
// MAX_NAME_RECORDS per name id, each decoded only up to MAX_NAME_CHARS characters.
|
||||
.flat_map(|id| {
|
||||
f.localized_strings(id).take(MAX_NAME_RECORDS).map(|s| norm(&s.chars().take(MAX_NAME_CHARS).collect::<String>())).collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -118,6 +122,10 @@ pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
|
||||
}
|
||||
|
||||
const MAX_FACES_PER_FILE: u32 = 32;
|
||||
/// Name records read per name id of a face, and characters read of each: real fonts have a few
|
||||
/// dozen short ones.
|
||||
const MAX_NAME_RECORDS: usize = 64;
|
||||
const MAX_NAME_CHARS: usize = 256;
|
||||
/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character.
|
||||
const MAX_FALLBACK_FILES: usize = 24;
|
||||
const MAX_FALLBACK_BYTES: usize = 160 << 20;
|
||||
|
||||
@@ -484,6 +484,9 @@ pub struct Polyline {
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// Sampled points one [`Polyline::tessellate`] call produces before arcs become chords.
|
||||
pub const MAX_TESSELLATED_POINTS: usize = 4_000_000;
|
||||
|
||||
impl Polyline {
|
||||
pub fn from_points(pts: &[Vec2], closed: bool) -> Self {
|
||||
Polyline { vertices: pts.iter().map(|p| PolyVertex::new(*p)).collect(), closed }
|
||||
@@ -516,10 +519,24 @@ impl Polyline {
|
||||
b
|
||||
}
|
||||
pub fn tessellate(&self, tol: f64) -> Vec<Vec2> {
|
||||
let mut budget = MAX_TESSELLATED_POINTS;
|
||||
self.tessellate_within(tol, &mut budget)
|
||||
}
|
||||
/// [`Polyline::tessellate`] spending about `budget` sampled points (shared by the caller
|
||||
/// across many polylines). The budget is checked before each segment, so the last segment
|
||||
/// started may overshoot it by that one segment's samples. Once it is spent, the remaining arcs are drawn as chords, so the
|
||||
/// output stays proportional to the input vertices. Each arc can sample thousands of points,
|
||||
/// so a compact polyline could otherwise expand into gigabytes (CWE-400).
|
||||
pub fn tessellate_within(&self, tol: f64, budget: &mut usize) -> Vec<Vec2> {
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
for s in self.segments() {
|
||||
let mut pts = Vec::new();
|
||||
s.tessellate(tol, &mut pts);
|
||||
if *budget > 0 {
|
||||
s.tessellate(tol, &mut pts);
|
||||
*budget = budget.saturating_sub(pts.len());
|
||||
} else {
|
||||
pts.extend([s.start(), s.end()]);
|
||||
}
|
||||
let skip = usize::from(out.last().is_some_and(|l| pts.first().is_some_and(|f| l.near(*f, 1e-9))));
|
||||
out.extend(pts.into_iter().skip(skip));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ mod vec;
|
||||
|
||||
pub use bounds::Bounds2;
|
||||
pub use curve::{
|
||||
Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, polygon_centroid, shoelace,
|
||||
Arc, Circle, Ellipse, Line, MAX_TESSELLATED_POINTS, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon,
|
||||
polygon_centroid, shoelace,
|
||||
};
|
||||
pub use fillet::{CornerFit, TanCurve, tangent_circles};
|
||||
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
||||
|
||||
+168
-3
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
use crate::spline::{basis, find_span};
|
||||
use crate::spline::{MAX_STACK_DEGREE, basis, find_span};
|
||||
use crate::{EPS, Ellipse, Spline, TAU, Vec2};
|
||||
|
||||
/// Highest degree the editing operations work with.
|
||||
@@ -88,6 +88,34 @@ fn elevate_bezier(p: &[H]) -> Vec<H> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Basis functions of degree `p` in `n[..=p]` and of degree `p - 1` in `lower[..p]` for `p >= 1`,
|
||||
/// in one Cox-de Boor sweep: the degree `p - 1` values are the state just before the last step,
|
||||
/// so they match [`basis`] exactly. `left` and `right` are scratch of `p + 1` values; the outputs
|
||||
/// stay untouched when a slice is too short.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn basis_pair_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], lower: &mut [f64], left: &mut [f64], right: &mut [f64]) {
|
||||
if p == 0 || n.len() <= p || lower.len() < p || left.len() <= p || right.len() <= p {
|
||||
return;
|
||||
}
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
|
||||
left[j] = u - k(span as isize + 1 - j as isize);
|
||||
right[j] = k(span as isize + j as isize) - u;
|
||||
if j == p {
|
||||
lower[..p].copy_from_slice(&n[..p]);
|
||||
}
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
let den = right[r + 1] + left[j - r];
|
||||
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
|
||||
n[r] = saved + right[r + 1] * tmp;
|
||||
saved = left[j - r] * tmp;
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
}
|
||||
|
||||
impl Spline {
|
||||
fn weight(&self, i: usize) -> f64 {
|
||||
if self.weights.is_empty() { 1.0 } else { self.weights.get(i).copied().unwrap_or(1.0) }
|
||||
@@ -130,6 +158,8 @@ impl Spline {
|
||||
/// First derivative without re-validating the spline. The caller must have confirmed
|
||||
/// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the
|
||||
/// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407).
|
||||
/// Up to degree [`MAX_STACK_DEGREE`] both bases come from one stack-only sweep; higher degrees
|
||||
/// compute them on the heap.
|
||||
fn derivative_unchecked(&self, t: f64) -> Vec2 {
|
||||
let d = self.velocity(t);
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
@@ -142,8 +172,17 @@ impl Spline {
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
let nb = basis(span, t, p, &self.knots);
|
||||
let nd = basis(span, t, p - 1, &self.knots);
|
||||
let (mut sb, mut sd, mut sl, mut sr) =
|
||||
([0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1], [0.0; MAX_STACK_DEGREE + 1]);
|
||||
let (hb, hd);
|
||||
let (nb, nd): (&[f64], &[f64]) = if (1..=MAX_STACK_DEGREE).contains(&p) {
|
||||
basis_pair_into(span, t, p, &self.knots, &mut sb, &mut sd, &mut sl, &mut sr);
|
||||
(&sb, &sd)
|
||||
} else {
|
||||
hb = basis(span, t, p, &self.knots);
|
||||
hd = basis(span, t, p - 1, &self.knots);
|
||||
(&hb, &hd)
|
||||
};
|
||||
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
|
||||
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
|
||||
@@ -624,6 +663,132 @@ mod tests {
|
||||
Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, -3.0), Vec2::new(9.0, 0.0), Vec2::new(12.0, 2.0)], 3)
|
||||
}
|
||||
|
||||
/// The previous implementation (two heap-allocated bases), kept as the oracle for the
|
||||
/// allocation-free `derivative_unchecked`.
|
||||
impl Spline {
|
||||
fn derivative_unchecked_old(&self, t: f64) -> Vec2 {
|
||||
let p = self.degree;
|
||||
let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO };
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
let nb = basis(span, t, p, &self.knots);
|
||||
let nd = basis(span, t, p - 1, &self.knots);
|
||||
let k = |i: usize| self.knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: Option<&f64>, den: f64| if den.abs() > 1e-300 { num.copied().unwrap_or(0.0) / den } else { 0.0 };
|
||||
let (mut a, mut w, mut da, mut dw) = (Vec2::ZERO, 0.0, Vec2::ZERO, 0.0);
|
||||
for j in 0..=p {
|
||||
let Some(i) = (span + j).checked_sub(p) else { continue };
|
||||
let Some(c) = self.control.get(i) else { continue };
|
||||
let wi = self.weight(i);
|
||||
let n0 = nb.get(j).copied().unwrap_or(0.0);
|
||||
// N'_{i,p} = p (N_{i,p-1} / (u_{i+p} - u_i) - N_{i+1,p-1} / (u_{i+p+1} - u_{i+1})).
|
||||
let left = if j >= 1 { ratio(nd.get(j - 1), k(i + p) - k(i)) } else { 0.0 };
|
||||
let right = if j < p { ratio(nd.get(j), k(i + p + 1) - k(i + 1)) } else { 0.0 };
|
||||
let dn = p as f64 * (left - right);
|
||||
a += *c * (n0 * wi);
|
||||
w += n0 * wi;
|
||||
da += *c * (dn * wi);
|
||||
dw += dn * wi;
|
||||
}
|
||||
if w.abs() < 1e-300 {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let d = (da - a / w * dw) / w;
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
}
|
||||
}
|
||||
|
||||
struct XorShift(u64);
|
||||
impl XorShift {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid random spline of degree `p`: clamped or unclamped knots, repeated interior knots,
|
||||
/// and non-rational, rational or zero/negative weights.
|
||||
fn random_spline(r: &mut XorShift, p: usize) -> Spline {
|
||||
let n = p + 1 + r.below(8);
|
||||
let control = (0..n).map(|_| Vec2::new(r.unit() * 100.0 - 50.0, r.unit() * 100.0 - 50.0)).collect();
|
||||
let total = n + p + 1;
|
||||
let mut knots: Vec<f64> =
|
||||
(0..total).map(|_| (r.unit() * 8.0).floor() * if r.below(3) == 0 { 0.5 } else { 1.0 } + r.unit() * f64::from(r.below(2) == 0)).collect();
|
||||
knots.sort_by(f64::total_cmp);
|
||||
if r.below(2) == 0 {
|
||||
// Clamp both ends.
|
||||
let (lo, hi) = (knots[p], knots[n]);
|
||||
knots[..=p].fill(lo);
|
||||
knots[n..].fill(hi);
|
||||
}
|
||||
let weights = match r.below(4) {
|
||||
0 => Vec::new(),
|
||||
1 => (0..n).map(|_| 0.2 + r.unit() * 4.0).collect(),
|
||||
2 => (0..n).map(|_| if r.below(5) == 0 { 0.0 } else { r.unit() * 2.0 - 0.5 }).collect(),
|
||||
_ => (0..n).map(|_| 1.0 + r.unit() * 1e-9).collect(),
|
||||
};
|
||||
Spline { degree: p, knots, control, weights, ..Spline::default() }
|
||||
}
|
||||
|
||||
fn bits(v: Vec2) -> (u64, u64) {
|
||||
(v.x.to_bits(), v.y.to_bits())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_is_bit_identical_to_the_heap_implementation() {
|
||||
let mut r = XorShift(0x9E37_79B9_7F4A_7C15);
|
||||
let mut zero_results = 0usize;
|
||||
for p in 1..=12usize {
|
||||
let mut compared = 0usize;
|
||||
for _ in 0..400 {
|
||||
let s = random_spline(&mut r, p);
|
||||
if !s.is_valid() {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = s.domain();
|
||||
let mut ts = vec![lo, hi, 0.5 * (lo + hi), lo - 1.0, hi + 1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
ts.extend(s.knots.iter().copied());
|
||||
ts.extend(s.knots.iter().map(|k| k.next_up()));
|
||||
ts.extend(s.knots.iter().map(|k| k.next_down()));
|
||||
ts.extend((0..8).map(|_| lo + (hi - lo) * r.unit()));
|
||||
for t in ts {
|
||||
let (new, old) = (s.derivative_unchecked(t), s.derivative_unchecked_old(t));
|
||||
assert_eq!(bits(new), bits(old), "degree {p}, t = {t}, knots {:?}, weights {:?}", s.knots, s.weights);
|
||||
compared += 1;
|
||||
zero_results += usize::from(old == Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
assert!(compared > 1000, "degree {p}: only {compared} comparisons ran");
|
||||
}
|
||||
// The zero-vector exits (vanishing weight sum, non-finite result) are exercised too.
|
||||
assert!(zero_results > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_dispatch_around_the_stack_limit_does_not_change_results() {
|
||||
// The last stack degree and the first heap degree both match the oracle on a clamped
|
||||
// uniform spline (the heap degree runs the unchanged old path; this checks the dispatch).
|
||||
for p in [MAX_STACK_DEGREE, MAX_STACK_DEGREE + 1] {
|
||||
let ctrl: Vec<Vec2> = (0..p + 4).map(|i| Vec2::new(i as f64, (i * i % 7) as f64)).collect();
|
||||
let s = Spline::from_control(ctrl, p);
|
||||
assert_eq!(s.degree, p);
|
||||
let (lo, hi) = s.domain();
|
||||
for i in 0..=50 {
|
||||
let t = lo + (hi - lo) * f64::from(i) / 50.0;
|
||||
assert_eq!(bits(s.derivative_unchecked(t)), bits(s.derivative_unchecked_old(t)), "degree {p}, t = {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivative_matches_finite_differences() {
|
||||
let mut s = cubic();
|
||||
|
||||
+306
-68
@@ -75,7 +75,108 @@ fn split_pair(segs: &[(Vec2, Vec2)], splits: &mut [Vec<f64>], i: usize, j: usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound on candidate-pair *visits*. Every visit counts, including repeats of a pair that shares several grid
|
||||
/// cells, not only the distinct pairs actually tested. A long segment replicated across many
|
||||
/// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
|
||||
/// repeated work unbounded (CWE-407).
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
/// Upper bound on distinct segment pairs tested for a crossing.
|
||||
const MAX_PAIRS_TESTED: usize = 20_000_000;
|
||||
|
||||
/// Split parameters of every segment (always `0` and `1`, plus every crossing), found with a uniform grid.
|
||||
fn pair_splits(segs: &[(Vec2, Vec2)], bb: &Bounds2) -> Option<Vec<Vec<f64>>> {
|
||||
pair_splits_limited(segs, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED)
|
||||
}
|
||||
|
||||
/// `pair_splits` with explicit budgets: `None` once more than `max_visits` candidate pairs were visited
|
||||
/// or more than `max_pairs` distinct pairs were tested.
|
||||
fn pair_splits_limited(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
// Cell lists in a flat grid, indexed `gx * cells + gy`; each list is in ascending segment order.
|
||||
let mut grid: Vec<Vec<usize>> = vec![Vec::new(); cells * cells];
|
||||
// The lowest cell of each segment's cell range; the lowest cell two segments share is the
|
||||
// componentwise maximum of their two.
|
||||
let mut low: Vec<(usize, usize)> = vec![(0, 0); n];
|
||||
let mut is_long = vec![false; n];
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if let Some(l) = low.get_mut(i) {
|
||||
*l = (x0, y0);
|
||||
}
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: too many cells to register in; it is tested against every segment below.
|
||||
long.push(i);
|
||||
if let Some(f) = is_long.get_mut(i) {
|
||||
*f = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
if let Some(list) = grid.get_mut(gx * cells + gy) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested: usize = 0;
|
||||
let mut visits: u64 = 0;
|
||||
for (c, list) in grid.iter().enumerate() {
|
||||
let (gx, gy) = (c / cells, c % cells);
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
let Some(&(ix, iy)) = low.get(i) else { continue };
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > max_visits {
|
||||
return None;
|
||||
}
|
||||
let Some(&(jx, jy)) = low.get(j) else { continue };
|
||||
// A pair sharing several cells is tested once, in the lowest cell both occupy.
|
||||
if i == j || gx != ix.max(jx) || gy != iy.max(jy) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A long segment is in no cell list. Two long segments are tested once, from the lower index.
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || (j < i && is_long.get(j).copied().unwrap_or(false)) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
tested += 1;
|
||||
if tested > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(splits)
|
||||
}
|
||||
|
||||
fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
build_with(polys, tol, pair_splits)
|
||||
}
|
||||
|
||||
/// `build` with the crossing search injected, so tests can run the old and the new search on the same input.
|
||||
fn build_with(polys: &[Vec<Vec2>], tol: f64, find_splits: impl Fn(&[(Vec2, Vec2)], &Bounds2) -> Option<Vec<Vec<f64>>>) -> Option<Graph> {
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
let mut owner: Vec<usize> = Vec::new();
|
||||
for (k, pl) in polys.iter().enumerate() {
|
||||
@@ -96,70 +197,9 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
if segs.is_empty() || segs.len() > MAX_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
// Split points per segment (parameters), found with a uniform grid.
|
||||
let bb = Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]));
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
// Very long segment: too many cells to register in; it is tested against every segment below.
|
||||
long.push(i);
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
// Count every candidate-pair *visit*, including repeats of a pair that shares several grid
|
||||
// cells, not only the distinct pairs actually tested. A long segment replicated across many
|
||||
// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
|
||||
// repeated hashing unbounded (CWE-407).
|
||||
let mut visits: u64 = 0;
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > MAX_PAIR_VISITS {
|
||||
return None;
|
||||
}
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(&segs, &mut splits, i, j);
|
||||
if tested.len() > 20_000_000 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let splits = find_splits(&segs, &bb)?;
|
||||
let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n), edge_src: HashMap::new() };
|
||||
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut vid = |p: Vec2, g: &mut Graph| -> usize {
|
||||
@@ -391,10 +431,10 @@ fn outside(core: &Graph, u: usize, steps: usize) -> Option<Vec<Vec2>> {
|
||||
}
|
||||
|
||||
/// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`).
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
|
||||
let comp = components(g);
|
||||
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
|
||||
let outer_pts = points(g, outer)?;
|
||||
let ob = Bounds2::from_points(outer_pts.iter().copied());
|
||||
let core = prune(g);
|
||||
// Leftmost (then lowest) vertex still on a cycle, per component.
|
||||
@@ -424,19 +464,32 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
let mut starts: Vec<(usize, usize)> = start.into_iter().collect();
|
||||
starts.sort_unstable();
|
||||
let mut out = Vec::new();
|
||||
// Each containment test scans a whole polygon, accepted or not: they share one allowance of
|
||||
// polygon edges, and the search ends when it is spent.
|
||||
let mut edges_left: usize = 100_000_000;
|
||||
for (c, u) in starts {
|
||||
if out.len() >= MAX_ISLANDS {
|
||||
break;
|
||||
}
|
||||
let Some(&pu) = core.pts.get(u) else { continue };
|
||||
if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) {
|
||||
if !ob.contains(pu) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(outer_pts.len())?;
|
||||
edges_left = left;
|
||||
if !point_in_polygon(&outer_pts, pu) {
|
||||
continue;
|
||||
}
|
||||
let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4);
|
||||
let Some(lp) = outside(&core, u, steps) else { continue };
|
||||
let area = shoelace(&lp);
|
||||
let ib = Bounds2::from_points(lp.iter().copied());
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) {
|
||||
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) {
|
||||
continue;
|
||||
}
|
||||
let left = edges_left.checked_sub(lp.len())?;
|
||||
edges_left = left;
|
||||
if point_in_polygon(&lp, p) {
|
||||
continue;
|
||||
}
|
||||
let mut src = sources.remove(&c).unwrap_or_default();
|
||||
@@ -444,7 +497,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
|
||||
src.dedup();
|
||||
out.push(Island { outline: lp, sources: src });
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
|
||||
@@ -457,6 +510,10 @@ fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, bu
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
// A curve with no segment costs a visit too, so many of them can't multiply the searches.
|
||||
if other.len() < 2 {
|
||||
*budget = budget.checked_sub(1).ok_or(())?;
|
||||
}
|
||||
for (k, w) in other.windows(2).enumerate() {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
@@ -558,7 +615,7 @@ pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Opti
|
||||
let g = build_gap(polys, tol, gap)?;
|
||||
let ids = outer_face(&g, p)?;
|
||||
let outer = points(&g, &ids)?;
|
||||
let islands = islands(&g, &ids, p);
|
||||
let islands = islands(&g, &ids, p)?;
|
||||
let mut outer_sources: Vec<usize> =
|
||||
ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect();
|
||||
outer_sources.sort_unstable();
|
||||
@@ -630,6 +687,187 @@ mod tests {
|
||||
vec![Vec2::new(x0, y0), Vec2::new(x0 + s, y0), Vec2::new(x0 + s, y0 + s), Vec2::new(x0, y0 + s), Vec2::new(x0, y0)]
|
||||
}
|
||||
|
||||
/// The crossing search before the lowest-shared-cell rule: a `HashSet` of tested pairs over a `HashMap` grid.
|
||||
fn pair_splits_oracle(segs: &[(Vec2, Vec2)], bb: &Bounds2, max_visits: u64, max_pairs: usize) -> Option<Vec<Vec<f64>>> {
|
||||
let n = segs.len();
|
||||
let cells = ((n as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
let cw = (bb.width() / cells as f64).max(1e-12);
|
||||
let ch = (bb.height() / cells as f64).max(1e-12);
|
||||
let cell = |p: Vec2| {
|
||||
(
|
||||
((p.x - bb.min.x) / cw).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
((p.y - bb.min.y) / ch).floor().clamp(0.0, (cells - 1) as f64) as usize,
|
||||
)
|
||||
};
|
||||
let mut grid: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let mut long: Vec<usize> = Vec::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
let (x0, y0) = cell(a.min(*b));
|
||||
let (x1, y1) = cell(a.max(*b));
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > 4096 {
|
||||
long.push(i);
|
||||
continue;
|
||||
}
|
||||
for gx in x0..=x1 {
|
||||
for gy in y0..=y1 {
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
let mut visits: u64 = 0;
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > max_visits {
|
||||
return None;
|
||||
}
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
if tested.len() > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for &i in &long {
|
||||
for j in 0..n {
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
split_pair(segs, &mut splits, i, j);
|
||||
if tested.len() > max_pairs {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(splits)
|
||||
}
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Random segments: short ones, some long ones, and some on a coarse lattice (shared ends, overlaps).
|
||||
fn random_segments(r: &mut Rng, n: usize, size: f64, long_every: u64) -> Vec<(Vec2, Vec2)> {
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..n {
|
||||
let a = Vec2::new(r.unit() * size, r.unit() * size);
|
||||
let seg = match r.next() % 4 {
|
||||
0 if long_every > 0 && r.next().is_multiple_of(long_every) => (
|
||||
Vec2::new(r.unit() * size * 0.02, r.unit() * size * 0.02),
|
||||
Vec2::new(size * (0.98 + r.unit() * 0.02), size * (0.98 + r.unit() * 0.02)),
|
||||
),
|
||||
1 => {
|
||||
let q = |r: &mut Rng| (r.next() % 9) as f64 * size / 8.0;
|
||||
(Vec2::new(q(r), q(r)), Vec2::new(q(r), q(r)))
|
||||
}
|
||||
_ => (a, a + Vec2::new((r.unit() - 0.5) * size * 0.1, (r.unit() - 0.5) * size * 0.1)),
|
||||
};
|
||||
if !seg.0.near(seg.1, 1e-9) {
|
||||
out.push(seg);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sorted_bits(splits: &[Vec<f64>]) -> Vec<Vec<u64>> {
|
||||
splits
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut v = v.clone();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.iter().map(|x| x.to_bits()).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bounds_of(segs: &[(Vec2, Vec2)]) -> Bounds2 {
|
||||
Bounds2::from_points(segs.iter().flat_map(|(a, b)| [*a, *b]))
|
||||
}
|
||||
|
||||
fn assert_same_search(segs: &[(Vec2, Vec2)], max_visits: u64, max_pairs: usize) {
|
||||
let bb = bounds_of(segs);
|
||||
let old = pair_splits_oracle(segs, &bb, max_visits, max_pairs);
|
||||
let new = pair_splits_limited(segs, &bb, max_visits, max_pairs);
|
||||
assert_eq!(old.as_deref().map(sorted_bits), new.as_deref().map(sorted_bits), "n={} visits<={max_visits} pairs<={max_pairs}", segs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_search_matches_oracle_with_budgets() {
|
||||
// Small inputs in few cells: many pairs share several cells. Sweeping each budget over
|
||||
// 0..=400 crosses its exhaustion point for most seeds, including the exact boundary.
|
||||
for seed in 1..=150u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
|
||||
let n = 2 + (r.next() % 28) as usize;
|
||||
let segs = random_segments(&mut r, n, 100.0, 0);
|
||||
if segs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for v in 0..=400u64 {
|
||||
assert_same_search(&segs, v, usize::MAX);
|
||||
}
|
||||
for p in 0..=400usize {
|
||||
assert_same_search(&segs, u64::MAX, p);
|
||||
}
|
||||
assert_same_search(&segs, 60_000_000, 20_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_search_matches_oracle_with_long_segments() {
|
||||
// Around 5000 segments give a 71x71 grid, so a diagonal covers more than 4096 cells and takes the long path.
|
||||
for seed in 1..=3u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0xD1B5_4A32_D192_ED03) | 1);
|
||||
let n = 5_000 + (r.next() % 500) as usize;
|
||||
let segs = random_segments(&mut r, n, 1000.0, 40);
|
||||
let bb = bounds_of(&segs);
|
||||
let cells = ((segs.len() as f64).sqrt().ceil() as usize).clamp(1, 512);
|
||||
assert!(cells >= 64, "grid too coarse to reach the long path");
|
||||
assert_same_search(&segs, 60_000_000, 20_000_000);
|
||||
// Budgets exhausted in the cell phase (visits, then pairs).
|
||||
assert_same_search(&segs, 3_000, 20_000_000);
|
||||
assert_same_search(&segs, 60_000_000, 200);
|
||||
assert!(pair_splits_limited(&segs, &bb, 3_000, 20_000_000).is_none());
|
||||
assert!(pair_splits_limited(&segs, &bb, 60_000_000, 200).is_none());
|
||||
let (cw, ch) = ((bb.width() / cells as f64).max(1e-12), (bb.height() / cells as f64).max(1e-12));
|
||||
let span = |a: f64, b: f64, o: f64, w: f64| {
|
||||
(((b.max(a) - o) / w).floor().min((cells - 1) as f64) - ((b.min(a) - o) / w).floor().max(0.0) + 1.0) as usize
|
||||
};
|
||||
let long = segs.iter().filter(|(a, b)| span(a.x, b.x, bb.min.x, cw) * span(a.y, b.y, bb.min.y, ch) > 4096).count();
|
||||
assert!(long > 0, "seed {seed}: no segment takes the long path");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_matches_oracle_on_random_drawings() {
|
||||
for seed in 1..=60u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0xA076_1D64_78BD_642F) | 1);
|
||||
let n = 5 + (r.next() % 400) as usize;
|
||||
let polys: Vec<Vec<Vec2>> = random_segments(&mut r, n, 100.0, 0).into_iter().map(|(a, b)| vec![a, b]).collect();
|
||||
let old = build_with(&polys, 1e-6, |s, bb| pair_splits_oracle(s, bb, MAX_PAIR_VISITS, MAX_PAIRS_TESTED));
|
||||
let new = build(&polys, 1e-6);
|
||||
let (Some(old), Some(new)) = (old, new) else { panic!("seed {seed}: build failed") };
|
||||
assert_eq!(old.pts, new.pts, "seed {seed}");
|
||||
assert_eq!(old.adj, new.adj, "seed {seed}");
|
||||
assert_eq!(old.src, new.src, "seed {seed}");
|
||||
assert_eq!(old.edge_src, new.edge_src, "seed {seed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_segment_crossing_in_unsampled_cell_is_split() {
|
||||
// A diagonal long enough to skip the grid, and a short segment crossing it in a cell the old coarse sampling missed.
|
||||
|
||||
@@ -347,21 +347,64 @@ fn basis_into(span: usize, u: f64, p: usize, knots: &[f64], n: &mut [f64], left:
|
||||
}
|
||||
}
|
||||
|
||||
/// Gaussian elimination with partial pivoting.
|
||||
/// Gaussian elimination with partial pivoting. The interpolation and least-squares matrices are
|
||||
/// banded, so every loop is limited to the band (lower width `kl`, upper width `ku`, measured from
|
||||
/// the matrix; partial pivoting widens the upper band to at most `kl + ku`). The skipped operations
|
||||
/// only combine entries that are exactly `+0.0` (they stay `+0.0` through the full sweep), so the
|
||||
/// result is bit for bit that of the full sweep (for finite input that does not overflow). A matrix with full bandwidth (the cyclic matrix of
|
||||
/// a closed fit) costs what it did before.
|
||||
///
|
||||
/// A skipped `0 * x` is not zero when `x` is NaN or infinite, so a matrix or right-hand side with
|
||||
/// NaN, infinity or magnitudes above `1e100` (which could overflow) takes the full sweep, and so
|
||||
/// does a back-substitution row whose banded sum is zero (skipped zero terms can flip its sign).
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
#[cfg(test)]
|
||||
if tests::DENSE_ORACLE.get() {
|
||||
return tests::solve_dense(a, b);
|
||||
}
|
||||
let n = b.len();
|
||||
// Per row: how far its non-zero entries reach left and right of the diagonal, and whether one
|
||||
// is NaN, infinite or large enough to overflow.
|
||||
let measure = |r: usize, row: &[f64]| {
|
||||
// Bit test, not `!= 0.0`: a stored `-0.0` is not the `+0.0` the skipped entries rely on.
|
||||
let row = row.get(..n).unwrap_or(row);
|
||||
let Some(first) = row.iter().position(|v| v.to_bits() != 0) else { return (0, 0, false) };
|
||||
let last = row.iter().rposition(|v| v.to_bits() != 0).unwrap_or(first);
|
||||
let wild = row.get(first..=last).is_some_and(|band| band.iter().any(|v| v.is_nan() || v.abs() > 1e100));
|
||||
(r.saturating_sub(first), last.saturating_sub(r), wild)
|
||||
};
|
||||
let join = |x: (usize, usize, bool), m: (usize, usize, bool)| (x.0.max(m.0), x.1.max(m.1), x.2 | m.2);
|
||||
// A last row that reaches column 0 (the cyclic matrix of a closed fit) makes every range of the
|
||||
// sweep full-width whatever the other rows hold, so they need no measuring.
|
||||
let mut m = (0usize, 0usize, false);
|
||||
for r in [0, n.saturating_sub(1)] {
|
||||
if let Some(row) = a.get(r) {
|
||||
m = join(m, measure(r, row));
|
||||
}
|
||||
}
|
||||
// (kl = n - 1 widens every range to the full matrix, so the unmeasured rows cannot matter.)
|
||||
if m.0 + 1 < n {
|
||||
m = a.iter().enumerate().take(n).fold(m, |x, (r, row)| join(x, measure(r, row)));
|
||||
}
|
||||
let (mut kl, mut ku, wild) = m;
|
||||
if wild || b.iter().any(|v| v.is_nan() || v.abs() > 1e100) {
|
||||
(kl, ku) = (n, n);
|
||||
}
|
||||
let kw = ku + kl;
|
||||
for c in 0..n {
|
||||
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
let end = (c + kl).min(n - 1) + 1;
|
||||
let piv = (c..end).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[piv][c].abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, piv);
|
||||
b.swap(c, piv);
|
||||
for r in c + 1..n {
|
||||
let kend = (c + kw).min(n - 1) + 1;
|
||||
for r in c + 1..end {
|
||||
let f = a[r][c] / a[c][c];
|
||||
if f != 0.0 {
|
||||
for k in c..n {
|
||||
for k in c..kend {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
@@ -370,7 +413,12 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for r in (0..n).rev() {
|
||||
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
let end = (r + kw).min(n - 1) + 1;
|
||||
let mut s: f64 = (r + 1..end).map(|k| a[r][k] * x[k]).sum();
|
||||
if s == 0.0 && end < n {
|
||||
// The skipped terms are signed zeros; added to a zero they can flip its sign.
|
||||
s = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
}
|
||||
x[r] = (b[r] - s) / a[r][r];
|
||||
}
|
||||
Some(x)
|
||||
|
||||
@@ -380,3 +380,196 @@ fn a_normal_low_degree_spline_is_valid() {
|
||||
assert!(s.is_valid());
|
||||
assert!(s.tessellate(1e-3).len() > 4);
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While set, `solve` runs `solve_dense` (this thread only, so parallel tests do not mix).
|
||||
pub(super) static DENSE_ORACLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// `solve` before it was limited to the matrix band, kept as the reference.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub(super) fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
for c in 0..n {
|
||||
let piv = (c..n).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?;
|
||||
if a[piv][c].abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, piv);
|
||||
b.swap(c, piv);
|
||||
for r in c + 1..n {
|
||||
let f = a[r][c] / a[c][c];
|
||||
if f != 0.0 {
|
||||
for k in c..n {
|
||||
a[r][k] -= f * a[c][k];
|
||||
}
|
||||
b[r] -= f * b[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for r in (0..n).rev() {
|
||||
let s: f64 = (r + 1..n).map(|k| a[r][k] * x[k]).sum();
|
||||
x[r] = (b[r] - s) / a[r][r];
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
fn bits(v: &[f64]) -> Vec<u64> {
|
||||
v.iter().map(|x| x.to_bits()).collect()
|
||||
}
|
||||
|
||||
fn bits2(v: &[Vec2]) -> Vec<u64> {
|
||||
v.iter().flat_map(|p| [p.x.to_bits(), p.y.to_bits()]).collect()
|
||||
}
|
||||
|
||||
/// Both solvers on one system: `None` together, or the same bits.
|
||||
fn assert_same_solution(a: &[Vec<f64>], b: &[f64], what: &str) {
|
||||
let old = solve_dense(a.to_vec(), b.to_vec());
|
||||
let new = solve(a.to_vec(), b.to_vec());
|
||||
assert_eq!(old.as_deref().map(bits), new.as_deref().map(bits), "{what}");
|
||||
}
|
||||
|
||||
/// A random matrix with `kl` sub- and `ku` super-diagonals (some entries exactly zero).
|
||||
fn banded(n: usize, kl: usize, ku: usize, seed: &mut u64) -> Vec<Vec<f64>> {
|
||||
let mut a = vec![vec![0.0; n]; n];
|
||||
for (r, row) in a.iter_mut().enumerate() {
|
||||
for (c, v) in row.iter_mut().enumerate() {
|
||||
if c + kl >= r && c <= r + ku && noise(seed) > 0.15 {
|
||||
*v = noise(seed) * 4.0 - 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banded_solve_matches_the_dense_sweep_bit_for_bit() {
|
||||
let mut seed = 0x5eed_u64;
|
||||
for n in [1, 2, 3, 4, 5, 8, 17, 40, 120] {
|
||||
for (kl, ku) in [(0, 0), (1, 0), (0, 2), (1, 1), (3, 3), (2, 5), (n, n)] {
|
||||
for _ in 0..6 {
|
||||
let a = banded(n, kl, ku, &mut seed);
|
||||
// Right-hand sides with zeros of both signs make the zero-sign cases reachable.
|
||||
let b: Vec<f64> = (0..n)
|
||||
.map(|i| [noise(&mut seed) * 9.0 - 4.0, 0.0, -0.0][(noise(&mut seed) * 3.0) as usize % 3] * (i % 4 != 0) as u8 as f64)
|
||||
.collect();
|
||||
assert_same_solution(&a, &b, &format!("n={n} kl={kl} ku={ku}"));
|
||||
assert_same_solution(&a, &vec![0.0; n], &format!("zero rhs n={n} kl={kl} ku={ku}"));
|
||||
assert_same_solution(&a, &vec![-0.0; n], &format!("negative-zero rhs n={n} kl={kl} ku={ku}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_handles_negative_zeros_signed_zero_sums_and_hostile_values() {
|
||||
let mut seed = 77_u64;
|
||||
for n in [3, 6, 30] {
|
||||
let mut a = banded(n, 1, 2, &mut seed);
|
||||
// A stored `-0.0` outside the band is still part of the matrix.
|
||||
a[0][n - 1] = -0.0;
|
||||
a[n - 1][0] = -0.0;
|
||||
let b: Vec<f64> = (0..n).map(|i| if i % 2 == 0 { -0.0 } else { 1.0 }).collect();
|
||||
assert_same_solution(&a, &b, &format!("negative zeros n={n}"));
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e200, 1e101] {
|
||||
let mut ab = a.clone();
|
||||
ab[n / 2][n / 2] = bad;
|
||||
assert_same_solution(&ab, &b, &format!("{bad:?} in the matrix, n={n}"));
|
||||
let mut bb = b.clone();
|
||||
bb[n - 1] = bad;
|
||||
assert_same_solution(&a, &bb, &format!("{bad:?} in the rhs, n={n}"));
|
||||
}
|
||||
}
|
||||
// Bad values in a narrow band with no corner entry, so the guard (not a full-width matrix) decides.
|
||||
for bad in [f64::NAN, f64::INFINITY, 1e200, 1e101] {
|
||||
let a = banded(12, 1, 2, &mut seed);
|
||||
let mut ab = a.clone();
|
||||
ab[5][5] = bad;
|
||||
ab[5][6] = 1.0;
|
||||
assert_same_solution(&ab, &[1.0; 12], &format!("{bad:?} in a narrow band"));
|
||||
let mut b = vec![1.0; 12];
|
||||
b[3] = bad;
|
||||
assert_same_solution(&a, &b, &format!("{bad:?} in the rhs of a narrow band"));
|
||||
}
|
||||
// Singular and empty systems.
|
||||
assert_same_solution(&[vec![0.0, 0.0], vec![0.0, 1.0]], &[1.0, 1.0], "singular");
|
||||
assert_same_solution(&[], &[], "empty");
|
||||
assert_eq!(solve(Vec::new(), Vec::new()), Some(Vec::new()));
|
||||
}
|
||||
|
||||
/// Fit points: a wavy line with noise; every `zero_every`-th coordinate is exactly zero.
|
||||
fn fit_points(n: usize, seed: &mut u64, zero_every: usize) -> Vec<Vec2> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let x = i as f64 * 3.0 + noise(seed);
|
||||
let y = (i as f64 * 0.3).sin() * 50.0 + noise(seed) * 5.0;
|
||||
if zero_every > 0 && i % zero_every == 0 { Vec2::new(x, 0.0) } else { Vec2::new(x, y) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spline_bits(s: &Spline) -> (Vec<u64>, Vec<u64>, Vec<u64>, Vec<u64>, usize) {
|
||||
(bits(&s.knots), bits2(&s.control), bits(&s.weights), bits2(&s.fit), s.degree)
|
||||
}
|
||||
|
||||
/// Fit with the banded `solve`, then with the dense reference, and compare every bit.
|
||||
/// Clears `DENSE_ORACLE` when dropped, so a failing assertion cannot leave it set.
|
||||
struct OracleOff;
|
||||
impl Drop for OracleOff {
|
||||
fn drop(&mut self) {
|
||||
DENSE_ORACLE.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fit_unchanged(fit: &[Vec2], closed: bool, opts: FitOptions, what: &str) {
|
||||
let _off = OracleOff;
|
||||
let new = Spline::fit_with(fit, closed, opts);
|
||||
DENSE_ORACLE.set(true);
|
||||
let old = Spline::fit_with(fit, closed, opts);
|
||||
DENSE_ORACLE.set(false);
|
||||
assert_eq!(spline_bits(&old), spline_bits(&new), "{what}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fits_are_bit_identical_to_the_dense_solver() {
|
||||
let mut seed = 2432_u64;
|
||||
let tolerant = FitOptions { tolerance: 0.5, ..FitOptions::default() };
|
||||
let tangents = FitOptions { start_tangent: Some(Vec2::new(0.0, 1.0)), end_tangent: Some(Vec2::new(1.0, -1.0)), ..FitOptions::default() };
|
||||
let chord = FitOptions { knots: crate::KnotParam::Chord, ..FitOptions::default() };
|
||||
for n in [3, 4, 5, 7, 12, 30, 100] {
|
||||
for round in 0..4 {
|
||||
let zero_every = [0, 2, 3, 1][round];
|
||||
let pts = fit_points(n, &mut seed, zero_every);
|
||||
for closed in [false, true] {
|
||||
for (name, opts) in [("default", FitOptions::default()), ("tolerance", tolerant), ("tangents", tangents), ("chord", chord)] {
|
||||
assert_fit_unchanged(&pts, closed, opts, &format!("n={n} closed={closed} {name} zero_every={zero_every}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Large point sets: open interpolation (banded) and a closed one (full bandwidth), and a
|
||||
// tolerance fit at the size the approximation search still accepts.
|
||||
let big = fit_points(1000, &mut seed, 0);
|
||||
assert_fit_unchanged(&big, false, FitOptions::default(), "n=1000 open");
|
||||
assert_fit_unchanged(&big[..150], true, FitOptions::default(), "n=150 closed");
|
||||
assert_fit_unchanged(&big[..300], false, tolerant, "n=300 tolerance");
|
||||
// Repeated points are kinks, which fit each piece on its own.
|
||||
let mut kinked = fit_points(40, &mut seed, 0);
|
||||
kinked.insert(10, kinked[10]);
|
||||
kinked.insert(25, kinked[25]);
|
||||
assert_fit_unchanged(&kinked, false, FitOptions::default(), "kinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cyclic_matrix_of_a_closed_fit_keeps_the_full_sweep() {
|
||||
// Corner entries give the closed matrix a full bandwidth; it must still agree with the dense sweep.
|
||||
let mut seed = 9_u64;
|
||||
for n in [4, 9, 60] {
|
||||
let mut a = banded(n, 1, 1, &mut seed);
|
||||
a[0][n - 1] = 0.7;
|
||||
a[n - 1][0] = -0.3;
|
||||
let b: Vec<f64> = (0..n).map(|_| noise(&mut seed)).collect();
|
||||
assert_same_solution(&a, &b, &format!("cyclic n={n}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,18 @@ fn segment_offset() {
|
||||
assert!(s.offset(6.0).is_none());
|
||||
}
|
||||
|
||||
/// A polyline of many fine arcs samples at most the point budget, then draws chords.
|
||||
#[test]
|
||||
fn polyline_tessellation_is_bounded() {
|
||||
let vertices: Vec<PolyVertex> = (0..3000).map(|i| PolyVertex { bulge: 1.0, ..PolyVertex::new(Vec2::new(f64::from(i) * 1e6, 0.0)) }).collect();
|
||||
let pl = Polyline { vertices, closed: false };
|
||||
let pts = pl.tessellate(1e-9);
|
||||
assert!(pts.len() <= MAX_TESSELLATED_POINTS + 2 * 3000 + 4096, "{}", pts.len());
|
||||
assert!(pts.len() > 3000);
|
||||
let mut budget = 10;
|
||||
assert!(pl.tessellate_within(1e-9, &mut budget).len() <= 4096 + 2 * 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_segments_bounded() {
|
||||
assert_eq!(arc_segments(0.0, 1.0, 0.1), 1);
|
||||
|
||||
@@ -25,8 +25,11 @@ quick-xml = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Reads CADCraft's DWG output as written (tests only; the same version as cadcraft-dwg's).
|
||||
acadrust = "0.6.6"
|
||||
tiny-skia = { workspace = true }
|
||||
# Reads plotted PDFs back.
|
||||
craft-pdf = { workspace = true, features = ["read"] }
|
||||
|
||||
# Inspect DWG output only where cadcraft-dwg builds its native bridge. An unconditional
|
||||
# dev-dependency also builds acadrust during --all-targets, breaking FreeBSD aarch64 (#2526).
|
||||
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dev-dependencies]
|
||||
acadrust = "0.6.6"
|
||||
|
||||
@@ -844,15 +844,33 @@ pub(crate) fn apply_anno(d: &mut cadcraft_doc::Drawing, p: AnnoPayload) {
|
||||
std::sync::Arc::make_mut(b).annotative = true;
|
||||
}
|
||||
}
|
||||
// Which layout holds each paper space object, found once: `modify_entity` searches every
|
||||
// layout for a handle model space lacks, which a long list of missing handles made quadratic.
|
||||
let mut in_layout: std::collections::HashMap<Handle, usize> = Default::default();
|
||||
if !p.objects.is_empty() {
|
||||
// First layout wins, as `modify_entity` did.
|
||||
for (i, l) in d.layouts.iter().enumerate() {
|
||||
for h in l.entities.handles() {
|
||||
in_layout.entry(h).or_insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in p.objects {
|
||||
let Some(h) = Handle::parse_hex(&o.handle) else { continue };
|
||||
let store = if d.model.contains(h) {
|
||||
&mut d.model
|
||||
} else if let Some(l) = in_layout.get(&h).and_then(|&i| d.layouts.get_mut(i)) {
|
||||
&mut l.entities
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut scales: Vec<cadcraft_doc::AnnoScale> = Vec::new();
|
||||
for s in o.scales.into_iter().filter(|s| s.is_valid() && !s.name.trim().is_empty()) {
|
||||
if scales.len() < cadcraft_doc::MAX_SCALES && !scales.iter().any(|x| x.name.eq_ignore_ascii_case(&s.name)) {
|
||||
scales.push(s);
|
||||
}
|
||||
}
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
store.modify(h, |e| {
|
||||
if cadcraft_doc::can_be_annotative(&e.kind) {
|
||||
e.common.anno_scales = scales;
|
||||
}
|
||||
|
||||
+232
-74
@@ -339,7 +339,8 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
let root = objects.first().filter(|(k, _, _)| is_dictionary(k)).and_then(|(_, _, t)| head(t).handle);
|
||||
let root_entries = objects.first().filter(|(k, _, _)| is_dictionary(k)).map(|(_, _, t)| entries(t)).unwrap_or_default();
|
||||
let root_key = |h: &str| root_entries.iter().find(|(_, eh)| eh == h).map(|(k, _)| k.clone());
|
||||
let root_keys = root_key_index(&root_entries);
|
||||
let root_key = |h: &str| root_keys.get(h).map(|k| (*k).to_string());
|
||||
// An extension dictionary of an entity or record (not of another object).
|
||||
let is_xdict = |k: usize| {
|
||||
let Some((_, _, t)) = objects.get(k) else { return false };
|
||||
@@ -417,10 +418,11 @@ pub(crate) fn read(secs: &[Section], d: &mut Drawing) {
|
||||
}
|
||||
}
|
||||
if proxies {
|
||||
f.complete_class_table = classes.iter().filter(|(k, _, _)| k == "CLASS").count() <= MAX_OBJECTS;
|
||||
f.class_order = class_order;
|
||||
}
|
||||
for (kind, _, t) in &classes {
|
||||
if kind == "CLASS" && val(t, 1).is_some_and(|n| types.contains(&up(&n))) && f.classes.len() < MAX_OBJECTS {
|
||||
if kind == "CLASS" && (f.complete_class_table || val(t, 1).is_some_and(|n| types.contains(&up(&n)))) && f.classes.len() < MAX_OBJECTS {
|
||||
f.classes.push(t.iter().map(raw).collect());
|
||||
}
|
||||
}
|
||||
@@ -493,6 +495,16 @@ fn extension_dictionaries(
|
||||
dictionaries
|
||||
}
|
||||
|
||||
/// The root dictionary's key for each entry handle (the first key when several name one handle),
|
||||
/// built once: a lookup per object scanning the entries made a large dictionary quadratic.
|
||||
fn root_key_index(root_entries: &[(String, String)]) -> HashMap<&str, &str> {
|
||||
let mut index = HashMap::with_capacity(root_entries.len());
|
||||
for (key, handle) in root_entries {
|
||||
index.entry(handle.as_str()).or_insert(key.as_str());
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Determine which objects belong to unmodelled dictionary entries or owners.
|
||||
fn retained_objects(
|
||||
objects: &[(String, usize, &[Tag])],
|
||||
@@ -503,7 +515,8 @@ fn retained_objects(
|
||||
root_entries: &[(String, String)],
|
||||
xdict_of: &HashMap<String, String>,
|
||||
) -> HashMap<usize, bool> {
|
||||
let root_key = |handle: &str| root_entries.iter().find(|(_, entry_handle)| entry_handle == handle).map(|(key, _)| key);
|
||||
let root_keys = root_key_index(root_entries);
|
||||
let root_key = |handle: &str| root_keys.get(handle).copied();
|
||||
let is_xdict = |index: usize| {
|
||||
let Some((_, _, tags)) = objects.get(index) else { return false };
|
||||
let header = head(tags);
|
||||
@@ -517,44 +530,108 @@ fn retained_objects(
|
||||
// hundreds of thousands of groups) would be read once per child (#862).
|
||||
let mut xdict_memo: HashMap<usize, bool> = HashMap::new();
|
||||
let mut is_xdict = |index: usize| *xdict_memo.entry(index).or_insert_with(|| is_xdict(index));
|
||||
let mut retained = HashMap::new();
|
||||
for index in 0..objects.len() {
|
||||
let mut path = Vec::new();
|
||||
let mut current = index;
|
||||
let keep = loop {
|
||||
if let Some(&known) = retained.get(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(current);
|
||||
let Some((_, _, tags)) = objects.get(current) else { break false };
|
||||
let header = head(tags);
|
||||
let (Some(handle), Some(owner)) = (header.handle, header.owner) else { break false };
|
||||
if by_handle.get(&handle) != Some(¤t) {
|
||||
break false;
|
||||
}
|
||||
if root == Some(owner.as_str()) {
|
||||
break root_key(&handle).is_some_and(|key| !modelled_key(key));
|
||||
}
|
||||
if let Some(&parent) = by_handle.get(&owner) {
|
||||
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
|
||||
let hard = hard_owners(objects.iter().filter_map(|(_, _, t)| Some((head(t).handle?, held(t)))));
|
||||
// One pass with `kept` the objects an earlier pass kept: a null-owner object goes with the
|
||||
// first of its hard owners that is kept.
|
||||
let mut pass = |kept: &HashMap<usize, bool>| {
|
||||
// Previously live objects already have a proved path to a retained owner. Seed that
|
||||
// proof before traversal, so mutual hard owners cannot hide it from their children.
|
||||
let mut retained: HashMap<usize, bool> = kept.iter().filter(|(_, live)| **live).map(|(&i, &live)| (i, live)).collect();
|
||||
let kept = |h: &str| by_handle.get(h).and_then(|i| kept.get(i)) == Some(&true);
|
||||
for index in 0..objects.len() {
|
||||
let mut path = Vec::new();
|
||||
let mut current = index;
|
||||
let keep = loop {
|
||||
if let Some(&known) = retained.get(¤t) {
|
||||
break known;
|
||||
}
|
||||
if path.contains(¤t) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
current = parent;
|
||||
continue;
|
||||
path.push(current);
|
||||
let Some((_, _, tags)) = objects.get(current) else { break false };
|
||||
let header = head(tags);
|
||||
let Some(handle) = header.handle else { break false };
|
||||
let Some(owner) = owner_of(header.owner, &handle, &hard, kept) else { break false };
|
||||
if by_handle.get(&handle) != Some(¤t) {
|
||||
break false;
|
||||
}
|
||||
if root == Some(owner.as_str()) {
|
||||
break root_key(&handle).is_some_and(|key| !modelled_key(key));
|
||||
}
|
||||
if let Some(&parent) = by_handle.get(&owner) {
|
||||
if is_xdict(parent) && objects.get(parent).is_some_and(|object| modelled_entry(object.2, &handle)) {
|
||||
break false;
|
||||
}
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
if is_xdict(current) {
|
||||
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
|
||||
}
|
||||
break entities.get(&owner) == Some(&true);
|
||||
};
|
||||
for visited in path {
|
||||
retained.insert(visited, keep);
|
||||
}
|
||||
if is_xdict(current) {
|
||||
break entries(tags).iter().any(|(key, _)| !MODELLED_XDICT_KEYS.contains(&up(key).as_str()));
|
||||
}
|
||||
retained
|
||||
};
|
||||
settle(&hard, |kept| pass(kept))
|
||||
}
|
||||
|
||||
/// Run `pass` until what it keeps settles. The first pass sees nothing kept, so a null-owner
|
||||
/// object goes with its first hard owner; while one of its other hard owners is kept and that
|
||||
/// one is not, the next pass moves it there. A file where no object has two hard owners takes
|
||||
/// one pass; otherwise it stops when a pass changes nothing, after at most [`MAX_DEPTH`] more.
|
||||
fn settle<K: Eq + std::hash::Hash>(
|
||||
hard: &HashMap<String, Vec<String>>,
|
||||
mut pass: impl FnMut(&HashMap<K, bool>) -> HashMap<K, bool>,
|
||||
) -> HashMap<K, bool> {
|
||||
let mut kept = pass(&HashMap::new());
|
||||
if hard.values().all(|owners| owners.len() < 2) {
|
||||
return kept;
|
||||
}
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let next = pass(&kept);
|
||||
if next == kept {
|
||||
break;
|
||||
}
|
||||
kept = next;
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// The handles an object names by a hard owner reference (group 360).
|
||||
fn held(tags: &[Tag]) -> impl Iterator<Item = String> + '_ {
|
||||
tags.iter().take_while(|g| g.code < 1000).filter(|g| g.code == 360).map(|g| g.str())
|
||||
}
|
||||
|
||||
/// The objects that own each object by a hard owner reference (group 360), in file order:
|
||||
/// `child handle → owner handles`.
|
||||
fn hard_owners<S: AsRef<str>>(objects: impl Iterator<Item = (String, impl Iterator<Item = S>)>) -> HashMap<String, Vec<String>> {
|
||||
// Membership is hashed, while the vector keeps file order. A long list of distinct
|
||||
// holders for one child must not scan all earlier holders once per reference.
|
||||
let mut out: HashMap<String, (Vec<String>, HashSet<String>)> = HashMap::new();
|
||||
for (h, children) in objects {
|
||||
for c in children {
|
||||
let (owners, seen) = out.entry(norm(c.as_ref())).or_default();
|
||||
if seen.insert(h.clone()) {
|
||||
owners.push(h.clone());
|
||||
}
|
||||
break entities.get(&owner) == Some(&true);
|
||||
};
|
||||
for visited in path {
|
||||
retained.insert(visited, keep);
|
||||
}
|
||||
}
|
||||
retained
|
||||
out.into_iter().map(|(child, (owners, _))| (child, owners)).collect()
|
||||
}
|
||||
|
||||
/// An object's owner: its group 330, or for one whose owner is null (another application's
|
||||
/// proxy that another proxy holds) the object that hard-owns it ([`hard_owners`]): the first
|
||||
/// one that is `kept`, or else the first one.
|
||||
fn owner_of(owner: Option<String>, handle: &str, hard: &HashMap<String, Vec<String>>, kept: impl Fn(&str) -> bool) -> Option<String> {
|
||||
owner.filter(|o| o != "0").or_else(|| {
|
||||
let owners = hard.get(handle)?;
|
||||
owners.iter().find(|o| kept(o)).or_else(|| owners.first()).cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Proxy records (DXF Reference, ACAD_PROXY_OBJECT and ACAD_PROXY_ENTITY): another application's
|
||||
@@ -641,6 +718,8 @@ pub(crate) fn unknown_entity(u: &Unknown, e: &Entity, owner: &str, paper: bool)
|
||||
}
|
||||
_ if in_group => continue,
|
||||
5 | 67 => continue,
|
||||
// After a subclass marker, 330 can be proxy data even without a source owner.
|
||||
100 => owner_seen = true,
|
||||
330 if !owner_seen => {
|
||||
owner_seen = true;
|
||||
continue;
|
||||
@@ -858,7 +937,7 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
for (i, n) in recs.iter().filter(|r| r.kind == "CLASS").filter_map(|r| val(body(r), 1)).map(|n| up(&n)).chain(appended).enumerate() {
|
||||
numbers.entry(n).or_insert(500_i64.saturating_add(i64::try_from(i).unwrap_or(i64::MAX)));
|
||||
}
|
||||
let renumber = |old: Option<i64>| class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied());
|
||||
let renumber = |old| proxy_class_number(f, &numbers, old);
|
||||
// Entities of types CADCraft doesn't model, placed among the written ones.
|
||||
let mut planned = plan_unknown(d, &recs, tags, &roles, modern);
|
||||
for list in planned.values_mut() {
|
||||
@@ -901,43 +980,56 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
let objs: HashMap<String, &ForeignObject> = f.objects.iter().map(|o| (o.handle.hex(), o)).collect();
|
||||
let dict_entries: HashMap<String, &str> = f.dictionary.iter().map(|(k, h)| (h.hex(), k.as_str())).collect();
|
||||
let nod = f.targets.iter().filter(|(_, k)| k.0 == "NOD" && k.1.is_empty()).map(|(h, _)| h.hex()).collect::<HashSet<_>>();
|
||||
let owners: HashMap<String, String> = f
|
||||
let hard = hard_owners(
|
||||
f.objects.iter().map(|o| (o.handle.hex(), o.tags.iter().take_while(|t| t.code < 1000).filter(|t| t.code == 360).map(|t| t.value.trim()))),
|
||||
);
|
||||
// Each kept object's own owner (group 330), read one record at a time.
|
||||
let own: HashMap<String, Option<String>> = f
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| Some((o.handle.hex(), head(&o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect::<Vec<_>>()).owner?)))
|
||||
.map(|o| {
|
||||
let t: Vec<Tag> = o.tags.iter().take_while(|t| t.code < 1000).map(tag).collect();
|
||||
(o.handle.hex(), head(&t).owner)
|
||||
})
|
||||
.collect();
|
||||
let mut alive_memo: HashMap<String, bool> = HashMap::new();
|
||||
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none()) {
|
||||
alive_memo.insert(o.handle.hex(), false);
|
||||
}
|
||||
for o in &f.objects {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = o.handle.hex();
|
||||
let verdict = loop {
|
||||
if let Some(&v) = alive_memo.get(&cur) {
|
||||
break v;
|
||||
}
|
||||
if path.contains(&cur) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(cur.clone());
|
||||
let Some(owner) = owners.get(&cur).cloned() else { break false };
|
||||
if objs.contains_key(&owner) {
|
||||
cur = owner;
|
||||
continue;
|
||||
}
|
||||
if nod.contains(&owner) {
|
||||
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
|
||||
}
|
||||
if attached.contains(&cur) {
|
||||
break true;
|
||||
}
|
||||
break unknown.contains(&owner) && written.contains(&owner);
|
||||
};
|
||||
for p in path {
|
||||
alive_memo.insert(p, verdict);
|
||||
let alive_memo = settle(&hard, |kept: &HashMap<String, bool>| {
|
||||
// Keep established live paths available to descendants during this pass as well.
|
||||
let mut alive_memo: HashMap<String, bool> = kept.iter().filter(|(_, live)| **live).map(|(h, &live)| (h.clone(), live)).collect();
|
||||
let kept = |h: &str| kept.get(h) == Some(&true);
|
||||
for o in f.objects.iter().filter(|o| is_proxy(&o.kind) && renumber(class_number(o.tags.iter().map(|r| (r.code, r.value.as_str())))).is_none())
|
||||
{
|
||||
alive_memo.insert(o.handle.hex(), false);
|
||||
}
|
||||
}
|
||||
for o in &f.objects {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = o.handle.hex();
|
||||
let verdict = loop {
|
||||
if let Some(&v) = alive_memo.get(&cur) {
|
||||
break v;
|
||||
}
|
||||
if path.contains(&cur) || path.len() >= MAX_DEPTH {
|
||||
break false;
|
||||
}
|
||||
path.push(cur.clone());
|
||||
let Some(owner) = owner_of(own.get(&cur).cloned().flatten(), &cur, &hard, kept) else { break false };
|
||||
if objs.contains_key(&owner) {
|
||||
cur = owner;
|
||||
continue;
|
||||
}
|
||||
if nod.contains(&owner) {
|
||||
break dict_entries.get(&cur).is_some_and(|k| !root_keys.contains(&up(k))) && root.is_some();
|
||||
}
|
||||
if attached.contains(&cur) {
|
||||
break true;
|
||||
}
|
||||
break unknown.contains(&owner) && written.contains(&owner);
|
||||
};
|
||||
for p in path {
|
||||
alive_memo.insert(p, verdict);
|
||||
}
|
||||
}
|
||||
alive_memo
|
||||
});
|
||||
// New handles for the written kept objects, above every written handle and the seed.
|
||||
let mut next = seed(tags).max(max_handle(tags).saturating_add(1));
|
||||
let mut alive: HashMap<String, String> = HashMap::new();
|
||||
@@ -954,11 +1046,8 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
if r.kind == "BLOCK" {
|
||||
block = up(&val(b, 2).unwrap_or_default());
|
||||
}
|
||||
if r.kind == "ENDSEC" && r.sec == "CLASSES" {
|
||||
for c in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(c.iter().map(tag));
|
||||
}
|
||||
if source_class_record(f, r, &recs, tags, &class_names, &mut out) {
|
||||
continue;
|
||||
}
|
||||
if r.kind == "ENDSEC" && r.sec == "OBJECTS" {
|
||||
for o in f.objects.iter().filter(|o| map.alive.contains_key(&o.handle.hex())) {
|
||||
@@ -1001,6 +1090,50 @@ pub(crate) fn finish(tags: &mut Vec<Tag>, d: &Drawing, reactors: &HashMap<Handle
|
||||
*tags = out;
|
||||
}
|
||||
|
||||
/// Complete source tables keep positional identities even when names repeat.
|
||||
fn proxy_class_number(f: &Foreign, numbers: &HashMap<String, i64>, old: Option<i64>) -> Option<i64> {
|
||||
if f.complete_class_table {
|
||||
class_index(old).filter(|&i| i < f.classes.len()).and(old)
|
||||
} else {
|
||||
class_index(old).and_then(|i| f.class_order.get(i)).and_then(|n| numbers.get(n).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// Source classes precede decoder-owned classes so proxy positions stay stable.
|
||||
fn source_class_record(f: &Foreign, record: &Rec, recs: &[Rec], tags: &[Tag], class_names: &HashSet<String>, out: &mut Vec<Tag>) -> bool {
|
||||
if record.sec != "CLASSES" {
|
||||
return false;
|
||||
}
|
||||
if !f.complete_class_table {
|
||||
if record.kind == "ENDSEC" {
|
||||
for class in f.classes.iter().filter(|c| c.iter().find(|t| t.code == 1).is_some_and(|n| !class_names.contains(&up(&n.value)))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(class.iter().map(tag));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if record.kind == "CLASS" {
|
||||
return true;
|
||||
}
|
||||
if record.kind != "ENDSEC" {
|
||||
return false;
|
||||
}
|
||||
for class in &f.classes {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend(class.iter().map(tag));
|
||||
}
|
||||
let source_names: HashSet<_> = f.classes.iter().filter_map(|c| c.iter().find(|t| t.code == 1)).map(|t| up(&t.value)).collect();
|
||||
for class in recs.iter().filter(|r| r.sec == "CLASSES" && r.kind == "CLASS") {
|
||||
let body = tags.get(class.start + 1..class.end).unwrap_or_default();
|
||||
if val(body, 1).is_some_and(|n| !source_names.contains(&up(&n))) {
|
||||
out.push(Tag::s(0, "CLASS"));
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Where the entities of types CADCraft doesn't model go: each before the next written entity of
|
||||
/// its space or block (else at the end of it), as `(section, record)` lists by the index of the
|
||||
/// record they precede. Owners come from the written block records.
|
||||
@@ -1260,3 +1393,28 @@ fn seed(tags: &[Tag]) -> u64 {
|
||||
fn max_handle(tags: &[Tag]) -> u64 {
|
||||
tags.iter().filter(|t| t.code == 5 || t.code == 105).filter_map(|t| Handle::parse_hex(&t.str())).map(|h| h.0).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hard_owner_tests {
|
||||
use super::hard_owners;
|
||||
|
||||
#[test]
|
||||
fn holders_are_unique_in_first_seen_order() {
|
||||
let objects =
|
||||
[("10".to_string(), ["00a", "A"].into_iter()), ("20".to_string(), ["A", "00A"].into_iter()), ("10".to_string(), ["A", "A"].into_iter())];
|
||||
let owners = hard_owners(objects.into_iter());
|
||||
assert_eq!(owners.get("A").unwrap(), &["10", "20"]);
|
||||
assert_eq!(owners.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_holders_keep_file_order() {
|
||||
let count = 10_000;
|
||||
let owners = hard_owners((0..count).map(|i| (format!("{i:X}"), ["FFFF", "FFFF"].into_iter())));
|
||||
let holders = owners.get("FFFF").unwrap();
|
||||
assert_eq!(holders.len(), count);
|
||||
for (i, holder) in holders.iter().enumerate() {
|
||||
assert_eq!(holder, &format!("{i:X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,17 @@ pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
|
||||
cadcraft_dxf::write_ascii(&tags)
|
||||
}
|
||||
|
||||
/// Subclass markers the DWG bridge knows by another spelling (it matches them exactly):
|
||||
/// AutoCAD writes a dynamic block lookup parameter's as `AcDbBlockLookUpParameter`, the bridge
|
||||
/// reads `AcDbBlockLookupParameter`; otherwise the parameter loses its name and its lookup
|
||||
/// action in the DWG, and AutoCAD's lookups stop working.
|
||||
/// Proxy type names and lookup-parameter subclass names must match the bridge reader's spelling.
|
||||
/// Ordinary DXF output retains source spellings.
|
||||
fn bridge_markers(tags: &mut [Tag]) {
|
||||
for tag in tags.iter_mut().filter(|tag| tag.code == 0) {
|
||||
for name in ["ACAD_PROXY_ENTITY", "ACAD_PROXY_OBJECT"] {
|
||||
if tag.str().trim().eq_ignore_ascii_case(name) {
|
||||
*tag = Tag::s(0, name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in tags.iter_mut().filter(|t| t.code == 100) {
|
||||
if t.str().trim().eq_ignore_ascii_case("AcDbBlockLookupParameter") {
|
||||
*t = Tag::s(100, "AcDbBlockLookupParameter");
|
||||
|
||||
@@ -1204,33 +1204,56 @@ fn add_class_instance_counts(tags: &mut Vec<Tag>, target: Version) {
|
||||
return;
|
||||
}
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
let mut classes = 0;
|
||||
for t in tags.iter().filter(|t| t.code == 0) {
|
||||
let kind = t.str();
|
||||
if kind != "CLASS" {
|
||||
*counts.entry(kind.to_ascii_uppercase()).or_default() += 1;
|
||||
} else {
|
||||
classes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = 0;
|
||||
while start < tags.len() {
|
||||
if tags.get(start).is_none_or(|t| t.code != 0 || t.str() != "CLASS") {
|
||||
start += 1;
|
||||
// One pass into a new list: inserting each missing count in place shifted every later tag,
|
||||
// quadratic in the number of CLASS records.
|
||||
// Tags move by value (no clone); the old and new buffers coexist while it runs. At most one
|
||||
// group 91 is added per CLASS record, so that is all the room the new list needs beyond the old one.
|
||||
let old = std::mem::take(tags);
|
||||
tags.reserve(old.len() + classes);
|
||||
let mut it = old.into_iter();
|
||||
let mut pending = it.next();
|
||||
while let Some(t) = pending.take() {
|
||||
if t.code != 0 || t.str() != "CLASS" {
|
||||
tags.push(t);
|
||||
pending = it.next();
|
||||
continue;
|
||||
}
|
||||
let end = tags[start + 1..].iter().position(|t| t.code == 0).map_or(tags.len(), |i| start + 1 + i);
|
||||
let name = tags[start + 1..end].iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let mut record = vec![t];
|
||||
for n in it.by_ref() {
|
||||
if n.code == 0 {
|
||||
pending = Some(n);
|
||||
break;
|
||||
}
|
||||
record.push(n);
|
||||
}
|
||||
let name = record.iter().skip(1).find(|t| t.code == 1).map(Tag::str).unwrap_or_default().to_ascii_uppercase();
|
||||
let count = counts.get(&name).copied().unwrap_or(0);
|
||||
let inserted = if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 91) {
|
||||
tags[start + 1 + i] = Tag::i(91, count);
|
||||
false
|
||||
} else if let Some(i) = tags[start + 1..end].iter().position(|t| t.code == 90) {
|
||||
tags.insert(start + 2 + i, Tag::i(91, count));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Inserting the group shifts the next record's start by one.
|
||||
start = end + usize::from(inserted);
|
||||
// The first 91 gets the count; without one, a 91 goes in after the first 90.
|
||||
let mut added = record.iter().skip(1).any(|t| t.code == 91);
|
||||
let mut replaced = false;
|
||||
for (i, t) in record.into_iter().enumerate() {
|
||||
let code = t.code;
|
||||
if i > 0 && code == 91 && !replaced {
|
||||
tags.push(Tag::i(91, count));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
tags.push(t);
|
||||
if i > 0 && code == 90 && !added {
|
||||
tags.push(Tag::i(91, count));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,3 +1281,31 @@ fn mark_xrefs(tags: &mut [Tag], d: &Drawing) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod class_count_tests {
|
||||
use super::*;
|
||||
|
||||
fn record(kind: &str, name: &str, extra: &[Tag]) -> Vec<Tag> {
|
||||
let mut r = vec![Tag::s(0, kind), Tag::s(1, name), Tag::i(90, 0)];
|
||||
r.extend_from_slice(extra);
|
||||
r
|
||||
}
|
||||
|
||||
/// The first 91 gets the count, a CLASS without one gets it after its 90, in one pass over many records.
|
||||
#[test]
|
||||
fn counts_are_set_and_added_in_one_pass() {
|
||||
let mut tags = record("CLASS", "FOO", &[]);
|
||||
tags.extend(record("CLASS", "BAR", &[Tag::i(91, 7), Tag::i(91, 8)]));
|
||||
for i in 0..50_000 {
|
||||
tags.extend(record("CLASS", &format!("X{i}"), &[]));
|
||||
}
|
||||
tags.push(Tag::s(0, "FOO"));
|
||||
let started = std::time::Instant::now();
|
||||
add_class_instance_counts(&mut tags, Version::R2018);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||
assert_eq!(&tags[..4], &[Tag::s(0, "CLASS"), Tag::s(1, "FOO"), Tag::i(90, 0), Tag::i(91, 1)]);
|
||||
assert_eq!(&tags[4..9], &[Tag::s(0, "CLASS"), Tag::s(1, "BAR"), Tag::i(90, 0), Tag::i(91, 0), Tag::i(91, 8)]);
|
||||
assert_eq!(tags.iter().filter(|t| t.code == 91).count(), 50_003);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -50,6 +50,8 @@ const MAX_LAYER_NAME: usize = 1000;
|
||||
/// Most link annotations in one file, and the longest link target (bytes).
|
||||
const MAX_LINKS: usize = 10_000;
|
||||
const MAX_URL: usize = 2048;
|
||||
/// The most link annotations in one PDF, over all pages (a page holds at most [`MAX_LINKS`]).
|
||||
const MAX_FILE_ANNOTATIONS: usize = 100_000;
|
||||
/// Longest title and page label written (characters).
|
||||
const MAX_TEXT: usize = 1000;
|
||||
|
||||
@@ -357,7 +359,10 @@ fn sheet_name(space: &Space) -> String {
|
||||
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
|
||||
/// layout name. The options apply to every sheet; layouts use their own page setups.
|
||||
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), o.clone())).collect();
|
||||
// Links are file-wide (`pdf_items` reads them from `o`), so a sheet's own options carry none:
|
||||
// a copy of the list per sheet multiplied it by the sheet count.
|
||||
let sheet = PdfOptions { links: Vec::new(), ..o.clone() };
|
||||
let items: Vec<(Space, PdfOptions)> = spaces.iter().take(MAX_PAGES + 1).map(|s| (s.clone(), sheet.clone())).collect();
|
||||
pdf_items(d, &items, o)
|
||||
}
|
||||
|
||||
@@ -453,8 +458,16 @@ fn write_pdf(d: &Drawing, items: &[(Space, PdfOptions)], o: &PdfOptions, text_as
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let mut fonts = fonts::Fonts::new(!text_as_geometry);
|
||||
let mut groups = Groups::default();
|
||||
// Link annotations of the whole file: each page keeps its own copy of the targets, so the
|
||||
// pages times the links must not multiply without a bound.
|
||||
let mut annotations_left = MAX_FILE_ANNOTATIONS;
|
||||
for (s, po) in items {
|
||||
let page = sheet(drawn, s, po, &links)?;
|
||||
// Once the budget is spent no link is built (scanned and cloned) for later pages.
|
||||
let mut page = sheet(drawn, s, po, if annotations_left == 0 { &links[..0] } else { &links })?;
|
||||
// Only targets `write_page` accepts count against the budget.
|
||||
page.links.retain(|(_, url)| uri(url).is_some());
|
||||
page.links.truncate(annotations_left);
|
||||
annotations_left -= page.links.len();
|
||||
let doc = writer.document_mut();
|
||||
// Groups are added to the document, so they must exist before the page starts.
|
||||
groups.add(doc, d, &page.layers);
|
||||
@@ -625,7 +638,8 @@ pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Shee
|
||||
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
|
||||
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
|
||||
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect();
|
||||
// A target past MAX_URL is refused at the annotation anyway; it is left out before it is copied.
|
||||
let mut out: Vec<(Handle, String)> = o.links.iter().filter(|(_, u)| u.len() <= MAX_URL).take(MAX_LINKS).cloned().collect();
|
||||
if !o.hyperlinks {
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -590,7 +590,10 @@ fn hidden(e: &BytesStart) -> bool {
|
||||
}
|
||||
|
||||
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
|
||||
for a in e.attributes().flatten() {
|
||||
// Stop at the first malformed attribute: skipping errors resumed the parser's duplicate check,
|
||||
// which rescans the attributes before it each time, so many duplicates were quadratic.
|
||||
for a in e.attributes() {
|
||||
let Ok(a) = a else { return None };
|
||||
if a.key.into_inner().as_bytes() == name {
|
||||
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
|
||||
}
|
||||
|
||||
+13
-3
@@ -396,6 +396,16 @@ fn many_distinct_symbol_names_read_quickly() {
|
||||
assert_eq!(d.text_style("ST0").map(|s| s.height), Some(2.0));
|
||||
}
|
||||
|
||||
/// Elements with many duplicate attributes are read in bounded time.
|
||||
#[test]
|
||||
fn svg_duplicate_attributes_are_not_rescanned() {
|
||||
let dup = " x=\"1\"".repeat(20_000);
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", format!("<rect{dup} width=\"1\" height=\"1\"/>").repeat(20));
|
||||
let started = std::time::Instant::now();
|
||||
let _ = read(svg.as_bytes(), "dup.svg");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_dxf_does_not_panic() {
|
||||
for t in [
|
||||
@@ -3511,7 +3521,7 @@ fn proxies_keep_their_class_when_classes_are_rewritten() {
|
||||
/// classes there: a 2018 drawing's piping proxies came out as LARGE_RADIAL_DIMENSION and
|
||||
/// SECTIONOBJECT, and the ODA File Converter refused the file ("AcDbProxyObject can't be cast to
|
||||
/// AcDbBlockTableRecord"). A DWG before 2013 leaves them out (acadrust writes no class for them).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn proxies_keep_their_class_in_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
@@ -3553,7 +3563,7 @@ fn proxies_keep_their_class_in_dwg() {
|
||||
/// its CLASSES ([`proxy_file`]'s "Stray", 537): kept with that number it would name some other
|
||||
/// class of the DWG, so the DWG leaves it out, as CADCraft's own save does, and keeps the proxies
|
||||
/// whose classes it can name.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_proxy_whose_class_is_unknown_is_left_out_of_the_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
@@ -3602,7 +3612,7 @@ fn a_proxy_left_out_of_the_dwg_is_counted_as_unwritten() {
|
||||
/// numbers can't be mapped to the DWG's classes. Kept as they were, they named other classes
|
||||
/// (the ODA refuses such a file), so the DWG leaves them out, as a pre-2013 DWG does, and the
|
||||
/// rest of the drawing is written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_binary_dxf_leaves_its_proxies_out_of_the_dwg() {
|
||||
use acadrust::entities::{EntityType, ExtendedEntityData};
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
//! the one written as `*Paper_Space` (the DWG header's paper space block), the others
|
||||
//! `*Paper_SpaceN`. Saving used to make the first tab the active one again.
|
||||
|
||||
// The DWG bridge is unavailable on wasm and FreeBSD ARM64 (#2526); DXF still works.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layout, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use cadcraft_io::Version;
|
||||
use cadcraft_io::{SaveOptions, read, write_with};
|
||||
|
||||
fn three_sheets() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
@@ -19,6 +23,7 @@ fn three_sheets() -> Drawing {
|
||||
}
|
||||
|
||||
/// The layout the DWG marks active: the one whose block the header names as paper space.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn dwg_active_layout(dwg: &[u8]) -> String {
|
||||
let doc = acadrust::DwgReader::from_stream(Cursor::new(dwg.to_vec())).read().unwrap();
|
||||
let ps = doc.header.paper_space_block_handle;
|
||||
@@ -31,11 +36,13 @@ fn dwg_active_layout(dwg: &[u8]) -> String {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn dwg(d: &Drawing) -> Vec<u8> {
|
||||
write_with(d, "case.dwg", &SaveOptions { version: Some(Version::R2018), binary: false }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn the_active_layout_stays_active_through_dwg_saves() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("S2".into());
|
||||
@@ -68,6 +75,7 @@ fn the_active_layout_survives_dxf_too() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn without_an_active_layout_the_first_tab_is_active() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("gone".into());
|
||||
@@ -75,6 +83,7 @@ fn without_an_active_layout_the_first_tab_is_active() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn the_active_layout_name_is_matched_ignoring_case() {
|
||||
let mut d = three_sheets();
|
||||
d.active_layout = Some("s2".into());
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! the same DWG version (an older DWG can't hold everything: a 2004 field has no value flags or
|
||||
//! format), read back; CADCraft's DWG must hold the same objects. When the DWG is the DXF's own
|
||||
//! version, that reference is also checked against the DXF as read.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! The DWG is written here with acadrust, giving the second 3D-polyline vertex the highest
|
||||
//! handle: exactly what an editing program produces when it inserts a vertex.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use acadrust::entities::{EntityType, Polyline3D, Vertex3DPolyline};
|
||||
use acadrust::types::{DxfVersion, Handle, Vector3};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! it as empty ("No data was imported..."), though it reads the same drawing as DXF (and as an
|
||||
//! AC1032 DWG) fine. The DWG bridge adds the dictionary AutoCAD writes, with its usual variables,
|
||||
//! when the DXF has none.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! TABLE heights inherit from the table style unless the file records a cell override.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
// FreeBSD ARM64 has no native DWG bridge (#2526), but the DXF regression still applies.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Space, Table, TableCell, TableStyle};
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
@@ -42,6 +44,7 @@ fn check(d: &Drawing) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn table_cell_heights_keep_inheritance_through_dwg() {
|
||||
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
|
||||
// A style record can be present for metadata without overriding its text height.
|
||||
@@ -68,11 +71,13 @@ fn table_cell_heights_keep_inheritance_through_dxf() {
|
||||
|
||||
// ---- cells with their own style record (review on #2588) ----
|
||||
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
use acadrust::entities::table::{CellContent, CellStyle, CellStylePropertyFlags};
|
||||
|
||||
/// Write the drawing as a DWG, edit its first cell through acadrust (`edit`), write it again as a
|
||||
/// DWG (or, with `as_dxf`, as a DXF by acadrust's writer) and read it with CADCraft. Returns the first cell's
|
||||
/// text height override.
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::table::TableCell)) -> Option<f64> {
|
||||
let bytes = cadcraft_io::write(&drawing(), "height.dwg").unwrap();
|
||||
let mut doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(bytes)).read().unwrap();
|
||||
@@ -100,6 +105,7 @@ fn first_cell_height(as_dxf: bool, edit: impl FnOnce(&mut acadrust::entities::ta
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
|
||||
let mut s = CellStyle::new();
|
||||
s.text_height = height;
|
||||
@@ -108,12 +114,14 @@ fn styled(height: f64, flags: CellStylePropertyFlags) -> CellStyle {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_cell_style_with_the_text_height_flag_keeps_its_height() {
|
||||
let h = first_cell_height(false, |c| c.style = Some(styled(0.5, CellStylePropertyFlags::TEXT_HEIGHT)));
|
||||
assert_eq!(h, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn content_with_format_override_bit_128_keeps_its_own_height() {
|
||||
// acadrust writes the content's height as group 144 and the style's as 140; they differ here
|
||||
// so the result shows which one CADCraft honoured.
|
||||
@@ -131,6 +139,7 @@ fn content_with_format_override_bit_128_keeps_its_own_height() {
|
||||
/// Only the first content's override stands for the cell: a later content's height must not
|
||||
/// replace the first content's inherited one (review on #2588).
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_later_contents_height_does_not_replace_the_first_contents() {
|
||||
let h = first_cell_height(false, |c| {
|
||||
c.style = Some(styled(0.18, CellStylePropertyFlags::NONE));
|
||||
@@ -147,6 +156,7 @@ fn a_later_contents_height_does_not_replace_the_first_contents() {
|
||||
/// Bit 128 with an unusable content height copies nothing, so the writer's default style height
|
||||
/// is still cleared (review on #2588).
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn bit_128_with_an_invalid_height_still_clears_the_default() {
|
||||
for bad in [0.0, -1.0, f64::NAN] {
|
||||
let h = first_cell_height(false, |c| {
|
||||
@@ -162,12 +172,14 @@ fn bit_128_with_an_invalid_height_still_clears_the_default() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_cell_with_no_style_record_inherits_the_table_style() {
|
||||
assert_eq!(first_cell_height(false, |c| c.style = None), None);
|
||||
}
|
||||
|
||||
/// A style record with a height and neither override is not an override, through DWG.
|
||||
#[test]
|
||||
#[cfg(not(all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
fn a_style_height_without_an_override_is_cleared() {
|
||||
assert_eq!(first_cell_height(false, |c| c.style = Some(styled(0.18, CellStylePropertyFlags::NONE))), None);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The targets without the native DWG bridge still handle DXF and report an actionable error
|
||||
//! for DWG instead of pretending that opening or saving succeeded.
|
||||
|
||||
#![cfg(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))]
|
||||
|
||||
use cadcraft_doc::Drawing;
|
||||
use cadcraft_io::{read, write};
|
||||
|
||||
#[test]
|
||||
fn no_dwg_bridge_preserves_dxf_and_reports_dwg_unavailable() {
|
||||
let drawing = Drawing::new_metric();
|
||||
let dxf = write(&drawing, "drawing.dxf").expect("DXF remains available");
|
||||
assert!(read(&dxf, "drawing.dxf").is_ok());
|
||||
|
||||
let read_error = read(b"AC1032unavailable", "drawing.dwg").unwrap_err().to_string();
|
||||
let write_error = write(&drawing, "drawing.dwg").unwrap_err().to_string();
|
||||
for error in [read_error, write_error] {
|
||||
assert!(error.contains("DWG"), "{error}");
|
||||
assert!(error.contains("DXF"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! An object of another application whose owner is null, but which another kept object holds by a
|
||||
//! hard owner reference (360), is kept with it, as one application's proxy object held by another.
|
||||
//! In DXF and in DWG, save after save.
|
||||
|
||||
use cadcraft_io::{read, read_dxf, write, write_dxf};
|
||||
|
||||
/// A named object dictionary entry `OTHER_APP` (a dictionary) with an XRECORD that holds a second
|
||||
/// XRECORD by a 360 reference; the second one's owner is null. An object nothing keeps names the
|
||||
/// second XRECORD by a 360 first.
|
||||
fn source() -> String {
|
||||
let groups: &[(i32, &str)] = &[
|
||||
(0, "SECTION"),
|
||||
(2, "ENTITIES"),
|
||||
(0, "LINE"),
|
||||
(5, "30"),
|
||||
(8, "0"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "0"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "SECTION"),
|
||||
(2, "OBJECTS"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "C"),
|
||||
(330, "0"),
|
||||
(100, "AcDbDictionary"),
|
||||
(3, "OTHER_APP"),
|
||||
(350, "50"),
|
||||
// An object nothing keeps (null owner, no holder) that also names the held record by a
|
||||
// 360, before the holder does: it is dropped, and the held record stays with the holder.
|
||||
(0, "XRECORD"),
|
||||
(5, "60"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "stray"),
|
||||
(360, "52"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "50"),
|
||||
(330, "C"),
|
||||
(100, "AcDbDictionary"),
|
||||
(281, "1"),
|
||||
(3, "HOLDER"),
|
||||
(360, "51"),
|
||||
(0, "XRECORD"),
|
||||
(5, "51"),
|
||||
(102, "{ACAD_REACTORS"),
|
||||
(330, "50"),
|
||||
(102, "}"),
|
||||
(330, "50"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "holder"),
|
||||
(360, "52"),
|
||||
(0, "XRECORD"),
|
||||
(5, "52"),
|
||||
(102, "{ACAD_REACTORS"),
|
||||
(330, "51"),
|
||||
(102, "}"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "held"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect()
|
||||
}
|
||||
|
||||
fn check(text: &str, label: &str) {
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let pairs: Vec<(&str, &str)> = lines.chunks(2).filter_map(|c| Some((*c.first()?, *c.get(1)?))).collect();
|
||||
// Each record from its `0` group on, as (code, value) pairs.
|
||||
let records: Vec<&[(&str, &str)]> = pairs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (c, _))| *c == "0")
|
||||
.map(|(i, _)| {
|
||||
let n = pairs[i + 1..].iter().position(|(c, _)| *c == "0").map_or(pairs.len(), |n| i + 1 + n);
|
||||
&pairs[i..n]
|
||||
})
|
||||
.collect();
|
||||
let with = |text: &str| records.iter().find(|r| r.contains(&("1", text))).copied().unwrap_or_else(|| panic!("{label}: no record {text}"));
|
||||
let holder = with("holder");
|
||||
let Some((_, held_handle)) = holder.iter().find(|(c, _)| *c == "360") else { panic!("{label}: the holder holds nothing: {holder:?}") };
|
||||
let held = with("held");
|
||||
assert!(held.contains(&("5", *held_handle)), "{label}: the holder's 360 names the held record: {holder:?} {held:?}");
|
||||
assert!(!records.iter().any(|r| r.contains(&("1", "stray"))), "{label}: the object nothing keeps is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_held_by_a_kept_one_is_kept() {
|
||||
let mut d = read_dxf(source().as_bytes()).expect("read");
|
||||
for generation in 1..=2 {
|
||||
let text = write_dxf(&d);
|
||||
check(&text, &format!("DXF {generation}"));
|
||||
d = read_dxf(text.as_bytes()).expect("reread");
|
||||
}
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
for generation in 1..=2 {
|
||||
let dwg = write(&d, "held.dwg").expect("DWG");
|
||||
d = read(&dwg, "held.dwg").expect("DWG read");
|
||||
check(&write_dxf(&d), &format!("DWG {generation}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutual hard owners must not erase the live path through a retained dictionary entry.
|
||||
#[test]
|
||||
fn cyclic_hard_owners_with_a_live_holder_stay_kept() {
|
||||
let groups = [
|
||||
(0, "SECTION"),
|
||||
(2, "OBJECTS"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "C"),
|
||||
(330, "0"),
|
||||
(100, "AcDbDictionary"),
|
||||
(3, "OTHER_APP"),
|
||||
(350, "50"),
|
||||
(0, "XRECORD"),
|
||||
(5, "60"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "stray"),
|
||||
(360, "54"),
|
||||
(0, "XRECORD"),
|
||||
(5, "52"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "first"),
|
||||
(360, "53"),
|
||||
(360, "54"),
|
||||
(0, "XRECORD"),
|
||||
(5, "53"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "second"),
|
||||
(360, "52"),
|
||||
(0, "XRECORD"),
|
||||
(5, "54"),
|
||||
(330, "0"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "child"),
|
||||
(0, "DICTIONARY"),
|
||||
(5, "50"),
|
||||
(330, "C"),
|
||||
(100, "AcDbDictionary"),
|
||||
(281, "1"),
|
||||
(3, "HOLDER"),
|
||||
(360, "51"),
|
||||
(0, "XRECORD"),
|
||||
(5, "51"),
|
||||
(330, "50"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "holder"),
|
||||
(360, "52"),
|
||||
(360, "53"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
let text = groups.iter().map(|(code, value)| format!("{code}\n{value}\n")).collect::<String>();
|
||||
let mut drawing = read_dxf(text.as_bytes()).unwrap();
|
||||
let mut names = vec!["copy.dxf", "again.dxf"];
|
||||
if cfg!(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64")))) {
|
||||
names.extend(["copy.dwg", "again.dwg"]);
|
||||
}
|
||||
for name in names {
|
||||
let bytes = write(&drawing, name).unwrap();
|
||||
drawing = read(&bytes, name).unwrap();
|
||||
let text = write_dxf(&drawing);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = cadcraft_dxf::records(&objects.tags);
|
||||
let record = |label: &str| records.iter().find(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == label)).unwrap().1.clone();
|
||||
let first = record("first");
|
||||
let second = record("second");
|
||||
let holder = record("holder");
|
||||
let child = record("child");
|
||||
assert!(!records.iter().any(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == "stray")), "{name}");
|
||||
let handle = |tags: &[cadcraft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&second)), "{name}");
|
||||
assert!(second.iter().any(|t| t.code == 360 && t.str() == handle(&first)), "{name}");
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&child)), "{name}");
|
||||
for object in [&first, &second] {
|
||||
assert!(holder.iter().any(|t| t.code == 360 && t.str() == handle(object)), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real 330 owner takes precedence, even when a retained object also names the record by 360.
|
||||
#[test]
|
||||
fn a_real_owner_is_not_replaced_by_a_hard_owner() {
|
||||
let text = source().replace("330\n0\n100\nAcDbXrecord\n280\n1\n1\nheld", "330\nDEAD\n100\nAcDbXrecord\n280\n1\n1\nheld");
|
||||
assert!(text.contains("330\nDEAD"));
|
||||
let drawing = read_dxf(text.as_bytes()).unwrap();
|
||||
let output = write_dxf(&drawing);
|
||||
assert!(output.lines().any(|line| line.trim() == "holder"));
|
||||
assert!(!output.lines().any(|line| line.trim() == "held"));
|
||||
}
|
||||
@@ -103,7 +103,7 @@ fn dxf_angular_block_text_keeps_the_decimal_separator() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn dwg_angular_block_text_keeps_the_decimal_separator() {
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! by their own block (entity mode 0 and its handle). Written as "paper space" (mode 1), AutoCAD
|
||||
//! put them on the first layout and left theirs empty.
|
||||
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Layout, Line, Space};
|
||||
|
||||
@@ -87,6 +87,7 @@ fn a_saved_drawing_keeps_the_dynamic_block_as_read() {
|
||||
}
|
||||
|
||||
/// The Door fixture with a scale action on the Width parameter: graph node 6, object 68.
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
fn with_scale_action() -> String {
|
||||
let node = "91 6\n93 32\n95 7\n360 68\n92 -1\n92 -1\n92 -1\n92 -1\n";
|
||||
let action = "0 BLOCKSCALEACTION\n5 68\n330 61\n100 AcDbEvalExpr\n90 7\n98 33\n99 232\n\
|
||||
@@ -105,6 +106,7 @@ fn with_scale_action() -> String {
|
||||
/// the DWG holds it with its connections, and it reads back as the same action. AutoCAD refused
|
||||
/// such DWGs while acadrust left out the byte after its connections (cadcraft-dwg's
|
||||
/// `scale_actions_keep_the_byte_autocad_reads_after_their_connections` covers the byte).
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn a_scale_action_survives_a_dwg_save() {
|
||||
use acadrust::objects::{DynamicBlockData, DynamicBlockObject, ObjectType};
|
||||
|
||||
@@ -74,7 +74,7 @@ fn dxf_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
let (original, expected) = drawing()?;
|
||||
@@ -103,7 +103,7 @@ fn dwg_mtext_caret_spaces_round_trip() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
#[test]
|
||||
fn bridge_caret_space_is_escaped_once_in_each_direction() -> TestResult {
|
||||
let (d, _) = drawing()?;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
mod cases;
|
||||
mod collections;
|
||||
mod compare;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
mod dwg;
|
||||
mod dxf;
|
||||
mod handle_limits;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Synthetic application data must keep its identity as well as its bytes.
|
||||
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
|
||||
use acadrust::classes::DxfClass;
|
||||
use acadrust::entities::{EntityCommon, EntityType, ExtendedEntity, ExtendedEntityData, Line, RegisteredClassEntityData};
|
||||
use acadrust::objects::{ProxyObjectReference, ProxyPayload, ProxyReferenceKind};
|
||||
use acadrust::types::{DxfVersion, Vector3};
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn source(version: DxfVersion) -> Vec<u8> {
|
||||
source_payload(version, ProxyPayload::from_bits(&[0xA5, 0x80], 9))
|
||||
}
|
||||
|
||||
fn source_payload(version: DxfVersion, payload: ProxyPayload) -> Vec<u8> {
|
||||
let mut document = acadrust::CadDocument::new();
|
||||
document.version = version;
|
||||
document.classes.add_or_update(DxfClass::new_entity("SPDSNOTEPOSITION", "mcsDbObjectNotePosition"));
|
||||
let target = document.add_entity(EntityType::Line(Line::from_points(Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0)))).unwrap();
|
||||
document
|
||||
.add_entity(EntityType::Extended(Box::new(ExtendedEntity {
|
||||
common: EntityCommon { graphic_data: Some(vec![0x12, 0x34, 0x56]), ..Default::default() },
|
||||
data: ExtendedEntityData::RegisteredClass(RegisteredClassEntityData {
|
||||
dxf_name: "SPDSNOTEPOSITION".into(),
|
||||
cpp_class_name: "mcsDbObjectNotePosition".into(),
|
||||
properties: vec![],
|
||||
payload,
|
||||
object_ids: vec![ProxyObjectReference { kind: ProxyReferenceKind::HardPointer, handle: target }],
|
||||
}),
|
||||
})))
|
||||
.unwrap();
|
||||
acadrust::DwgWriter::write_to_vec(&document).unwrap()
|
||||
}
|
||||
|
||||
fn check(bytes: &[u8], name: &str) -> cadcraft_doc::Drawing {
|
||||
let drawing = cadcraft_io::read(bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 2);
|
||||
let line = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle;
|
||||
let unknown = drawing
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::Unknown(u) => Some(u),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(unknown.tags.iter().any(|t| t.code == 340 && t.value == line.hex()));
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let classes = sections.iter().find(|s| s.name == "CLASSES").unwrap();
|
||||
let classes = cadcraft_dxf::records(&classes.tags);
|
||||
let decoded = acadrust::DxfReader::from_reader(Cursor::new(dxf)).unwrap().read().unwrap();
|
||||
let proxy = decoded
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
EntityType::Extended(e) => match &e.data {
|
||||
ExtendedEntityData::Proxy(p) => Some(p),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(proxy.payload.bit_count, 9);
|
||||
assert_eq!(proxy.payload.data(), [0xA5, 0x80]);
|
||||
assert_eq!(proxy.graphics.data(), [0x12, 0x34, 0x56]);
|
||||
let class = classes.get(usize::try_from(proxy.class_id - 500).unwrap()).unwrap();
|
||||
assert!(class.1.iter().any(|t| t.code == 1 && t.str() == "SPDSNOTEPOSITION"));
|
||||
assert!(class.1.iter().any(|t| t.code == 2 && t.str() == "mcsDbObjectNotePosition"));
|
||||
drawing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_registered_entities_survive_repeated_saves_with_their_original_class() {
|
||||
for source_version in [DxfVersion::AC1015, DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let input = source(source_version);
|
||||
let initial = check(&input, "source.dwg");
|
||||
for target in [Version::R2013, Version::R2018] {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let mut drawing = initial.clone();
|
||||
for _ in 0..3 {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(target), ..Default::default() }).unwrap();
|
||||
drawing = check(&bytes, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_dwg_leaves_proxies_out_while_legacy_dxf_retains_the_data() {
|
||||
let drawing = check(&source(DxfVersion::AC1032), "source.dwg");
|
||||
for target in [Version::R2000, Version::R2004, Version::R2007, Version::R2010] {
|
||||
let options = SaveOptions { version: Some(target), ..Default::default() };
|
||||
// A DWG before 2013 can't name the proxy's class: it is left out, and the rest is saved.
|
||||
let bytes = cadcraft_io::write_with(&drawing, "old.dwg", &options).unwrap();
|
||||
let reread = cadcraft_io::read(&bytes, "old.dwg").unwrap();
|
||||
assert_eq!(reread.entity_count(), 1, "{target:?}");
|
||||
assert!(reread.model.iter().all(|e| matches!(e.kind, EntityKind::Line(_))), "{target:?}");
|
||||
let bytes = cadcraft_io::write_with(&drawing, "old.dxf", &options).unwrap();
|
||||
check(&bytes, "old.dxf");
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_class_dxf() -> Vec<u8> {
|
||||
let mut text = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n");
|
||||
for (cpp, entity, flags) in [("First", 1, 1025), ("Second", 1, 128), ("Object", 0, 17)] {
|
||||
text.push_str(&format!("0\nCLASS\n1\nCUSTOM\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n1\n280\n1\n281\n{entity}\n"));
|
||||
}
|
||||
text.push_str("0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n0\n20\n0\n11\n10\n21\n0\n");
|
||||
for (handle, class) in [("20", 500), ("21", 501)] {
|
||||
text.push_str(&format!("0\nACAD_PROXY_ENTITY\n5\n{handle}\n100\nAcDbEntity\n8\n0\n100\nAcDbProxyEntity\n90\n498\n91\n{class}\n92\n0\n93\n9\n310\nA580\n330\n10\n340\n10\n350\n10\n360\n10\n94\n0\n95\n33\n70\n0\n"));
|
||||
}
|
||||
text.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n100\nAcDbDictionary\n281\n1\n3\nCUSTOM_OBJECT\n350\n30\n0\nACAD_PROXY_OBJECT\n5\n30\n330\nC\n100\nAcDbProxyObject\n90\n499\n91\n502\n93\n9\n310\nA580\n340\n10\n94\n0\n95\n33\n70\n0\n0\nENDSEC\n0\nEOF\n");
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
|
||||
let source = duplicate_class_dxf();
|
||||
for binary in [false, true] {
|
||||
let input = if binary { cadcraft_dxf::write_binary(&cadcraft_dxf::parse(&source).unwrap(), true) } else { source.clone() };
|
||||
let mut drawing = cadcraft_io::read(&input, "source.dxf").unwrap();
|
||||
for _ in 0..3 {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let classes = cadcraft_dxf::records(§ions.iter().find(|s| s.name == "CLASSES").unwrap().tags);
|
||||
let custom: Vec<_> = classes.iter().filter(|(_, t)| t.iter().any(|t| t.code == 1 && t.str() == "CUSTOM")).collect();
|
||||
assert_eq!(custom.len(), 3);
|
||||
for (class, (cpp, flags)) in custom.iter().zip([("First", 1025), ("Second", 128), ("Object", 17)]) {
|
||||
assert!(class.1.iter().any(|t| t.code == 2 && t.str() == cpp));
|
||||
assert!(class.1.iter().any(|t| t.code == 90 && t.i64() == flags));
|
||||
assert!(class.1.iter().any(|t| t.code == 3 && t.str() == "Synthetic"));
|
||||
assert!(class.1.iter().any(|t| t.code == 280 && t.i64() == 1));
|
||||
}
|
||||
let records: Vec<_> =
|
||||
sections.iter().flat_map(|s| cadcraft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
assert_eq!(records.len(), 3);
|
||||
let mut identities = Vec::new();
|
||||
for (_, tags) in &records {
|
||||
let id = tags.iter().find(|t| t.code == 91).unwrap().i64();
|
||||
identities.push(classes[usize::try_from(id - 500).unwrap()].1.iter().find(|t| t.code == 2).unwrap().str());
|
||||
assert!(tags.iter().any(|t| matches!(t.code, 93 | 161) && t.i64() == 9), "{name}: {tags:?}");
|
||||
assert!(tags.iter().any(|t| t.code == 310 && t.str() == "A580"));
|
||||
}
|
||||
identities.sort();
|
||||
assert_eq!(identities, ["First", "Object", "Second"]);
|
||||
drawing = cadcraft_io::read(&bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
let line = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap().handle.hex();
|
||||
for entity in drawing.model.iter().filter_map(|e| if let EntityKind::Unknown(u) = &e.kind { Some(u) } else { None }) {
|
||||
for code in [330, 340, 350, 360] {
|
||||
assert!(entity.tags.iter().any(|t| t.code == code && t.value == line), "missing reference {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_bit_opaque_entities_are_still_retained() {
|
||||
let source = source_payload(DxfVersion::AC1032, ProxyPayload::default());
|
||||
let mut drawing = cadcraft_io::read(&source, "source.dwg").unwrap();
|
||||
for name in ["copy.dxf", "copy.dwg", "again.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
drawing = cadcraft_io::read(&bytes, name).unwrap();
|
||||
assert_eq!(drawing.entity_count(), 2);
|
||||
assert!(drawing.model.iter().any(|e| matches!(e.kind, EntityKind::Unknown(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxies_inside_block_definitions_survive_repeated_saves() {
|
||||
use cadcraft_doc::{Block, Common, Entity};
|
||||
let mut drawing = check(&source(DxfVersion::AC1032), "source.dwg");
|
||||
let kind = drawing.model.iter().find(|e| matches!(e.kind, EntityKind::Unknown(_))).unwrap().kind.clone();
|
||||
let handle = drawing.new_handle();
|
||||
let mut block = Block::new("CUSTOM_BLOCK");
|
||||
block.entities.push(Entity { handle, common: Common::default(), kind });
|
||||
drawing.blocks.insert("CUSTOM_BLOCK".into(), std::sync::Arc::new(block));
|
||||
for name in ["copy.dxf", "copy.dwg", "again.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
drawing = check(&bytes, name);
|
||||
let block = drawing.blocks.get("CUSTOM_BLOCK").unwrap();
|
||||
assert_eq!(block.entities.len(), 1);
|
||||
assert!(block.entities.iter().any(|e| matches!(e.kind, EntityKind::Unknown(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_malformed_classes_do_not_block_plain_geometry() {
|
||||
for (name, cpp, flags, count) in [("", "Unused", 0, 0), ("UNUSED", "", 0, 0), ("UNUSED", "Unused", 65536, 0), ("UNUSED", "Unused", 0, i64::MAX)] {
|
||||
let dxf = format!(
|
||||
"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nCLASSES\n0\nCLASS\n1\n{name}\n2\n{cpp}\n3\nSynthetic\n90\n{flags}\n91\n{count}\n281\n1\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nLINE\n5\n10\n100\nAcDbEntity\n8\n0\n100\nAcDbLine\n10\n2\n20\n3\n11\n12\n21\n13\n0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let bytes = cadcraft_dwg::dxf_to_dwg_as(dxf.as_bytes(), "AC1032").unwrap();
|
||||
let drawing = cadcraft_io::read(&bytes, "plain.dwg").unwrap();
|
||||
let lines: Vec<_> = drawing.model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l) } else { None }).collect();
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].a.x, 2.0);
|
||||
assert_eq!(lines[0].a.y, 3.0);
|
||||
assert_eq!(lines[0].b.x, 12.0);
|
||||
assert_eq!(lines[0].b.y, 13.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
|
||||
let source = String::from_utf8(duplicate_class_dxf()).unwrap();
|
||||
for (entity, object) in [("acad_proxy_entity", "acad_proxy_object"), ("AcAd_PrOxY_EnTiTy", "AcAd_PrOxY_ObJeCt")] {
|
||||
let dxf = source.replace("ACAD_PROXY_ENTITY", entity).replace("ACAD_PROXY_OBJECT", object);
|
||||
let drawing = cadcraft_io::read(dxf.as_bytes(), "source.dxf").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
let out = cadcraft_io::write_with(&drawing, "copy.dxf", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&out).unwrap();
|
||||
assert!(tags.iter().any(|t| t.code == 0 && t.str() == entity));
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = cadcraft_dwg::dwg_to_dxf(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&decoded).unwrap();
|
||||
let proxies: Vec<_> = cadcraft_dxf::sections(&tags)
|
||||
.iter()
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind.starts_with("ACAD_PROXY_"))
|
||||
.collect();
|
||||
assert_eq!(proxies.len(), 3);
|
||||
for (_, tags) in proxies {
|
||||
assert!(tags.iter().any(|t| t.code == 310 && t.str() == "A580"));
|
||||
assert!(tags.iter().any(|t| matches!(t.code, 93 | 161) && t.i64() == 9));
|
||||
}
|
||||
let recovered = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(recovered.entity_count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the actual ASCII bridge, which writes Unicode as CIF escapes.
|
||||
#[test]
|
||||
fn unicode_class_identity_survives_the_ascii_bridge() {
|
||||
let mut tags = cadcraft_dxf::parse(&duplicate_class_dxf()).unwrap();
|
||||
for tag in &mut tags {
|
||||
let text = tag.str();
|
||||
let replacement = match (tag.code, text.as_str()) {
|
||||
(1, "CUSTOM") => Some("Объект"),
|
||||
(2, "First") => Some("Первый"),
|
||||
(2, "Second") => Some("Второй"),
|
||||
(2, "Object") => Some("ОбъектКласс"),
|
||||
(3, "Synthetic") => Some("Café🙂"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = replacement {
|
||||
*tag = cadcraft_dxf::Tag::s(tag.code, text);
|
||||
}
|
||||
}
|
||||
let text = cadcraft_dxf::write_ascii(&tags);
|
||||
assert!(text.contains("\\U+"));
|
||||
let mut drawing = cadcraft_io::read(text.as_bytes(), "unicode.dxf").unwrap();
|
||||
for _ in 0..2 {
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = acadrust::DwgReader::from_stream(std::io::Cursor::new(&bytes)).read().unwrap();
|
||||
let classes: Vec<_> = decoded.classes.iter().filter(|c| c.dxf_name == "Объект").collect();
|
||||
assert_eq!(classes.len(), 3);
|
||||
for (class, cpp) in classes.iter().zip(["Первый", "Второй", "ОбъектКласс"]) {
|
||||
assert_eq!(class.cpp_class_name, cpp);
|
||||
assert_eq!(class.application_name, "Café🙂");
|
||||
}
|
||||
drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed unused class must not silently collapse two otherwise valid class identities.
|
||||
#[test]
|
||||
fn ambiguous_fallback_classes_are_reported_without_losing_plain_geometry() {
|
||||
for escaped in [false, true] {
|
||||
let mut source = String::from_utf8(duplicate_class_dxf()).unwrap().replacen(
|
||||
"0\nENDSEC\n0\nSECTION\n2\nENTITIES",
|
||||
"0\nCLASS\n1\nUNUSED\n2\nUnused\n90\n65536\n0\nENDSEC\n0\nSECTION\n2\nENTITIES",
|
||||
1,
|
||||
);
|
||||
if escaped {
|
||||
// Different DXF spellings decode to the same name. The unrelated object class
|
||||
// remains unambiguous and must still be written.
|
||||
source = source.replace("1\nCUSTOM\n2\nSecond", "1\n\\U+0043USTOM\n2\nSecond");
|
||||
source = source.replace("1\nCUSTOM\n2\nObject", "1\nOTHER\n2\nObject");
|
||||
}
|
||||
let (bytes, unwritten) = cadcraft_dwg::dxf_to_dwg_noted(source.as_bytes(), "AC1032").unwrap();
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_ENTITY"), Some(&2));
|
||||
assert_eq!(unwritten.get("ACAD_PROXY_OBJECT"), if escaped { None } else { Some(&1) });
|
||||
let drawing = cadcraft_io::read(&bytes, "copy.dwg").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 1);
|
||||
assert!(drawing.model.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,6 @@ getrandom = "0.4"
|
||||
[dev-dependencies]
|
||||
# Reads the pixels of rendered PNGs in the tests.
|
||||
tiny-skia = { workspace = true }
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# O_NOFOLLOW when reading the token file (MIT OR Apache-2.0).
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
@@ -76,9 +76,17 @@ impl Remote {
|
||||
/// The per-session token for this port, read (and cached) from the owner-only token file. A
|
||||
/// missing file is not fatal here: the request is sent without a token and the server replies
|
||||
/// with an authentication error the user can act on.
|
||||
///
|
||||
/// The desktop app listens only on `127.0.0.1:PORT`, so the token goes only to that exact
|
||||
/// peer: a name that also resolves elsewhere (`localhost` → `::1`, where another local account
|
||||
/// may listen on the same port) must not be handed the desktop's credential (CWE-522).
|
||||
fn token(&mut self) -> Option<&str> {
|
||||
let port = port_of(&self.addr)?;
|
||||
let peer = self.conn.as_ref().and_then(|(_, s)| s.peer_addr().ok());
|
||||
if peer != Some(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) {
|
||||
return None;
|
||||
}
|
||||
if self.token.is_none()
|
||||
&& let Some(port) = port_of(&self.addr)
|
||||
&& let Ok(t) = crate::control_token::read(port)
|
||||
{
|
||||
self.token = Some(t);
|
||||
@@ -207,6 +215,14 @@ fn is_read_only(method: &str) -> bool {
|
||||
|
||||
impl Remote {
|
||||
/// Put the current token (if any) on `req`, replacing the one it carries.
|
||||
/// Connect if needed, put the token for the peer actually connected to on `req`, and send it.
|
||||
fn sign_and_send(&mut self, req: &mut Value) -> Result<String, (std::io::Error, bool)> {
|
||||
if self.conn.is_none() {
|
||||
self.reach().map_err(|e| (e, false))?;
|
||||
}
|
||||
self.set_token(req);
|
||||
self.roundtrip(&req.to_string())
|
||||
}
|
||||
fn set_token(&mut self, req: &mut Value) {
|
||||
let token = self.token().map(str::to_string);
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
@@ -219,7 +235,7 @@ impl Remote {
|
||||
/// Send `req` and parse the reply, refreshing the token before a safe retry after a transport
|
||||
/// error. A mutation that may have reached the server is never resent automatically.
|
||||
fn exchange(&mut self, method: &str, req: &mut Value) -> Result<Value, String> {
|
||||
let reply = match self.roundtrip(&req.to_string()) {
|
||||
let reply = match self.sign_and_send(req) {
|
||||
Ok(r) => r,
|
||||
Err((e, sent)) => {
|
||||
self.token = None;
|
||||
@@ -232,8 +248,7 @@ impl Remote {
|
||||
));
|
||||
}
|
||||
// A restarted server may have rotated its token before dropping the old socket.
|
||||
self.set_token(req);
|
||||
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
|
||||
self.sign_and_send(req).map_err(|(e, _)| {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
format!("CADCraft at {}: {e}", self.addr)
|
||||
@@ -256,14 +271,12 @@ impl Backend for Remote {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut req = json!({"id": id, "method": method, "params": params});
|
||||
self.set_token(&mut req);
|
||||
let mut v = self.exchange(method, &mut req)?;
|
||||
// The token is read before `--launch` opens the app, which then writes a new one (#2087); the
|
||||
// file can also be left over from an earlier session. Once, so a wrong token can't loop.
|
||||
if unauthorized(&v) && v.get("state").and_then(Value::as_str) == Some("not-run") {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
self.set_token(&mut req);
|
||||
v = self.exchange(method, &mut req)?;
|
||||
}
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -595,6 +608,24 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// A listener on another loopback address with the desktop's port (here `::1`) never receives
|
||||
/// the desktop's token.
|
||||
#[test]
|
||||
fn token_is_not_sent_to_another_loopback_listener() {
|
||||
let Ok(listener) = TcpListener::bind("[::1]:0") else { return };
|
||||
let addr = listener.local_addr().unwrap();
|
||||
crate::control_token::issue(addr.port()).unwrap();
|
||||
let server = fake_app(listener, move |req, _| {
|
||||
assert!(req.get("token").is_none(), "token leaked to {addr}");
|
||||
json!({"ok": true, "result": "x"})
|
||||
});
|
||||
let mut remote = Remote::lazy(&addr.to_string(), false);
|
||||
let got = remote.call("ping", json!({}));
|
||||
crate::control_token::revoke(addr.port());
|
||||
assert_eq!(got, Ok(json!("x")));
|
||||
assert_eq!(server.join().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// `--launch` reads the token before it opens the app, so the first request carries the token
|
||||
/// of an earlier session (or none); the app refuses it without running it, and the call is
|
||||
/// resent once with the token the app has just written (#2087).
|
||||
|
||||
@@ -32,7 +32,9 @@ fn private_dir() -> io::Result<PathBuf> {
|
||||
if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(h).join(".cache"));
|
||||
}
|
||||
Ok(std::env::temp_dir())
|
||||
// The shared temporary directory would put the credential at a path any local account can
|
||||
// predict and plant a link at: refuse instead.
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR or HOME is required for a private control token"))
|
||||
}
|
||||
|
||||
/// Require the Windows per-user local app-data directory for session token storage.
|
||||
@@ -75,7 +77,31 @@ fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
|
||||
|
||||
/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge).
|
||||
pub fn read(port: u16) -> io::Result<String> {
|
||||
let raw = std::fs::read_to_string(token_path(port)?)?;
|
||||
read_at(&token_path(port)?)
|
||||
}
|
||||
|
||||
/// Read a token file the issuer wrote: not through a link, a plain file, and (Unix) readable by
|
||||
/// its owner only, so a file another account planted or left open is not taken as the credential.
|
||||
fn read_at(path: &std::path::Path) -> io::Result<String> {
|
||||
use std::io::Read;
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "the control token is not a plain file"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if std::os::unix::fs::PermissionsExt::mode(&meta.permissions()) & 0o077 != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the control token file is readable by other users"));
|
||||
}
|
||||
let mut raw = String::new();
|
||||
file.take(1024).read_to_string(&mut raw)?;
|
||||
let token = raw.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token"));
|
||||
@@ -181,6 +207,24 @@ mod tests {
|
||||
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
|
||||
}
|
||||
|
||||
/// A link, or a file other users can read, is not accepted as the token.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_refuses_links_and_shared_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-token-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let secret = dir.join("secret");
|
||||
issue_at(&secret, b"abc").unwrap();
|
||||
assert_eq!(read_at(&secret).unwrap(), "abc");
|
||||
let link = dir.join("link");
|
||||
std::os::unix::fs::symlink(&secret, &link).unwrap();
|
||||
assert!(read_at(&link).is_err());
|
||||
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(read_at(&secret).is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Token issuance creates its missing directory hierarchy before writing the credential.
|
||||
#[test]
|
||||
fn issuance_creates_missing_parent() {
|
||||
|
||||
+100
-9
@@ -12,7 +12,7 @@ use super::{Builder, Ctx, Ink, fill, ink, max_scale};
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::library::{PatternLine, pattern_for};
|
||||
use cadcraft_doc::{Drawing, Entity, Gradient, Hatch};
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon};
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance.
|
||||
@@ -128,7 +128,17 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
|
||||
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
|
||||
// One point budget for all the loops: many short arcs across loops must not add up unbounded.
|
||||
let mut budget = cadcraft_geom::MAX_TESSELLATED_POINTS;
|
||||
// Source loops that are the same boundary up to start vertex and winding are tessellated once, so a
|
||||
// duplicate cannot be sampled coarser than its twin by the shared budget and then escape `dedup_polygons`.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut loops: Vec<Vec<Vec2>> = h
|
||||
.loops
|
||||
.iter()
|
||||
.filter(|l| seen.insert(canonical_key(&l.vertices)))
|
||||
.map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget))
|
||||
.collect();
|
||||
dedup_polygons(&mut loops);
|
||||
let max_depth = match h.style {
|
||||
1 => 1,
|
||||
@@ -146,6 +156,50 @@ pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
loops.iter().enumerate().filter(|(i, l)| depth(*i, l) <= max_depth).map(|(_, l)| l.clone()).collect()
|
||||
}
|
||||
|
||||
/// Start index of the least rotation of `s` (two-pointer scan, O(n)).
|
||||
fn least_rotation(s: &[[u64; 3]]) -> usize {
|
||||
let n = s.len();
|
||||
let (mut i, mut j, mut k) = (0, 1, 0);
|
||||
while i < n && j < n && k < n {
|
||||
let (a, b) = (s.get((i + k) % n), s.get((j + k) % n));
|
||||
if a == b {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
if a > b {
|
||||
i += k + 1;
|
||||
} else {
|
||||
j += k + 1;
|
||||
}
|
||||
if i == j {
|
||||
j += 1;
|
||||
}
|
||||
k = 0;
|
||||
}
|
||||
i.min(j)
|
||||
}
|
||||
|
||||
/// A key equal for loops that are the same boundary starting at another vertex or wound the other way
|
||||
/// (reversing moves each bulge to the other end of its edge and negates it). Exact bits only; the
|
||||
/// tolerance-based `dedup_polygons` still catches near matches.
|
||||
fn canonical_key(v: &[PolyVertex]) -> Vec<[u64; 3]> {
|
||||
let bits = |x: f64| (x + 0.0).to_bits(); // -0.0 and 0.0 match
|
||||
let n = v.len();
|
||||
let fwd: Vec<[u64; 3]> = v.iter().map(|q| [bits(q.p.x), bits(q.p.y), bits(q.bulge)]).collect();
|
||||
let rev: Vec<[u64; 3]> = (0..n)
|
||||
.map(|j| {
|
||||
let i = n - 1 - j;
|
||||
let edge = v.get((i + n - 1) % n).map_or(0.0, |q| -q.bulge);
|
||||
v.get(i).map_or([0; 3], |q| [bits(q.p.x), bits(q.p.y), bits(edge)])
|
||||
})
|
||||
.collect();
|
||||
let rotated = |s: &[[u64; 3]]| {
|
||||
let k = least_rotation(s);
|
||||
s.iter().cycle().skip(k).take(s.len()).copied().collect::<Vec<_>>()
|
||||
};
|
||||
rotated(&fwd).min(rotated(&rev))
|
||||
}
|
||||
|
||||
/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge
|
||||
/// (vertices may touch an enclosing loop; edge midpoints rarely do).
|
||||
fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
@@ -367,6 +421,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
|
||||
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
|
||||
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
|
||||
/// the part of the hatch inside it is generated; `None` = all of it.
|
||||
#[cfg(test)]
|
||||
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_until(d, h, loops, px, view, None)
|
||||
}
|
||||
@@ -380,6 +435,21 @@ pub(crate) fn pattern_lines_until(
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
|
||||
}
|
||||
|
||||
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pattern_lines_within(
|
||||
d: &Drawing,
|
||||
h: &Hatch,
|
||||
loops: &[Vec<Vec2>],
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
max_runs: usize,
|
||||
work_limit: u64,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
let lines = lines_of(d, h);
|
||||
let scale = scale_of(h);
|
||||
@@ -388,7 +458,7 @@ pub(crate) fn pattern_lines_until(
|
||||
// Shared work budget across all families and spans, counting dash-processing steps even when
|
||||
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
|
||||
// their cost (CWE-400).
|
||||
let mut work: u64 = MAX_HATCH_WORK;
|
||||
let mut work: u64 = work_limit;
|
||||
for fam in &lines {
|
||||
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
|
||||
// are not swept at all.
|
||||
@@ -403,8 +473,8 @@ pub(crate) fn pattern_lines_until(
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= max_runs || work == 0 || is_set(cancel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -415,6 +485,8 @@ pub(crate) fn pattern_lines_until(
|
||||
const SWATCH_REPEATS: f64 = 6.0;
|
||||
/// Most runs a swatch holds.
|
||||
const MAX_SWATCH_RUNS: usize = 4000;
|
||||
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
|
||||
const MAX_SWATCH_WORK: u64 = 2_000_000;
|
||||
|
||||
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
|
||||
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
|
||||
@@ -441,14 +513,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines: lines.to_vec(),
|
||||
};
|
||||
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
|
||||
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
|
||||
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
|
||||
// (about a million runs) to keep a few thousand.
|
||||
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
|
||||
runs.truncate(MAX_SWATCH_RUNS);
|
||||
runs.shrink_to_fit();
|
||||
runs
|
||||
}
|
||||
|
||||
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
|
||||
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
|
||||
struct Family<'a> {
|
||||
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
|
||||
max_runs: usize,
|
||||
origin: Vec2,
|
||||
ang: f64,
|
||||
delta: Vec2,
|
||||
@@ -487,6 +564,13 @@ impl Family<'_> {
|
||||
|
||||
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
|
||||
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
|
||||
let transform_charge = verts / 8 + 1;
|
||||
if transform_charge > *work {
|
||||
return;
|
||||
}
|
||||
*work -= transform_charge;
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
@@ -505,10 +589,17 @@ impl Family<'_> {
|
||||
// family draws anything at all).
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
|
||||
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
|
||||
// operations, whether or not any dash is made.
|
||||
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
|
||||
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
|
||||
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
|
||||
return;
|
||||
}
|
||||
*work -= crossings_charge;
|
||||
let y = k as f64 * self.spacing;
|
||||
let xs = crossings(&local, y);
|
||||
let offset = shift_per_line * k as f64;
|
||||
@@ -538,10 +629,10 @@ impl Family<'_> {
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
*work = work.saturating_sub(1);
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
|
||||
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
*work -= 1;
|
||||
let len = d.abs();
|
||||
if *d > 0.0 {
|
||||
let a = x.max(x0);
|
||||
|
||||
@@ -29,11 +29,17 @@ const MAX_TEXTS: usize = 10_000;
|
||||
/// repeated many times).
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
|
||||
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
|
||||
const MAX_TEXT_POINTS: usize = 4_000_000;
|
||||
|
||||
/// One text or shape element of the pattern, shaped once at the origin.
|
||||
struct Element {
|
||||
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
|
||||
at: f64,
|
||||
local: Shaped,
|
||||
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
|
||||
points: usize,
|
||||
rotation: f64,
|
||||
absolute: bool,
|
||||
offset: Vec2,
|
||||
@@ -58,6 +64,7 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
return out;
|
||||
}
|
||||
let mut walk = Walk { pts, i: 0, start: 0.0 };
|
||||
let mut points_left = MAX_TEXT_POINTS;
|
||||
let mut base = 0.0;
|
||||
let mut points = 0usize;
|
||||
'repeats: while base <= len {
|
||||
@@ -67,6 +74,8 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
|
||||
break 'repeats;
|
||||
}
|
||||
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
|
||||
points_left = left;
|
||||
let Some((p, dir)) = walk.at(s) else { break 'repeats };
|
||||
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
|
||||
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
|
||||
@@ -111,6 +120,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
{
|
||||
out.push(Element {
|
||||
at,
|
||||
points: point_count(&local),
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -135,8 +145,16 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
..TextParams::new(Vec2::ZERO, height)
|
||||
};
|
||||
let (local, _) = cadcraft_fonts::place(&font, text, ¶ms);
|
||||
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
|
||||
// glyphs still has a cost proportional to its size.
|
||||
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
|
||||
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
|
||||
+ local.strokes.len()
|
||||
+ local.glyphs.len()
|
||||
+ 1;
|
||||
out.push(Element {
|
||||
at,
|
||||
points,
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -217,6 +235,20 @@ mod tests {
|
||||
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
|
||||
#[test]
|
||||
fn placed_labels_stop_at_the_point_allowance() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut lt = gas();
|
||||
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
|
||||
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
|
||||
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], <, 1.0, 0.0);
|
||||
let points: usize =
|
||||
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
|
||||
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
|
||||
assert!(points <= MAX_TEXT_POINTS, "{points}");
|
||||
}
|
||||
|
||||
/// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2.
|
||||
fn register_shapes() {
|
||||
let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n";
|
||||
|
||||
+45
-13
@@ -133,6 +133,20 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
|
||||
if parts.len() <= 1 {
|
||||
return layout(d, t);
|
||||
}
|
||||
// Every part is laid out and kept, so the cells of all of them count together (a header repeated
|
||||
// on each part adds to each); past the limit only the table's outline is drawn.
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let total: usize = parts.iter().map(|p| p.rows.len().min(10_000).saturating_mul(cols)).fold(0, usize::saturating_add);
|
||||
if total > MAX_LAYOUT_CELLS {
|
||||
// One outline per part, at the part's origin and as tall as its displayed rows.
|
||||
let w = edges(&t.col_widths, cols).last().copied().unwrap_or(0.0);
|
||||
let mut g = TableGeom::default();
|
||||
for p in parts {
|
||||
let h: f64 = p.rows.iter().take(10_000).map(|r| t.row_heights.get(*r).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).sum();
|
||||
outline_box(&mut g, p.origin, w, h);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
let mut g = TableGeom::default();
|
||||
for p in parts {
|
||||
let mut pg = layout(d, &part_table(t, &p.rows, p.origin));
|
||||
@@ -156,16 +170,20 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
|
||||
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
|
||||
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
|
||||
// `run[i]`: how many rows from part row `i` follow one another in the table, found once so a
|
||||
// merge is cut by a lookup, not by walking the rows it spans.
|
||||
let mut run = vec![1u32; rows.len()];
|
||||
for i in (0..rows.len().saturating_sub(1)).rev() {
|
||||
if rows.get(i + 1).zip(rows.get(i)).is_some_and(|(next, this)| *next == this + 1) {
|
||||
run[i] = run.get(i + 1).copied().unwrap_or(0).saturating_add(1);
|
||||
}
|
||||
}
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
}
|
||||
let n = rs.max(1).min(run.get(i).copied().unwrap_or(1));
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +204,27 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// The table's outline alone, for a table too large to lay out.
|
||||
fn outline(t: &Table) -> TableGeom {
|
||||
let rows = t.row_heights.len().min(10_000);
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let (w, h) = (edges(&t.col_widths, cols).last().copied().unwrap_or(0.0), edges(&t.row_heights, rows).last().copied().unwrap_or(0.0));
|
||||
let mut g = TableGeom::default();
|
||||
outline_box(&mut g, t.insert.xy(), w, h);
|
||||
g
|
||||
}
|
||||
|
||||
/// The four sides of a `w` by `h` box hanging down from `o`.
|
||||
fn outline_box(g: &mut TableGeom, o: Vec2, w: f64, h: f64) {
|
||||
let p = |x: f64, y: f64| o + Vec2::new(x, -y);
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
if let [a, b] = s {
|
||||
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out one part.
|
||||
fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
let st = table_style_of(d, t);
|
||||
@@ -203,14 +242,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
}
|
||||
let cov = coverage(t);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
|
||||
let (w, h) = (x_at(cols), y_at(rows));
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
if let [a, b] = s {
|
||||
g.lines.push(TableLine { a: *a, b: *b, color: Color::ByBlock, lineweight: Lineweight::ByBlock });
|
||||
}
|
||||
}
|
||||
return g;
|
||||
return outline(t);
|
||||
}
|
||||
let k = style_scale(&st, t);
|
||||
// Cell styles by name, scaled to the table.
|
||||
|
||||
@@ -1165,3 +1165,55 @@ fn without_linetypes_every_line_is_drawn_whole() {
|
||||
let drawn = build(&drawing("DASHED"), &Space::Model, &Options::default());
|
||||
assert!(drawn.prims.len() > 2 * continuous.prims.len(), "drawn with its linetype: {} primitives", drawn.prims.len());
|
||||
}
|
||||
|
||||
/// A broken table whose parts, with their repeated labels, together hold more cells than a table may
|
||||
/// lay out is drawn as its outline, though the table's own cells are within the limit.
|
||||
#[test]
|
||||
fn broken_table_cells_count_across_its_parts() {
|
||||
let d = Drawing::new_imperial();
|
||||
let t = cadcraft_doc::Table {
|
||||
col_widths: vec![1.0; 600],
|
||||
row_heights: vec![1.0; 1000],
|
||||
title: true,
|
||||
header: true,
|
||||
breaks: Some(cadcraft_doc::TableBreak { enabled: true, height: 3.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(t.parts().len() > 500 && 1000 * 600 <= crate::MAX_LAYOUT_CELLS);
|
||||
let g = crate::table_geometry(&d, &t);
|
||||
let parts = t.parts();
|
||||
assert_eq!((g.lines.len(), g.texts.len(), g.fills.len()), (4 * parts.len(), 0, 0));
|
||||
// Each part's box starts at its own origin.
|
||||
let top = |i: usize| g.lines[4 * i].a;
|
||||
assert!((0..parts.len()).all(|i| top(i) == parts[i].origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reversed_copies_of_a_curved_loop_fill_once() {
|
||||
let ring = |rot: usize, rev: bool| {
|
||||
let mut v: Vec<PolyVertex> = [(0.0, 0.0, 0.5), (4.0, 0.0, -0.25), (4.0, 3.0, 0.0), (0.0, 3.0, 0.1)]
|
||||
.map(|(x, y, bulge)| PolyVertex { bulge, ..PolyVertex::new(Vec2::new(x, y)) })
|
||||
.to_vec();
|
||||
if rev {
|
||||
// The same boundary wound the other way: bulges move to the other end of their edge and flip.
|
||||
let b: Vec<f64> = v.iter().map(|q| q.bulge).collect();
|
||||
v.reverse();
|
||||
let n = v.len();
|
||||
for (j, q) in v.iter_mut().enumerate() {
|
||||
q.bulge = -b[(n - 1 - j + n - 1) % n];
|
||||
}
|
||||
}
|
||||
v.rotate_left(rot);
|
||||
HatchLoop { vertices: v, outer: true, sources: Vec::new() }
|
||||
};
|
||||
let mut h = pattern_hatch("SOLID", 1.0, (0.0, 0.0, 1.0, 1.0));
|
||||
h.loops = vec![ring(0, false), ring(2, false), ring(1, true), ring(3, true)];
|
||||
assert_eq!(loops_of(&h).len(), 1);
|
||||
// A different boundary is kept.
|
||||
h.loops.push(HatchLoop {
|
||||
vertices: ring(0, false).vertices.into_iter().map(|q| PolyVertex { bulge: 0.0, ..q }).collect(),
|
||||
outer: true,
|
||||
sources: Vec::new(),
|
||||
});
|
||||
assert_eq!(loops_of(&h).len(), 2);
|
||||
}
|
||||
|
||||
@@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
|
||||
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
|
||||
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
|
||||
#[test]
|
||||
fn compact_patterns_stop_at_the_swatch_limits() {
|
||||
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
|
||||
let started = std::time::Instant::now();
|
||||
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
|
||||
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user