Compare commits

...
134 Commits
Author SHA1 Message Date
Dylan M. Taylor 7526dc62c1 Merge tested main for Flatpak update patch release 2026-11-11 15:25:57 -05:00
Dylan M. Taylor e1d379ad9f chore: bump version to 0.20.1 for the Flatpak update fix (#3084) 2026-11-11 14:55:43 -05:00
Dylan M. Taylor 161af2a8da Release 0.20.1: repair Flatpak update checks and self-updates (#3078)
* Release 0.20.1: fix Flatpak update permissions

* test: avoid unchecked option parsing in Flatpak regression
2026-11-11 14:27:14 -05:00
Dylan M. Taylor dc7f382cec Fix Flatpak permissions for update checks and self-updates (#3075)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:01 -05:00
FredApps e5021f0175 Merge pull request #3047 from V1dalTheAbidal/pr/bsaveas-source
Block Editor: BSAVEAS keeps the source block's last saved definition
2026-11-11 18:56:45 +01:00
Dylan M. Taylor 2d25308cfc Merge pull request #3068 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:38 -05:00
Dylan M. Taylor 20b4472f0b Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:30 -05:00
Dylan M. Taylor ecb362aa55 ci: run Harden-Runner on every job (#3058)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:09:19 -05:00
FredApps 28241abb8f Merge main into release for CADCraft v0.20.0 2026-11-11 16:38:54 +01:00
FredApps dea9f2d048 Release: CADCraft v0.20.0 (#3054) 2026-11-11 16:38:50 +01:00
Dylan M. Taylor d6d16bd474 fix(io): make four DXF/SVG passes linear in their input (#2721)
* fix(io): make four DXF/SVG passes linear in their input

Preserving foreign objects looked each root-owned object's key up by scanning the root dictionary; applying annotation scales searched every layout for each handle model space lacked; writing CLASS instance counts inserted each missing group into the middle of the tag list; and SVG attribute lookup resumed after a malformed attribute, re-running the parser's duplicate scan each time. Each is now one indexed or single pass with the same results.

* address review: first layout wins for duplicate handles; move tags instead of cloning in class count pass

* address review: reserve room for the added class counts, correct the peak-memory note
2026-11-11 05:01:27 -05:00
Dylan M. Taylor 2410a1fd07 hatch: one canonical key per source loop before tessellation (#3043) 2026-11-11 04:32:31 -05:00
V1dalTheAbidal f132d81609 Document the BSAVEAS source test 2026-11-11 11:14:22 +02:00
Dylan M. Taylor a1574780c2 fix(render): bound hatch swatches, charge boundary sweeps and cap linetype label points (#2744)
* fix(render): bound hatch swatches, charge boundary sweeps and cap linetype label points

* address review: charge hatch boundary transform, count glyph slots in linetype text

* fix(render): charge boundary transforms and scanline sorts to the shared hatch budget
2026-11-11 04:06:57 -05:00
Dylan M. Taylor ea2637ae73 fix(lisp): charge READ text, EQUAL string bytes, SETVAR and ENTMAKE copies, and nested callbacks against one budget (#2741)
* fix(lisp): charge READ text, EQUAL string bytes, SETVAR and ENTMAKE copies, and nested callbacks against one budget

* address review: charge callback steps live and reject completion past the shared allowance
2026-11-11 03:55:17 -05:00
Dylan M. Taylor 7e780548be fix: bound MOCORO copies, array EXPLODE, COMPARE bounds, attribute extraction, PDF links and control-server parsing (#2738)
* fix: bound MOCORO copies, array EXPLODE, COMPARE bounds, attribute extraction, PDF links and control-server parsing

* address review: key extract cache by exact block, count live MOCORO objects, PDF link budget

* review: select extraction columns before copying values, count live objects for MOCORO copies

* address review: count exploded array objects before cloning; scan each block's constants once
2026-11-11 03:16:11 -05:00
Dylan M. Taylor 6d744887f4 fix(mcp): send the control token only to 127.0.0.1:PORT and read it only from a private file (#2718)
The bridge picked the token by port alone and sent it to whatever the address resolved to, so a listener another account opened on ::1 with the same port received the desktop's credential. The token is now attached only when the connected peer is exactly 127.0.0.1:PORT, where the app listens. Reading the token refuses a symlink, a non-plain file and a file others can read, and the shared temporary directory is no longer a fallback location for it.
2026-11-10 21:33:51 -05:00
Dylan M. Taylor 2c25c2d700 fix: index or bound array replacements, DIM field spellings, path samples, hatch refills and broken tables (#2734)
* fix: index or bound array replacements, DIM field spellings, path samples, hatch refills and broken tables

* address review: index placeable array replacements, search past zero-length DIVIDE runs, per-part broken-table outlines
2026-11-10 21:33:42 -05:00
Dylan M. Taylor 95fdf8d11a fix: bound region grid registrations, island containment tests, snap_ends visits, inference duplicate searches and font name decoding (#2742)
* fix: bound region grid registrations, island containment tests, snap_ends visits, inference duplicate searches and font name decoding

* address review: charge empty curves only in snap_ends search

* fix(geom): report a spent containment budget instead of partial islands

* address review: count pending point candidates in the perpendicular cap
2026-11-10 21:33:39 -05:00
Dylan M. Taylor 0843770389 fix(engine): read local files for RECOVER, INSERT, IMPORT, COMPARE, NEW, DATALINK and STYLESMANAGER through the capped reader (#2687)
A path naming an endless device or a FIFO was buffered to EOF before any size check ran, so one short command could exhaust memory or block. These paths now go through capped_read::read_capped, which refuses non-regular files and enforces the byte limit while reading.
2026-11-10 21:29:52 -05:00
Dylan M. Taylor 36f5ad64f9 fix(engine): move LISP output to the command history in one pass (#2714)
lisp_flush drained each line from the front of the output buffer, shifting everything after it every time, so newline-dense PRINC output cost quadratic time outside the interpreter's step budget. It now walks the buffer once and drops the printed prefix once.
2026-11-10 21:29:48 -05:00
Dylan M. Taylor 7804e94ee0 fix(geom): bound the points one polyline tessellation samples (#2723)
* fix(geom): bound the points one polyline tessellation samples

Polyline::tessellate sampled every arc segment (up to 4096 points each) with no total limit, and hatch boundary preparation did this for every loop, so compact curved vertices expanded into gigabytes. tessellate_within takes a point budget; once spent, remaining arcs are drawn as chords. Hatch loops share one budget.

* address review: correct tessellate_within doc
2026-11-10 21:29:40 -05:00
Dylan M. Taylor bc9ad6b059 fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines) (#2728)
* fix: index or bound four small lookups (DOF selection, xref palette, QSELECT colours, log lines)

DOF scanned the selection list per slot; the xref palette scanned the xref list per INSERT; QSELECT parsed a possibly long layer name as a colour for every entity; and a log message could carry CR/LF or other control characters that started apparent extra records. Each now uses a set or map, a length bound, or escaping, with the same results for ordinary input.

* address review: trim colour text before length limit
2026-11-10 21:29:35 -05:00
V1dalTheAbidal 650ae383f2 Block Editor: BSAVEAS keeps the source block's last saved definition
After BSAVE the editor's block table can still hold the older source definition.
Restore the saved source block from the host when composing a copy and when
reconciling the editor and its history, so a later save of the copy keeps it.
2026-11-11 01:32:16 +02:00
FredAppsand.CooCoo 51f5bbcc21 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954) (#3024)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.

(cherry picked from commit 6f0f1fefa8)

Co-authored-by: .CooCoo <RobertLeeLW@gmail.com>
2026-11-10 19:49:24 +01:00
Michael Higginsandmocenigo 2f8f186188 macOS: file dialogs no longer run inside the window's event handler (#1534)
On macOS the Open and Save panels ran modally inside winit's event handler, and resizing one aborted the app (#1527). Every file dialog now goes through CadApp::pick_then / pick_then_open. On macOS, Services::start_pick shows the dialog as a sheet on the window and the answer is acted on in a later frame. The answer is bound to the drawing that asked for it. Windows and Linux are unchanged.

Fixes #1527

Co-authored-by: mocenigo <4894657+mocenigo@users.noreply.github.com>
2026-11-10 19:20:25 +01:00
.CooCoo 6f0f1fefa8 Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.
2026-11-10 12:01:17 +01:00
Dylan M. Taylor 227e3c385b Release 0.19.0 2026-11-09 06:29:44 -05:00
Dylan M. Taylor a3d9348f63 Merge pull request #2951 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 23:01:58 -05:00
dotCooCoo 3074f18960 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:45:36 -08:00
Dylan M. Taylor 10ffb7c7bd Merge pull request #2947 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:58 -05:00
skylar taylor-barrick 4744b4cffb Merge pull request #1034 from vangel76/fix/fonts-bidi-hardening
fix(fonts): paragraph direction skips bidi isolates; harakat no longer block the Lam-Alef ligature
2026-11-09 01:41:49 +01:00
skylar taylor-barrick d9e62df1dd Merge pull request #1273 from vangel76/fix/fonts-mtext-width-factor
fix(fonts): MTEXT \W…x multiplies the width factor; document the atom cap
2026-11-09 01:41:42 +01:00
FredApps 9bd7f8f8f1 Merge pull request #819 from vangel76/docs/gaps-audit-2026-10-12
docs: re-audit gaps.md and parity docs against main
2026-11-09 00:07:54 +01:00
FredApps d847eac134 Merge pull request #1036 from vangel76/fix/fonts-mtext-hardening
fix(fonts): MTEXT keeps the text after an unterminated code; bound oblique, line spacing and relative heights
2026-11-09 00:07:20 +01:00
FredApps cd71385da9 Merge pull request #2436 from vangel76/perf/region-build-no-siphash-set
Region build: test candidate pairs once in their lowest shared cell
2026-11-09 00:06:43 +01:00
FredApps 004cbecd48 Merge pull request #2435 from vangel76/perf/spline-solve-band
perf(geom): limit the spline fit solver to the matrix band
2026-11-09 00:05:17 +01:00
FredApps 0e7e0f59f0 Merge pull request #2434 from vangel76/perf-nurbs-derivative-stack
perf(geom): make NURBS derivative allocation-free up to degree 10
2026-11-09 00:04:03 +01:00
FredApps 6cb8e9b1a0 Merge pull request #2755 from vangel76/contributors-vangel76
contributors: set names for vangel76
2026-11-09 00:02:16 +01:00
dotCooCoo 3a9aebc210 Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-update moves from craft-libs c5a1cbfb to c7c84230, a branch that is
craft-libs main at 0a9d1f48 with storytold/craft-libs#687 and #696 merged
in. Cargo.lock changes the craft-update entry and drops zopfli from zip.

The check result window now shows whenever the updater's status is not
idle, not only after Help > Check for Updates, and never over the startup
prompt. At the first start after an update it reports that the update
installed or why it failed. Restart to Update calls
Updater::restart_to_update. The install helper starts when the app exits
and drops the updater, so a canceled close keeps the update ready.
2026-11-08 12:45:18 -08:00
FredApps 909af5e389 Merge pull request #2939 from FredApps/fix/freebsd-arm64-alpha
Fix FreeBSD ARM64 all-targets test build
2026-11-08 17:24:24 +01:00
FredApps 78810069c7 Merge main and gate the remaining DWG-only unit test 2026-11-08 17:02:58 +01:00
FredApps bd4a47491a Merge pull request #2919 from mkzung/fix/dxf-hard-owned-objects
DXF/DWG: an object a kept object holds by a hard owner reference is kept
2026-11-08 16:57:04 +01:00
FredApps 54c973c919 Merge main to validate proxy preservation and hard-owner retention together 2026-11-08 16:47:57 +01:00
FredApps 53962a5cd5 Merge pull request #2654 from mkzung/fix/opaque-proxy-roundtrip
DWG: keep opaque registered entities and their source classes
2026-11-08 16:44:27 +01:00
FredApps 1434d1017b Use public DXF codecs in the streaming class scanner 2026-11-08 16:31:40 +01:00
FredApps baf2424164 Keep hard-owner liveness monotonic and deduplicate holders in linear time 2026-11-08 16:31:08 +01:00
FredApps ec09c87c43 Keep DXF regressions enabled without the DWG bridge 2026-11-08 16:26:26 +01:00
FredApps a0dfe07f5a Preserve decoded proxy class identities through the DWG bridge 2026-11-08 16:25:00 +01:00
FredApps 9ec5728159 Fix FreeBSD ARM64 test dependency gating 2026-11-08 16:11:26 +01:00
mkzungandClaude Opus 5.5 42490afe8e Merge main into fix/opaque-proxy-roundtrip
Main now has its own proxy class handling for DWG saves (`proxy_classes`, from bf137e2c,
31935157 and 946af874) and the application object conversion of #2917. This branch is
brought onto that:

- Cargo.lock, crates/dwg/Cargo.toml and xtask/src/layers.rs are main's. `dwg` no longer
  depends on `dxf`: the source classes are read from the bridge's ASCII DXF by a bounded
  scan in `proxy_transport::source_classes`, as main's `dxf_class_names` reads their names.
- Saving as DWG 2013 or later restores the source classes in their places (duplicate
  names and metadata kept) when every CLASS record can be written back. Otherwise (before
  2013, a binary DXF, a malformed or unused bad CLASS) main's `proxy_classes` runs: proxies
  are matched by name or left out and counted as unwritten. Nothing returns an error any
  more, so a drawing with proxies saves in its own pre-2013 version, without them, as the
  save report says. A proxy whose number names no source class is left out and counted.
- The loss report is main's `not_saved_as` (`Foreign::proxy_class`);
  `preserves_proxy_entity` and this branch's report test are gone.
- On DWG read, `prepare` runs after `application_objects`, so the proxy objects that makes
  are numbered as the DXF numbers them. It uses main's `proxy_dwg_version`. A registered
  entity with decoded properties and opaque data now stays as read, as on main, and a read
  note names it, instead of being dropped.

Tests: `cargo test -p cadcraft-dwg` 37 passed, `-p cadcraft-io` all passed (25 ignored),
`-p cadcraft-doc`, `-p cadcraft-engine --test save_reports_not_saved` and `--test it`
passed; clippy `-p cadcraft-dwg -p cadcraft-io -p cadcraft-doc -p cadcraft-engine
--all-targets -D warnings`, fmt, `cargo xtask layers` and the wasm32 check of cadcraft-io
pass. `registered_proxy` now checks that a pre-2013 DWG save leaves the proxy out and
keeps the rest; main's proxy tests in crates/io/src/tests.rs pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H43qQA41CJLauqXnPgBQ7r
2026-11-08 14:27:18 +00:00
mkzungandClaude Opus 5.5 adf02f9f04 DXF/DWG: a null-owner object goes with a hard owner that is kept
hard_owners took the first object naming a null-owner object in a 360 as its owner, kept or not.
When an object that is left out named it first, the reader dropped the object, though a kept one
held it too, and the save wrote the kept holder's 360 as 0.

hard_owners now lists every object naming it, in file order, and owner_of takes the first of
them that is kept, else the first. The read (retained_objects) and the write liveness pass in
finish run until what they keep settles; a file where no object has two hard owners takes one
pass, as before. The write pass no longer keeps a converted copy of every object's groups.

The test adds an object nothing keeps that names the held record before its holder does: the
held record stays with the holder and the stray object is dropped, DXF and DWG, twice. It fails
before this change ("DXF 1: no record held").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H43qQA41CJLauqXnPgBQ7r
2026-11-08 14:15:03 +00:00
FredApps e56cc4beeb Merge pull request #2917 from mkzung/fix/dwg-application-object-proxies
DWG: another application's objects are kept as proxy objects of their class
2026-11-08 14:52:50 +01:00
Luis 7a9ec9bae3 Release: refresh contributor credits for each release (#2880) (#2881)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 01:44:24 +01:00
mkzungandClaude Opus 5.5 1363afb883 DXF/DWG: an object a kept object holds by a hard owner reference is kept
An object whose owner (group 330) is null but which another kept object
holds by a hard owner reference (group 360), as one application's proxy
object held by another, broke the owner chain the reader and writer follow
to decide what to keep: it was dropped, and the holder kept a reference to
nothing. Such an object's owner is now the object that hard-owns it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H43qQA41CJLauqXnPgBQ7r
2026-11-07 23:32:34 +00:00
mkzungandClaude Opus 5.5 3f9e45f726 DWG: another application's objects are kept as proxy objects of their class
An object of another application's class that acadrust has no model for is
read only as its DWG record (ObjectType::Unknown), which its DXF writer
leaves out. The DWG to DXF bridge lost every such object with no note, and
the references to it went too (an extension dictionary entry naming the
object, say).

Each one is now carried as a proxy object of its class (ACAD_PROXY_OBJECT):
the record is split into 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 describes them. The
references are read to the end of the handle stream (to its exact length
from R2010 on), null ones too: a null reference is an empty field, and the
references after it belong to the next fields. Objects of the drawing
database's own classes, and records that don't split, stay as they were and
are named in a note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H43qQA41CJLauqXnPgBQ7r
2026-11-07 23:32:34 +00:00
vangel76andClaude Sonnet 5.5 5ece420543 docs: XCLIP, existing-MTEXT editing and Vietnamese are shipped; date the 245/491 and 233/491 references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:18:39 +01:00
vangel76 bd37c89ca1 Merge upstream/main into docs/gaps-audit-2026-10-12 2026-11-07 18:17:35 +01:00
vangel76andClaude Sonnet 5.5 62caaa30d9 docs: alpha-gate drafting gap, POLARMODE bit, DWG failsafe boundary, ACIS wording, breadth metric label
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:33:15 +01:00
FredApps 486d558a78 Merge pull request #2887 from helenkwok/web/page-api
cadcraft-web: window.cadcraft, the page command API from craft-web-api
2026-11-07 15:36:49 +01:00
Helen Kwok 6304abd8f0 cadcraft: keep the ui.screenshot web note inside the table row 2026-11-07 15:29:59 +08:00
Helen Kwok 07b6b8c53a cadcraft: document that ui.screenshot ignores path in the web build 2026-11-07 10:28:05 +08:00
Helen Kwok 14a6cc3cdc cadcraft-ui-egui: ui.screenshot returns the PNG inline on wasm
Without a path the screenshot was written into a private temp directory, and std::env::temp_dir panics on wasm32 (no file system), which stopped the whole web app: the page's call never got a reply and nothing after it ran. On wasm the PNG now comes back inline (pngBase64, width, height, bytes), as ui.render's does. Native builds are unchanged.
2026-11-07 08:11:48 +08:00
Helen Kwok 85b35fc5b9 cadcraft-web: window.cadcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. PendingReply gains try_reply, so a transport that cannot block can poll it, and cancels a request the app has not started when it is dropped, as a timed-out wait does: the page was told the call failed, so the app skips it. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 07:59:36 +08:00
Tanish Manku 865fa9afb0 Merge pull request #2874 from storytold/stack/s3
Stage 1 stack 3: land #2403, #1105, #1002, #1908, #1907, #2134, #2371
2026-11-06 15:33:47 -07:00
Tanish Manku 1452ab6f9f Stack 3: CodeRabbit on #2874: editor limits for braces and deep nesting, OK on a rewritten but unedited text, round-off next to zero in the palette, test folder cleanup 2026-11-06 14:58:21 -07:00
Tanish Manku 0140e275f3 Stack 3: the multileader save test saves as 2007 DXF, where a multileader stays one 2026-11-06 14:33:18 -07:00
Tanish Manku 098fa7fd80 Stack 3: #1105's multileader height field takes #2403's ids as they are 2026-11-06 13:44:57 -07:00
Tanish Manku 32db96f5e3 Stage 1 stack 3: review fixes on #1105, #1002, #1908, #1907, #2134, #2371 2026-11-06 13:35:40 -07:00
Tanish Manku 4e36021812 Merge branch 'stk-2371' into stack/s3
# Conflicts:
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/options.rs
#	docs/gaps.md
#	docs/ui-parity.md
2026-11-06 13:33:05 -07:00
Tanish Manku fafc9e1910 Merge branch 'stk-2134' into stack/s3
# Conflicts:
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/lib.rs
2026-11-06 13:32:34 -07:00
Tanish Manku a42760af80 Merge branch 'stk-1907' into stack/s3
# Conflicts:
#	docs/customization.md
2026-11-06 13:32:16 -07:00
Tanish Manku b1263ed3a4 Merge branch 'stk-1908' into stack/s3 2026-11-06 13:31:50 -07:00
Tanish Manku 141a1ac8cd Merge branch 'stk-1002' into stack/s3
# Conflicts:
#	crates/engine/src/cmd/modify2/textedit.rs
#	crates/ui-egui/src/mtext_editor.rs
2026-11-06 13:29:50 -07:00
Tanish Manku 42c1dca89d Merge branch 'stk-1105' into stack/s3 2026-11-06 13:29:27 -07:00
Tanish Manku b36d7aea40 Merge branch 'stk-2403' into stack/s3 2026-11-06 13:29:26 -07:00
mkzung 968b5bd648 Merge remote-tracking branch 'origin/main' into rebase/opaque-proxy-onto-2026-11-05-main 2026-11-05 02:21:44 +00:00
vangel76andClaude Sonnet 5.5 35545af16d Merge upstream/main into docs/gaps-audit-2026-10-12
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:58:56 +01:00
mkzung 876e103488 Merge branch 'rebase/opaque-proxy-onto-2026-11-03-main' into rebase/opaque-proxy-onto-2026-11-05-main 2026-11-05 00:18:23 +00:00
vangel76andClaude Sonnet 5.5 01e28fc0d4 contributors: set names for vangel76
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:12:01 +01:00
lforgiariniandClaude Sonnet 5.5 bd639d3204 Properties palette: a text row that holds the separator is shown whole
shown_part took off everything after U+001F, so a Contents or dimension
text with that character was shown cut off and an edit wrote the cut-off
text back. Only a tail that reads as the tagged number is taken off now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:31:03 +01:00
ATARYS-LABandClaude Opus 5.5 cce67d27c7 Shortcuts: outside macOS Ctrl and Cmd are one key when checking who has a shortcut
One Ctrl press runs a Ctrl and a Cmd shortcut alike on Windows and Linux, so
two commands could hold Ctrl+K and Cmd+K and only the first would ever run.
same() now compares them as one key there; macOS keeps Control apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8y75DE7yLZ7utxSRDeP
2026-11-03 18:45:14 +01:00
ATARYS-LABandClaude Opus 5.5 d23c5f0182 Keyboard Shortcuts: the Chinese label of its menu item, as main's catalog test asks since #2510
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8y75DE7yLZ7utxSRDeP
2026-11-03 17:19:34 +01:00
ATARYS-LABandClaude Opus 5.5 789c4e9542 Shortcuts: ui.set checks them as the preferences do, and a malformed preference keeps them
ui.set took any map: bad keys, unknown or fixed commands and keys taken twice
reached the menus. A preference value that isn't an object cleared the
shortcuts instead of being ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8y83ji68FwkuL22Qf4n
2026-11-03 17:19:34 +01:00
ATARYS-LABandClaude Opus 5.5 5aa0e2da9e egui 0.37 ids in the Keyboard Shortcuts dialog: Id::unique, as the rest of the UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8yA6ibQ4twvHAJjDU3d
2026-11-03 17:19:34 +01:00
ATARYS-LABandClaude Opus 5.5 cdbc32ac8f Shortcuts: Cmd+Alt+C/X/V stay clipboard keys; one key per command from prefs
egui-winit turns Cmd+C, X and V into clipboard events whatever else is held,
so Alt variants are refused too. Preferences that give one key to two
commands keep it for the first and drop the other, so no menu shows a
shortcut that runs something else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 17:19:34 +01:00
ATARYS-LABandClaude Opus 5.5 7e141247dd Shortcuts: keep a macOS Control key, and Shift+Cmd+C/V stay clipboard keys
A recorded or JSON Ctrl+K (macOS Control) was stored as Cmd+K and Cmd+Ctrl
lost its Control: shortcuts are now written the same on every platform,
Cmd for the command key and Ctrl for a Control that isn't it. Copy with
Base Point and Paste as Block arrive as clipboard events like Copy and
Paste, so their keys can't be given to another command and they keep theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 17:19:34 +01:00
2cbd67f963 Keyboard Shortcuts dialog: any command gets a shortcut, and the user's wins
Tools ▸ Customize ▸ Keyboard Shortcuts lists every command with its
shortcut, a filter, and a button to record a new one (click, press the
keys, Esc cancels), Clear, Default and Reset to Defaults. A shortcut the
user gives wins over a built-in one: the command that had it loses it and
the dialog says which; menus show the shortcut in force. Shortcuts are
kept with the preferences (`shortcuts`), hostile entries dropped. JSON
form `ui.shortcuts {set: {command: key or ""}, reset}` never opens the
dialog. Clipboard keys, Delete and Cmd+Q stay as they are.

Recording and the conflict rules come from the storey shortcuts of #1703.

Co-authored-by: Alex-BB44 <273073052+Alex-BB44@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 17:19:34 +01:00
mkzung 56dc397680 DWG: reconcile proxy preservation with current worker and lockfile 2026-11-03 09:19:16 +00:00
mkzung d2052eb4c7 DWG: isolate proxy conversion failures and normalize proxy names 2026-11-03 09:15:24 +00:00
mkzung 0722afc5bd DWG: keep opaque registered entities and their source classes 2026-11-03 09:15:24 +00:00
vangel76andClaude Sonnet 5.5 5ca4b220aa Merge upstream/main into fix/fonts-mtext-hardening
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:43:32 +01:00
Nicholas Gower b254a28a5e aliases: keep the APPLOAD picker beside the PGP picker on main 2026-11-02 08:38:27 -08:00
Nicholas Gower 168f4a699a Alias Editor: point the web at ALIASEDIT's text parameter (#1900)
Without a native picker the Import… message told users to give ALIASEDIT a
path, but the web has no file system and rejects paths. Name the supported
route instead: the file's contents in ALIASEDIT's text parameter. The
Ukrainian reference translation and the customization docs follow the new
wording; the desktop picker path is unchanged.
2026-11-02 08:37:05 -08:00
Nicholas Gower ff6b0e375b Alias Editor: import an existing .pgp file (#1900) 2026-11-02 08:37:05 -08:00
Nicholas Gower f977b6e7cc docs: leave the parity tables to the maintainers
The affected rows are named in the pull request description, as AGENTS.md asks.
2026-11-02 08:09:44 -08:00
Nicholas Gower c9082d974d aliases: the profile loader accepts a full alias set (#1899)
`MAX_PROFILE_BYTES` (64 KiB) was smaller than a valid profile: ALIASEDIT holds
up to 10,000 aliases, and a serialized full set can be megabytes. On restart
`load_profile` then dropped the whole profile, aliases and unrelated settings
included.

The engine now states the largest alias section `alias_profile` can produce
(`MAX_ALIAS_PROFILE_BYTES`, from the alias count, name and command-id limits,
and the alias file path); the loader's cap is that plus fixed room for the
sysvars and interface preferences. Hostile input over the cap is still
rejected without changing anything.

Tests: a full 10,000-alias profile (620 KB, far past the old 64 KiB) restores
completely, one byte over the new cap is refused, every catalog command id
fits the bound, and the hostile-profile tests now pad past the real cap.
2026-11-02 08:09:17 -08:00
Nicholas Gower fd3a0b9e15 aliases: persist ALIASEDIT aliases in the user profile (#1899)
ALIASEDIT kept its aliases in a process-wide store and nothing saved or
restored them, so they were gone after a restart. The engine now gives
the set and the alias file REINIT reads to the host profile
(alias_profile) and restores them (restore_aliases) in CadApp's
profile_json / load_profile, bounded and safe on a malformed saved set;
command names still win. The Command Aliases dialog says where the
aliases are kept.
2026-11-02 08:09:17 -08:00
lforgiariniandClaude Sonnet 5.5 b8efe39acc fix(ui): Properties palette tells values apart that the display rounds alike
The shared values of several objects were compared as the text each row shows, so a
text height of 7 and one of 7.000001 (both "7.0000") read as one common value. Numeric
rows now carry the number they were formatted from and compare it to 12 significant
digits, which separates anything the display could show at 8 decimals and ignores float
noise.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 12:42:12 +01:00
lforgiariniandClaude Sonnet 5.5 b1bab3043c test(ui): Properties palette with several objects: undo, mixed types, one object
Covers the cases a reviewer asked for: one undo restores every edited
object, a mixed selection shows General only (no Radius row), and one
object shows its own value with nothing varying.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 09:20:46 +01:00
vangel76andClaude Sonnet 5.5 0e949bffe1 Merge upstream/main into docs/gaps-audit-2026-10-12
Resolve the gaps and parity-doc conflicts: keep this branch's re-audit
rows and history, fold in upstream's newer QSAVE and justification
changes, and recount the file tests (88) and catalogs (16).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 11:18:17 +01:00
lforgiariniandClaude Sonnet 5.5 50bc6c1158 fix(ui): Properties palette keeps the polyline Closed value untranslated in its memo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 09:38:12 +01:00
lforgiariniandClaude Sonnet 5.5 f8f0504b9a perf(ui): Properties palette keeps the selection's shared values per revision and selection
Working out every shared value on each frame made the palette slow with thousands of
objects selected. The values (and the selected handles' hex ids) are kept in ui.data
until the drawing revision, the selection or the units change. Entity helpers no longer
clone whole hatches, texts or polylines for one field. Tests: a value typed in the
palette reaches every selected object; the shown values follow revision and selection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 09:38:12 +01:00
lforgiariniandClaude Sonnet 5.5 6ecabc3451 fix(ui): several block references show the dynamic block's name; gaps.md status line
The Name row of the several-objects Properties palette used the block's own name, which for a
dynamic block is an anonymous `*U..` that differs from one reference to the next. It now resolves
each reference's name as the single-object palette does. docs/gaps.md: status line updated for the
revision entry.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 09:38:11 +01:00
lforgiariniandClaude Sonnet 5.5 c223550cb3 feat(ui): Properties palette shows the type's properties for several objects of one type
With more than one object selected the palette showed only General. When all selected objects are
of one type (line, circle, arc, text, mtext, polyline, hatch, block reference, dimension) it now
shows that type's properties, `*VARIES*` where they differ; an edit applies to every selected
object. Positions stay read-only, since typing one would move all objects to the same place.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 09:38:10 +01:00
vangel76andClaude Haiku 5.5 ef167c3140 Merge upstream/main into docs/gaps-audit-2026-10-12
Resolve the parity-doc conflicts: take upstream's header lines and newer
catalog rows, and keep this branch's re-audit history rows and the
re-audit line in each header.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-30 23:39:55 +01:00
vangel76andClaude Sonnet 5.5 08ea23ec2d geom: tighten region oracle test budget assertions
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 23:59:45 +01:00
vangel76andClaude Sonnet 5.5 98b47c1984 perf(geom): limit the spline fit solver to the matrix band
`solve` ran dense Gaussian elimination on a banded matrix. The pivot
scan, the elimination and the back-substitution now stop at the band
measured from the matrix. The skipped operations only combine exact
zeros, so results are bit-identical; the cyclic matrix of a closed fit
has full bandwidth and keeps the full sweep. The old solver stays as a
test-only oracle.

Part of #2432

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 23:58:35 +01:00
vangel76andClaude Sonnet 5.5 1f75a9fb25 geom: test region candidate pairs once, in their lowest shared grid cell
The region build kept a SipHash HashSet of tested pairs over a SipHash
HashMap grid. Each pair is now tested only in the lowest grid cell both
segments occupy, and the grid is a flat Vec. The pairs tested, the visit
and distinct-pair budgets and the output are unchanged; the old search is
kept as a test oracle.

Closes #2430

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 23:57:30 +01:00
vangel76andClaude Sonnet 5.5 c2175377fd perf(geom): make NURBS derivative allocation-free up to degree 10
derivative_unchecked allocated six Vecs per call and computed the degree
p-1 basis separately although the degree p recurrence passes through it.
Up to degree 10 both bases now come from one sweep into stack buffers;
higher degrees keep the previous heap path. The operations and their order
are unchanged, so results are bit-identical.

The old implementation stays as a test-only oracle, compared bit for bit
over random splines of degree 1 to 12.

Closes #2431

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 23:55:20 +01:00
Aldo Zarate c1d7e4415b Merge remote-tracking branch 'upstream/main' into feat/mleader-text 2026-10-28 10:10:05 -06:00
Aldo ZarateandClaude Opus 5.5 1e02e6f263 Text editor: relative heights stay relative, long texts are refused, focus stays put
A \H2x; height is written back as \H2x; (relative over the text's own height), so the part
keeps scaling when the text's height changes; an absolute height stays absolute. TEXTEDIT
refuses a text longer than the editor holds instead of opening it cut short, which OK would
have saved. With the text empty the editor takes the focus back only when nothing else has
it, so a click on the height field keeps it there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:10:04 -06:00
Aldo ZarateandClaude Opus 5.5 d0ab457f31 Relative height codes kept when a text opens in the editor
\H2x; with no inline height before it scaled nothing and was dropped, so saving a multileader's
(or an MTEXT's) text from the editor lost it. The editor now parses the contents against the
text's own height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:24:29 -06:00
Aldo Zarate eae1fb98bf Merge remote-tracking branch 'upstream/main' into feat/mleader-text 2026-10-28 09:21:31 -06:00
Aldo Zarate c35d18e803 Merge remote-tracking branch 'upstream/main' into feat/mleader-text
# Conflicts:
#	crates/engine/src/cmd/props.rs
2026-10-27 19:04:13 -06:00
Francesco BVandClaude Opus 5.5 811bd3cb4e Options: Right-Click Customization (click duration; Default, Edit and Command modes) (F20)
Options > User Preferences > Right-Click Customization... sets the
time-sensitive right-click and its duration (rightClickDuration, 100-2000 ms),
what a right-click does with nothing or something selected and no command
running (rightClickDefault, rightClickEdit: the shortcut menu or repeat the
last command), and, with the time-sensitive right-click off, while a command
runs (rightClickCommand: the menu, Enter, or the menu only when the prompt has
options). The defaults keep today's behaviour; the settings are kept in the
profile.

context_menu::show repeats Session::last_command in Repeat mode, and
in_command uses the configured hold time and Command mode.

Italian and Ukrainian strings; gaps F20 and ui-parity updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 21:30:24 +01:00
vangel76andClaude Sonnet 5.5 603e1acaf9 Merge upstream/main into docs/gaps-audit-2026-10-12
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:13:08 +01:00
Aldo Zarate 5585298128 Merge remote-tracking branch 'upstream/main' into feat/mleader-text
# Conflicts:
#	crates/engine/src/cmd/modify2.rs
#	crates/ui-egui/src/canvas.rs
2026-10-26 19:45:50 -06:00
Aldo Zarate e67de34cf0 Merge remote-tracking branch 'upstream/main' into feat/dblclick-edit
# Conflicts:
#	crates/engine/src/cmd/modify2.rs
2026-10-26 19:45:47 -06:00
vangel76andClaude Sonnet 5.5 ec37f02f46 Merge upstream/main into docs/gaps-audit-2026-10-12
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:56:01 +01:00
Aldo ZarateandClaude Opus 5.5 f02ac726ba MTEXT editor: a state kept for another MTEXT is not reused
The editor's state was kept between frames and only dropped by a frame with no text prompt, so
TEXTEDIT moving to the next MTEXT within one frame (Enter, then a point) opened it with the first
one's text, and OK wrote that text to it. The state now remembers the MTEXT it was made for (its
insertion point and contents when it opened) and is made afresh for another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:53:24 -06:00
Aldo ZarateandClaude Opus 5.5 f892a6f60d Merge upstream/main into feat/mleader-text
Keeps main's accessible name for the MTEXT editor with the focus taken on the first frame, and
the multileader text field uses scope_id like main's other Properties fields (egui update).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:05:01 -06:00
Aldo ZarateandClaude Opus 5.5 0934a629e6 Merge upstream/main into feat/dblclick-edit
Keeps main's accessible name for the MTEXT editor's text box with the focus taken on the first
frame; main's new editor tests build State with `focused`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:53:58 -06:00
vangel76andClaude Sonnet 5.5 22c6b398fd Merge upstream/main into docs/gaps-audit-2026-10-12
Conflicts in docs/gaps.md and docs/target-app-parity.md resolved by keeping both
sides' log rows and headers; docs/parity-checklist.md taken from main (generated).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:22:12 +01:00
vangel76andClaude Sonnet 5.5 609148a444 Merge upstream/main into docs/gaps-audit-2026-10-12 and re-audit against main 41d9b5e95
Conflicts in file-format-parity, localization-parity, target-app-parity and
ui-parity resolved by starting from upstream and keeping only the audit
claims that still hold on main. Re-measured: catalog row counts and English
fallback counts for all fifteen catalogs, io test count (86), geometry test
counts, open PR and issue counts, acadrust version (0.6.5). Corrected rows
that main has overtaken: CI gate on pull requests (#1987), MVIEW Restore
(#633), PDF plot margins, selection cycling, Korean/Greek/Hungarian/Turkish
catalogs in the language table. Regenerated docs/parity-checklist.md with
cargo xtask parity.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:49:55 +02:00
Aldo ZarateandClaude Opus 5.5 2830c7c176 Merge upstream/main into feat/mleader-text
The multileader text test moves into tests/it (one test binary per crate, #1989).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:41:26 -06:00
Aldo Zarate a841f225cf Merge remote-tracking branch 'upstream/main' into feat/mleader-text 2026-10-23 11:39:14 -06:00
vangel76andClaude Sonnet 5.5 6549b97b6c Merge upstream/main into docs/gaps-audit-2026-10-12
Resolve doc conflicts in favour of upstream's newer rows; refresh open-PR count, file test count and catalog list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 01:05:12 +02:00
vangel76andClaude Sonnet 5.5 1d29b626e6 fix(fonts): MTEXT \W...x multiplies the width factor; document the atom cap (#1272)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 23:49:56 +02:00
Aldo ZarateandClaude Opus 5.5 cab962a7cb Let multileader text be edited (double-click, TEXTEDIT, Properties)
Double-clicking a multileader's text now opens the in-place editor, the
same as MTEXT. TEXTEDIT accepts a multileader and edits its MText
contents (newlines round-trip as \P). The Properties palette shows a
Contents field and a Text height field for a multileader with text, and
properties.set writes them back.

Fixes #1081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:21:59 -06:00
vangel76andClaude Sonnet 5.5 101397d9c5 fix(fonts): MTEXT keeps the text after an unterminated code; bound oblique, line spacing and relative heights (#1035)
- \U+hhhh (with surrogate pairs; a lone surrogate gives U+FFFD) and \M+nhhhh are
  decoded before plain_mtext, the layout parser and Paragraphs::scan read the
  contents. An unknown code without an argument now drops only the backslash and
  the code character instead of everything up to the next ';'.
- layout_mtext_with: a non-finite oblique is 0; line spacing is 1 when not finite
  and positive, and clamped to 0.25..=4 (also in the paragraph layout).
- Relative \H...x heights stay within 1e-3..1e3 times the MTEXT height.
- cadcraft-fonts depends on cadcraft-dxf (L0) for the \M+ code page tables;
  mif_escape_at is public for that.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:50:47 +02:00
vangel76andClaude Sonnet 5.5 c32c0f5e30 fix(fonts): paragraph direction skips bidi isolates; harakat no longer block the Lam-Alef ligature (#1033)
base_rtl now ignores strong characters between an isolate initiator and its PDI (or the end, when unmatched), per UBA P2. presentation_forms looks past combining marks for the Alef after a Lam and emits the marks after the ligature.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:48:35 +02:00
vangel76andClaude Sonnet 5.5 950453c7fd docs: re-audit gaps.md and the parity docs against main e707ab4d
Redo of the earlier audit after ~100 PRs landed on 2026-10-13. Every change
cites a merged PR, a file or a count taken from the code; claims that could
not be verified are left as they were. Percentages and hours are not
re-scored (listed in the PR description).

Corrected: REFEDIT (#818), dynamic block references (#838-#883), Block
Editor, MLEADEREDIT/ALIGN/COLLECT (#816), in-place MTEXT editor (#792),
AutoLISP (#855), MVIEW Restore and model-space PDF margins (#633),
SpaceMouse 2D navigation (#650), ACIS data kept on save (#781),
attribute modes (#876), polyline offset bulges (#825), acadrust 0.6.4
(#859), twelve interface catalogs with re-measured row counts, closed
winit PRs #110-#119, PR/issue counts and test counts, and ui-parity rows
that earlier work had already closed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:15:38 +02:00
Aldo ZarateandClaude Opus 5.5 7003c5b930 TEXTEDIT on an MTEXT opens it in the in-place editor (part of #842)
Double-clicking an MTEXT (#868) starts TEXTEDIT, which only asked for
the new text on the command line, with its formatting codes. Now TEXTEDIT
on an MTEXT reports it as the text being edited, so the in-place editor
(#792) opens on it with its text and formats; OK writes the edited
contents back, and TEXTEDIT goes on asking for objects as before. An
editor emptied while editing keeps the text, as an empty answer does.

The editor also takes keyboard focus when it opens on existing text,
with the caret at the end: it only did so for an empty box, so typing
into an existing text went to the command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:50:32 -06:00
146 changed files with 8168 additions and 880 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+12 -1
View File
@@ -18,12 +18,22 @@ 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
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
@@ -58,7 +68,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 +80,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
+35 -31
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.20.1"
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.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -660,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -677,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -688,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
@@ -696,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
@@ -705,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -726,8 +727,9 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.20.1"
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.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -767,13 +769,14 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.20.1"
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.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -795,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -822,15 +825,17 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.20.1"
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1219,6 +1224,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1232,10 +1238,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 +5994,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde_json",
@@ -6210,7 +6227,6 @@ dependencies = [
"nix 0.31.3",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6225,18 +6241,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
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.20.1"
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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+27 -24
View File
@@ -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) |
+4 -1
View File
@@ -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"] }
+61
View File
@@ -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;
}
+4 -1
View File
@@ -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)) {
+5 -1
View File
@@ -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]
+7 -6
View File
@@ -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 }),
}
}
+22 -3
View File
@@ -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
View File
@@ -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;
+51
View File
@@ -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);
})
}
+3 -1
View File
@@ -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"
+12 -6
View File
@@ -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() });
}
+3 -1
View File
@@ -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
View File
@@ -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();
+6
View File
@@ -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);
}
}
+2
View File
@@ -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).
+9 -1
View File
@@ -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());
+11
View File
@@ -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
View File
@@ -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::*;
+632
View File
@@ -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());
}
}
}
+1 -1
View File
@@ -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;
}
+9 -12
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
restore_saved_definition(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,16 +176,13 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -302,7 +299,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
restore_saved_definition(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+24 -2
View File
@@ -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),
}
}
+2 -1
View File
@@ -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 => {
+1 -1
View File
@@ -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")]
+75 -15
View File
@@ -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};
@@ -88,22 +88,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 +165,9 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
} else {
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).cloned().collect()
};
let selected = selected_tags(&ents, d, o);
let mut out = Vec::new();
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
for e in &ents {
let EntityKind::Insert(ins) = &e.kind else { continue };
let anonymous = ins.block.starts_with('*') || d.block(&ins.block).is_some_and(|b| b.anonymous);
@@ -121,12 +176,17 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
}
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
out.push(Found {
block,
handle: e.handle.hex(),
layer: e.common.layer.clone(),
at: ins.insert.xy(),
rotation: ins.rotation.to_degrees(),
values: values(d, ins),
block: block.clone(),
values: values(
ins,
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
&selected,
),
});
}
Ok(out)
+2 -1
View File
@@ -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"))
+54 -8
View File
@@ -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();
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -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`"))
+2 -2
View File
@@ -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;
+13 -9
View File
@@ -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
+83 -2
View File
@@ -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"));
}
}
+1 -1
View File
@@ -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")]
+3 -5
View File
@@ -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`"));
+12
View File
@@ -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)?;
+4 -2
View File
@@ -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);
+34 -2
View File
@@ -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),
+11
View File
@@ -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;
+31 -2
View File
@@ -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")));
}
}
+14 -6
View File
@@ -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"]]);
+13 -3
View File
@@ -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);
+8 -2
View File
@@ -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)
}
}
}
+39 -2
View File
@@ -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)),
}),
@@ -1125,3 +1138,27 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
s.lisp_support_dir().map(|d| candidates.iter().map(|c| d.join(c).to_string_lossy().into_owned()).collect::<Vec<_>>()).unwrap_or_default();
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
}
#[cfg(test)]
mod budget_tests {
use super::*;
/// One 64 KB string occurring many times is charged by its length each time.
#[test]
fn shared_strings_spend_the_conversion_budgets() {
let big = Value::str(&"x".repeat(64 << 10));
// 600 x 64 KB is over the 32 MiB an entity list may hold; 100 x 64 KB is not.
let list = |n: usize| {
let mut items = vec![Value::cons(Value::Int(0), Value::str("TEXT"))];
items.extend(std::iter::repeat_n(Value::cons(Value::Int(1), big.clone()), n));
Value::list(items)
};
assert!(list_to_groups(&list(600)).is_err());
assert!(list_to_groups(&list(100)).is_ok());
// 1,000 x 64 KB is 4,096,000 units of the 2,000,000 SETVAR allows; 100 x 64 KB is not.
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big.clone(); 1_000]), 0, &mut budget).is_none());
let mut budget = TO_JSON_BUDGET;
assert!(to_json_bounded(&Value::list(vec![big; 100]), 0, &mut budget).is_some());
}
}
+30 -11
View File
@@ -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")),
}
}
+42
View File
@@ -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\"");
}
+32 -2
View File
@@ -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
View File
@@ -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))));
}
}
+3 -2
View File
@@ -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
}
+51
View File
@@ -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());
+37
View File
@@ -0,0 +1,37 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
+1
View File
@@ -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;
+124
View File
@@ -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();
}
+14
View File
@@ -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");
}
+1
View File
@@ -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 }
+68 -8
View File
@@ -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
View File
@@ -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]
+5 -2
View File
@@ -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(&paras, &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,
+9 -1
View File
@@ -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;
+18 -1
View File
@@ -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));
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+53 -5
View File
@@ -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)
+193
View File
@@ -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}"));
}
}
+12
View File
@@ -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);
+5 -2
View File
@@ -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"
+19 -1
View File
@@ -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
View File
@@ -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(&current) {
break known;
}
if path.contains(&current) || 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(&current) {
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(&current) {
break known;
}
if path.contains(&current) || 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(&current) {
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}"));
}
}
}
+10 -4
View File
@@ -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");
+68 -17
View File
@@ -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
View File
@@ -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;
}
+4 -1
View File
@@ -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
View File
@@ -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};
+10 -1
View File
@@ -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());
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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;
+12
View File
@@ -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);
}
+21
View File
@@ -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}");
}
}
+204
View File
@@ -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"));
}
+1 -1
View File
@@ -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
View File
@@ -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};
+2
View File
@@ -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};
+2 -2
View File
@@ -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()?;
+1 -1
View File
@@ -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;
+296
View File
@@ -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(&sections.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(_))));
}
}
+3
View File
@@ -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"
+38 -7
View File
@@ -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).
+46 -2
View File
@@ -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
View File
@@ -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);
+32
View File
@@ -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, &params);
// 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)], &lt, 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
View File
@@ -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.
+52
View File
@@ -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);
}
+11
View File
@@ -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