Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 8a4e275466 Route MCP protocol through shared libraries 2026-11-15 15:30:38 +00:00
Dimitrios Vidalis 7064a685a8 Greek: commands, aliases and prompt keywords typed with the Greek layout, and 46 missing Greek rows (#2722)
**What**

Typing a command with the Greek keyboard layout active (ΛΙΝΕ for LINE, ΜΙΡΡΟΡ for MIRROR) now works like typing the Latin name, in four places: starting a command, answering a prompt keyword (ΘΝΔΟ = Undo), the selection words at "Select objects" (ΑΛΛ = ALL), and command-line AutoComplete. The layout puts each Greek letter on the key of the Latin letter it sounds like (θ on U, ψ on C, ω on V, Q types `;`, W the final sigma), so `keyboard::latin_keys` maps letter by letter and ignores accents, including a combining tonos or dialytika straight after its letter. It returns `None` for a word with no Greek letter, so Latin input is never touched. ASCII characters in a Greek word are kept, in lower case (the dash of `-ΛΑΥΕΡ`), and a word with any other character is not read as Latin keys.

A command the interface handles itself answers to its Greek-typed name too: ΨΛΟΣΕ asks about unsaved changes as CLOSE does, `;ΣΑΩΕ` opens the Save picker for a drawing without a file, ΛΑΥΕΡ opens the Layers dialog.

What keeps existing behaviour:
- A word that is already a command, alias or LISP function keeps its own meaning, as typed or in another case or accent (a Greek `(defun c:ΛΙΝΕ …)` runs as itself, also when typed λινε).
- A keyword a prompt offers in its own Greek spelling (a LISP program's list) wins over its Latin reading, with Greek case folded.
- A selection word wins over a keyword on the same key, as it does for Latin typing: at COUNT's "Select object to count or [List/Spaces]", Λ is Last, like L.
- A name that exists wins over the keyword or selection word its keys spell: an existing block at BEDIT's "block name or select" and at BLOCKREPLACE's two name prompts (a block called Μ opens at BEDIT, it is not Multiple), and a loaded hatch pattern at HATCH's pattern prompt (a pattern called ΣΟΛΙΔ is that pattern, not Solid). The name is found in any case or accent and the command gets it as stored.
- Only the lookup reads the Greek keys: text that is no selection word reaches the command as typed (a Greek block name at BEDIT's "block name or select"), and an unknown Greek command is reported as typed.

Known and accepted:
- At a prompt that takes a name and offers a keyword, a Greek word whose keys spell the keyword is the keyword while no block has that name (Σ is Select at BLOCKREPLACE), as the Latin `S` is there.
- Two aliases whose Greek names differ only in case or accent can both exist (the alias editor folds ASCII only). Each runs when typed as stored; another spelling of the word runs the first of them in the store's order.
- Object snap overrides and the other point modifiers (END, FROM, M2P, TT, TK, `.X`) are not read from Greek-layout typing. They need the Latin layout, as before.
- Calls by command id are unchanged: a `cmd {json}` line and `execute` (control channel, MCP) do not read a Greek-typed name.
- In a script a line that starts with `;` is still a comment, so a line `;ΣΑΩΕ` there is not QSAVE.

Also adds 46 `el.tsv` rows for strings that were in the source but missing from the catalog when the rows were written (alias editor, save-on-close and units dialogs, a few menu and command labels). On this head the i18n report lists 39 strings that fall back to English for `el` (mostly keyboard shortcuts, right-click customization and alias import); they are not in this PR. Four command labels keep the English name in parentheses, like the rest of the catalog. The `el.tsv` row of `ATTRIBUTION.md` credits these rows (AI-assisted, read by the contributor, a native Greek speaker), and `contributors/people.toml` has the author's entry.

**Tests**
- `keyboard::tests`: the mapping, non-Greek words left alone, combining marks only straight after a Greek letter, one Greek word in any case, accent or final sigma, a character the layout does not type is no spelling of a Greek letter (the micro sign is not μ), 100 000-letter and malformed input do not panic
- `engine/tests/it/greek_keyboard.rs`: ΛΙΝΕ draws a line (lower case and accents too), `;ΣΑΩΕ`, an unknown Greek word is reported as typed, the selection word ΑΛΛ, Λ, λ and ΛΑΣΤ at COUNT's prompt count like L (Last, not the List option), keywords at MIRROR's prompt (Ν is read as No and keeps the sources, Υ erases them), a Greek LISP command runs as itself and in any case or accent, a Greek block name at BEDIT reaches the command as typed, a block called Μ opens at BEDIT (in either case), a block called Σ at BLOCKREPLACE, a loaded pattern called ΣΟΛΙΔ at HATCH's pattern prompt (upper case, lower case, accented)
- `engine/tests/greek_keyboard_aliases.rs` (its own binary, the alias store is process-global): a Greek alias keeps its meaning, as typed and in any case or accent; a word of 1 000 000 Greek letters is looked up among 5 000 Greek aliases within 5 s (170 s before the word was folded once per lookup)
- `prompt::tests`: a Greek keyword is matched as spelled, before its Latin reading
- `ui-egui`: ΨΛΟΣΕ asks before it closes a drawing with unsaved changes, and a LISP `c:ΨΛΟΣΕ` still runs as itself; ΛΑΥΕΡ (also λάυερ) opens the Layers dialog and `;ΣΑΩΕ` the Save picker; AutoComplete reads a Greek prefix as Latin keys, offers the Greek alias that Enter would run, and leaves a Greek word the drawing's LISP defines as typed
- Each fix after review landed with a test that failed before it.

**What ran** on e171e083, `cargo xtask ci` step by step: `cargo fmt --all -- --check` and `cargo clippy --workspace --all-targets -- -D warnings` clean; `cargo test --workspace --lib --bins --tests --no-fail-fast`: 3759 passed, 0 failed, 28 ignored; the doctest step (the workspace has no doctests), the i18n report, `cargo xtask assets`, `cargo xtask layers` and `cargo xtask wasm` passed. The same steps passed on e171e083 merged with main at e1d379ad, a local merge that is not pushed: 3761 passed, 0 failed, 28 ignored.

Main is merged in. The merge commit e3b3a2e0 had kept only this branch's side of `cmd/file.rs` and `cmd/file/pgp.rs`; e8a79d19 restores main's alias-persistence helpers and exports next to `greek_alias`, so those two files are main's again plus this PR's own lines.

**Docs (not edited here, per AGENTS.md)**: `docs/localization.md` "Greek (`el`) interface" and `docs/localization-parity.md` row "Greek | el" (its row count) could mention that commands typed with the Greek layout are accepted.

Nothing here is Autodesk-derived: the mapping is the standard Greek layout, written from the layout itself.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Greek keyboard-layout input is recognized as Latin keystrokes when entering commands, aliases, autocomplete prefixes, selection keywords, and prompt options.
  * Greek commands and aliases can be matched across case and accent variants. Existing commands, aliases, and matching block names take precedence over Greek-layout interpretations; unrecognized input retains its original text.
  * At prompts that accept either a block name or a selection keyword, Greek input is treated as a name when a matching block exists; otherwise, it can be interpreted as a selection keyword.
  * Greek-layout input can also match loaded or built-in hatch pattern names, which take precedence over prompt keyword interpretation.
  * In BLOCKREPLACE prompts, you can select a block reference to choose a block.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-15 12:12:28 +01:00
Dimitrios Vidalis e43c39c55e Selection and grips respect the layers frozen in the active viewport (#3055) (#3124)
Fixes #3055.

## What
Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, VP Freeze) is not drawn, but it
could still be picked, window-selected and edited through the viewport, and a selected one kept its grips.
`Drawing::is_visible` has no viewport, and selection and grips asked only that.

Three questions now take the active viewport into account:
- `select::selectable(d, e, frozen)`: what a selection can take. `frozen` is `DocState::viewport_frozen()`, the
  layers frozen in the active MSPACE viewport (empty in paper space and on the Model tab). The selection functions
  have `_in` variants that take it (`pick_in`, `pick_all_in`, `select_window_in`, `select_fence_in`,
  `select_polygon_in`, `select_group_in`, `SelectMode::select_in`); the existing functions keep their signatures
  and select as before.
- `DocState::shows(e)`: what the view being edited shows, `is_visible` plus the active viewport. Grips ask it where
  #3048 made them ask `is_visible`.
- `Session::selection()`: the selection as the view being edited has it, without the model-space objects the active
  viewport does not draw (`DocState::shown_selection`). The stored selection, `DocState::selection`, keeps them.

**Selecting.** Every selection made in the space being edited passes the active viewport's frozen layers:
- idle clicks and windows, "Select objects" (a pick, the implied window, W, C, BOX, F, WP, CP, ALL, G, and L and P,
  which take the last object the viewport draws and the part of the Previous set it draws), selection cycling, and
  prompts that pick one object;
- `select`, `selectall`, `selectsimilar`, and the group members a pick brings (PICKSTYLE);
- the commands that select for themselves: STRETCH (JSON and typed, with its own All, Last and Previous and its
  multiline vertex rule), TRIM and EXTEND by fence and by crossing window;
- AutoLISP `ssget` (W, C, F, WP, CP, a point, P, L) and `entsel`;
- in the UI, the rollover highlight and the hatch under the cursor that a double-click edits. The object under
  the cursor and selection cycling's badge are worked out again when the active viewport's frozen layers change
  under a pointer that does not move.

**Already selected.** An object that is selected when its layer is frozen in the active viewport stays in the
stored selection until the selection is replaced or cleared, and counts as selected again once the layer is thawed
there. Until then `Session::selection()` leaves it out, and with it everything that starts from the selection: the
selection made before a command (PICKFIRST), a JSON command that names no objects, Delete, the grip edits that
cover the selection, AutoLISP's implied selection (`ssget`, `ssget "I"`, `ssgetfirst`), the palettes, and what the
session reports as the selection (`summary`, `drawing.inspect`). Two UI functions read the stored selection
themselves and now read `shown_selection` too: the hatch editor's edit mode and BEDIT's choice between editor and
dialog. The selection highlight inside the active viewport leaves the object out as well.

Adding to the selection or removing from it (a click, a window, `select` with `add` or with nothing to select
with, QSELECT appending) starts from the stored selection (`Session::stored_selection()`), so such an object is not
dropped while the selection changes around it. A command that takes the selection, or a new selection, replaces
it as before. `Session::selection()` gives the stored selection unchanged wherever the active viewport hides
none of the selected objects: in paper space, on the Model tab, and in a viewport that freezes no layer.

The UI asks for the selection several times a frame, so `DocState::drawn_in_viewport` does not look every selected
object up: a few handles are looked up one by one, and beyond that it passes over model space once.

**Grips.** Static grip painting and hit testing, hot grips (`click_grip`), the grip edit's targets and the typed
GRIP command (`griphot.rs`), and dynamic grips ask `shows`. The dynamic-grip cache key now holds the active
viewport's frozen layers: entering another viewport changes which references are shown without changing the
drawing revision, so the key did not change. The grip code asks for itself, as it does for `is_visible` since
#3048, so it does not depend on who hands it the objects.

A viewport's frozen layers hide model-space objects only, never an object on the sheet. Layer names are compared
the way the renderer compares them. Outside a viewport, and in a viewport that freezes no layer, nothing changes.

## Test
The tests build two viewports on one layout, one of which freezes a layer. Most take the same path through both:
through the viewport that freezes the layer the object on it is left out, through the other one it is taken as
before. The ones about an object that is already selected freeze and thaw its layer in the viewport being edited.

`crates/engine/tests/it/viewport_frozen_selection.rs`, 22 tests:
- selecting: a pick, window / crossing / fence, idle clicks and windows, selection cycling, every answer to
  "Select objects" (with ERASE), group members, `selectall` and `selectsimilar`, STRETCH (JSON; typed crossing
  window, All, a pick, Last, Previous; the multiline vertex rule), TRIM by fence and by crossing window, a prompt
  for one object (BREAK), LEADER's Copy, AutoLISP `ssget` and `entsel`;
- already selected: a typed command with PICKFIRST, a JSON command without handles and Delete; Previous and Last;
  the grip edits that cover the selection; what the session reports, with paper space and the Model tab unchanged;
  adding to and removing from the selection; a small and a large selection (the two paths of
  `drawn_in_viewport`, in a drawing whose draw order is not the order of its handles);
- grips: a shared grip, the grips of an object whose layer is frozen and thawed again while it is selected, and
  what the edited view shows (model-space objects only, never an object on the sheet).

`crates/ui-egui`, 9 unit tests (hot grips, the grip and the hatch under the cursor, the viewport highlight, dynamic
grips and their cache key, BEDIT's choice, the hatch editor's edit mode, selection cycling's badge under a resting
pointer) and `tests/viewport_frozen_grips.rs`, 3 tests (no grips painted for a selected object the viewport does
not draw; the rollover highlight; the highlight under a resting pointer while the layer is frozen and thawed).

EXTEND runs TRIM's selection code and has no test of its own.

Without the change these tests fail, checked call by call with a temporary switch that is not in this PR: each of
the 60 calls of `viewport_frozen`, `shows`, `hidden_in_viewport`, `drawn_in_viewport`, `shown_selection`, `last_in`
and `stored_selection` outside tests was put back to the old behaviour on its own. 51 of them made at least one
test fail directly; 5 grip checks that sit behind `Session::selection()` did so with the getter switched back as
well; the other 4 are calls inside `DocState` that the same tests reach through their callers. Each path of
`drawn_in_viewport`, and its sort, fails a test when switched off. Of the 42 calls of `Session::selection()`, 10
have a test that fails when that one call reads the stored selection; the other 32 receive the same list and have
no test of their own. The second commit's two conditions (the hover and the badge under a resting pointer) each
have a test that fails without them.

## What ran
The seven steps of `cargo xtask ci`, one by one, on this branch at 6754b960 (main at ddfa833b plus its two commits):
fmt, clippy (workspace, all targets, `-D warnings`), test (3725 passed, 0 failed, 26 ignored; the doctest
step ran, the workspace has no doctests), the i18n report, assets, layers and wasm. All pass.

## Not in this PR
- A selected object whose layer is turned off or frozen for the whole drawing is still taken by a command that
  starts from the selection, as before (ran: ERASE and MOVE typed and as JSON, and Delete, edit it). The issue asks
  to keep the drawing-wide behaviour, so this PR leaves it alone.
- A command that is given object handles (`handles` in JSON, an entity name from AutoLISP) acts on them as before,
  whatever the viewport draws. QSELECT and `ssget "X"` look at the whole drawing; what they put in the stored
  selection is then left out like any other selected object the viewport does not draw.
- `ssget "L"` takes the last object of any space, as before; inside a viewport it now passes over the objects
  that viewport does not draw.
- Object snap, hatch boundary detection, the implied cutting edges of TRIM and EXTEND, constraint inference, and
  the Z that ID and DIST read from an object under the point are not selections and are unchanged.
- A block reference on a layer the viewport draws, with parts on a layer frozen in the viewport: the parts are not
  drawn, but the hit geometry of a reference is built without the viewport, so a pick where such a part would be
  still finds the reference. Ran on eaae189f with a test that is not in the PR: a pick, a crossing window and a
  fence on such a part select the reference, and a window around the drawn part alone does not; with the layer
  frozen for the whole drawing it is the other way round. It needs extents that know the viewport, which
  `crates/doc` does not have, so it is left for its own change.
- Visibility that changes while an interactive command is already waiting, or while a grip list is open, is #3056.

## Compared with AutoCAD 2022
Observed as a black box, in a drawing made there for this and driven by a script: two lines, one of them on a layer
frozen (VPLAYER) in one of two layout viewports. No file from it is in this PR.

Inside the viewport that freezes the layer, the same there and here: a pick, a window, a crossing window, a fence
and a crossing polygon (`ssget`), ALL, Last, a crossing window and a fence at "Select objects", `ssget "L"`, select
all, and STRETCH's crossing window leave the object out; `ssget "X"` returns it; a command that is given the object
itself (its entity name there, `handles` here) changes it. Through the other viewport the pick, the windows, the
fence, ALL, Last and STRETCH took it.

Different, where the issue asks for more ("cannot be edited through that viewport, including when they were
already selected"):
- An object selected through the other viewport stays in the implied selection when the viewport that freezes its
  layer becomes the active one (`ssgetfirst`, `ssget "I"`), and ERASE typed there erased it. Here the commands leave
  it out.
- Previous: `ssget "P"` returned the set made through the other viewport whole, and CHPROP with Previous changed
  the object. Here Previous takes the part of that set the viewport draws.

A comment on the issue asks which of the two is wanted. This PR follows the issue as written; if AutoCAD's
behaviour is preferred for these two cases, I will change them here.

Not observed there: grips (a script cannot read them), and freezing the layer while objects are selected (VPLAYER
is a command and cleared the selection).

## Parity rows
U5 (grips) and F15 (viewports) in docs/gaps.md. No docs edited.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Selection, picking, hover highlighting, and grip interactions now respect layers frozen in the active model-space viewport.
  * Selection reports and editing tools show only objects visible in the active viewport, while stored selections retain hidden objects.
  * Window, crossing, fence, point, and other selection modes now apply viewport visibility consistently.
* **Bug Fixes**
  * Changing viewports or freezing and thawing layers now updates selection highlights, hover targets, and grips to match what the active viewport displays.
  * Selection commands and object inspection now consistently reflect viewport visibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-15 12:11:56 +01:00
neronek22 deff6cb092 Add Polish localization (#218)
Register Polish interface localization with catalog provenance, locale fallback and plural-form regressions.

Original contribution by @neronek22.
2026-11-15 00:34:02 -06:00
FredAppsandFredApps 67514021ae Release: CADCraft v0.21.0 on main (#3172)
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-14 21:45:54 +01:00
Zinadin Zidan 8e69890223 DXF: write a MINSERT with the AcDbMInsertBlock marker (#3127) (#3131)
An INSERT repeated in rows or columns (groups 70/71 above 1) was written with
the subclass marker AcDbBlockReference, the marker of a single block
reference. Readers that go by the marker, BricsCAD among them, then drew only
the first copy of the array. The writer now marks such a reference
AcDbMInsertBlock, as AutoCAD does, and a plain INSERT keeps
AcDbBlockReference. The reader does not look at the marker, so CADCraft reads
both back as before.
2026-11-14 15:22:22 +01:00
Dylan M. Taylor fbb083803b Merge pull request #3143 from storytold/codex/craft-dwg-cutover
Move CADCraft DWG bridge to craft-libs
2026-11-13 20:49:19 -05:00
Dylan M. Taylor 54dfb2ee00 Preserve CADCraft DWG diagnostics 2026-11-14 00:32:49 +00:00
FredAppsandFredApps fa7a97728b Naming: use Additional Tools for the utility menu (#2945)
* Naming: use Additional Tools for the utility menu

* test(naming): guard neutral display labels

* test(ui): handle escaped literals in feature-name guard

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-14 01:18:12 +01:00
Dylan M. Taylor 2a9fc8f241 Use shared DWG bridge in CADCraft 2026-11-13 23:50:21 +00:00
Dimitrios Vidalis ddfa833bc2 Grips: hidden objects paint no grips and take no grip hits (#3048)
* ui: omit hidden objects from grip painting and hit tests

Respect the drawing's existing visibility predicate when painting selection
grips and finding the grip under the pointer. A hidden handle restored through
another selection path must not show or activate grips.

The visibility predicate also suppresses grips for entities on off or frozen
layers, not only objects hidden with HIDEOBJECTS.

The frame regression uses ordinary model geometry and HIDEOBJECTS, with an
on-screen selected-object control.

* Grips: a hidden object takes no hot-grip stretch and no dynamic grips

Clicking a hot grip stretches every hot-grip object, and the set can still hold
an object that was hidden after it became hot (its grips are no longer painted,
but the stretch still moved it). `click_grip` now drops hidden objects from the
hot-grip set first.

Dynamic-block grips come from the selection, which Undo or another selection
source can restore with a hidden block reference in it; `dyngrips::of_selection`
now skips hidden references as ordinary grips already do.

Adds a test for each, a doc comment on the test helpers, and `cadcraft-io` as a
dev-dependency of the UI crate so the dynamic grips test can read the synthetic
dynamic-block fixture of the io tests.

* Grips: a hidden object in the selection takes no part in a grip edit

A grip edit took the whole selection as its targets. A hidden object that was back in the
selection (Undo, sssetfirst, a control call) was stretched with a visible object's grip that
shared its point, and the Move, Rotate, Scale and Mirror modes transformed it.

The grip edit now leaves out what the drawing does not show when a grip becomes hot: a hidden
object's own grip is never made hot, the object is not among the targets, and the typed GRIP
command does not find its grips. A grip edit with no grip left does not start.
2026-11-13 14:12:27 +01:00
FredApps bf581c2376 CI: use the shared git-sources check (#3089)
nix.yml ran its own copy of the git-sources job; it now calls
craft-common-actions git-sources.yml. release.yml's copy is removed:
the shared release.yml runs the same check before any build.
2026-11-13 14:05:25 +01:00
Khuong DoanandClaude Opus 5.5 ca87680fb0 xtask: clippy passes on Windows again (#3091)
stats.rs's test helper fresh_dir mutates its DirBuilder only on Unix
(DirBuilderExt::mode), so on Windows `cargo clippy --workspace
--all-targets -- -D warnings`, and with it `cargo xtask ci`, fails with
unused_mut. Allow that lint off Unix on that binding.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:19:16 -05:00
Dylan M. Taylor c736e488ba Use craft-dxf from craft-libs for DXF tags (#3040)
* Use craft-dxf from craft-libs; remove crates/dxf

cadcraft-dxf moved to craft-libs unchanged as craft-dxf
(storytold/craft-libs#791) so VectorCraft can write DXF through the same tag
layer. CADCRAFT_MAX_DXF_TAGS still sets the tag limit.

* Pin craft-dxf to the merge of storytold/craft-libs#796

* address review: restore the dated measurement text in file-format-parity.md
2026-11-11 19:51:53 -05:00
FredApps 2ef7c3bed1 CI: nightly release dry run (#3088)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:03:49 +01:00
Dylan M. Taylor 5a6aa9b65f fix: bound or confine seven name, path and file inputs (#2732)
* fix: bound or confine seven name, path and file inputs (QSELECT wildcard, layout template name, LISP OPEN/CLOSE, font manifest, xref palette, test walkers)

* address review: reserved device names, fresh fifo test dir, stat before open in build.rs

* review: honour the name alias and Unicode case in exact xrefs, reserve Windows device basenames, charge line headers, skip non-directories in the module walk

* address review: distinct exact xref names, exact-capacity LISP line queue, fail the module walk on entry errors

* address review: xrefs differing only by case cannot be loaded together; panic-free default template name

* address review: refuse detach and path changes across case-variant xrefs; ASCII case folding for xref names

* address review: report the case-variant xref a load skips; assert both xref paths in the test
2026-11-11 16:11:19 -05:00
Dylan M. Taylor b460a3b8d9 fix(engine): an atomic replacement keeps the old file's group, or drops the group bits (#2743)
* fix(engine): an atomic replacement keeps the old file's group, or drops the group bits

* address review: backup group bits, skip no-op fchown

* fix(engine): keep atomic writes of one path serialized, backup bits capped by the drawing's group

* fix(engine): the first backup takes the drawing's group, else drops its group bits
2026-11-11 16:10:52 -05:00
Dylan M. Taylor cb96c182ef fix(fonts): one retained-point allowance per MTEXT layout and per stroke line (#3070)
* fix(fonts): one retained-point allowance per MTEXT layout and per stroke line

* fix(fonts): the layout allowance counts both retained copies
2026-11-11 16:10:25 -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 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 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
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
213 changed files with 7010 additions and 6149 deletions
+2 -36
View File
@@ -23,39 +23,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
+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
+6 -39
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "8 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -24,47 +28,9 @@ jobs:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: [git-sources, credits]
needs: credits
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
@@ -72,6 +38,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: cadcraft
app-title: CADCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+2 -1
View File
@@ -62,6 +62,7 @@ Generated-in-code assets are original and have no file to list:
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/pl.tsv` | @neronek22 (Echo Rozumu & Gadajło; AI-assisted) | Original Polish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
@@ -76,6 +77,6 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted); 46 later rows: @V1dalTheAbidal with Claude Code (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; the 46 later rows were read by their contributor, a native Greek speaker; independent native proofreading of the whole file pending |
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
| `crates/ui-egui/src/i18n/ka.tsv` | Davit Matchakhelidze (@devius), native speaker, with Claude Code | Original Georgian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; terminology and doubtful strings decided by a native Georgian speaker; no font assets added (the Georgian face comes from craft-fonts) |
Generated
+60 -36
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,36 +687,19 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
dependencies = [
"encoding_rs",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
"cadcraft-doc",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"craft-docpath",
"craft-dxf",
"libc",
"log",
"rstar",
@@ -727,10 +710,10 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
"craft-dxf",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
@@ -740,23 +723,23 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"acadrust",
"cadcraft-color",
"cadcraft-doc",
"cadcraft-dwg",
"cadcraft-dxf",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-render",
"craft-dwg",
"craft-dxf",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
@@ -769,13 +752,16 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
"cadcraft-render",
"clap",
"craft-jsonrpc",
"craft-mcp",
"getrandom 0.4.3",
"libc",
"log",
"serde_json",
"tiny-skia",
@@ -783,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -797,13 +783,14 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-geom",
"cadcraft-io",
"cadcraft-render",
"chrono",
"craft-ui",
@@ -824,7 +811,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1151,6 +1138,25 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-dwg"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf#7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "craft-dxf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=006e4df225fca57e95ead0fb1f91920900798a99#006e4df225fca57e95ead0fb1f91920900798a99"
dependencies = [
"encoding_rs",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1179,6 +1185,23 @@ dependencies = [
"writeable",
]
[[package]]
name = "craft-jsonrpc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"serde_json",
]
[[package]]
name = "craft-mcp"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2c9903ca1d892b5446acf3598d8b142f3e64842f#2c9903ca1d892b5446acf3598d8b142f3e64842f"
dependencies = [
"craft-jsonrpc",
"serde_json",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -1213,7 +1236,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1223,6 +1246,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -5992,7 +6016,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.21.0"
dependencies = [
"chrono",
"serde_json",
+7 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,8 +13,9 @@ repository = "https://github.com/storytold/cadcraft"
[workspace.dependencies]
# internal
cadcraft-geom = { path = "crates/geom" }
cadcraft-dxf = { path = "crates/dxf" }
cadcraft-dwg = { path = "crates/dwg" }
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
craft-dwg = { git = "https://github.com/storytold/craft-libs", rev = "7bb6c90076a0177d01cc4c482e7fd8dc9f31a8bf" }
cadcraft-color = { path = "crates/color" }
cadcraft-doc = { path = "crates/doc" }
cadcraft-constraints = { path = "crates/constraints" }
@@ -24,6 +25,8 @@ cadcraft-io = { path = "crates/io" }
cadcraft-engine = { path = "crates/engine" }
cadcraft-ui-egui = { path = "crates/ui-egui" }
cadcraft-mcp = { path = "crates/mcp" }
craft-mcp = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
craft-jsonrpc = { git = "https://github.com/storytold/craft-libs", rev = "2c9903ca1d892b5446acf3598d8b142f3e64842f" }
# Which paths stored in a drawing (xrefs, data links) may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
@@ -71,7 +74,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 = "1b34eba41e62138fc2438d2734523b302840b45d", 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 }
+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 }),
}
}
+3
View File
@@ -118,3 +118,6 @@ sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
[people.V1dalTheAbidal]
sync_github_name = true
+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() });
}
+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);
}
}
+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);
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "cadcraft-dwg"
description = "CADCraft DWG bridge: converts DWG to and from DXF bytes using the acadrust crate (MPL-2.0, used unmodified)"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
log = { workspace = true }
# The same targets as build.rs's `no_dwg_bridge` leave acadrust out: wasm32, and FreeBSD aarch64,
# where acadrust 0.6.6 passes a `*const c_char` (u8 there) to its own `*const i8` sysctlbyname.
# 0.6.7 and 0.6.8 fix that but read some damaged DWGs as empty with no note (tests.rs, #800).
[target.'cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))'.dependencies]
# MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE).
acadrust = "0.6.6"
-12
View File
@@ -1,12 +0,0 @@
//! Sets `no_dwg_bridge` on the targets built without acadrust (see Cargo.toml): the DWG functions
//! there return an error that says to use DXF.
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo::rustc-check-cfg=cfg(no_dwg_bridge)");
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if arch == "wasm32" || (os == "freebsd" && arch == "aarch64") {
println!("cargo::rustc-cfg=no_dwg_bridge");
}
}
File diff suppressed because it is too large Load Diff
-632
View File
@@ -1,632 +0,0 @@
//! 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());
}
}
}
-378
View File
@@ -1,378 +0,0 @@
//! The DWG reader and writer in a child process.
//!
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
//! that reads one request from standard input and writes one reply to standard output. A file that
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
//!
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
//!
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
//! untrusted input.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 2;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Most bytes accepted for what a reply carries besides the drawing.
const MAX_NOTES_BYTES: u64 = 64 << 20;
/// Most bytes accepted as a written DWG.
const MAX_DWG_REPLY: u64 = 2 << 30;
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
const MAX_REQUEST: u64 = (2 << 30) + 256;
struct Worker {
program: PathBuf,
args: Vec<OsString>,
}
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
if let Ok(mut w) = WORKER.lock() {
*w = Some(Worker { program, args });
}
}
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
/// found, the reader and writer stay in this process.
pub fn use_own_worker() {
match std::env::current_exe() {
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
}
}
/// Serve one request and return the exit code if this process was started as a worker.
pub fn serve_if_asked() -> Option<i32> {
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
}
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
// Before starting a process, and so the same limit holds with or without one.
if bytes.len() > limits.max_dwg_bytes {
return super::dwg_to_dxf_noted(bytes, limits);
}
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
Some(reply) => reply?,
None => super::dwg_to_dxf_noted(bytes, limits),
}
}
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
encode_kept_noted(dxf, version, None)
}
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
// A kept version too long to send is no version CADCraft knows: it changes nothing.
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
let mut header = request(ENCODE, Limits::DEFAULT, version);
push_str(&mut header, kept.unwrap_or(""));
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => encode_here(dxf, version, kept),
}
}
/// The conversion in this process.
fn encode_here(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
match kept {
Some(kept) => super::dxf_to_dwg_kept_noted(dxf, version, kept),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
fn unwritten_from(notes: &[String]) -> super::Unwritten {
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
super::Unwritten::new(),
|mut m, (kind, n)| {
*m.entry(kind).or_insert(0) = n;
m
},
)
}
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
#[doc(hidden)]
pub fn crash_worker_for_test() -> Result<(), String> {
call(&request(CRASH, Limits::DEFAULT, ""), &[], 1024, "testing").ok_or("no worker")??.map(|_| ())
}
fn request(op: u8, limits: Limits, version: &str) -> Vec<u8> {
let mut r = vec![PROTOCOL, op];
r.extend((limits.max_dwg_bytes as u64).to_le_bytes());
r.extend((limits.max_dxf_bytes as u64).to_le_bytes());
push_str(&mut r, version);
r
}
/// `text` with a one-byte length before it; callers keep it under 256 bytes.
fn push_str(r: &mut Vec<u8>, text: &str) {
r.push(u8::try_from(text.len()).unwrap_or(0));
r.extend(text.as_bytes());
}
type Reply = Result<(Vec<u8>, Vec<String>), String>;
/// Run one request in a worker. `None`: none could be started, so the caller does the work.
/// `Err`: the worker went wrong, and `doing` says at what, for the user.
fn call(header: &[u8], payload: &[u8], reply_limit: u64, doing: &str) -> Option<Result<Reply, String>> {
let mut command = {
let worker = WORKER.lock().ok()?;
let worker = worker.as_ref()?;
let mut c = Command::new(&worker.program);
c.args(&worker.args);
c
};
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
log::warn!("DWG worker: can't start it: {e}");
return None;
}
};
let (Some(mut to_child), Some(from_child)) = (child.stdin.take(), child.stdout.take()) else {
let _ = child.kill();
let _ = child.wait();
return None;
};
let received = std::thread::scope(|scope| {
// Written beside the read: a worker that replies before it has read everything, or dies,
// must block neither side. A failed write shows up as the reply going wrong.
scope.spawn(move || {
let _ = to_child.write_all(header).and_then(|()| to_child.write_all(payload));
});
let (tx, rx) = channel();
scope.spawn(move || {
let mut reply = Vec::new();
let read = from_child.take(reply_limit).read_to_end(&mut reply);
let _ = tx.send(read.map(|_| reply));
});
let received = rx.recv_timeout(TIMEOUT);
if matches!(received, Err(RecvTimeoutError::Timeout)) {
let _ = child.kill();
}
received
});
let status = child.wait();
let stopped = || format!("the DWG worker stopped while {doing}; nothing else is affected");
Some(match (received, status) {
(Err(RecvTimeoutError::Timeout), _) => Err(format!("{doing} took too long, so the DWG worker was stopped; nothing else is affected")),
(Ok(Ok(reply)), Ok(status)) if status.success() => parse_reply(reply).ok_or_else(stopped),
_ => Err(stopped()),
})
}
/// Take `n` bytes off the front of `b`.
fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
let (head, tail) = b.split_at_checked(n)?;
*b = tail;
Some(head)
}
fn take_u32(b: &mut &[u8]) -> Option<usize> {
let bytes: [u8; 4] = take(b, 4)?.try_into().ok()?;
usize::try_from(u32::from_le_bytes(bytes)).ok()
}
fn take_u64(b: &mut &[u8]) -> Option<u64> {
Some(u64::from_le_bytes(take(b, 8)?.try_into().ok()?))
}
/// A reply: 0, the note count, each note with its length, then the bytes with their length; or 1
/// and a message. `None` when it isn't one, whole. Takes the reply, because the bytes can be most
/// of the memory the app may use: they are kept, not copied.
fn parse_reply(mut reply: Vec<u8>) -> Option<Reply> {
let total = reply.len();
let mut b = &reply[..];
match *take(&mut b, 1)?.first()? {
0 => {
let count = take_u32(&mut b)?;
let mut notes = Vec::new();
for _ in 0..count {
let n = take_u32(&mut b)?;
notes.push(String::from_utf8_lossy(take(&mut b, n)?).into_owned());
}
let n = usize::try_from(take_u64(&mut b)?).ok()?;
if b.len() != n {
return None;
}
reply.drain(..total - n);
Some(Ok((reply, notes)))
}
1 => Some(Err(String::from_utf8_lossy(b).into_owned())),
_ => None,
}
}
fn write_reply(out: &mut impl Write, reply: Reply) -> std::io::Result<()> {
match reply {
Ok((bytes, notes)) => {
out.write_all(&[0])?;
out.write_all(&u32::try_from(notes.len()).unwrap_or(0).to_le_bytes())?;
for note in &notes {
out.write_all(&u32::try_from(note.len()).unwrap_or(0).to_le_bytes())?;
if u32::try_from(note.len()).is_ok() {
out.write_all(note.as_bytes())?;
}
}
out.write_all(&(bytes.len() as u64).to_le_bytes())?;
out.write_all(&bytes)?;
}
Err(e) => {
out.write_all(&[1])?;
out.write_all(e.as_bytes())?;
}
}
out.flush()
}
/// What a request asks for, or why it can't be served.
fn handle(mut b: &[u8]) -> Reply {
let bad = || "the DWG worker got a request it can't read (a different CADCraft version?)".to_string();
if take(&mut b, 1).and_then(<[u8]>::first) != Some(&PROTOCOL) {
return Err(bad());
}
let op = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let max_dwg_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let max_dxf_bytes = take_u64(&mut b).and_then(|n| usize::try_from(n).ok()).ok_or_else(bad)?;
let version_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => {
// The version the DXF's carried-over groups were read from; empty for none.
let kept_len = take(&mut b, 1).and_then(<[u8]>::first).copied().ok_or_else(bad)?;
let kept = take(&mut b, usize::from(kept_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
encode_here(b, version, (!kept.is_empty()).then_some(kept))
.map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect()))
}
CRASH => std::process::abort(),
_ => Err(bad()),
}
}
/// Serve the one request on `input`. The exit code is 0 unless the reply couldn't be sent.
fn serve(input: impl Read, mut output: impl Write) -> i32 {
let mut request = Vec::new();
let reply = match input.take(MAX_REQUEST).read_to_end(&mut request) {
Ok(_) => handle(&request),
Err(e) => Err(format!("the DWG worker could not read its request: {e}")),
};
i32::from(write_reply(&mut output, reply).is_err())
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(reply: Reply) -> Vec<u8> {
let mut out = Vec::new();
write_reply(&mut out, reply).unwrap();
out
}
#[test]
fn what_the_writer_left_out_survives_the_reply() {
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
assert_eq!(unwritten_from(&notes), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
}
#[test]
fn replies_round_trip() {
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
assert_eq!(parse_reply(wire(ok.clone())), Some(ok));
let failed: Reply = Err("DWG: no".into());
assert_eq!(parse_reply(wire(failed.clone())), Some(failed));
}
#[test]
fn a_reply_cut_short_or_made_up_is_refused() {
let whole = wire(Ok((b"bytes".to_vec(), vec!["note".into()])));
for n in 0..whole.len() {
assert_eq!(parse_reply(whole[..n].to_vec()), None, "cut at {n}");
}
assert_eq!(parse_reply(vec![7, 0, 0]), None);
// A note count far beyond what follows ends at the first missing note.
assert_eq!(parse_reply(vec![0, 0xff, 0xff, 0xff, 0xff]), None);
assert_eq!(parse_reply(vec![0, 1, 0, 0, 0, 0xff, 0xff, 0xff, 0xff]), None);
}
#[test]
fn a_request_it_cannot_read_is_an_error_not_a_crash() {
for request in [
&[][..],
&[PROTOCOL + 1, DECODE],
&[PROTOCOL],
&[PROTOCOL, DECODE, 1, 2, 3],
&[PROTOCOL, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
] {
assert!(handle(request).is_err(), "{request:?}");
}
}
/// An encode request carries the kept version after the target one; a request without it is
/// one it can't read, and one with it reaches the converter.
#[test]
fn an_encode_request_carries_the_kept_version() {
let bad = "the DWG worker got a request it can't read (a different CADCraft version?)";
let short = self::request(ENCODE, Limits::DEFAULT, "AC1032");
assert_eq!(handle(&short).unwrap_err(), bad);
for kept in ["", "AC1032"] {
let mut request = self::request(ENCODE, Limits::DEFAULT, "AC1032");
push_str(&mut request, kept);
request.extend(b"not a drawing");
let err = handle(&request).unwrap_err();
assert_ne!(err, bad, "kept {kept:?}");
}
}
#[test]
fn serving_reports_a_decode_failure_as_a_reply() {
let mut request = self::request(DECODE, Limits::DEFAULT, "");
request.extend(b"AC1032 not really a drawing");
let mut out = Vec::new();
assert_eq!(serve(&request[..], &mut out), 0);
assert!(matches!(parse_reply(out.clone()), Some(Err(_))), "{out:?}");
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "cadcraft-dxf"
description = "Standalone DXF tag reader/writer (ASCII and binary, pre-2007 code pages), from the published DXF reference"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
log = { workspace = true }
encoding_rs = { workspace = true }
-398
View File
@@ -1,398 +0,0 @@
//! Text decoding for DXF files.
//!
//! R2007 (AC1021) and later DXF files are UTF-8. Older ones store text in the code page named by
//! the `$DWGCODEPAGE` header variable (`ANSI_1252`, `ANSI_932`, `DOS850`, …); characters outside
//! it are written as `\U+XXXX` (any character) or `\M+nXXXX` (a double-byte character of an East
//! Asian code page) escapes, which [`crate::parse`] decodes after the bytes.
//!
//! The Windows, ISO and East Asian code pages are decoded with `encoding_rs`; DOS 437, 850 and
//! 852 with the tables below (the upper halves of the IBM PC code pages; the lower halves are
//! ASCII). Decoding never fails: a byte sequence that is invalid in the code page (a truncated
//! double-byte character, an unassigned byte) becomes U+FFFD and the bytes after it decode
//! normally, so line ends and the group structure survive.
use std::borrow::Cow;
use encoding_rs::Encoding;
/// How the bytes of a code page become text.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Decoder {
Utf8,
Encoding(&'static Encoding),
/// Single-byte DOS code page: ASCII, then these 128 characters for bytes 0x80–0xFF.
Table(&'static [char; 128]),
}
/// The decoder for a `$DWGCODEPAGE` value (case-insensitive), or `None` when CADCraft doesn't
/// know the code page.
pub(crate) fn decoder(code_page: &str) -> Option<Decoder> {
use encoding_rs as e;
let name = code_page.trim().to_ascii_uppercase().replace(['-', ' '], "_");
let enc = match name.as_str() {
"UTF8" | "UTF_8" | "UNICODE" => return Some(Decoder::Utf8),
"ASCII" | "ANSI_1252" | "ISO8859_1" | "ISO_8859_1" => e::WINDOWS_1252,
"ANSI_874" => e::WINDOWS_874,
"ANSI_932" | "DOS932" | "SHIFT_JIS" => e::SHIFT_JIS,
"ANSI_936" | "GB2312" | "GBK" => e::GBK,
"ANSI_949" | "KSC5601" | "KOREAN" => e::EUC_KR,
"ANSI_950" | "BIG5" => e::BIG5,
"ANSI_1250" => e::WINDOWS_1250,
"ANSI_1251" => e::WINDOWS_1251,
"ANSI_1253" => e::WINDOWS_1253,
"ANSI_1254" | "ISO8859_9" | "ISO_8859_9" => e::WINDOWS_1254,
"ANSI_1255" => e::WINDOWS_1255,
"ANSI_1256" => e::WINDOWS_1256,
"ANSI_1257" => e::WINDOWS_1257,
"ANSI_1258" => e::WINDOWS_1258,
"ISO8859_2" | "ISO_8859_2" => e::ISO_8859_2,
"ISO8859_3" | "ISO_8859_3" => e::ISO_8859_3,
"ISO8859_4" | "ISO_8859_4" => e::ISO_8859_4,
"ISO8859_5" | "ISO_8859_5" => e::ISO_8859_5,
"ISO8859_6" | "ISO_8859_6" => e::ISO_8859_6,
"ISO8859_7" | "ISO_8859_7" => e::ISO_8859_7,
"ISO8859_8" | "ISO_8859_8" => e::ISO_8859_8,
"DOS866" => e::IBM866,
"MACINTOSH" | "MAC_ROMAN" => e::MACINTOSH,
"DOS437" => return Some(Decoder::Table(&DOS437)),
"DOS850" => return Some(Decoder::Table(&DOS850)),
"DOS852" => return Some(Decoder::Table(&DOS852)),
_ => return None,
};
Some(Decoder::Encoding(enc))
}
/// Decode `bytes` from the code page `code_page` names (a `$DWGCODEPAGE` value such as
/// `ANSI_1251`), or `None` when CADCraft doesn't know that code page. Invalid sequences become
/// U+FFFD; escapes (`\U+XXXX`, `\M+nXXXX`) are left as they are.
pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
decoder(code_page).map(|d| decode(bytes, d).into_owned())
}
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
/// reader expects.
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
if code_page.trim().eq_ignore_ascii_case("ASCII") {
return None;
}
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
}
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
let mut buf = [0; 4];
match d {
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
Decoder::Utf8 => return false,
Decoder::Encoding(e) => {
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
// A code page byte that is ASCII would read back as another character, and a NUL,
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
return false;
}
out.extend_from_slice(&bytes);
}
Decoder::Table(t) => {
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
out.push(0x80 | i);
}
}
true
}
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
match d {
Decoder::Utf8 => match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => encoding_rs::WINDOWS_1252.decode_without_bom_handling(bytes).0,
},
Decoder::Encoding(e) => e.decode_without_bom_handling(bytes).0,
Decoder::Table(t) => match std::str::from_utf8(bytes) {
Ok(s) if s.is_ascii() => Cow::Borrowed(s),
_ => Cow::Owned(
bytes
.iter()
.map(|&b| if b.is_ascii() { char::from(b) } else { t.get(usize::from(b & 0x7F)).copied().unwrap_or('\u{FFFD}') })
.collect(),
),
},
}
}
/// The `$DWGCODEPAGE` value of an ASCII DXF file: the line two lines after a `$DWGCODEPAGE`
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
while let Some(line) = lines.next() {
if line == b"ENDSEC" {
return None;
}
if line.eq_ignore_ascii_case(b"$DWGCODEPAGE") {
return match (lines.next(), lines.next()) {
(Some(b"3"), Some(value)) => Some(value),
_ => None,
};
}
}
None
}
impl Decoder {
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
fn is_east_asian(self) -> bool {
use encoding_rs as e;
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
}
}
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
/// text, and older files from writers that ignored the code page); anything else comes from the
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
/// from the code page (see [`BinaryText::string`]).
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => decode(bytes, d),
}
}
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
/// that code page, because a short double-byte string (two half-width katakana, many kanji
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
if let Ok(s) = std::str::from_utf8(bytes) {
return Cow::Borrowed(s);
}
let d = match ascii_code_page(bytes) {
Some(name) => {
let name = String::from_utf8_lossy(name);
decoder(&name).unwrap_or_else(|| {
log::warn!("DXF: unknown code page `{name}`; reading the text as ANSI_1252");
Decoder::Utf8
})
}
None => Decoder::Utf8,
};
if d.is_east_asian() {
return decode(bytes, d);
}
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
// a multi-byte character of the supported code pages) and decode each line on its own.
let mut out = String::with_capacity(bytes.len());
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
out.push_str(&decode_text(line, d));
}
Cow::Owned(out)
}
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
/// first in the HEADER section and are ASCII, so every later string is decoded right.
#[derive(Default)]
pub(crate) struct BinaryText {
/// The header variable whose value comes next (after a group 9).
var: Option<HeaderVar>,
utf8: bool,
code_page: Option<Decoder>,
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
code_page_only: bool,
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
ea_utf8: bool,
ea_other: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum HeaderVar {
AcadVer,
CodePage,
}
impl BinaryText {
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
/// not: every string of an East Asian code page is decoded from that code page.
pub(crate) fn code_page_pass() -> Self {
Self { code_page_only: true, ..Self::default() }
}
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
pub(crate) fn needs_code_page_pass(&self) -> bool {
!self.code_page_only && self.ea_utf8 && self.ea_other
}
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
let s = if d.is_east_asian() && !self.code_page_only {
if !raw.is_ascii() {
if std::str::from_utf8(raw).is_ok() {
self.ea_utf8 = true;
} else {
self.ea_other = true;
}
}
decode_text(raw, d).into_owned()
} else {
decode(raw, d).into_owned()
};
match (code, self.var.take()) {
(9, _) => {
self.var = match s.trim() {
"$ACADVER" => Some(HeaderVar::AcadVer),
"$DWGCODEPAGE" => Some(HeaderVar::CodePage),
_ => None,
}
}
(1, Some(HeaderVar::AcadVer)) => self.utf8 = is_utf8_version(&s),
(3, Some(HeaderVar::CodePage)) => {
self.code_page = decoder(&s);
if self.code_page.is_none() {
log::warn!("DXF: unknown code page `{}`; reading the text as ANSI_1252", s.trim());
}
}
_ => {}
}
s
}
}
/// True for an `$ACADVER` of R2007 (AC1021) or later, whose text is UTF-8.
fn is_utf8_version(acadver: &str) -> bool {
acadver.trim().strip_prefix("AC").and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1021)
}
/// The double-byte code page of a `\M+n` escape: 1 Japanese (Shift-JIS), 2 Traditional Chinese
/// (Big5), 3 Korean (Wansung), 5 Simplified Chinese (GB 2312). 4 (Korean Johab) is not
/// supported, so its escapes stay literal.
pub(crate) fn mif_encoding(n: char) -> Option<&'static Encoding> {
match n {
'1' => Some(encoding_rs::SHIFT_JIS),
'2' => Some(encoding_rs::BIG5),
'3' => Some(encoding_rs::EUC_KR),
'5' => Some(encoding_rs::GBK),
_ => None,
}
}
// Generated from the IBM PC code page definitions (bytes 0x80–0xFF in order).
const DOS437: [char; 128] = [
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}',
'\u{255D}', '\u{255C}', '\u{255B}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}', '\u{255F}',
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}',
'\u{2558}', '\u{2552}', '\u{2553}', '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}', '\u{2580}',
'\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}', '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}',
'\u{221E}', '\u{03C6}', '\u{03B5}', '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}', '\u{2248}',
'\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
];
const DOS850: [char; 128] = [
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00F8}', '\u{00A3}', '\u{00D8}', '\u{00D7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{00AE}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{00C0}', '\u{00A9}', '\u{2563}', '\u{2551}', '\u{2557}',
'\u{255D}', '\u{00A2}', '\u{00A5}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{00E3}', '\u{00C3}',
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{00F0}', '\u{00D0}', '\u{00CA}', '\u{00CB}',
'\u{00C8}', '\u{0131}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{00A6}', '\u{00CC}', '\u{2580}',
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{00D2}', '\u{00F5}', '\u{00D5}', '\u{00B5}', '\u{00FE}', '\u{00DE}', '\u{00DA}', '\u{00DB}', '\u{00D9}',
'\u{00FD}', '\u{00DD}', '\u{00AF}', '\u{00B4}', '\u{00AD}', '\u{00B1}', '\u{2017}', '\u{00BE}', '\u{00B6}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
'\u{00B0}', '\u{00A8}', '\u{00B7}', '\u{00B9}', '\u{00B3}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
];
const DOS852: [char; 128] = [
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{016F}', '\u{0107}', '\u{00E7}', '\u{0142}', '\u{00EB}', '\u{0150}', '\u{0151}',
'\u{00EE}', '\u{0179}', '\u{00C4}', '\u{0106}', '\u{00C9}', '\u{0139}', '\u{013A}', '\u{00F4}', '\u{00F6}', '\u{013D}', '\u{013E}', '\u{015A}',
'\u{015B}', '\u{00D6}', '\u{00DC}', '\u{0164}', '\u{0165}', '\u{0141}', '\u{00D7}', '\u{010D}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
'\u{0104}', '\u{0105}', '\u{017D}', '\u{017E}', '\u{0118}', '\u{0119}', '\u{00AC}', '\u{017A}', '\u{010C}', '\u{015F}', '\u{00AB}', '\u{00BB}',
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{011A}', '\u{015E}', '\u{2563}', '\u{2551}', '\u{2557}',
'\u{255D}', '\u{017B}', '\u{017C}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{0102}', '\u{0103}',
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{0111}', '\u{0110}', '\u{010E}', '\u{00CB}',
'\u{010F}', '\u{0147}', '\u{00CD}', '\u{00CE}', '\u{011B}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{0162}', '\u{016E}', '\u{2580}',
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{0143}', '\u{0144}', '\u{0148}', '\u{0160}', '\u{0161}', '\u{0154}', '\u{00DA}', '\u{0155}', '\u{0170}',
'\u{00FD}', '\u{00DD}', '\u{0163}', '\u{00B4}', '\u{00AD}', '\u{02DD}', '\u{02DB}', '\u{02C7}', '\u{02D8}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
'\u{00B0}', '\u{00A8}', '\u{02D9}', '\u{0171}', '\u{0158}', '\u{0159}', '\u{25A0}', '\u{00A0}',
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_page_names() {
let d = |bytes: &[u8], cp: &str| decode_code_page(bytes, cp);
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", "ANSI_1251").as_deref(), Some("Привет"));
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", " ansi_1251 ").as_deref(), Some("Привет"));
assert_eq!(d(b"\x90\x7D\x96\xCA", "ANSI_932").as_deref(), Some("図面"));
assert_eq!(d(b"\xCD\xBC\xB2\xE3", "GB2312").as_deref(), Some("图层"));
assert_eq!(d(b"\xB9\xCF\xBC\x68", "BIG5").as_deref(), Some("圖層"));
assert_eq!(d(b"\xB5\xB5\xB8\xE9", "ANSI_949").as_deref(), Some("도면"));
assert_eq!(d(b"\xD3\xF7\xDD\xE4\xE9\xEF", "ANSI_1253").as_deref(), Some("Σχέδιο"));
assert_eq!(d(b"\xC0\xD2\xBE", "ANSI_874").as_deref(), Some("ภาพ"));
assert_eq!(d(b"\x80", "ANSI_1252").as_deref(), Some("€"));
// DOS 437 and 850 agree on letters but differ on 0x9B (¢ / ø).
assert_eq!(d(b"Gr\x94\xE1e \x9B", "DOS437").as_deref(), Some("Größe ¢"));
assert_eq!(d(b"Gr\x94\xE1e \x9B", "dos850").as_deref(), Some("Größe ø"));
assert_eq!(d(b"\xBD\xA2\x88w", "DOS852").as_deref(), Some("Żółw"));
assert_eq!(d(b"\x91\xAB\xAE\xA9", "DOS866").as_deref(), Some("Слой"));
assert_eq!(d("é".as_bytes(), "UTF8").as_deref(), Some("é"));
assert_eq!(d(b"x", "ANSI_1361"), None);
assert_eq!(d(b"x", "EBCDIC"), None);
assert_eq!(d(b"x", ""), None);
}
#[test]
fn invalid_sequences_become_replacement_characters() {
// A Shift-JIS lead byte cut off by the line end keeps the line end.
assert_eq!(decode_code_page(b"\x90\n1\n", "ANSI_932").as_deref(), Some("\u{FFFD}\n1\n"));
assert_eq!(decode_code_page(b"\x90", "ANSI_932").as_deref(), Some("\u{FFFD}"));
assert_eq!(decode_code_page(b"\xFF\xFF", "ANSI_936").as_deref(), Some("\u{FFFD}\u{FFFD}"));
}
#[test]
fn header_scan() {
let head = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\r\n0\nENDSEC\n";
assert_eq!(ascii_code_page(head), Some(&b"ANSI_1251"[..]));
// Only the HEADER section counts.
assert_eq!(ascii_code_page(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n9\n$DWGCODEPAGE\n3\nANSI_1251\n"), None);
// Wrong group code, or the file ends early.
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n1\nANSI_1251\n"), None);
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n3"), None);
assert!(is_utf8_version("AC1021") && is_utf8_version(" AC1032 "));
assert!(!is_utf8_version("AC1018") && !is_utf8_version("AC1009") && !is_utf8_version("garbage"));
}
#[test]
fn mif_escapes() {
let one = |s: &str| crate::parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap()[0].str();
assert_eq!(one("\\M+5BCFE\\M+1907D\\M+2B9CF\\M+3B5B5!"), "件図圖도!");
assert_eq!(one("\\M+5BCFE\\U+0021"), "件!");
// Johab, unknown code pages, bad hex, short escapes and unassigned codes stay literal.
for s in ["\\M+4ABCD", "\\M+9BCFE", "\\M+5GGGG", "\\M+5BC", "\\M+", "\\M+50041", "\\M+5FFFF"] {
assert_eq!(one(s), s);
}
}
}
-925
View File
@@ -1,925 +0,0 @@
//! A standalone DXF tag reader and writer.
//!
//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
//! Reference; depends on nothing else in the workspace.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::fmt::Write as _;
use std::io::Write as _;
mod version;
pub use version::Version;
#[derive(Debug, thiserror::Error)]
pub enum DxfError {
#[error("not a DXF file")]
NotDxf,
#[error("line {line}: bad group code `{text}`")]
BadCode { line: usize, text: String },
#[error("unexpected end of file")]
Eof,
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
/// limit was reached), `limit` the maximum.
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
TooLarge { tags: usize, limit: usize },
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
OutOfMemory { tags: usize },
}
pub type Result<T> = std::result::Result<T, DxfError>;
/// A typed group value.
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Str(String),
Real(f64),
Int(i64),
Bool(bool),
/// Binary chunk (310–319, 1004) as hex text.
Hex(String),
}
impl Value {
pub fn as_str(&self) -> String {
match self {
Value::Str(s) | Value::Hex(s) => s.clone(),
Value::Real(r) => format_real(*r),
Value::Int(i) => i.to_string(),
Value::Bool(b) => i64::from(*b).to_string(),
}
}
pub fn as_f64(&self) -> f64 {
match self {
Value::Real(r) if r.is_finite() => *r,
Value::Int(i) => *i as f64,
Value::Str(s) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
Value::Bool(b) => f64::from(u8::from(*b)),
_ => 0.0,
}
}
pub fn as_i64(&self) -> i64 {
match self {
Value::Int(i) => *i,
Value::Real(r) if r.is_finite() => *r as i64,
Value::Str(s) => {
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
}
Value::Bool(b) => i64::from(*b),
_ => 0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Tag {
pub code: i32,
pub value: Value,
}
impl Tag {
pub fn new(code: i32, value: Value) -> Self {
Tag { code, value }
}
pub fn s(code: i32, v: impl Into<String>) -> Self {
Tag { code, value: Value::Str(v.into()) }
}
pub fn f(code: i32, v: f64) -> Self {
Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) }
}
pub fn i(code: i32, v: i64) -> Self {
Tag { code, value: Value::Int(v) }
}
pub fn str(&self) -> String {
self.value.as_str()
}
pub fn f64(&self) -> f64 {
self.value.as_f64()
}
pub fn i64(&self) -> i64 {
self.value.as_i64()
}
}
/// The value type for a group code (DXF Reference, "Group Code Value Types").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Str,
Real,
Int16,
Int32,
Int64,
Bool,
Hex,
}
pub fn kind_of(code: i32) -> Kind {
match code {
0..=9 => Kind::Str,
10..=59 => Kind::Real,
60..=79 => Kind::Int16,
90..=99 => Kind::Int32,
100..=109 => Kind::Str,
110..=149 => Kind::Real,
160..=169 => Kind::Int64,
170..=179 => Kind::Int16,
210..=239 => Kind::Real,
270..=289 => Kind::Int16,
290..=299 => Kind::Bool,
300..=309 => Kind::Str,
310..=319 => Kind::Hex,
320..=369 => Kind::Str,
370..=389 => Kind::Int16,
390..=399 => Kind::Str,
400..=409 => Kind::Int16,
410..=419 => Kind::Str,
420..=429 => Kind::Int32,
430..=439 => Kind::Str,
440..=449 => Kind::Int32,
450..=459 => Kind::Int32,
460..=469 => Kind::Real,
470..=479 => Kind::Str,
480..=481 => Kind::Str,
999 => Kind::Str,
1000..=1003 => Kind::Str,
1004 => Kind::Hex,
1005..=1009 => Kind::Str,
1010..=1059 => Kind::Real,
1060..=1070 => Kind::Int16,
1071 => Kind::Int32,
_ => Kind::Str,
}
}
fn typed(code: i32, text: &str) -> Value {
let t = text.trim();
match kind_of(code) {
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
// the integer spelling reads them); NaN, inf and text become 0.
let (lo, hi) = match kind_of(code) {
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
_ => (i64::MIN, i64::MAX),
};
t.parse::<i64>()
.map(Value::Int)
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
}
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
Kind::Hex => Value::Hex(normalize_hex(t)),
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
}
}
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
/// raise or lower it.
pub const MAX_TAGS: usize = 100_000_000;
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
/// [`MAX_TAGS`].
pub fn max_tags() -> usize {
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
}
fn max_tags_from(v: Option<&str>) -> usize {
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
}
mod codepage;
pub use codepage::decode_code_page;
/// Parse a DXF file (ASCII or binary).
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
parse_with_limit(bytes, max_tags())
}
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in
/// milliseconds instead of after it has filled memory.
pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
return parse_binary_with_limit(body, max_tags);
}
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
// EOF just as the parser does. Blank lines do not count toward the limit.
let mut lines = byte_lines(bytes);
let mut tags = 0;
while let Some(code) = lines.next() {
let code = code.trim_ascii();
if code.is_empty() {
continue;
}
let Some(value) = lines.next() else { break };
tags += 1;
if code == b"0" && value.trim_ascii() == b"EOF" {
break;
}
}
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
}
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
fn escape_at(chars: &[char], i: usize) -> Option<u32> {
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'U') || chars.get(i + 2) != Some(&'+') {
return None;
}
let hex = chars.get(i + 3..i + 7)?;
if !hex.iter().all(char::is_ascii_hexdigit) {
return None;
}
hex.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
}
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
/// double-byte code page and `XXXX` is the character's two bytes in hex.
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
return None;
}
let enc = codepage::mif_encoding(*chars.get(i + 3)?)?;
let hex = chars.get(i + 4..i + 8)?;
if !hex.iter().all(char::is_ascii_hexdigit) {
return None;
}
let code = hex.iter().try_fold(0u16, |acc, c| Some(acc * 16 + u16::try_from(c.to_digit(16)?).ok()?))?;
let bytes = code.to_be_bytes();
let text = enc.decode_without_bom_handling_and_without_replacement(&bytes)?;
let mut it = text.chars();
it.next().filter(|_| it.next().is_none())
}
/// Unescape the `\U+XXXX` sequences pre-R2007 DXF uses for characters outside the file's code
/// page (four hex digits, one UTF-16 code unit; characters above U+FFFF are a surrogate pair of
/// escapes), and the `\M+nXXXX` sequences for double-byte East Asian characters. Malformed
/// escapes, unpaired surrogates and unknown `\M+` code pages are kept as literal text.
fn unescape_unicode(s: &str) -> String {
if !s.contains("\\U+") && !s.contains("\\M+") {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while let Some(&c) = chars.get(i) {
if let Some(ch) = mif_escape_at(&chars, i) {
out.push(ch);
i += 8;
continue;
}
if let Some(unit) = escape_at(&chars, i) {
if let Some(ch) = char::from_u32(unit) {
out.push(ch);
i += 7;
continue;
}
if (0xD800..0xDC00).contains(&unit)
&& let Some(low) = escape_at(&chars, i + 7).filter(|u| (0xDC00..0xE000).contains(u))
&& let Some(ch) = char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00))
{
out.push(ch);
i += 14;
continue;
}
}
out.push(c);
i += 1;
}
out
}
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
/// [`str::lines`] otherwise (no empty line after a final terminator).
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
let mut rest = text;
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
let line = rest.get(..end).unwrap_or_default();
let after = rest.get(end..).unwrap_or_default();
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
Some(line)
})
}
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
std::iter::from_fn(move || {
if rest.is_empty() {
return None;
}
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
let line = &rest[..end];
let after = &rest[end..];
rest = match after {
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
_ => after,
};
Some(line)
})
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_counted(text, max_tags(), 0)
}
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
/// with an allocation that can fail: where the system refuses that much memory, opening the file
/// is an error instead of the end of the app. Growing the list instead could also briefly take
/// half as much again.
#[allow(clippy::while_let_loop)]
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
let ct = code_line.trim();
if ct.is_empty() {
continue;
}
let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?;
let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) };
let v = typed(code, val);
let v = match v {
Value::Str(s) => Value::Str(unescape_unicode(&s)),
o => o,
};
tags.push(Tag { code, value: v });
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
}
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
break;
}
}
if tags.is_empty() {
return Err(DxfError::NotDxf);
}
Ok(tags)
}
struct Cur<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Cur<'a> {
fn take(&mut self, n: usize) -> Result<&[u8]> {
let end = self.p.checked_add(n).ok_or(DxfError::Eof)?;
let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?;
self.p = end;
Ok(s)
}
fn u8(&mut self) -> Result<u8> {
Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?)
}
fn i16(&mut self) -> Result<i16> {
let s = self.take(2)?;
Ok(i16::from_le_bytes([s[0], s[1]]))
}
fn i32(&mut self) -> Result<i32> {
let s = self.take(4)?;
Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn i64(&mut self) -> Result<i64> {
let s = self.take(8)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
Ok(i64::from_le_bytes(a))
}
fn f64(&mut self) -> Result<f64> {
let s = self.take(8)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
Ok(f64::from_le_bytes(a))
}
/// The bytes of a zero-terminated string (without the zero), decoded by the caller.
fn cstr(&mut self) -> Result<&'a [u8]> {
let start = self.p;
while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
self.p += 1;
}
let s = self.b.get(start..self.p).unwrap_or(&[]);
if self.p >= self.b.len() {
return Err(DxfError::Eof);
}
self.p += 1;
Ok(s)
}
}
/// Binary DXF (R13+: 2-byte group codes).
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
parse_binary_with_limit(b, max_tags())
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
let mut text = codepage::BinaryText::default();
let tags = parse_binary_pass(b, max_tags, &mut text)?;
if !text.needs_code_page_pass() {
return Ok(tags);
}
// The first pass's tags go before the second pass runs: both held at once would double
// the peak near the tag limit.
drop(tags);
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
}
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
let mut c = Cur { b, p: 0 };
let mut tags = Vec::new();
while c.p < b.len() {
let mut code = i32::from(c.i16()?);
if code == 255 {
code = i32::from(c.i16()?);
}
let v = match kind_of(code) {
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
Kind::Int64 => Value::Int(c.i64()?),
Kind::Bool => Value::Bool(c.u8()? != 0),
Kind::Hex => {
let n = usize::from(c.u8()?);
let bytes = c.take(n)?;
let mut s = String::with_capacity(n * 2);
for x in bytes {
let _ = write!(s, "{x:02X}");
}
Value::Hex(s)
}
};
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
tags.push(Tag { code, value: v });
if tags.len() > max_tags {
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
}
if eof {
break;
}
}
if tags.is_empty() {
return Err(DxfError::NotDxf);
}
Ok(tags)
}
/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point).
pub fn format_real(v: f64) -> String {
if !v.is_finite() {
return "0.0".into();
}
let s = format!("{v}");
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") }
}
/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional).
///
/// The output is pure ASCII: non-ASCII characters in strings are written as `\U+XXXX` escapes,
/// the pre-R2007 convention, so it is correct whatever `$DWGCODEPAGE` the header declares.
pub fn write_ascii(tags: &[Tag]) -> String {
write_ascii_as(tags, false)
}
/// Write tags as ASCII DXF with text in UTF-8 (`utf8`, the R2007+ convention: non-ASCII
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
/// breaks inside strings become spaces either way.
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
// Both forms are UTF-8 (the escaped one is ASCII).
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
/// DXF that is valid UTF-8 as UTF-8.
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
return ascii(tags, Strings::Escaped(None));
}
bytes
}
/// How a writer stores string values.
#[derive(Clone, Copy)]
enum Strings {
/// As they are (R2007+).
Utf8,
/// ASCII as it is, other characters in this code page when it has them, otherwise as
/// `\U+XXXX` escapes (before R2007).
Escaped(Option<codepage::Decoder>),
}
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(tags.len() * 16);
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) => push_string(s, strings, &mut out),
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
Value::Hex(h) => {
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
// longer data continues in further groups of the same code. Only hex digits are
// written, so a hostile value can't inject tags.
let digits = normalize_hex(h);
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
if chunks.is_empty() {
chunks.push("");
}
for (n, chunk) in chunks.into_iter().enumerate() {
if n > 0 {
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
}
out.extend_from_slice(chunk.as_bytes());
}
}
}
out.extend_from_slice(b"\r\n");
}
out
}
/// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel,
/// then per tag a 2-byte little-endian group code and the value in the binary form of the code's
/// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte
/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into
/// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
/// is stored as a number; a code outside the 16-bit range is skipped.
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
}
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
/// [`write_ascii_in`] does.
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
}
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
out.extend_from_slice(BINARY_SENTINEL);
for t in tags {
let Ok(code) = i16::try_from(t.code) else { continue };
let code_bytes = code.to_le_bytes();
match kind_of(t.code) {
Kind::Str => {
out.extend_from_slice(&code_bytes);
let s = match &t.value {
Value::Str(s) => s.clone(),
v => v.as_str(),
};
let mut text = Vec::with_capacity(s.len());
push_string(&s, strings, &mut text);
out.extend(text.into_iter().filter(|&b| b != 0));
out.push(0);
}
Kind::Real => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&t.value.as_f64().to_le_bytes());
}
Kind::Int16 => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
}
Kind::Int32 => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
}
Kind::Int64 => {
out.extend_from_slice(&code_bytes);
out.extend_from_slice(&t.value.as_i64().to_le_bytes());
}
Kind::Bool => {
out.extend_from_slice(&code_bytes);
out.push(u8::from(t.value.as_i64() != 0));
}
Kind::Hex => {
let bytes = hex_bytes(&t.value.as_str());
// An empty chunk is still one group, as in ASCII.
let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect();
if chunks.is_empty() {
chunks.push(&[]);
}
for chunk in chunks {
out.extend_from_slice(&code_bytes);
out.push(u8::try_from(chunk.len()).unwrap_or(u8::MAX));
out.extend_from_slice(chunk);
}
}
}
}
out
}
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
fn normalize_hex(s: &str) -> String {
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
if out.len() % 2 == 1 {
out.push('0');
}
out
}
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
fn hex_bytes(s: &str) -> Vec<u8> {
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
}
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
fn wrap_i16(v: i64) -> [u8; 2] {
match u16::try_from(v) {
Ok(u) => u.to_le_bytes(),
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
}
}
fn wrap_i32(v: i64) -> [u8; 4] {
match u32::try_from(v) {
Ok(u) => u.to_le_bytes(),
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
}
}
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
pub fn is_binary(bytes: &[u8]) -> bool {
bytes.starts_with(BINARY_SENTINEL)
}
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
let chars: Vec<char> = s.chars().collect();
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
for (i, &c) in chars.iter().enumerate() {
match (c, strings) {
('\r' | '\n', _) => out.push(b' '),
('\\', _) if escape_at(i + 1) => {
out.extend_from_slice(b"\\U+005C");
}
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
(c, Strings::Escaped(_)) => {
let mut units = [0u16; 2];
for u in c.encode_utf16(&mut units) {
let _ = write!(out, "\\U+{u:04X}");
}
}
}
}
}
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
let start = out.len();
if !codepage::encode_char(c, d, out) {
return false;
}
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
out.truncate(start);
return false;
}
true
}
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
#[derive(Clone, Debug, Default)]
pub struct Section {
pub name: String,
pub tags: Vec<Tag>,
}
/// Split a tag stream into sections.
pub fn sections(tags: &[Tag]) -> Vec<Section> {
let mut out = Vec::new();
let mut i = 0;
while i < tags.len() {
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
if is_sec {
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
let start = i + 2;
let mut j = start;
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
j += 1;
}
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
i = j + 1;
} else {
i += 1;
}
}
out
}
/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0).
pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
let mut out: Vec<(String, Vec<Tag>)> = Vec::new();
for t in tags {
if t.code == 0 {
out.push((t.str(), Vec::new()));
} else if let Some(last) = out.last_mut() {
last.1.push(t.clone());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_escape_text_roundtrips() {
for utf8 in [false, true] {
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
let tags = vec![Tag::s(8, text)];
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
assert_eq!(back, tags, "utf8={utf8} {text}");
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
}
}
}
#[test]
fn blank_lines_do_not_consume_group_code_limit() {
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
}
#[test]
fn ascii_roundtrip() {
let tags = vec![
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "LINE"),
Tag::s(8, "0"),
Tag::f(10, 1.5),
Tag::f(20, 2.0),
Tag::i(62, 1),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
];
let text = write_ascii(&tags);
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back, tags);
let secs = sections(&back);
assert_eq!(secs.len(), 1);
assert_eq!(secs[0].name, "ENTITIES");
let recs = records(&secs[0].tags);
assert_eq!(recs[0].0, "LINE");
assert_eq!(recs[0].1.len(), 4);
}
#[test]
fn binary_parse() {
let mut b = BINARY_SENTINEL.to_vec();
let push_str = |b: &mut Vec<u8>, code: i16, s: &str| {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s.as_bytes());
b.push(0);
};
push_str(&mut b, 0, "LINE");
b.extend_from_slice(&10i16.to_le_bytes());
b.extend_from_slice(&3.25f64.to_le_bytes());
b.extend_from_slice(&62i16.to_le_bytes());
b.extend_from_slice(&5i16.to_le_bytes());
push_str(&mut b, 0, "EOF");
let tags = parse(&b).unwrap();
assert_eq!(tags[1], Tag::f(10, 3.25));
assert_eq!(tags[2], Tag::i(62, 5));
}
#[test]
fn malformed_inputs() {
assert!(parse(b"").is_err());
assert!(parse(b"abc\nxyz\n").is_err());
assert!(parse(b"0\n").is_err());
let mut b = BINARY_SENTINEL.to_vec();
b.extend_from_slice(&10i16.to_le_bytes());
b.extend_from_slice(&[1, 2, 3]);
assert!(parse(&b).is_err());
// Bad numbers become 0 rather than failing.
let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap();
assert_eq!(t[0].f64(), 0.0);
}
#[test]
fn unicode_escapes_and_latin1() {
let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap();
assert_eq!(t[0].str(), "Café");
let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap();
assert_eq!(t[0].str(), "Café");
}
#[test]
fn surrogate_pair_escapes() {
let t = parse(b"1\n\\U+D83D\\U+DE00 \\u+56fe\\U+53f7\n0\nEOF\n").unwrap();
assert_eq!(t[0].str(), "😀 \\u+56fe号");
}
#[test]
fn hostile_escapes_stay_literal() {
for s in [
"\\U+",
"\\U",
"\\",
"a\\U+ZZZZ",
"\\U+12",
"\\U+D800",
"\\U+DC00",
"\\U+D800\\U+0041",
"\\U+D83D\\U+DE",
"\\U++123",
"\\U+-123",
"x\\U+12\u{e9}4",
] {
let t = parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap();
let expect = if s == "\\U+D800\\U+0041" { "\\U+D800A" } else { s };
assert_eq!(t[0].str(), expect, "{s:?}");
}
}
#[test]
fn writes_non_ascii_as_escapes() {
let tags = vec![Tag::s(1, "图号 İø 😀\r\nend"), Tag::s(1000, "Ø"), Tag::s(0, "EOF")];
let text = write_ascii(&tags);
assert!(text.is_ascii());
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+D83D\\U+DE00 end\r\n"));
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back[0].str(), "图号 İø 😀 end");
assert_eq!(back[1].str(), "Ø");
}
#[test]
fn binary_strings_unescape() {
let mut b = BINARY_SENTINEL.to_vec();
b.extend_from_slice(&1i16.to_le_bytes());
b.extend_from_slice(b"\\U+56FE\\U+53F7\0");
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF\0");
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
}
}
#[cfg(test)]
mod limit_tests {
use super::*;
#[test]
fn tag_limit_defaults_and_env_override() {
assert_eq!(max_tags_from(None), MAX_TAGS);
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
const { assert!(MAX_TAGS >= 100_000_000) };
}
#[test]
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
assert_eq!((tags.len(), tags.capacity()), (3, 3));
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
}
}
-117
View File
@@ -1,117 +0,0 @@
//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`).
/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format
/// also serves the releases after it (2018 is still AutoCAD's current format).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Version {
/// AutoCAD 2000/2002 (AC1015).
R2000,
/// AutoCAD 2004–2006 (AC1018).
R2004,
/// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8.
R2007,
/// AutoCAD 2010–2012 (AC1024).
R2010,
/// AutoCAD 2013–2017 (AC1027).
R2013,
/// AutoCAD 2018 and later (AC1032), the default.
#[default]
R2018,
}
impl Version {
/// Every version, oldest first.
pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018];
/// The `$ACADVER` code ("AC1032").
pub fn code(self) -> &'static str {
match self {
Version::R2000 => "AC1015",
Version::R2004 => "AC1018",
Version::R2007 => "AC1021",
Version::R2010 => "AC1024",
Version::R2013 => "AC1027",
Version::R2018 => "AC1032",
}
}
/// The release year that names the version ("2018").
pub fn year(self) -> &'static str {
match self {
Version::R2000 => "2000",
Version::R2004 => "2004",
Version::R2007 => "2007",
Version::R2010 => "2010",
Version::R2013 => "2013",
Version::R2018 => "2018",
}
}
/// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case.
pub fn parse(s: &str) -> Option<Version> {
let s = s.trim();
let year = s.strip_prefix(['R', 'r']).unwrap_or(s);
Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s))
}
/// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes
/// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when
/// the code isn't a version code at all.
pub fn for_acadver(code: &str) -> Option<Version> {
let code = code.trim();
if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) {
return Some(v);
}
code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?;
let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::<u32>().ok())?;
Some(match n {
..1018 => Version::R2000,
1018..1021 => Version::R2004,
1021..1024 => Version::R2007,
1024..1027 => Version::R2010,
1027..1032 => Version::R2013,
_ => Version::R2018,
})
}
/// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in
/// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.
pub fn utf8(self) -> bool {
self >= Version::R2007
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.year())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_years_and_codes() {
assert_eq!(Version::parse("2018"), Some(Version::R2018));
assert_eq!(Version::parse(" r2004 "), Some(Version::R2004));
assert_eq!(Version::parse("ac1021"), Some(Version::R2007));
assert_eq!(Version::parse("2005"), None);
assert_eq!(Version::parse(""), None);
for v in Version::ALL {
assert_eq!(Version::parse(v.code()), Some(v));
assert_eq!(Version::parse(v.year()), Some(v));
}
}
#[test]
fn file_versions_map_to_the_nearest_writable_one() {
assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000));
assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000));
assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010));
assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018));
assert_eq!(Version::for_acadver("MC0.0"), None);
assert_eq!(Version::for_acadver("AC"), None);
assert_eq!(Version::for_acadver("AC99999999999999999999"), None);
}
}
-19
View File
@@ -1,19 +0,0 @@
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
#[test]
fn utf8_bom_bytes_are_skipped() {
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(BODY.as_bytes());
let with_bom = cadcraft_dxf::parse(&bytes);
assert!(with_bom.is_ok(), "{with_bom:?}");
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
}
#[test]
fn bom_text_is_skipped() {
let text = format!("\u{feff}{BODY}");
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
}
-75
View File
@@ -1,75 +0,0 @@
//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`,
//! and hostile binary input never panics.
use cadcraft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary};
fn sample() -> Vec<Tag> {
vec![
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "TEXT"),
Tag::s(5, "1F"),
Tag::f(10, -3.25),
Tag::i(62, 5),
Tag::i(90, 70_000),
Tag::i(160, 1 << 40),
Tag::new(290, Value::Bool(true)),
Tag::new(310, Value::Hex("00FFA5".into())),
Tag::s(1, "图号 Café 😀"),
Tag::i(1071, -2),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
]
}
#[test]
fn binary_roundtrips_every_value_kind() {
for utf8 in [false, true] {
let bytes = write_binary(&sample(), utf8);
assert!(is_binary(&bytes));
assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}");
}
// Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8.
let old = write_binary(&sample(), false);
assert!(old.windows(7).any(|w| w == b"\\U+56FE"));
let new = write_binary(&sample(), true);
assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes()));
}
#[test]
fn binary_converts_values_to_their_codes_kind() {
// A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk.
let long = "AB".repeat(300);
let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")];
let back = parse(&write_binary(&tags, true)).unwrap();
assert_eq!(back[0], Tag::i(70, 12));
assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX)));
assert_eq!(back[2].code, 310);
assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255");
assert_eq!(back[2].str().len() + back[3].str().len(), 600);
}
#[test]
fn utf8_ascii_keeps_text_and_drops_line_breaks() {
let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true);
assert!(text.contains("图号 Ä\r\n"));
assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä");
}
#[test]
fn hostile_binary_input_never_panics() {
let good = write_binary(&sample(), true);
// Every truncation, and every single-byte corruption of the body.
for n in 0..good.len() {
let _ = parse(good.get(..n).unwrap());
}
for i in 22..good.len() {
for b in [0u8, 0x7f, 0xff] {
let mut bad = good.clone();
bad[i] = b;
let _ = parse(&bad);
}
}
// A sentinel and nothing else is not a drawing.
assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err());
}
-116
View File
@@ -1,116 +0,0 @@
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
fn file(code_page: &str, text: &str) -> Vec<Tag> {
vec![
Tag::s(0, "SECTION"),
Tag::s(2, "HEADER"),
Tag::s(9, "$ACADVER"),
Tag::s(1, "AC1015"),
Tag::s(9, "$DWGCODEPAGE"),
Tag::s(3, code_page),
Tag::s(0, "ENDSEC"),
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
Tag::s(0, "TEXT"),
Tag::s(1, text),
Tag::s(0, "ENDSEC"),
Tag::s(0, "EOF"),
]
}
fn contains(bytes: &[u8], part: &[u8]) -> bool {
bytes.windows(part.len()).any(|w| w == part)
}
#[test]
fn text_is_written_in_the_code_page() {
// (code page, text, the bytes written for it)
let cases: [(&str, &str, &[u8]); 6] = [
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
];
for (cp, text, raw) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
#[test]
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
let tags = file(cp, "Ação\r\nfim");
let bytes = write_ascii_in(&tags, cp);
assert!(bytes.is_ascii(), "{cp}");
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
}
}
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
#[test]
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
let tags = file("ANSI_1251", "СІ");
let ascii = write_ascii_in(&tags, "ANSI_1251");
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
let binary = write_binary_in(&tags, "ANSI_1251");
assert!(contains(&binary, b"\xD1\xB2"));
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
// Bytes that are not valid UTF-8 stay as they are.
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
}
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
/// would read as UTF-8.
#[test]
fn literal_escapes_survive_code_page_text() {
for (cp, text) in
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
{
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
#[test]
fn a_trail_byte_backslash_never_starts_an_escape() {
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
];
for (cp, text, written, never) in cases {
let tags = file(cp, text);
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
}
}
}
-145
View File
@@ -1,145 +0,0 @@
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
//! Handwritten examples, not files from a CAD application or generated by our writer.
//! Background on the tag model and file structure:
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
vec![
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
]
}
#[test]
fn reads_independently_authored_ascii_and_binary_values() {
for (expected, ascii, binary) in fixtures() {
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
let bytes = [SENTINEL, binary].concat();
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
}
}
#[test]
fn writes_independently_specified_ascii_and_binary_bytes() {
for (tag, ascii, binary) in fixtures() {
for utf8 in [false, true] {
let tags = std::slice::from_ref(&tag);
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
}
}
}
#[test]
fn integer_code_ranges_have_independent_binary_widths() {
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
// can round-trip every boundary value while still using the wrong number of bytes.
let widths = [
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
];
for (ranges, width, min, max) in widths {
for code in ranges.into_iter().flatten() {
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
// little-endian two's-complement integer. Encoding is version-independent.
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
let value_bytes = value.to_le_bytes();
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
let tags = [Tag::i(code, value)];
for utf8 in [false, true] {
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
}
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
}
}
}
}
#[test]
fn text_encoding_has_independent_wire_expectations() {
let tags = [Tag::s(1, "é😀")];
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
assert_eq!(parse(&ascii).unwrap(), tags);
assert_eq!(parse(&binary).unwrap(), tags);
}
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
let binary = [
SENTINEL,
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
]
.concat();
for bytes in [ascii.as_slice(), binary.as_slice()] {
let tags = parse(bytes).unwrap();
assert_eq!(tags[7], Tag::s(1, "ێ"));
}
}
#[test]
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
for newline in ["\n", "\r\n"] {
for final_newline in [false, true] {
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
if final_newline {
text.push_str(newline);
}
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
}
}
}
#[test]
fn malformed_and_truncated_values_are_errors_not_panics() {
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
for (_, _, binary) in fixtures() {
// Every cut inside the first record is an error, unlike cuts between complete records:
// the tag API deliberately accepts streams without an EOF record.
for cut in 1..binary.len() {
let bytes = [SENTINEL, &binary[..cut]].concat();
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
}
}
}
#[test]
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
for bytes in [ascii.as_slice(), binary.as_slice()] {
for limit in 0..3 {
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
}
for limit in [3, 4] {
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
}
}
}
-220
View File
@@ -1,220 +0,0 @@
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
let mut b = SENTINEL.to_vec();
f(&mut b);
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF\0");
b
}
#[test]
fn binary_reals_that_are_not_finite_become_zero() {
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let b = bin_with(|b| {
b.extend_from_slice(&10i16.to_le_bytes());
b.extend_from_slice(&bits.to_le_bytes());
});
let tags = parse(&b).unwrap();
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
}
}
#[test]
fn ascii_accepts_bom_and_cr_only_line_ends() {
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
assert_eq!(parse(text.as_bytes()).unwrap(), want);
let mut bom = b"\xEF\xBB\xBF".to_vec();
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
assert_eq!(parse(&bom).unwrap(), want);
// Mixed terminators.
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
}
#[test]
fn cr_only_files_count_against_the_tag_limit() {
let mut text = String::new();
for _ in 0..50 {
text.push_str("999\rcomment\r");
}
text.push_str("0\rEOF\r");
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
}
#[test]
fn one_bad_byte_spoils_one_line_not_the_file() {
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
b.extend_from_slice("Größe".as_bytes());
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
let t = parse(&b).unwrap();
assert_eq!(t[1].str(), "ANSI_1251");
assert_eq!(t[2].str(), "Größe");
assert_eq!(t[3].str(), "Привет");
}
#[test]
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
let utf8 = "Größe".as_bytes();
let latin = b"Caf\xE9";
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
a.extend_from_slice(utf8);
a.extend_from_slice(b"\n1\n");
a.extend_from_slice(latin);
a.extend_from_slice(b"\n0\nEOF\n");
let from_ascii = parse(&a).unwrap();
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
let from_binary = parse(&b).unwrap();
assert_eq!(from_ascii[2].str(), "Größe");
assert_eq!(from_ascii[3].str(), "Café");
assert_eq!(from_binary[2].str(), "Größe");
assert_eq!(from_binary[3].str(), "Café");
}
#[test]
fn trailing_spaces_keep_sections_and_eof() {
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
// The EOF with a trailing space still ends the file.
assert_eq!(t.len(), 5);
let s = sections(&t);
assert_eq!(s.len(), 1);
assert_eq!(s[0].name, "ENTITIES");
assert_eq!(s[0].tags.len(), 1);
// Binary EOF too.
let mut b = SENTINEL.to_vec();
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF \0");
b.extend_from_slice(&1i16.to_le_bytes());
b.extend_from_slice(b"after\0");
assert_eq!(parse(&b).unwrap().len(), 1);
}
#[test]
fn integer_groups_reject_non_finite_and_clamp() {
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
assert_eq!(t[0], Tag::i(70, 0));
assert_eq!(t[1], Tag::i(70, 0));
// Clamped to what the group's width holds, unsigned values included (the binary writer
// keeps up to 65535 and 4294967295 as their bit patterns).
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
assert_eq!(t[5], Tag::i(160, i64::MAX));
assert_eq!(t[6], Tag::i(160, 0));
assert_eq!(t[7], Tag::i(62, 12));
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
}
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
/// `65535.0` is 65535 like `65535`, not 32767.
#[test]
fn unsigned_values_read_the_same_in_either_spelling() {
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
assert_eq!(t[0], t[1]);
assert_eq!(t[0], Tag::i(70, 65535));
assert_eq!(t[2], t[3]);
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
}
#[test]
fn hex_groups_pad_odd_digits_and_drop_junk() {
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
assert_eq!(t[2].value, Value::Hex(String::new()));
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
}
#[test]
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back.len(), 2, "{text:?}");
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
let long = "AB".repeat(300);
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
assert_eq!(sizes, vec![254, 254, 92]);
// An empty chunk is still one group.
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
assert_eq!(back[0].code, 310);
}
#[test]
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
let tags = [
Tag::i(70, 65535),
Tag::i(70, 40000),
Tag::i(70, 70000),
Tag::i(70, -40000),
Tag::i(90, 0xFFFF_FFFF),
Tag::i(90, 1 << 40),
Tag::i(90, -(1 << 40)),
Tag::s(0, "EOF"),
];
let back = parse(&write_binary(&tags, true)).unwrap();
assert_eq!(back[0], Tag::i(70, -1));
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(back[4], Tag::i(90, -1));
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
}
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
for s in strings {
a.extend_from_slice(b"1\n");
a.extend_from_slice(s);
a.push(b'\n');
}
a.extend_from_slice(b"0\nEOF\n");
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
[parse(&a).unwrap(), parse(&b).unwrap()]
}
#[test]
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
// string is plainly Shift-JIS, so the file is not UTF-8.
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
assert_eq!(t[3].str(), "図面");
}
}
#[test]
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
assert_eq!(t[2].str(), "図面");
assert_eq!(t[3].str(), "ñ");
assert_eq!(t[4].str(), "plain");
}
}
-119
View File
@@ -1,119 +0,0 @@
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
//! Pending writer regressions extend the chunk-size finding in issue #1025.
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
fn payload(length: usize) -> Vec<u8> {
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
}
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
}
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
let wire = write_ascii_as(&input(code, bytes), utf8);
let lines: Vec<_> = wire.lines().collect();
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
assert_eq!(following[0].trim(), "70");
assert_eq!(following[1], "-7");
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
let (records, remainder) = chunks.as_chunks::<2>();
assert!(remainder.is_empty(), "incomplete group/value pair");
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
let mut back = Vec::new();
for record in records {
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
let text = record[1];
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
assert!(remainder.is_empty(), "{context}: partial byte");
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
}
assert_eq!(back, bytes, "{context}: complete payload and byte order");
}
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
let wire = write_binary(&input(code, bytes), utf8);
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
let mut back = Vec::new();
while !chunks.is_empty() {
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
let (chunk, next) = rest.split_at(usize::from(length));
back.extend_from_slice(chunk);
chunks = next;
}
assert_eq!(back, bytes, "{context}: complete payload and byte order");
}
#[test]
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
for code in (310..=319).chain([1004]) {
for length in [0, 1, 2, 126, 127] {
for utf8 in [false, true] {
let bytes = payload(length);
check_ascii(code, &bytes, utf8);
check_binary(code, &bytes, utf8);
}
}
}
}
#[test]
fn binary_reader_tolerates_the_full_length_byte_domain() {
// This is deliberately permissive input, not an assertion that oversized chunks conform
// or that the writer should produce them. The following record catches framing mistakes.
for code in (310_u16..=319).chain([1004]) {
for length in 0..=u8::MAX {
let bytes = payload(usize::from(length));
let mut wire = SENTINEL.to_vec();
wire.extend_from_slice(&code.to_le_bytes());
wire.push(length);
wire.extend_from_slice(&bytes);
wire.extend_from_slice(FOLLOWING_BINARY);
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
}
}
}
#[test]
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
for code in (310..=319).chain([1004]) {
for length in [128, 254, 255, 256, 1024] {
for utf8 in [false, true] {
check_ascii(code, &payload(length), utf8);
}
}
}
}
#[test]
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
for code in (310..=319).chain([1004]) {
for length in [128, 254, 255, 256, 1024] {
for utf8 in [false, true] {
check_binary(code, &payload(length), utf8);
}
}
}
}
-148
View File
@@ -1,148 +0,0 @@
//! Deterministic tag preservation through the public byte API, in both encodings and every
//! supported version. Inputs have the group code's type and representable values; lossy
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
fn check(case: &str, body: &[Tag]) {
for version in Version::ALL {
let mut original = vec![
Tag::s(0, "SECTION"),
Tag::s(2, "HEADER"),
Tag::s(9, "$ACADVER"),
Tag::s(1, version.code()),
Tag::s(9, "$DWGCODEPAGE"),
Tag::s(3, "ANSI_1252"),
Tag::s(0, "ENDSEC"),
Tag::s(0, "SECTION"),
Tag::s(2, "ENTITIES"),
];
original.extend_from_slice(body);
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
for binary in [false, true] {
let mut current = original.clone();
for pass in 1..=2 {
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
let bytes = if binary { write_binary(&current, version.utf8()) } else { write_ascii_as(&current, version.utf8()).into_bytes() };
assert_eq!(is_binary(&bytes), binary, "{context}");
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
assert_eq!(back.len(), original.len(), "{context}: tag count");
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
assert_eq!(actual, expected, "{context}: tag[{index}]");
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
}
}
assert_eq!(back, current, "{context}: no additional changes on resave");
current = back;
}
}
}
}
#[test]
fn integer_width_boundaries() {
// Explicit reference ranges, not obtained from the codec's kind_of().
let widths = [
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
];
for (ranges, min, max) in widths {
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
check(&format!("integer/{min}..{max}/{value}"), &tags);
}
}
}
#[test]
fn finite_reals_keep_all_bits() {
let values = [
0.0,
-0.0,
f64::from_bits(1),
-f64::from_bits(1),
f64::MIN_POSITIVE,
f64::MIN_POSITIVE.next_down(),
0.1,
-3.25,
1.0_f64.next_down(),
1.0,
1.0_f64.next_up(),
1e-100,
-1e100,
f64::MAX,
f64::MIN,
];
for value in values {
let tags: Vec<_> =
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
check(&format!("real/{:016X}", value.to_bits()), &tags);
}
}
#[test]
fn booleans() {
for value in [false, true] {
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
check(&format!("bool/{value}"), &tags);
}
}
#[test]
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
let values = [
("empty", ""),
("spaces", " leading and trailing "),
("tabs", "\tindented\t"),
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
];
for (case, value) in values {
let tags: Vec<_> =
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
.into_iter()
.flatten()
.map(|code| Tag::s(code, value))
.collect();
check(case, &tags);
}
for length in [1, 254, 255, 256, 1024] {
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
}
}
#[test]
fn hex_chunks_keep_bytes_and_order() {
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
// byte can represent more, but that is reader tolerance, not writer conformance.
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
for length in [0, 1, 2, 126, 127] {
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
check(&format!("hex/bytes={length}"), &tags);
}
}
#[test]
fn mixed_values_and_repeated_codes_keep_order() {
check(
"mixed/repeated-codes",
&[
Tag::s(0, "TEST"),
Tag::f(10, 1.25),
Tag::s(1, "first"),
Tag::f(10, -2.5),
Tag::i(70, -7),
Tag::s(1, "second"),
Tag::new(290, Value::Bool(true)),
Tag::new(310, Value::Hex("00FF80".into())),
Tag::i(160, i64::MAX),
Tag::s(1, ""),
],
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ workspace = true
cadcraft-geom = { workspace = true }
cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-dxf = { workspace = true }
craft-dxf = { workspace = true }
cadcraft-constraints = { workspace = true }
cadcraft-fonts = { workspace = true }
cadcraft-render = { workspace = true }
+85 -1
View File
@@ -16,10 +16,31 @@ use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
/// combine permissions from one version of a path with the group of another.
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let key = match (absolute.parent(), absolute.file_name()) {
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
_ => absolute,
};
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
locks.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
lock
} else {
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
lock
}
}
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
@@ -33,13 +54,15 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
// A symlinked destination is resolved so the link stays a link and its target is replaced.
let resolved = resolve_symlink(path)?;
let path = resolved.as_deref().unwrap_or(path);
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
write_atomic_with(path, permissions, |file| file.write_all(bytes))
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
@@ -67,10 +90,30 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
write_atomic_with_locked(path, permissions, fill)
}
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
pub fn write_atomic_with_permissions(
path: &Path,
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = permissions()?;
write_atomic_with_locked(path, permissions, fill)
}
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
#[cfg(unix)]
let permissions = keep_group(&file, path, permissions);
file.set_permissions(permissions)?;
}
fill(&mut file)?;
@@ -90,6 +133,20 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
Ok(())
}
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
/// if the file is still in the old file's group, so the group is restored first. When it can't be
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
#[cfg(unix)]
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
return permissions;
}
Permissions::from_mode(permissions.mode() & !0o070)
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
@@ -231,6 +288,33 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
/// says) together with its mode, so the group bits never name a different group.
#[cfg(unix)]
#[test]
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&dest).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&dest, None, Some(gid)).unwrap();
}
let want = std::fs::metadata(&dest).unwrap();
write_atomic(&dest, b"new").unwrap();
let got = std::fs::metadata(&dest).unwrap();
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
+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 => {
+5 -3
View File
@@ -15,7 +15,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub(super) fn blockreplace_spec() -> CommandSpec {
CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace)
.menu(&["Tools", "Express Tools", "Replace Block..."])
.menu(&["Tools", "Additional Tools", "Replace Block..."])
.alias(&["-blockreplace"])
.params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}")
.interactive(|_| Ok(Box::new(BlockreplaceM::default())))
@@ -184,8 +184,10 @@ impl Interactive for BlockreplaceM {
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]),
Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]),
Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]).takes_block(),
Stage::New => {
Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]).takes_block()
}
Stage::Select => Prompt::new("Select a block reference", Accept::SELECT),
Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"),
}
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{Result, Session};
pub(super) fn burst_spec() -> CommandSpec {
CommandSpec::new("burst", "Explode Attributes to Text", run_burst)
.menu(&["Tools", "Express Tools", "Explode Attributes to Text"])
.menu(&["Tools", "Additional Tools", "Explode Attributes to Text"])
.params("{handles?} (block references; other objects are left alone) → {handles}")
.interactive(|_| Ok(Box::new(SelectRun::new("burst", "BURST"))))
}
+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)
+56 -1
View File
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-xref", "External References", run_dash_xref)
.alias(&["-xr"])
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
CommandSpec::new("xbind", "Bind...", run_xbind)
.menu(&["Modify", "Object", "External Reference", "Bind..."])
@@ -201,6 +201,46 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
/// The xrefs a JSON call names (`names`, default all); an error when none match.
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
if p.get("exact").and_then(Value::as_bool) == Some(true) {
let lits: Vec<&str> = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
Some(Value::String(t)) => vec![t.as_str()],
_ => str_param(p, "name").into_iter().collect(),
};
let d = s.doc()?;
let mut names = Vec::new();
let mut selected = std::collections::HashSet::new();
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
let mut folded = std::collections::HashMap::new();
for literal in &lits {
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
continue;
}
let normalized = literal.to_ascii_lowercase();
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
if let Some(block) = matches.next() {
if matches.next().is_some() {
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
}
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
}
}
if names.is_empty() {
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
}
return Ok(names);
}
let pattern = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
Some(Value::String(t)) => t.clone(),
@@ -223,6 +263,19 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(r)
}
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
/// command must refuse before it changes anything.
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
for n in names {
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
}
}
Ok(())
}
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
let host = host(s);
@@ -252,6 +305,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"detach" | "d" => {
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
let mut erased = 0;
for n in &names {
erased += load::detach(s.doc_mut()?, n);
@@ -276,6 +330,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
let d = s.doc_mut()?;
for n in &names {
if let Some(b) = d.blocks.get_mut(n) {
+17 -1
View File
@@ -313,7 +313,23 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
// Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
let mut seen = std::collections::HashMap::new();
names
.iter()
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(n.as_str());
load_with(d, n, host, chosen, &mut cx)
}
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
name: String::new(),
status: XrefStatus::Unresolved,
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
}],
})
.collect()
}
impl Ctx {
+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();
+1 -1
View File
@@ -20,7 +20,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("breakline", "Break-line Symbol", run_breakline)
.menu(&["Tools", "Express Tools", "Break-line Symbol"])
.menu(&["Tools", "Additional Tools", "Break-line Symbol"])
.params(
"{p1, p2, at?: [x,y] (symbol location; default the midpoint), size? (default 1, times DIMSCALE), \
extension? (past both points; default 0), block? (a block with two points on Defpoints; default the built-in symbol)} → {handle}",
+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 };
+15 -6
View File
@@ -557,9 +557,15 @@ fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
let space = s.space();
let d = s.doc()?;
let frozen = s.viewport_frozen();
let hs: Vec<Handle> = d
.space(&space)
.map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
.map(|st| {
st.iter()
.filter(|e| crate::select::selectable(d, e, frozen) && d.layer(&e.common.layer).is_none_or(|l| !l.locked))
.map(|e| e.handle)
.collect()
})
.unwrap_or_default();
let n = hs.len();
s.set_selection(hs);
@@ -580,16 +586,19 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
let add = bool_or(p, "add", false);
let picked: Vec<Handle> = if let Some(w) = points_param(p, "window") {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), Bounds2::new(*a, *b), bool_or(p, "crossing", false))
} else if let Some(at) = point_param(p, "at") {
let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), at, ap).into_iter().collect()
} else if let Some(f) = points_param(p, "fence") {
crate::select::select_fence(s.doc()?, &space, &f)
} else {
crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &f)
} else if ["handles", "handle"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
targets(s, p)?
} else {
// Nothing to select with: the selection stays, also what the active viewport does not draw.
s.stored_selection()
};
let mut sel = if add { s.selection() } else { Vec::new() };
let mut sel = if add { s.stored_selection() } else { Vec::new() };
sel.extend(picked);
s.set_selection(sel);
let sel = s.selection();
+102 -18
View File
@@ -8,7 +8,7 @@ mod template;
use std::sync::OnceLock;
use cadcraft_doc::{Drawing, Format};
pub use cadcraft_dxf::Version;
pub use craft_dxf::Version;
use serde_json::{Value, json};
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
@@ -17,7 +17,7 @@ use crate::{Accept, Input, Prompt, Result, Session, Step};
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
pub use pgp::{MAX_ALIAS_PROFILE_BYTES, MAX_ALIASES, alias_name, alias_profile, command_id, restore_aliases};
pub(crate) use pgp::{specs as alias_specs, user_alias};
pub(crate) use pgp::{greek_alias, specs as alias_specs, user_alias};
pub use template::{qnew_template, set_qnew_template};
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
@@ -53,9 +53,9 @@ pub fn io() -> Option<&'static IoHooks> {
/// (installed by the app with the DXF reader and writer, a lower layer).
pub struct EntityCodec {
/// The entity's type name and the groups of its record, without its `0` group.
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<craft_dxf::Tag>),
/// An entity from a type name and groups.
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
pub entity: fn(&str, &[craft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
}
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
@@ -340,7 +340,7 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
}
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
Ok(ReadFile { drawing, binary_dxf: craft_dxf::is_binary(bytes), name: name.to_string(), path })
}
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
@@ -503,7 +503,7 @@ pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
if dwg || cadcraft_dxf::is_binary(bytes) {
if dwg || craft_dxf::is_binary(bytes) {
return true;
}
// ASCII DXF: optional comments (999), then group 0 SECTION.
@@ -533,7 +533,7 @@ pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
// Too short for any DWG's header: cut off, not unknown.
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
}
if cadcraft_dxf::is_binary(head) {
if craft_dxf::is_binary(head) {
return Some(tail.ends_with(b"EOF\0"));
}
let text = String::from_utf8_lossy(tail);
@@ -625,6 +625,40 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
#[cfg(unix)]
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
let mut mode = src_mode & 0o777;
if let Some((old_mode, old_gid)) = old {
mode &= old_mode;
if old_gid != src_gid {
mode = without_group(src_mode, mode);
}
}
mode
}
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
/// drawing's group bits (`src_mode`).
#[cfg(unix)]
fn without_group(src_mode: u32, mode: u32) -> u32 {
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
}
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
#[cfg(unix)]
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
return Ok(());
}
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
@@ -648,19 +682,23 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
let source_metadata = src.metadata()?;
#[cfg(unix)]
let first_backup = std::cell::Cell::new(false);
#[cfg(unix)]
let permissions = || {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
first_backup.set(old.is_none());
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
};
#[cfg(not(unix))]
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
let permissions = || Ok(None);
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
#[cfg(unix)]
if first_backup.get() {
adopt_source_group(out, &source_metadata)?;
}
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
@@ -1031,6 +1069,52 @@ mod tests {
use crate::Session;
use crate::temp_dir::TempDir;
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
/// it would keep the old `.bak`'s group while the drawing is in another.
#[cfg(unix)]
#[test]
fn backup_mode_drops_group_bits_for_a_different_group() {
use super::backup_mode;
assert_eq!(backup_mode(0o640, 10, None), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
}
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
/// group, as `id -G` says), never the group the new file would be created in.
#[cfg(unix)]
#[test]
fn first_backup_takes_the_drawings_group() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = TempDir::new("first-bak-grp");
let src = dir.join("a.dxf");
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&src).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&src, None, Some(gid)).unwrap();
}
super::backup(&src).unwrap();
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
}
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
#[cfg(unix)]
#[test]
fn without_group_drops_group_bits_and_caps_other() {
assert_eq!(super::without_group(0o640, 0o640), 0o600);
assert_eq!(super::without_group(0o604, 0o604), 0o600);
assert_eq!(super::without_group(0o644, 0o644), 0o604);
}
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
+12 -8
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};
@@ -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
+6
View File
@@ -59,6 +59,12 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
store().map.get(name).cloned()
}
/// The user alias that is the Greek word `word` in another case or accent.
pub(crate) fn greek_alias(word: &str) -> Option<String> {
let same = crate::keyboard::same_greek_word_as(word)?;
store().map.keys().find(|alias| same(alias)).cloned()
}
/// The user aliases and the file REINIT reads, as the host's user profile keeps them:
/// `{"aliases": {alias: command}, "file": path or null}`. Not drawing data.
pub fn alias_profile() -> Value {
+25 -6
View File
@@ -43,7 +43,8 @@ pub(crate) struct GripEdit {
hot: Vec<(Handle, usize)>,
/// Typed `GRIP`: still collecting grips ("Select grips").
picking: bool,
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection.
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
/// selection that the view being edited shows.
targets: Vec<Handle>,
base: Vec2,
mode: GripMode,
@@ -69,6 +70,13 @@ fn positive(v: Option<f64>) -> Result<f64> {
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
}
/// Whether the view being edited shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer,
/// a layer frozen in the active MSPACE viewport) takes no part in a grip edit, even when Undo or
/// a selection command put it back into the selection.
fn shown(s: &Session, h: Handle) -> bool {
s.state().is_ok_and(|st| st.doc.entity(h).is_some_and(|e| st.shows(e)))
}
impl GripEdit {
fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result<Self> {
let mut g = GripEdit {
@@ -87,16 +95,22 @@ impl GripEdit {
Ok(g)
}
/// Make grip `index` of `h` hot (the first one becomes the base point).
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
/// the view being edited does not show is left out, and so are the hidden objects of the
/// selection.
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
return Ok(());
}
let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?;
// After the lookup: a missing object or grip stays the error it was.
if !shown(s, h) {
return Ok(());
}
if self.hot.is_empty() {
self.base = p;
self.targets.push(h);
self.targets.extend(s.selection().into_iter().filter(|x| *x != h));
self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x)));
} else if !self.targets.contains(&h) {
self.targets.push(h);
}
@@ -107,13 +121,13 @@ impl GripEdit {
/// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there.
fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> {
let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len()));
let Ok(d) = s.doc() else { return Vec::new() };
let Ok(st) = s.state() else { return Vec::new() };
let mut out = Vec::new();
for h in s.selection().into_iter().take(MAX_HOT) {
if super::curves::is_locked(s, h) {
continue;
}
let Some(e) = d.entity(h) else { continue };
let Some(e) = st.doc.entity(h).filter(|e| st.shows(e)) else { continue };
let gs = e.kind.grips();
// The nearest grip of this object within the tolerance.
let best = gs
@@ -470,7 +484,9 @@ impl Interactive for GripEdit {
impl Session {
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
/// modes and their options then run at the command line like any prompt.
/// modes and their options then run at the command line like any prompt. Grips of objects the
/// view being edited does not show are left out (the first grip left is the base grip), and it
/// is an error when none is left.
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
if self.running.is_some() {
self.cancel();
@@ -479,6 +495,9 @@ impl Session {
return Err(other("no grip"));
}
let g = GripEdit::new(self, hot.to_vec())?;
if g.hot.is_empty() {
return Err(other("no grip"));
}
let label = g.label();
let machine = crate::guard::Guarded::wrap(Box::new(g));
let st = self.state()?;
+4 -2
View File
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
const MEMBER_PICKS: &[&str] = &["groupedit", "-group"];
/// `hs` plus the other members of the selectable groups they belong to (in the space being
/// edited, visible), when PICKSTYLE has group selection on. Order: `hs` first.
/// edited, visible, and inside an MSPACE viewport not on a layer frozen in it), when PICKSTYLE
/// has group selection on. Order: `hs` first.
pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
if s.settings.pickstyle & 1 == 0 || hs.is_empty() || s.running.as_ref().is_some_and(|r| MEMBER_PICKS.contains(&r.id.as_str())) {
return hs;
@@ -39,6 +40,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
}
let space = s.space();
let Some(store) = d.space(&space) else { return hs };
let frozen = s.viewport_frozen();
// Member → its selectable groups, built once (window selections may hold many objects).
let mut of: HashMap<Handle, Vec<usize>> = HashMap::new();
for (i, g) in d.groups.iter().enumerate().filter(|(_, g)| g.selectable) {
@@ -56,7 +58,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
}
let Some(g) = d.groups.get(*gi) else { continue };
for m in &g.members {
if store.get(*m).is_some_and(|e| d.is_visible(e)) && seen.insert(*m) {
if store.get(*m).is_some_and(|e| crate::select::selectable(d, e, frozen)) && seen.insert(*m) {
out.push(*m);
}
}
+14 -1
View File
@@ -533,6 +533,19 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "message": format!("{n} Region(s) created.") }))
}
/// Whether the upper-case `name` is a pattern: a standard one or a loaded one.
fn pattern_exists(name: &str) -> bool {
cadcraft_doc::library::pattern(name).is_some() || patfile::user_pattern(name).is_some()
}
/// The existing pattern `name` means, typed at the pattern prompt: read upper-cased, else a loaded
/// pattern's own name when only its Greek spelling differs in case or accent (σολιδ for ΣΟΛΙΔ).
pub(crate) fn pattern_named(name: &str) -> Option<String> {
let name = name.trim();
let up = name.to_ascii_uppercase();
if pattern_exists(&up) { Some(up) } else { patfile::user_pattern_name(name) }
}
/// The origin sub-prompt's keywords (a point sets a new origin).
const ORIGIN_KW: [&str; 6] = ["Bottom left", "bottom Right", "Top left", "top riGht", "Center", "Use current"];
@@ -597,7 +610,7 @@ impl HatchM {
Some(n) => n,
None => t.trim().to_ascii_uppercase(),
};
if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() {
if pattern_exists(&up) {
self.settings.pattern = up.clone();
s.doc_mut()?.header.set_str("HPNAME", &up);
} else {
+7
View File
@@ -28,6 +28,13 @@ pub(crate) fn user_pattern(name: &str) -> Option<UserPattern> {
store().iter().find(|p| p.name.eq_ignore_ascii_case(name)).cloned()
}
/// The name of the user pattern that is the Greek word `name` in another case or accent
/// ([`user_pattern`] folds ASCII only).
pub(crate) fn user_pattern_name(name: &str) -> Option<String> {
let same = crate::keyboard::same_greek_word_as(name)?;
store().iter().find(|p| same(&p.name)).map(|p| p.name.clone())
}
/// The line families a new hatch of pattern `name` carries: a user pattern's, else none (the
/// library's patterns are drawn and saved from the library).
pub(crate) fn lines_for(name: &str) -> Vec<PatternLine> {
+1 -1
View File
@@ -591,7 +591,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"selection": st.shown_selection().iter().map(|h| h.hex()).collect::<Vec<_>>(),
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
+52 -4
View File
@@ -26,11 +26,30 @@ const MAX_PURGE_PASSES: usize = 64;
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
pub(super) fn default_path(s: &Session, layout: &str) -> String {
let file = format!("{layout}.dwt");
match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
None => file,
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
// beside the drawing, so those become `_`.
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file),
None => std::path::PathBuf::from(file),
};
if !candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
let alternative = (2u64..)
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
.find(|alternative| !alternative.exists())
.unwrap_or(candidate.clone());
alternative.to_string_lossy().into_owned()
}
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
@@ -133,3 +152,32 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
}
}
#[cfg(test)]
mod default_path_tests {
use super::*;
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
#[test]
fn layout_name_is_one_file_name() {
let s = Session::empty();
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
let p = default_path(&s, name);
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
assert!(p.ends_with(".dwt"));
}
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
}
/// Windows device names are prefixed so the suggestion is never a device.
#[test]
fn reserved_device_names_are_prefixed() {
let s = Session::empty();
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
}
for name in ["COM0", "CONSOLE", "COM10"] {
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
}
}
}
+27 -18
View File
@@ -21,14 +21,17 @@ impl StretchArea {
}
}
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
/// selection tests the drawn element lines, which sit away from the control vertices by the
/// multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
let Some(store) = d.space(space) else { return };
/// Add to `hs` the multilines in the edit space that a selection can take there (visible, and
/// inside an MSPACE viewport not on a layer frozen in it) with a control vertex inside `area`. A
/// crossing selection tests the drawn element lines, which sit away from the control vertices by
/// the multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(s: &Session, area: &StretchArea, hs: &mut Vec<Handle>) {
let Ok(d) = s.doc() else { return };
let Some(store) = d.space(&s.space()) else { return };
let frozen = s.viewport_frozen();
for e in store.iter() {
if let EntityKind::MLine(m) = &e.kind
&& d.is_visible(e)
&& crate::select::selectable(d, e, frozen)
&& !hs.contains(&e.handle)
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
{
@@ -94,19 +97,19 @@ pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
};
// A fence marks no area: the objects it crosses move whole.
if let Some(fence) = listed("fence", 2)? {
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
let hs = crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &fence);
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
return Ok(json!({ "stretched": moved.len() }));
}
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
(crate::select::select_polygon_in(s.doc()?, &space, s.viewport_frozen(), &poly, true), StretchArea::Polygon(poly))
} else {
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
let bx = cadcraft_geom::Bounds2::new(*a, *b);
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
(crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, true), StretchArea::Box(bx))
};
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
add_mline_vertex_hits(s, &area, &mut hs);
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
let doc = s.doc_mut()?;
let areas = [area];
@@ -173,7 +176,7 @@ impl StretchM {
/// of two) the mode is dropped.
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
let space = s.space();
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let Some(mut hs) = m.select_in(s.doc()?, &space, s.viewport_frozen()) else { return Ok(()) };
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
(SelectKind::Box, Some((a, b))) => b.x < a.x,
@@ -189,7 +192,7 @@ impl StretchM {
};
let whole = area.is_none();
if let (Some(a), true, false) = (&area, crossing, self.removing) {
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
add_mline_vertex_hits(s, a, &mut hs);
}
if let (Some(a), false) = (area, self.removing) {
self.windows.push(a);
@@ -228,10 +231,10 @@ impl StretchM {
Some(c) => {
let bx = cadcraft_geom::Bounds2::new(c, p);
let space = s.space();
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
let mut hs = crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, p.x < c.x);
if !self.removing {
if p.x < c.x {
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
add_mline_vertex_hits(s, &StretchArea::Box(bx), &mut hs);
}
self.windows.push(StretchArea::Box(bx));
}
@@ -240,7 +243,7 @@ impl StretchM {
None => {
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
match crate::select::pick(s.doc()?, &space, p, aperture) {
match crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), p, aperture) {
Some(h) => self.add(s, vec![h], true),
None => self.corner = Some(p),
}
@@ -251,6 +254,7 @@ impl StretchM {
self.corner = None;
let d = s.doc()?;
let store = d.space(&s.space());
let frozen = s.viewport_frozen();
let word = t.trim().to_ascii_lowercase();
// `_C`, `_L`: the English name, as scripts write it.
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
@@ -267,9 +271,14 @@ impl StretchM {
self.removing = false;
None
}
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
"all" => Some(
store.map(|st| st.iter().filter(|e| crate::select::selectable(d, e, frozen)).map(|e| e.handle).collect()).unwrap_or_default(),
),
"l" | "last" => Some(store.and_then(|st| crate::select::last_in(st, frozen)).map(|h| vec![h]).unwrap_or_default()),
"p" | "previous" => {
let st = s.state()?;
Some(st.drawn_in_viewport(&st.previous_selection))
}
_ => {
s.echo("*Invalid selection*");
None
+1 -1
View File
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.menu(&["Tools", "Additional Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
+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);
+4 -3
View File
@@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
pub fn wildcard(pat: &str, text: &str) -> bool {
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
// Iterative matcher with single back-track point (linear-ish, no recursion).
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
@@ -279,7 +280,7 @@ fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
let sel = match mode.as_str() {
"exclude" => excluded,
"append" => {
let mut cur = s.selection();
let mut cur = s.stored_selection();
for h in matched {
if !cur.contains(&h) {
cur.push(h);
+9 -2
View File
@@ -99,14 +99,21 @@ pub(crate) fn similar(e: &Entity, r: &Entity, mode: u32) -> bool {
&& (mode & NAME == 0 || same_text(object_name(e), object_name(r)))
}
/// The visible objects of the current space like any of `refs`.
/// The objects of the current space like any of `refs` that a selection can take there: visible,
/// and inside an MSPACE viewport not on a layer frozen in it.
fn similar_objects(s: &Session, d: &Drawing, refs: &[Handle], mode: u32) -> Vec<Handle> {
let refs: Vec<&Entity> = refs.iter().filter_map(|h| d.entity(*h).map(|e| e.as_ref())).collect();
if refs.is_empty() {
return Vec::new();
}
let Some(store) = d.space(&s.space()) else { return Vec::new() };
store.iter().take(MAX_SCAN).filter(|e| d.is_visible(e) && refs.iter().any(|r| similar(e, r, mode))).map(|e| e.handle).collect()
let frozen = s.viewport_frozen();
store
.iter()
.take(MAX_SCAN)
.filter(|e| crate::select::selectable(d, e, frozen) && refs.iter().any(|r| similar(e, r, mode)))
.map(|e| e.handle)
.collect()
}
fn mode_param(s: &Session, p: &Value) -> Result<u32> {
+2 -2
View File
@@ -59,7 +59,7 @@ fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bo
let fb = Bounds2::from_points(pts.iter().copied());
let tol = crate::select::window_tol(&fb);
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
for h in crate::select::select_fence(d, &space, pts) {
for h in crate::select::select_fence_in(d, &space, s.viewport_frozen(), pts) {
let Some(e) = d.entity(h) else { continue };
let polys = crate::select::hit_polylines(d, e, tol);
let mut xs: Vec<Vec2> = Vec::new();
@@ -96,7 +96,7 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
];
let inside = |p: Vec2| bx.expand(tol).contains(p);
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
for h in crate::select::select_window(d, &space, bx, true) {
for h in crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, true) {
let Some(e) = d.entity(h) else { continue };
let mut best: Option<Vec2> = None;
for pl in crate::select::hit_polylines(d, e, tol) {
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, St
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("txt2mtxt", "Convert Text to Mtext", run_txt2mtxt)
.menu(&["Tools", "Express Tools", "Convert Text to Mtext"])
.menu(&["Tools", "Additional Tools", "Convert Text to Mtext"])
.params(
"{handles? (default the selection; TEXT, and MTEXT when combining), combine? (default true: one MTEXT; false: one per TEXT), \
sort?: \"topdown\" (default) | \"selection\", wrap? (default false: one line per text)} → {handles}",
+59 -1
View File
@@ -8,6 +8,12 @@ pub struct Snapshot {
pub selection: Vec<Handle>,
}
/// [`DocState::drawn_in_viewport`] looks its handles up one by one while there is fewer than one
/// of them for every `LOOKUP_COST` objects of model space, and passes over model space once
/// otherwise. Timed on drawings of 40,000 and of 200,000 lines, the two cost the same at about
/// one handle for every 47 and for every 18 objects.
const LOOKUP_COST: usize = 32;
/// One open drawing.
#[derive(Clone, Debug)]
pub struct DocState {
@@ -119,13 +125,65 @@ impl DocState {
}
/// The active MSPACE viewport, if it still exists in the current layout.
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
self.active_viewport_ref().map(|(h, v)| (h, v.clone()))
}
/// [`DocState::active_viewport`] without copying the viewport.
fn active_viewport_ref(&self) -> Option<(Handle, &cadcraft_doc::Viewport)> {
let h = self.mspace?;
let e = self.doc.space(&self.space)?.get(h)?;
match &e.kind {
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v)),
_ => None,
}
}
/// The layers frozen in the active MSPACE viewport (VPLAYER, "VP Freeze"): that viewport does
/// not draw their objects, so picks, selections and grips made through it leave them out.
/// Empty in paper space and on the Model tab.
pub fn viewport_frozen(&self) -> &[String] {
match self.active_viewport_ref() {
Some((_, v)) => &v.frozen_layers,
None => &[],
}
}
/// Whether `e` is a model-space object the active MSPACE viewport does not draw: it is on a
/// layer frozen in that viewport ([`DocState::viewport_frozen`]). Objects on the sheet are not
/// drawn through the viewport, so its frozen layers do not hide them.
pub fn hidden_in_viewport(&self, e: &cadcraft_doc::Entity) -> bool {
crate::select::on_frozen_layer(self.viewport_frozen(), e) && self.doc.model.contains(e.handle)
}
/// Whether the view being edited shows `e`: the drawing shows it ([`Drawing::is_visible`]),
/// and the active MSPACE viewport, if there is one, draws it
/// ([`DocState::hidden_in_viewport`]).
pub fn shows(&self, e: &cadcraft_doc::Entity) -> bool {
self.doc.is_visible(e) && !self.hidden_in_viewport(e)
}
/// `handles` without the objects the active MSPACE viewport does not draw
/// ([`DocState::hidden_in_viewport`]): a set of objects made earlier (the selection, the
/// Previous set) as that viewport takes it. All of them anywhere else.
pub(crate) fn drawn_in_viewport(&self, handles: &[Handle]) -> Vec<Handle> {
let frozen = self.viewport_frozen();
if frozen.is_empty() || handles.is_empty() {
return handles.to_vec();
}
let model = &self.doc.model;
let hidden = |e: &Arc<cadcraft_doc::Entity>| crate::select::on_frozen_layer(frozen, e);
// The UI asks for the selection several times a frame. A few handles are looked up one
// by one; beyond that, one pass over model space costs less than the lookups.
if handles.len().saturating_mul(LOOKUP_COST) < model.len() {
return handles.iter().copied().filter(|h| !model.get(*h).is_some_and(hidden)).collect();
}
let mut gone: Vec<Handle> = model.iter().filter(|e| hidden(e)).map(|e| e.handle).collect();
gone.sort_unstable();
handles.iter().copied().filter(|h| gone.binary_search(h).is_err()).collect()
}
/// The selection as the view being edited has it: [`DocState::selection`] without the objects
/// the active MSPACE viewport does not draw. Those stay in [`DocState::selection`] until the
/// selection is replaced or cleared, and count as selected again once that viewport draws
/// them; until then nothing that starts from the selection (a command, a grip, a palette)
/// edits them through that viewport.
pub fn shown_selection(&self) -> Vec<Handle> {
self.drawn_in_viewport(&self.selection)
}
pub fn paper_view(&self) -> View {
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
}
+186
View File
@@ -0,0 +1,186 @@
//! Words typed on a Greek keyboard layout.
//!
//! With the Greek layout active, the keys that type LINE produce ΛΙΝΕ. Command names, aliases and
//! prompt keywords are Latin, so a Greek speaker would have to switch layouts for every command.
//! [`latin_keys`] reads a word as the Latin keys that produced it (the layout puts each Greek
//! letter on the key of the Latin letter it sounds like): the command line then accepts ΛΙΝΕ and
//! ΣΤΡΕΤΨΗ as LINE and STRETCH. Accents (the layout's dead key) are ignored.
/// The Latin key a Greek letter is typed on (lower case, accents removed).
fn key_of(c: char) -> Option<char> {
Some(match c {
'α' | 'ά' => 'a',
'β' => 'b',
'γ' => 'g',
'δ' => 'd',
'ε' | 'έ' => 'e',
'ζ' => 'z',
'η' | 'ή' => 'h',
'θ' => 'u',
'ι' | 'ί' | 'ϊ' | 'ΐ' => 'i',
'κ' => 'k',
'λ' => 'l',
'μ' => 'm',
'ν' => 'n',
'ξ' => 'j',
'ο' | 'ό' => 'o',
'π' => 'p',
'ρ' => 'r',
'σ' => 's',
// The W key types the final sigma.
'ς' => 'w',
'τ' => 't',
'υ' | 'ύ' | 'ϋ' | 'ΰ' => 'y',
'φ' => 'f',
'χ' => 'x',
'ψ' => 'c',
'ω' | 'ώ' => 'v',
// The Q key types the semicolon, which Greek text writes as ";" or U+037E.
';' | '\u{37e}' => 'q',
_ => return None,
})
}
/// Whether `c` is a Greek letter (the basic and accented ranges the layout types).
fn is_greek_letter(c: char) -> bool {
matches!(c, '\u{386}'..='\u{3ce}') && c != '\u{387}'
}
/// `word` read as the Latin keys of a Greek-layout keyboard, lower case: `Some` only when the word
/// has a Greek letter and every character is a Greek letter the layout types, the semicolon on
/// the Q key, or ASCII (itself, in lower case). A combining tonos or dialytika, the accent of
/// decomposed text, is ignored like the accent of a precomposed letter, but only straight after
/// the Greek letter it accents and once each: anywhere else the word is not Greek-layout typing.
pub fn latin_keys(word: &str) -> Option<String> {
let mut greek = false;
// What the previous Greek letter already carries (tonos, dialytika); `None` when the previous
// character is not a Greek letter, which leaves a combining mark nothing to accent.
let mut marks: Option<(bool, bool)> = None;
let mut out = String::with_capacity(word.len());
for c in word.chars() {
if matches!(c, '\u{301}' | '\u{308}') {
let (tonos, dialytika) = marks.as_mut()?;
if std::mem::replace(if c == '\u{301}' { tonos } else { dialytika }, true) {
return None;
}
continue;
}
if c.is_ascii() {
// The Q key types the ASCII semicolon in the Greek layout; every other ASCII character is itself.
out.push(if c == ';' { 'q' } else { c.to_ascii_lowercase() });
marks = None;
continue;
}
let lower = c.to_lowercase().next().unwrap_or(c);
out.push(key_of(lower)?);
let letter = is_greek_letter(lower);
greek |= letter;
marks = letter.then_some((false, false));
}
greek.then_some(out)
}
/// `word` with its case, accents and final sigma folded away, when it is Greek-layout typing: every
/// spelling of one Greek word gives the same string. It is the word's keys, with the final sigma
/// read as the letter sigma instead of the W key.
pub fn greek_fold(word: &str) -> Option<String> {
latin_keys(&word.replace('ς', "σ"))
}
/// Whether `a` and `b` are one Greek word, whatever their case, accents or final sigma.
pub fn same_greek_word(a: &str, b: &str) -> bool {
same_greek_word_as(b).is_some_and(|same| same(a))
}
/// A test for "is the Greek word `word`, whatever the case, accents or final sigma", or `None`
/// when `word` is not Greek-layout typing. `word` is folded here, once: looking a typed word up
/// among many names costs its length once, not once for every name.
pub fn same_greek_word_as(word: &str) -> Option<impl Fn(&str) -> bool> {
let fold = greek_fold(word)?;
Some(move |name: &str| greek_fold(name).as_deref() == Some(fold.as_str()))
}
#[cfg(test)]
mod tests {
use super::{latin_keys, same_greek_word};
/// One Greek word compares equal in any case, accent or final sigma; another word, or a Latin
/// one, does not.
#[test]
fn one_greek_word_is_the_same_in_any_case_accent_or_final_sigma() {
for (a, b) in [("ΣΟΛΙΔ", "σολιδ"), ("ΣΟΛΙΔ", "σολίδ"), ("ΛΑΣ", "λας"), ("ΛΑΣ", "λασ"), ("ΛΙ\u{301}ΝΕ", "λινε")]
{
assert!(same_greek_word(a, b), "{a} {b}");
}
for (a, b) in [("ΣΟΛΙΔ", "ΣΟΛΙ"), ("ΣΟΛΙΔ", "solid"), ("solid", "SOLID")] {
assert!(!same_greek_word(a, b), "{a} {b}");
}
}
/// A character the layout does not type is never taken for the Greek letter Unicode upper-cases
/// it to: the micro sign is not μ, the long s and the sharp s are not σ, an alpha with an iota
/// subscript is not αι, the theta symbol is not θ.
#[test]
fn a_character_the_layout_does_not_type_is_no_spelling_of_a_greek_letter() {
let pairs = [("\u{b5}", "μ"), ("Λ\u{17f}", "ΛΣ"), ("Λ\u{df}", "ΛΣΣ"), ("\u{1fb3}", "αι"), ("\u{3d1}", "θ")];
let same: Vec<_> = pairs.iter().filter(|(a, b)| same_greek_word(a, b) || same_greek_word(b, a)).collect();
assert!(same.is_empty(), "{same:?}");
}
/// Greek-layout typing reads as its Latin keys: plain, accented and decomposed letters, the Q
/// and W keys, ASCII prefixes.
#[test]
fn greek_layout_commands_read_as_their_latin_keys() {
assert_eq!(latin_keys("ΛΙΝΕ").as_deref(), Some("line"));
assert_eq!(latin_keys("λινε").as_deref(), Some("line"));
assert_eq!(latin_keys("ΣΤΡΕΤΨΗ").as_deref(), Some("stretch"));
assert_eq!(latin_keys("ΜΙΡΡΟΡ").as_deref(), Some("mirror"));
assert_eq!(latin_keys("ΘΝΔΟ").as_deref(), Some("undo"));
// The Q key types ";", the W key the final sigma.
assert_eq!(latin_keys(";ΣΑΩΕ").as_deref(), Some("qsave"));
assert_eq!(latin_keys("ςΒΛΟΨΚ").as_deref(), Some("wblock"));
// Accented letters (the layout's dead key) read as the plain letter.
assert_eq!(latin_keys("ΛΊΝΕ").as_deref(), Some("line"));
assert_eq!(latin_keys("ΆΡΡΑΥ").as_deref(), Some("array"));
// The same accents written as a combining mark after the letter (decomposed text).
assert_eq!(latin_keys("ΛΙ\u{301}ΝΕ").as_deref(), Some("line"));
assert_eq!(latin_keys("ΛΙΝΕ\u{301}").as_deref(), Some("line"));
assert_eq!(latin_keys("ΛΙ\u{308}ΝΕ").as_deref(), Some("line"));
assert_eq!(latin_keys("ΛΙ\u{308}\u{301}ΝΕ").as_deref(), Some("line"), "dialytika and tonos on one letter");
// Prefixes and digits are kept.
assert_eq!(latin_keys("-ΛΑΥΕΡ").as_deref(), Some("-layer"));
assert_eq!(latin_keys("_ΛΙΝΕ").as_deref(), Some("_line"));
}
/// A word with no Greek letter, or with a character the layout does not type, is not read.
#[test]
fn words_that_are_not_greek_layout_typing_are_left_alone() {
assert_eq!(latin_keys("line"), None, "no Greek letter");
assert_eq!(latin_keys(""), None);
assert_eq!(latin_keys(";"), None, "a semicolon alone is not a word");
assert_eq!(latin_keys("ΛΙΝΕ Ж"), None, "a letter the layout does not type");
assert_eq!(latin_keys("ΛΙΝΕ 日本"), None);
assert_eq!(latin_keys("\u{387}"), None, "the ano teleia is punctuation, not a letter");
}
/// A combining tonos or dialytika counts only straight after a Greek letter, and once each.
#[test]
fn a_combining_mark_must_follow_the_greek_letter_it_accents() {
assert_eq!(latin_keys("\u{301}ΛΙΝΕ"), None, "no letter before the mark");
assert_eq!(latin_keys("\u{308}ΛΙΝΕ"), None);
assert_eq!(latin_keys("ΛΙΝΕ-\u{301}"), None, "after punctuation");
assert_eq!(latin_keys("a\u{301}ΛΙΝΕ"), None, "after a Latin letter");
assert_eq!(latin_keys("Λ\u{301}\u{301}ΙΝΕ"), None, "a second tonos on one letter");
assert_eq!(latin_keys("Λ\u{308}\u{308}ΙΝΕ"), None, "a second dialytika on one letter");
}
/// A 100 000-letter word and odd code points are read or refused; nothing panics.
#[test]
fn hostile_text_never_panics() {
let long = "Ω".repeat(100_000);
assert_eq!(latin_keys(&long).map(|s| s.len()), Some(100_000));
for s in ["\u{0}", "\u{301}", "ΛΙΝΕ\u{301}", "\u{10ffff}", "ς\u{3c2}\u{3a3}"] {
let _ = latin_keys(s);
}
}
}
+15
View File
@@ -22,6 +22,7 @@ mod finite;
pub mod grip_dims;
pub mod grips;
mod guard;
pub mod keyboard;
pub mod lisp;
pub mod mtext_edit;
pub mod pointmod;
@@ -236,11 +237,25 @@ impl Session {
pub fn space(&self) -> Space {
self.state().map(DocState::edit_space).unwrap_or_default()
}
/// The layers frozen in the active MSPACE viewport ([`DocState::viewport_frozen`]): what a
/// pick or a selection in [`Session::space`] leaves out. Empty with no drawing open.
pub fn viewport_frozen(&self) -> &[String] {
self.state().map(DocState::viewport_frozen).unwrap_or_default()
}
/// The tab being displayed: Model or a layout, regardless of MSPACE.
pub fn layout_space(&self) -> Space {
self.state().map(|s| s.space.clone()).unwrap_or_default()
}
/// The selected objects as the view being edited has them ([`DocState::shown_selection`]):
/// inside an MSPACE viewport, without the objects on layers frozen in that viewport. Commands,
/// grips and palettes start from this; [`DocState::selection`] holds every selected object.
pub fn selection(&self) -> Vec<Handle> {
self.state().map(DocState::shown_selection).unwrap_or_default()
}
/// Every selected object ([`DocState::selection`]), also the ones the active MSPACE viewport
/// does not draw, which [`Session::selection`] leaves out. Adding to the selection or removing
/// from it starts from this, so that it keeps them.
pub(crate) fn stored_selection(&self) -> Vec<Handle> {
self.state().map(|s| s.selection.clone()).unwrap_or_default()
}
pub fn set_selection(&mut self, sel: Vec<Handle>) {
+6
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
+47 -4
View File
@@ -27,8 +27,14 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
if !writing {
match read_text_file(&path) {
Some(t) => {
charge_bytes(t.len())?;
f.lines = t.lines().map(str::to_string).collect();
// Each line has a String header and a separately allocated buffer. Charge a
// conservative 64 bytes per line for both, including the allocator's minimum
// allocation size, before collecting so many short lines cannot evade the budget.
let count = t.lines().count();
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
f.lines = std::collections::VecDeque::with_capacity(count);
f.lines.extend(t.lines().map(str::to_string));
}
None => return Ok(Flow::Value(Value::Nil)),
}
@@ -100,8 +106,19 @@ fn can_write(_path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn write_text_file(path: &str, text: &str, append: bool) {
use std::io::Write;
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
if let Ok(mut f) = f {
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
// instead of stalling the interpreter; only a regular file is then truncated and written.
let mut o = std::fs::OpenOptions::new();
o.create(true).write(true).append(append);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.custom_flags(libc::O_NONBLOCK);
}
if let Ok(mut f) = o.open(path)
&& f.metadata().is_ok_and(|m| m.is_file())
&& (append || f.set_len(0).is_ok())
{
let _ = f.write_all(text.as_bytes());
}
}
@@ -118,3 +135,29 @@ fn file_exists(path: &str) -> bool {
fn file_exists(_path: &str) -> bool {
false
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
#[test]
fn write_to_a_fifo_without_a_reader_does_not_block() {
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
write_text_file(fifo.to_str().unwrap(), "x", false);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
let f = dir.join("f.txt");
std::fs::write(&f, "long old text").unwrap();
write_text_file(f.to_str().unwrap(), "new", false);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
write_text_file(f.to_str().unwrap(), "+", true);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
std::fs::remove_dir_all(&dir).ok();
}
}
+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)
}
}
}
+57 -16
View File
@@ -10,8 +10,8 @@
use std::sync::{Arc, Mutex};
use cadcraft_doc::{Entity, Handle, Space};
use cadcraft_dxf::Tag;
use cadcraft_geom::Vec2;
use craft_dxf::Tag;
use super::LispError;
use super::builtins::args_err;
@@ -179,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() {
@@ -248,20 +253,20 @@ fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
continue;
}
let v = match t.value {
cadcraft_dxf::Value::Real(r) => r,
cadcraft_dxf::Value::Int(i) => i as f64,
craft_dxf::Value::Real(r) => r,
craft_dxf::Value::Int(i) => i as f64,
_ => continue,
};
t.value = cadcraft_dxf::Value::Real(f(v));
t.value = craft_dxf::Value::Real(f(v));
}
}
fn tag_value(t: &Tag) -> Value {
match &t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => Value::str(s),
craft_dxf::Value::Real(r) => Value::Real(*r),
craft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
craft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
}
}
@@ -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)),
}),
@@ -638,15 +651,19 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
Some("X") => all(s, true),
Some("L") => {
let order = handles_in_order(ctx);
order.last().copied().into_iter().collect()
// Inside an MSPACE viewport, the objects that viewport does not draw are passed over.
let st = ctx.s.state().ok();
let drawn = |h: &Handle| st.is_none_or(|st| st.doc.entity(*h).is_none_or(|e| !st.hidden_in_viewport(e)));
order.iter().rev().copied().find(drawn).into_iter().collect()
}
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
Some("P") => ctx.s.state().map(|st| st.drawn_in_viewport(&st.previous_selection)).unwrap_or_default(),
Some("I") => ctx.s.selection(),
Some("W") | Some("C") => {
let (p1, p2) = (point(0)?, point(1)?);
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
let bx = cadcraft_geom::Bounds2::from_points([p1, p2]);
crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, mode.as_deref() == Some("C"))
}
Some("F") | Some("WP") | Some("CP") => {
let poly: Vec<Vec2> =
@@ -654,8 +671,8 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
match mode.as_deref() {
Some("F") => crate::select::select_fence(d, &space, &poly),
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
Some("F") => crate::select::select_fence_in(d, &space, s.viewport_frozen(), &poly),
m => crate::select::select_polygon_in(d, &space, s.viewport_frozen(), &poly, m == Some("CP")),
}
}
Some(":S") | Some("A") | None => {
@@ -671,7 +688,7 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let s = &*ctx.s;
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::pick(d, &space, p, ap).into_iter().collect()
crate::select::pick_in(d, &space, s.viewport_frozen(), p, ap).into_iter().collect()
}
}
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
@@ -1125,3 +1142,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")),
}
}
+1 -1
View File
@@ -141,7 +141,7 @@ impl GetRequest {
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
match crate::select::pick_in(d, &space, s.viewport_frozen(), Vec2::new(p[0], p[1]), ap) {
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
None => Err("Missed, try again.".into()),
}
+32
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() {
@@ -987,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");
+52 -2
View File
@@ -102,6 +102,10 @@ pub struct Prompt {
/// the routine writes, so it cannot be read for the word "Select" (#1598).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub picks_one: bool,
/// The prompt takes a block's name as well as its keywords: a Greek word that is an existing
/// block's name is that block, not a keyword read from its Latin keys.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub takes_block: bool,
}
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
@@ -136,6 +140,11 @@ impl Prompt {
self.picker = Some(p);
self
}
/// Takes a block's name besides its keywords (the `takes_block` field).
pub fn takes_block(mut self) -> Self {
self.takes_block = true;
self
}
pub fn kw(mut self, k: &[&str]) -> Self {
self.keywords = k.iter().map(ToString::to_string).collect();
self
@@ -215,6 +224,24 @@ impl Prompt {
return None;
}
let tl = t.to_ascii_lowercase();
// A keyword typed with the Greek keyboard layout active (ΘΝΔΟ for Undo) is its Latin keys, unless
// the prompt offers that spelling itself (a Greek keyword in a LISP program's list): its word
// or a prefix of it as typed (ΘΝ for ΘΝΔΟ, not UN for Undo) is read before the Latin keys.
if let Some(latin) = crate::keyboard::latin_keys(t) {
// Greek letters have case, accents and a final sigma, which the ASCII-only folding above does not see.
let typed = crate::keyboard::greek_fold(t).unwrap_or_else(|| latin.clone());
let greek = || self.keywords.iter().filter_map(|k| crate::keyboard::greek_fold(k).map(|fold| (k, fold)));
return greek()
.find(|(_, k)| *k == typed)
.or_else(|| greek().find(|(_, k)| k.starts_with(&typed)))
.map(|(k, _)| k.clone())
.or_else(|| self.match_latin_keyword(&latin));
}
self.match_latin_keyword(&tl)
}
/// [`Prompt::match_keyword`] for text already read as Latin letters, in lower case.
fn match_latin_keyword(&self, tl: &str) -> Option<String> {
let keys = self.keywords.iter().map(|k| {
let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::<String>().to_ascii_lowercase();
let shortcut =
@@ -227,8 +254,8 @@ impl Prompt {
// CHPROP: "Ambiguous response, please clarify..."), `LTS` is ltScale.
let past_capitals = |k: &str| k.char_indices().rfind(|(_, c)| c.is_ascii_uppercase() || c.is_ascii_digit()).map_or(1, |(i, _)| i + 1);
keys.clone()
.find(|(_, kl, sc)| tl == *kl || tl == *sc)
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(&tl)))
.find(|(_, kl, sc)| tl == kl.as_str() || tl == sc.as_str())
.or_else(|| keys.clone().find(|(k, kl, _)| tl.len() >= past_capitals(k) && kl.starts_with(tl)))
.map(|(k, _, _)| k.clone())
}
}
@@ -394,6 +421,29 @@ mod tests {
assert_eq!(parse_point("1,2,3,4", last), None);
}
/// A keyword the prompt offers in Greek is matched as spelled (any case, accent or final sigma,
/// or a prefix) before the Latin reading of the same keys; without one, the keys answer the
/// Latin keyword.
#[test]
fn a_greek_keyword_is_matched_as_spelled_before_its_latin_reading() {
// ΘΝΔΟ is the keys U-N-D-O, so it answers Undo, unless the prompt offers the Greek word itself
// (a LISP program's keyword list).
let both = Prompt::new("", Accept::TEXT).kw(&["Undo", "ΘΝΔΟ"]);
assert_eq!(both.match_keyword("ΘΝΔΟ").as_deref(), Some("ΘΝΔΟ"));
assert_eq!(both.match_keyword("θνδο").as_deref(), Some("ΘΝΔΟ"), "Greek case is folded like Latin case");
assert_eq!(both.match_keyword("Undo").as_deref(), Some("Undo"));
// A prefix of the Greek word as typed (ΘΝ) is that keyword, not the Latin prefix UN of Undo.
assert_eq!(both.match_keyword("ΘΝ").as_deref(), Some("ΘΝΔΟ"));
assert_eq!(both.match_keyword("θν").as_deref(), Some("ΘΝΔΟ"), "in either case");
assert_eq!(both.match_keyword("θνδό").as_deref(), Some("ΘΝΔΟ"), "with an accent");
let sigma = Prompt::new("", Accept::TEXT).kw(&["Last", "ΛΑΣΤ"]);
assert_eq!(sigma.match_keyword("λας").as_deref(), Some("ΛΑΣΤ"), "a final sigma folds too");
let latin = Prompt::new("", Accept::TEXT).kw(&["Undo"]);
assert_eq!(latin.match_keyword("ΘΝ").as_deref(), Some("Undo"));
assert_eq!(latin.match_keyword("ΘΝΔΟ").as_deref(), Some("Undo"));
assert_eq!(latin.match_keyword("Θ").as_deref(), Some("Undo"), "a Greek-layout shortcut still works");
}
#[test]
fn keywords() {
let p = Prompt::new("Specify next point", Accept::POINT).kw(&["Undo", "Close"]);
+101 -21
View File
@@ -152,8 +152,28 @@ fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
!b.is_empty() && b.contains(turn(p))
}
fn selectable(d: &Drawing, e: &Entity) -> bool {
d.is_visible(e)
/// Whether `e` is on one of the layers in `frozen`: the layers frozen in a layout viewport, which
/// does not draw their objects. The names are compared as the renderer compares them.
pub(crate) fn on_frozen_layer(frozen: &[String], e: &Entity) -> bool {
frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer))
}
/// The last object of `store`, in draw order, that is not on one of the layers in `frozen`, the
/// layers frozen in the layout viewport the selection is made through: what the Last option
/// takes there. With no such layers it is the last object, whether the drawing shows it or not.
pub(crate) fn last_in(store: &cadcraft_doc::EntityStore, frozen: &[String]) -> Option<Handle> {
if frozen.is_empty() {
return store.last().map(|e| e.handle);
}
store.iter().filter(|e| !on_frozen_layer(frozen, e)).last().map(|e| e.handle)
}
/// Whether a selection can take `e`: the drawing shows it ([`Drawing::is_visible`]) and it is not
/// on one of the layers in `frozen`, the layers frozen in the layout viewport the selection is
/// made through ([`crate::DocState::viewport_frozen`]; empty anywhere else). A viewport shows
/// model space, so `frozen` goes with a selection in model space only.
pub(crate) fn selectable(d: &Drawing, e: &Entity, frozen: &[String]) -> bool {
d.is_visible(e) && !on_frozen_layer(frozen, e)
}
/// Entities to test, in the order to test them, each with its bounds when already known.
@@ -196,15 +216,28 @@ pub(crate) fn candidates<'a>(
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
/// picked, and DRAWORDER changes which).
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
let ix = spatial::index(d, space);
pick_with(d, space, &ix, p, aperture)
pick_in(d, space, &[], p, aperture)
}
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
/// [`pick`] through a layout viewport: the objects on the layers in `frozen`, the layers frozen
/// in that viewport, are not drawn there and are not picked.
pub fn pick_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Option<Handle> {
let ix = spatial::index(d, space);
pick_with(d, space, &ix, frozen, p, aperture)
}
pub(crate) fn pick_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
p: Vec2,
aperture: f64,
) -> Option<Handle> {
let probe = Bounds2::new(p, p).expand(aperture);
// Candidates come topmost first: the first within the aperture is the one.
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
if !selectable(d, e) {
if !selectable(d, e, frozen) {
continue;
}
let infinite = is_infinite(e);
@@ -221,11 +254,16 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
/// the objects selection cycling lists.
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
pick_all_in(d, space, &[], p, aperture)
}
/// [`pick_all`] through a layout viewport that freezes the layers in `frozen` ([`pick_in`]).
pub fn pick_all_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Vec<Handle> {
let ix = spatial::index(d, space);
let probe = Bounds2::new(p, p).expand(aperture);
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
cands
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
.filter(|(e, known)| selectable(d, e, frozen) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
.map(|(e, _)| e.handle)
.collect()
@@ -253,14 +291,27 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
/// Window selection (entirely inside) or crossing selection (inside or touching).
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_window_with(d, space, &ix, bx, crossing)
select_window_in(d, space, &[], bx, crossing)
}
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
/// [`select_window`] through a layout viewport: the objects on the layers in `frozen`, the layers
/// frozen in that viewport, are not drawn there and are not selected.
pub fn select_window_in(d: &Drawing, space: &Space, frozen: &[String], bx: Bounds2, crossing: bool) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_window_with(d, space, &ix, frozen, bx, crossing)
}
pub(crate) fn select_window_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
bx: Bounds2,
crossing: bool,
) -> Vec<Handle> {
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
let tol = window_tol(&bx);
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
cands.filter(|(e, known)| selectable(d, e, frozen) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
}
/// The tessellation tolerance window and crossing tests use for a box.
@@ -306,11 +357,23 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bo
/// Fence selection: entities crossed by the fence polyline.
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_fence_with(d, space, &ix, fence)
select_fence_in(d, space, &[], fence)
}
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
/// [`select_fence`] through a layout viewport that freezes the layers in `frozen`
/// ([`select_window_in`]).
pub fn select_fence_in(d: &Drawing, space: &Space, frozen: &[String], fence: &[Vec2]) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_fence_with(d, space, &ix, frozen, fence)
}
pub(crate) fn select_fence_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
fence: &[Vec2],
) -> Vec<Handle> {
let fb = Bounds2::from_points(fence.iter().copied());
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
let probe = fb.expand(tol);
@@ -318,7 +381,7 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
let mut out = Vec::new();
for (e, known) in cands {
// Infinite objects have no useful bounds; like window selection, test their geometry directly.
if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
if !selectable(d, e, frozen) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
continue;
}
let polys = hit_polylines(d, e, tol);
@@ -339,6 +402,12 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
/// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection
/// (inside or crossing its edges). The polygon closes back to its first point.
pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec<Handle> {
select_polygon_in(d, space, &[], poly, crossing)
}
/// [`select_polygon`] through a layout viewport that freezes the layers in `frozen`
/// ([`select_window_in`]).
pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[Vec2], crossing: bool) -> Vec<Handle> {
let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() };
let ix = spatial::index(d, space);
let pb = Bounds2::from_points(poly.iter().copied());
@@ -349,7 +418,7 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
let mut out = Vec::new();
for (e, known) in cands {
let infinite = is_infinite(e);
if !selectable(d, e) || (infinite && !crossing) {
if !selectable(d, e, frozen) || (infinite && !crossing) {
continue;
}
let eb = bounds_of(d, e, known);
@@ -378,9 +447,15 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
/// drawing has no such group.
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
select_group_in(d, space, &[], name)
}
/// [`select_group`] through a layout viewport that freezes the layers in `frozen`: the group's
/// objects on those layers are left out.
pub fn select_group_in(d: &Drawing, space: &Space, frozen: &[String], name: &str) -> Option<Vec<Handle>> {
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
let store = d.space(space)?;
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| selectable(d, e, frozen))).collect())
}
/// A selection mode typed at "Select objects", collecting its points before it selects.
@@ -454,14 +529,19 @@ impl SelectMode {
}
/// The objects the mode selects, or `None` while it has too few points.
pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {
self.select_in(d, space, &[])
}
/// [`SelectMode::select`] through a layout viewport: the objects on the layers in `frozen`,
/// the layers frozen in that viewport, are not drawn there and are not selected.
pub fn select_in(&self, d: &Drawing, space: &Space, frozen: &[String]) -> Option<Vec<Handle>> {
match (self.kind, self.pts.as_slice()) {
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
}
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
(SelectKind::Box, [a, b, ..]) => Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), b.x < a.x)),
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence_in(d, space, frozen, pts)),
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
Some(select_polygon_in(d, space, frozen, pts, self.kind == SelectKind::CPolygon))
}
_ => None,
}
+20 -3
View File
@@ -116,9 +116,26 @@ impl Session {
};
}
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
let selection_mode = prompt.accept.select
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
// keyword sharing the letter (HATCH's "picK internal point" against `P`). A word typed with
// the Greek keyboard layout active is read by its keys there too: Λ is Last at COUNT's prompt,
// not List.
let selection_mode = prompt.accept.select && {
let word = tt.strip_prefix('_').unwrap_or(tt);
let keys = crate::keyboard::latin_keys(word).unwrap_or_else(|| word.to_ascii_lowercase());
matches!(keys.as_str(), "all" | "l" | "last" | "p" | "previous")
};
// A Greek word that is an existing name at a prompt that takes one is that name, in any case:
// ΣΟΛΙΔ is also the keys of Solid.
let existing = if matches!(prompt.picker, Some(crate::Picker::HatchPattern(_))) {
crate::keyboard::greek_fold(tt).and_then(|_| cmd::hatch::pattern_named(tt))
} else if prompt.takes_block {
self.greek_block_name(tt)
} else {
None
};
if let Some(name) = existing {
return self.input(Input::Text(name));
}
// A leading underscore asks for a keyword by its English name, whatever the interface
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
// overrides such as `_cen` were taken above.)
+76 -16
View File
@@ -8,6 +8,39 @@ use super::{
impl Session {
// ---------------- interactive commands ----------------
/// Whether `name`, as typed, is a command, an alias or a LISP command: the lookups
/// [`Session::start`] dispatches by, so only ASCII is lower-cased and a Greek alias stays as stored.
fn names_a_command(&self, name: &str) -> bool {
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
let dashed = name.trim_start_matches(['_', '.']);
find_command(&cmd::resolve_alias(dashed)).is_some()
|| find_command(&cmd::resolve_alias(&lower)).is_some()
|| self.lisp_command(&lower).is_some()
}
/// The alias or LISP command that is the Greek word `name` in another case or accent (λινε for
/// c:ΛΙΝΕ): those lookups fold ASCII only.
fn greek_command_name(&self, name: &str) -> Option<String> {
let bare = name.trim_start_matches(['_', '.', '-']);
let same = crate::keyboard::same_greek_word_as(bare)?;
let lisp = || self.lisp_commands().into_iter().find(|c| same(c) && self.lisp_command(c).is_some());
cmd::file::greek_alias(bare).or_else(lisp)
}
/// Whether the Greek word `name` is a command of its own, which [`Session::start`] runs instead
/// of the Latin name its keys type.
pub fn greek_word_is_a_command(&self, name: &str) -> bool {
self.names_a_command(name) || self.greek_command_name(name).is_some()
}
/// The existing block that is the Greek word `word`: as spelled, else in another case or accent
/// (μ for Μ).
pub(super) fn greek_block_name(&self, word: &str) -> Option<String> {
let same = crate::keyboard::same_greek_word_as(word)?;
let blocks = &self.doc().ok()?.blocks;
blocks.keys().find(|k| *k == word).or_else(|| blocks.keys().find(|k| same(k))).cloned()
}
/// Start a command by name or alias as if typed at the command line.
pub fn start(&mut self, name: &str) -> Result<()> {
self.last_polar = None;
@@ -17,6 +50,12 @@ impl Session {
Some(n) => (true, n),
None => (false, name),
};
// A name typed with the Greek keyboard layout active (ΛΙΝΕ for LINE) is the Latin name's keys,
// unless it is a command, alias or LISP function of its own, in any case or accent.
let typed = name;
let latin =
crate::keyboard::latin_keys(name).filter(|_| !self.names_a_command(name)).map(|latin| self.greek_command_name(name).unwrap_or(latin));
let name = latin.as_deref().unwrap_or(name);
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
@@ -37,7 +76,7 @@ impl Session {
self.start(if transparent { "'setvar" } else { "setvar" })?;
return self.input(Input::Text(var));
}
return Err(EngineError::UnknownCommand(name.to_string()));
return Err(EngineError::UnknownCommand(typed.to_string()));
};
if transparent && spec.transparent && self.running.is_some() {
// Transparent commands (zoom, pan…) run without cancelling the active one.
@@ -175,17 +214,33 @@ impl Session {
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
other => other,
};
if let Input::Text(t) | Input::Keyword(t) = &input
&& let Some(m) = select::SelectMode::from_keyword(t)
// A selection word typed with the Greek keyboard layout active (ΑΛΛ for ALL) is looked up as its
// Latin keys. Only the lookup is: text that is no selection word (a block name at BEDIT's prompt,
// which also takes a selection) reaches the command as typed. The name of an existing block at
// a prompt that takes a name reaches it as stored, in whatever case it was typed: a block
// called Μ is that block there, not Multiple.
let block = match &input {
Input::Text(t) if prompt.accept.text => self.greek_block_name(t.trim()),
_ => None,
};
let is_block = block.is_some();
let input = block.map_or(input, Input::Text);
let word = match &input {
Input::Text(t) => Some(crate::keyboard::latin_keys(t.trim()).filter(|_| !is_block).unwrap_or_else(|| t.clone())),
Input::Keyword(k) => Some(k.clone()),
_ => None,
};
if let Some(w) = &word
&& let Some(m) = select::SelectMode::from_keyword(w)
{
self.pending_window = None;
self.select_mode = Some(m);
return Ok(None);
}
if let Input::Text(t) | Input::Keyword(t) = &input
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
if let Some(w) = &word
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(w.trim()))
{
match t.trim().to_ascii_lowercase().as_str() {
match w.trim().to_ascii_lowercase().as_str() {
"si" | "single" => {
self.select_single = true;
return Ok(None);
@@ -202,10 +257,10 @@ impl Session {
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
match select::pick(self.doc()?, &space, p, ap) {
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
None => {
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
@@ -213,14 +268,18 @@ impl Session {
}
}
}
Input::Text(ref t) | Input::Keyword(ref t) => {
let tl = t.trim().to_ascii_lowercase();
Input::Text(_) | Input::Keyword(_) => {
let tl = word.as_deref().unwrap_or_default().trim().to_ascii_lowercase();
let d = self.doc()?;
let store = d.space(&space);
let frozen = self.viewport_frozen();
let picked = match tl.as_str() {
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
"all" => store.map(|s| s.iter().filter(|e| select::selectable(d, e, frozen)).map(|e| e.handle).collect()),
"l" | "last" => store.and_then(|s| select::last_in(s, frozen)).map(|h| vec![h]),
"p" | "previous" => {
let st = self.state()?;
Some(st.drawn_in_viewport(&st.previous_selection))
}
_ => None,
};
match picked {
@@ -241,8 +300,9 @@ impl Session {
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
if mode.kind == select::SelectKind::Group {
let space = self.space();
return match input {
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
Input::Text(name) | Input::Keyword(name) => match select::select_group_in(self.doc()?, &space, self.viewport_frozen(), &name) {
Some(hs) => Ok(Some(Input::Pick(hs))),
None => {
self.echo(format!("No group named \"{}\".", name.trim()));
@@ -262,14 +322,14 @@ impl Session {
match input {
Input::Point(p) => {
if mode.push(p) {
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick));
}
}
Input::Keyword(k) if k == "Undo" => {
mode.pts.pop();
}
// Enter ends a fence or polygon; with too few points the mode is dropped.
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
Input::Enter => return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick)),
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
_ => self.echo("Invalid point."),
}
+10 -7
View File
@@ -32,9 +32,9 @@ impl Session {
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
let hs = cmd::groups::expand(self, hs);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
if shift && pickadd {
sel.retain(|h| !hs.contains(h));
} else {
@@ -44,7 +44,7 @@ impl Session {
return Ok(());
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
match select::pick(self.doc()?, &space, p, ap) {
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
Some(h) => self.idle_pick(h, shift),
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
}
@@ -56,7 +56,7 @@ impl Session {
let pickadd = self.settings.pickadd;
// With group selection the object's selectable groups come (or go) with it.
let hs = cmd::groups::expand(self, vec![h]);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
let had = sel.contains(&h);
if shift && (pickadd || had) {
sel.retain(|x| !hs.contains(x));
@@ -93,7 +93,7 @@ impl Session {
return Vec::new();
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
self.doc().map(|d| select::pick_all_in(d, &self.space(), self.viewport_frozen(), p, ap)).unwrap_or_default()
}
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
@@ -155,7 +155,8 @@ impl Session {
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
/// object under `p` within `aperture`.
/// object under `p` within `aperture`. Inside an MSPACE viewport a pick in model space finds
/// what that viewport draws: not the objects on the layers frozen in it.
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
let d = self.doc()?;
if let Some((h, q)) = self.pick_hint
@@ -163,7 +164,9 @@ impl Session {
{
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
}
Ok(select::pick(d, space, p, aperture))
// The viewport shows model space only: its frozen layers hide nothing on the sheet.
let frozen = if *space == Space::Model { self.viewport_frozen() } else { &[] };
Ok(select::pick_in(d, space, frozen, p, aperture))
}
}
+13 -7
View File
@@ -424,8 +424,8 @@ mod tests {
for k in 0..400 {
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
let ap = [0.05, 0.3, 1.5][k % 3];
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
let a = select::pick_with(&d, &Space::Model, &ix, &[], p, ap);
let b = select::pick_with(&d, &Space::Model, &None, &[], p, ap);
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
hits += usize::from(a.is_some());
}
@@ -444,12 +444,15 @@ mod tests {
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
let bx = Bounds2::new(a, b);
for crossing in [false, true] {
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
let x = select::select_window_with(&d, &Space::Model, &ix, &[], bx, crossing);
let y = select::select_window_with(&d, &Space::Model, &None, &[], bx, crossing);
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
}
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
assert_eq!(
select::select_fence_with(&d, &Space::Model, &ix, &[], &fence),
select::select_fence_with(&d, &Space::Model, &None, &[], &fence)
);
}
}
}
@@ -484,10 +487,13 @@ mod tests {
for (p, ap) in
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
{
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
assert_eq!(select::pick_with(&d, &Space::Model, &ix, &[], p, ap), select::pick_with(&d, &Space::Model, &None, &[], p, ap));
}
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
assert_eq!(
select::select_window_with(&d, &Space::Model, &ix, &[], bx, true),
select::select_window_with(&d, &Space::Model, &None, &[], bx, true)
);
}
#[test]
+10
View File
@@ -1551,3 +1551,13 @@ fn setvar_rejects_non_finite_text_for_real_variables() {
}
}
}
#[test]
fn additional_tools_keeps_compatible_command_ids() {
assert_eq!(crate::cmd::resolve_alias("-blockreplace"), "blockreplace");
for id in ["blockreplace", "burst", "breakline", "mocoro", "txt2mtxt"] {
let command = crate::cmd::find_command(id).expect("existing command id");
assert!(command.menu.contains(&"Additional Tools"), "{id}: {:?}", command.menu);
assert!(!command.menu.contains(&"Express Tools"));
}
}
+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);
}
@@ -0,0 +1,67 @@
//! A word typed with the Greek keyboard layout that is an alias of its own (ALIASEDIT) is that
//! alias, not the Latin name its keys type. The aliases are process-wide, so this file keeps to
//! one test at a time.
use std::sync::Mutex;
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
/// The alias store is process-wide: each test holds this while it changes the aliases.
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
/// A Greek word the user made an alias runs the alias's command, not the command its keys type.
#[test]
fn a_greek_alias_starts_its_command_not_the_latin_keys_command() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
// ΛΙΝΕ types LINE, but the user made it an alias of CIRCLE: aliases are stored as typed (only
// ASCII is lower-cased), and the alias wins.
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
let ran = s.script(
"ΛΙΝΕ 0,0 5
",
);
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
ran.unwrap();
let doc = s.doc().unwrap();
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
assert_eq!((circles, lines), (1, 0), "the alias drew the circle, LINE was not started");
}
/// The alias is found when it is typed in lower case or with an accent too.
#[test]
fn a_greek_alias_starts_its_command_in_any_case_or_accent() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
s.execute("aliasedit", &json!({ "set": { "ΛΙΝΕ": "circle" } })).unwrap();
let ran: Vec<_> = ["λινε", "λίνε"].iter().map(|typed| s.script(&format!("{typed} 0,0 5\n"))).collect();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert!(ran.iter().all(Result::is_ok), "{ran:?}");
let doc = s.doc().unwrap();
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
let lines = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count();
assert_eq!((circles, lines), (2, 0), "the alias drew both circles, LINE was not started");
}
/// 5 000 Greek aliases and a pasted word of a million letters that is none of them: looking it up
/// costs the word's length once, not once for every alias.
#[test]
fn a_long_greek_word_is_folded_once_however_many_greek_aliases_there_are() {
let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
let set: serde_json::Map<String, serde_json::Value> = (0..5_000).map(|i| (format!("ΞΞ{i}"), json!("line"))).collect();
s.execute("aliasedit", &json!({ "set": set })).unwrap();
let word = "Ω".repeat(1_000_000);
let t = std::time::Instant::now();
let started = s.start(&word);
let took = t.elapsed();
s.execute("aliasedit", &json!({ "clear": true })).unwrap();
assert!(started.is_err(), "no command has those keys");
assert!(took.as_secs() < 5, "{took:?}");
}
+225
View File
@@ -0,0 +1,225 @@
//! A command or keyword typed with the Greek keyboard layout active (ΛΙΝΕ for LINE, ΘΝΔΟ for
//! Undo) works as its Latin name: the layout types each Greek letter on the Latin key it sounds
//! like.
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
/// How many lines the drawing's model space holds.
fn lines(s: &Session) -> usize {
s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count()
}
/// ΛΙΝΕ draws a line like LINE, in lower case and with accents too; `;ΣΑΩΕ` starts QSAVE; a
/// Greek word that is no command's keys is reported as typed.
#[test]
fn greek_layout_commands_start_like_the_latin_ones() {
let mut s = Session::new();
s.script("ΛΙΝΕ 0,0 10,0\n\n").unwrap();
assert_eq!(lines(&s), 1, "{}", s.prompt_text());
// Accents the layout's dead key adds are ignored; lower case works too.
s.script("λίνε 0,5 10,5\n\n").unwrap();
assert_eq!(lines(&s), 2);
// The Q key types a semicolon: QSAVE is ";ΣΑΩΕ", which only starts the command (no file needed
// here: it reports, it never fails as an unknown command).
let err = s.start(";ΣΑΩΕ").map(|_| String::new()).unwrap_or_else(|e| e.to_string());
assert!(!err.contains("unknown command"), "{err}");
// A Greek word that is no command's keys is reported as typed, not as the Latin letters read from it.
let err = s.start("ΧΥΖ").unwrap_err().to_string();
assert!(err.contains("ΧΥΖ") && !err.contains("xyz"), "{err}");
}
/// A LISP command with a Greek name runs as itself, not as the command its keys type.
#[test]
fn a_greek_lisp_command_runs_as_itself_not_as_the_latin_keys_command() {
let mut s = Session::new();
// ΛΙΝΕ types LINE, but the routine defines a command of that Greek name: it is the command typed.
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
s.cmdline("ΛΙΝΕ").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let doc = s.doc().unwrap();
let circles = doc.model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
assert_eq!((circles, lines(&s)), (1, 0), "the routine drew the circle, LINE was not started");
}
/// ΑΛΛ at "Select objects" selects everything, as ALL does.
#[test]
fn greek_layout_selection_words_select_like_the_latin_ones() {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
s.execute("circle", &json!({ "center": [20, 0], "radius": 5 })).unwrap();
// ΑΛΛ is the keys A-L-L: ALL at the "Select objects" prompt.
s.script(
"ERASE ΑΛΛ
",
)
.unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(s.doc().unwrap().model.is_empty(), "ΑΛΛ selected nothing");
}
/// Ν and Υ answer MIRROR's "Erase source objects?" as N and Y do.
#[test]
fn greek_layout_keywords_match_prompt_options() {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
s.script("ΜΙΡΡΟΡ ALL\n\n0,0 10,0\n").unwrap();
// ... the last prompt asks whether to delete the sources: N (Greek layout: Ν) keeps them. Text
// the prompt does not know keeps them too, so what Ν is read as is checked itself.
let no = s.current_prompt().and_then(|p| p.match_keyword("Ν"));
assert_eq!(no.as_deref(), Some("No"), "{}", s.prompt_text());
s.script("Ν\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
assert_eq!(circles, 2, "the circle and its mirror image");
// Υ (the Y key) answers Yes, which erases the source: unmatched Greek text would count as No, so
// only a recognised keyword gives this result.
let mut yes = Session::new();
yes.execute("circle", &json!({ "center": [0, 0], "radius": 5 })).unwrap();
yes.script(
"ΜΙΡΡΟΡ ALL
0,0 10,0
Υ
",
)
.unwrap();
assert!(yes.running.is_none(), "{}", yes.prompt_text());
let circles = yes.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
assert_eq!(circles, 1, "Υ answered Yes and the source was erased");
}
/// A Greek block name typed at BEDIT's "block name or select" opens that block: it is not read
/// as a selection word.
#[test]
fn a_greek_block_name_typed_at_a_name_or_select_prompt_reaches_the_command_as_typed() {
let mut s = Session::new();
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "ΠΟΡΤΑ", "base": [0, 0], "handles": [l] })).unwrap();
// BEDIT asks for a block name or a selected reference. ΠΟΡΤΑ is also the keys P-O-R-T-A, which a
// selection word would be read from, but no selection word is meant here: the name is typed as it is.
s.start("BEDIT").unwrap();
s.cmdline("ΠΟΡΤΑ").unwrap();
let open = s.state().unwrap().block_editor().map(str::to_string);
assert_eq!(open.as_deref(), Some("ΠΟΡΤΑ"), "{}", s.prompt_text());
}
/// Μ is the M key, Multiple at a selection prompt; once a block has that name, typing it at
/// BEDIT opens the block.
#[test]
fn a_block_named_like_a_greek_selection_word_opens_at_a_name_or_select_prompt() {
// The Greek capital Mu (U+039C) is the M key: Multiple at a selection prompt.
let mu = "\u{39c}";
let mut s = Session::new();
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
// With no block of that name it stays the selection word, which BEDIT's prompt waits through.
s.start("BEDIT").unwrap();
s.cmdline(mu).unwrap();
assert!(s.running.is_some(), "Multiple keeps the prompt: {}", s.prompt_text());
assert!(s.state().unwrap().block_editor().is_none());
s.cancel();
// Once a block has that name, typing it opens that block.
s.execute("block", &json!({ "name": mu, "base": [0, 0], "handles": [l] })).unwrap();
s.start("BEDIT").unwrap();
s.cmdline(mu).unwrap();
let open = s.state().unwrap().block_editor().map(str::to_string);
assert_eq!(open.as_deref(), Some(mu), "{}", s.prompt_text());
}
/// ΣΟΛΙΔ is the keys of Solid at HATCH's pattern prompt; once a loaded pattern has that name,
/// typing it in any case or accent chooses that pattern.
#[test]
fn a_loaded_pattern_named_like_the_greek_keys_of_solid_is_that_pattern_at_the_pattern_prompt() {
// ΣΟΛΙΔ is the keys S-O-L-I-D: Solid at HATCH's pattern prompt, which takes no scale or angle.
let mut s = Session::new();
s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();
s.start("hatch").unwrap();
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
s.cmdline("ΣΟΛΙΔ").unwrap();
assert!(!s.prompt_text().contains("scale"), "Solid: {}", s.prompt_text());
s.cancel();
// A pattern file that defines a pattern of that name: typing the name chooses that pattern, which asks its scale.
s.execute("hatch.patterns", &json!({ "text": "*ΣΟΛΙΔ, test\n0,0,0,0,1\n" })).unwrap();
let asked = ["ΣΟΛΙΔ", "σολιδ", "σολίδ"].map(|typed| {
s.start("hatch").unwrap();
s.input(cadcraft_engine::Input::Keyword("Pattern".into())).unwrap();
s.cmdline(typed).unwrap();
let asked = s.prompt_text();
s.cancel();
asked
});
s.execute("hatch.patterns", &json!({ "clear": true })).unwrap();
assert!(asked.iter().all(|a| a.contains("scale")), "the loaded pattern, in any spelling: {asked:?}");
}
/// A LISP command with a Greek name runs when typed in upper case, lower case or with an accent.
#[test]
fn a_greek_lisp_command_runs_in_any_case_or_accent() {
for typed in ["ΛΙΝΕ", "λινε", "λίνε"] {
let mut s = Session::new();
s.cmdline("(defun c:ΛΙΝΕ () (command \"_.CIRCLE\" '(0 0) 5) (princ))").unwrap();
s.cmdline(typed).unwrap();
let circles = s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Circle(_))).count();
assert_eq!((circles, lines(&s)), (1, 0), "{typed}: {}", s.prompt_text());
}
}
/// A block called Μ opens at BEDIT when its name is typed in either case.
#[test]
fn a_block_opens_in_any_case_or_accent_at_a_prompt_that_also_takes_a_selection() {
for typed in ["\u{39c}", "\u{3bc}"] {
let mut s = Session::new();
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "\u{39c}", "base": [0, 0], "handles": [l] })).unwrap();
s.start("BEDIT").unwrap();
s.cmdline(typed).unwrap();
let open = s.state().unwrap().block_editor().map(str::to_string);
assert_eq!(open.as_deref(), Some("\u{39c}"), "{typed}: {}", s.prompt_text());
}
}
/// Σ is the S key, Select at BLOCKREPLACE; once a block has that name, typing it in either case
/// names the block.
#[test]
fn a_block_named_like_the_keys_of_select_is_that_block_at_blockreplace() {
let mut s = Session::new();
// Σ is the S key: the Select option of BLOCKREPLACE, while no block has that name.
s.start("BLOCKREPLACE").unwrap();
s.cmdline("Σ").unwrap();
assert!(s.prompt_text().contains("Select a block reference"), "{}", s.prompt_text());
s.cancel();
let l = s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap()["handles"][0].clone();
s.execute("block", &json!({ "name": "Σ", "base": [0, 0], "handles": [l] })).unwrap();
s.execute("insert", &json!({ "name": "Σ", "at": [0, 0] })).unwrap();
for typed in ["Σ", "σ"] {
s.start("BLOCKREPLACE").unwrap();
s.cmdline(typed).unwrap();
let asked = s.prompt_text();
s.cancel();
assert!(asked.contains("replacement block for Σ"), "{typed}: {asked}");
}
}
/// Λ is the L key. At COUNT's "Select object to count or [List/Spaces]" the Latin L is the selection
/// word Last, not the List option: the Greek key is read the same way, and so is the whole word.
#[test]
fn a_greek_selection_word_wins_over_a_keyword_on_the_same_key() {
let answer = |typed: &str| {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
s.execute("line", &json!({ "points": [[0, 0], [5, 0]] })).unwrap();
s.start("COUNT").unwrap();
let from = s.log.len();
s.cmdline(typed).unwrap();
s.log[from..].join("\n")
};
let last = answer("L");
assert!(last.contains("like the selected one"), "L counts the objects like the last one: {last}");
assert_ne!(last, answer("List"));
for typed in ["Λ", "λ", "ΛΑΣΤ"] {
assert_eq!(answer(typed), last, "{typed}");
}
}
@@ -0,0 +1,161 @@
//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst`
//! or a control call can restore it there, and the grips it shares with a visible object must not
//! drag it along.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle};
use cadcraft_engine::geom::Vec2;
use serde_json::json;
/// Draws a line from `a` to `b` and returns its handle.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// The two ends of the line `h`.
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
k => panic!("not a line: {k:?}"),
}
}
/// Two lines sharing the end (0, 0), the second one hidden, both in the selection.
fn shared_corner() -> (Session, Handle, Handle) {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden");
s.set_selection(vec![shown, hidden]);
(s, shown, hidden)
}
/// The index of the grip of `h` at `p`.
fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize {
s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap()
}
/// Stretching the shared corner moves the visible line only; the hidden one stays where it was.
#[test]
fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The Move mode of a grip edit moves the visible selection only.
#[test]
fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() {
let (mut s, shown, hidden) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(10.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text());
s.cmdline("12,5").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
/// The grip of a hidden object is never made hot, so a grip edit cannot start on it.
#[test]
fn the_grip_of_a_hidden_object_cannot_be_made_hot() {
let (mut s, _, hidden) = shared_corner();
let before = s.state().unwrap().doc.clone();
let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string();
assert!(err.contains("no grip"), "{err}");
assert!(s.running.is_none());
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
}
/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the
/// first grip the drawing shows is the base grip, and only the visible line is stretched.
#[test]
fn a_hidden_grip_among_the_hot_grips_is_left_out() {
for hidden_first in [false, true] {
let (mut s, shown, hidden) = shared_corner();
let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))];
if hidden_first {
hot.reverse();
}
s.start_grip_edit(&hot).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}"
);
}
}
/// Only what the drawing does not show is left out: a grip of an object that does not exist is
/// still an error, also after a good one.
#[test]
fn a_missing_object_among_the_hot_grips_is_still_an_error() {
let (mut s, shown, _) = shared_corner();
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string();
assert!(err.contains("no such object"), "{err}");
assert!(s.running.is_none());
}
/// A selected line on a layer that is off is not shown either, and is not stretched with a
/// shared grip.
#[test]
fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap();
let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]);
s.execute("layer.current", &json!({ "name": "0" })).unwrap();
s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap();
let d = s.doc().unwrap();
assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off");
s.set_selection(vec![shown, off]);
let i = grip_at(&s, shown, Vec2::new(0.0, 0.0));
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, off);
assert!(
start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9),
"the line on the layer that is off moved: {start:?} {end:?}"
);
}
/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared
/// corner it makes only the visible line's grip hot.
#[test]
fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() {
let (mut s, shown, hidden) = shared_corner();
s.cmdline("grip").unwrap();
assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text());
// Only the hidden line has a grip at (0, 10): the command says so and asks again.
s.cmdline("0,10").unwrap();
assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log);
s.cmdline("0,0").unwrap();
s.cmdline("").unwrap();
assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text());
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let (start, end) = ends(&s, shown);
assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}");
let (start, end) = ends(&s, hidden);
assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}");
}
+4 -4
View File
@@ -23,10 +23,10 @@ fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, V
.into_iter()
.map(|t| {
let v = match t.value {
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
cadcraft_dxf::Value::Real(r) => json!(r),
cadcraft_dxf::Value::Int(i) => json!(i),
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => json!(s),
craft_dxf::Value::Real(r) => json!(r),
craft_dxf::Value::Int(i) => json!(i),
craft_dxf::Value::Bool(b) => json!(i32::from(b)),
};
(t.code, v)
})
+3
View File
@@ -52,6 +52,7 @@ mod extend_polyline_arc_end;
mod fillet_chamfer;
mod fix_holds_grips;
mod followups_626;
mod greek_keyboard;
mod grip_dim_input;
mod grip_options;
mod grip_selection;
@@ -62,6 +63,7 @@ mod hatch_dialog_helpers;
mod hatch_numeric_validation;
mod hatch_options;
mod helix;
mod hidden_grip_targets;
mod inquiry_keywords;
mod inquiry_variables;
mod inquiry_z;
@@ -165,6 +167,7 @@ mod trim_extend_options;
mod undo_count;
mod undo_layout_tab;
mod viewport_clip;
mod viewport_frozen_selection;
mod vplayer_prompts;
mod vports;
mod wipeout_grips;
+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");
}
@@ -0,0 +1,783 @@
//! Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, "VP Freeze")
//! is not drawn there, so the selections made through that viewport leave it out: picks, windows,
//! the typed selection modes, the commands that select for themselves, and, for an object that is
//! already selected, its grips and the commands that start from the selection. In a viewport that
//! does not freeze the layer, in paper space and on the Model tab everything is as before.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle, Space};
use cadcraft_engine::geom::Vec2;
use serde_json::{Value, json};
/// Draws a line from `a` to `b` on the current layer and returns its handle.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// Puts `h` on the layer "Hidden", which [`viewports`] freezes in its first viewport.
fn hide(s: &mut Session, h: Handle) {
if s.doc().unwrap().layer("Hidden").is_none() {
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
}
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
assert_eq!(s.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
}
/// Two viewports on Layout1: the first freezes the layer "Hidden", the second freezes nothing.
/// The session is left in paper space.
fn viewports(s: &mut Session) -> (String, String) {
if s.doc().unwrap().layer("Hidden").is_none() {
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
}
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string());
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
(a, b)
}
/// Makes `viewport` the active MSPACE viewport.
fn enter(s: &mut Session, viewport: &str) {
s.execute("mspace", &json!({ "handle": viewport })).unwrap();
assert_eq!(s.space(), Space::Model, "inside a viewport the edit space is model space");
}
/// What most tests draw: `shown` on layer 0 and, drawn after it and so on top of it, `hidden` on
/// the layer "Hidden", both from (0, 0) to (10, 0); and the two viewports of [`viewports`].
struct Sheet {
s: Session,
shown: Handle,
hidden: Handle,
/// The viewport that freezes "Hidden".
a: String,
/// The viewport that freezes nothing.
b: String,
}
/// The drawing of [`Sheet`], in paper space.
fn sheet() -> Sheet {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
hide(&mut s, hidden);
assert_eq!(s.doc().unwrap().entity(shown).unwrap().common.layer, "0");
let (a, b) = viewports(&mut s);
Sheet { s, shown, hidden, a, b }
}
/// The two ends of the line `h`.
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
k => panic!("not a line: {k:?}"),
}
}
/// Whether the line `h` still runs from (0, 0) to (10, 0).
fn untouched(s: &Session, h: Handle) -> bool {
let (a, b) = ends(s, h);
a.near(Vec2::new(0.0, 0.0), 1e-9) && b.near(Vec2::new(10.0, 0.0), 1e-9)
}
/// Whether the drawing still has the object `h`.
fn exists(s: &Session, h: Handle) -> bool {
s.doc().unwrap().entity(h).is_some()
}
/// Every selected object, in handle order: the stored selection, which also holds the objects
/// the active viewport does not draw ([`Session::selection`] leaves those out).
fn selected(s: &Session) -> Vec<Handle> {
let mut v = s.state().unwrap().selection.clone();
v.sort();
v
}
/// Whether the line `h` was erased or no longer runs from (0, 0) to (10, 0).
fn edited(s: &Session, h: Handle) -> bool {
!exists(s, h) || !untouched(s, h)
}
/// The index of the grip of `h` at the origin.
fn origin_grip(s: &Session, h: Handle) -> usize {
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(0.0, 0.0), 1e-9)).unwrap()
}
/// `hs` in handle order.
fn sorted(mut hs: Vec<Handle>) -> Vec<Handle> {
hs.sort();
hs
}
/// Runs `text` as a script and ends whatever prompt it leaves open with Enter.
fn run(s: &mut Session, text: &str) {
s.script(text).unwrap();
for _ in 0..4 {
if s.running.is_none() {
break;
}
s.cmdline("").unwrap();
}
assert!(s.running.is_none(), "`{text}` left a prompt open: {}", s.prompt_text());
}
/// The JSON `select` command replaces the selection with what `how` finds.
fn select(s: &mut Session, how: Value) -> Vec<Handle> {
s.set_selection(Vec::new());
s.execute("select", &how).unwrap();
selected(s)
}
/// A pick takes the topmost object the viewport draws: the frozen line lies on top of the other
/// one, and a pick at the same point finds the line under it. Where the layer is not frozen the
/// pick finds the line on top, as it always did.
#[test]
fn a_pick_finds_the_topmost_object_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
let at = json!({ "at": [5, 0], "aperture": 0.5 });
enter(&mut s, &a);
assert_eq!(select(&mut s, at.clone()), [shown], "the viewport freezes the layer of the line on top");
enter(&mut s, &b);
assert_eq!(select(&mut s, at.clone()), [hidden], "this viewport draws both: the one on top is picked");
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
assert_eq!(select(&mut s, at), [hidden], "the Model tab draws both");
}
/// Window, crossing and fence selection through the viewport leave the frozen line out, and take
/// both lines through the viewport that draws both.
#[test]
fn a_window_a_crossing_and_a_fence_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
let ways =
[json!({ "window": [[-1, -1], [11, 1]] }), json!({ "window": [[4, -1], [6, 1]], "crossing": true }), json!({ "fence": [[5, -1], [5, 1]] })];
for how in &ways {
enter(&mut s, &a);
assert_eq!(select(&mut s, how.clone()), [shown], "{how}");
enter(&mut s, &b);
assert_eq!(select(&mut s, how.clone()), sorted(vec![shown, hidden]), "{how}");
}
}
/// Clicks with no command running: a click on the two lines selects the one the viewport draws,
/// and a window dragged around them takes only that one.
#[test]
fn idle_clicks_and_windows_select_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
s.set_selection(Vec::new());
// A click on nothing opens a window; the next click closes it (left to right: a window).
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
assert!(s.pending_window.is_some(), "the first click must be on nothing");
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [hidden], "this viewport draws both: the one on top is picked");
s.set_selection(Vec::new());
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// Selection cycling lists the objects under the pick box that the viewport draws.
#[test]
fn selection_cycling_lists_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [shown]);
enter(&mut s, &b);
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [hidden, shown], "topmost first");
}
/// Every way of answering "Select objects" leaves the frozen line out: ERASE through the
/// viewport that freezes its layer erases the other line only, and through the other viewport
/// both.
#[test]
fn the_answers_to_select_objects_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
// Group selection off: a pick takes the object alone, so each answer is tested by itself.
s.settings.pickstyle = 0;
let answers = [
"5,0",
"-1,-1 11,1",
"ALL",
"W -1,-1 11,1",
"C 6,1 4,-1",
"BOX 6,1 4,-1",
"F 5,-1 5,1\n",
"WP -1,-1 11,-1 11,1 -1,1\n",
"CP 4,-1 6,-1 5,1\n",
"G PAIR",
];
for answer in answers {
enter(&mut s, &a);
run(&mut s, &format!("ERASE {answer}\n"));
assert!(!exists(&s, shown), "`{answer}` must erase the line the viewport draws");
assert!(exists(&s, hidden), "`{answer}` erased a line the viewport does not draw");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
enter(&mut s, &b);
run(&mut s, &format!("ERASE {answer}\n"));
// A pick takes one object, the one on top; every other answer takes both.
assert_eq!((exists(&s, shown), exists(&s, hidden)), (answer == "5,0", false), "`{answer}` where both are drawn");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
}
}
/// With group selection on, a pick brings the members of its group that the viewport draws.
#[test]
fn a_pick_brings_the_group_members_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
s.settings.pickstyle = 1;
s.set_selection(Vec::new());
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// Select All and Select Similar take what the viewport draws.
#[test]
fn select_all_and_select_similar_take_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// Mode 0: any object of the same type is similar, whatever its layer.
let similar = json!({ "handles": [shown.hex()], "mode": 0 });
enter(&mut s, &a);
s.execute("selectall", &json!({})).unwrap();
assert_eq!(selected(&s), [shown]);
s.execute("selectsimilar", &similar).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.execute("selectall", &json!({})).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
s.execute("selectsimilar", &similar).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// STRETCH selects for itself: its crossing window, polygon and fence, called with JSON or
/// typed, and its typed All, Last and Previous, move the line the viewport draws and leave the
/// frozen one where it was.
#[test]
fn stretch_moves_what_the_viewport_draws() {
let calls = [
json!({ "window": [[9, -1], [11, 1]], "delta": [0, 5] }),
json!({ "polygon": [[9, -1], [11, -1], [11, 1], [9, 1]], "delta": [0, 5] }),
json!({ "fence": [[5, -1], [5, 1]], "delta": [0, 5] }),
];
for call in &calls {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("stretch", call).unwrap();
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must move");
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.execute("stretch", call).unwrap();
assert!(!untouched(&s, hidden), "{call}: where both are drawn both move");
}
// Typed: a crossing window, named and implied, All, a pick, Last, and Previous (both lines).
for objects in ["C 11,1 9,-1", "11,1 9,-1", "ALL", "5,0", "L", "P"] {
let typed = format!("STRETCH {objects}\n\n0,0 0,5\n");
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.remember_selection(&[shown, hidden]);
run(&mut s, &typed);
assert!(!untouched(&s, shown), "{typed:?}: the line the viewport draws must move");
assert!(untouched(&s, hidden), "{typed:?}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.remember_selection(&[shown, hidden]);
run(&mut s, &typed);
assert!(!untouched(&s, hidden), "{typed:?}: where both are drawn the one on top moves");
}
}
/// A multiline whose control vertex is inside STRETCH's crossing window is stretched although
/// the window misses its drawn lines, but not when the viewport does not draw it.
#[test]
fn stretch_leaves_a_multiline_the_viewport_does_not_draw() {
/// The control vertices of the multiline `h`.
fn vertices(s: &Session, h: Handle) -> Vec<Vec2> {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::MLine(m) => m.vertices.iter().map(|v| v.p.xy()).collect(),
k => panic!("not a multiline: {k:?}"),
}
}
let mut s = Session::new();
// Zero justification at scale 2: the element lines run one unit either side of the vertices.
let r = s.execute("mline", &json!({ "points": [[0, 0], [10, 0]], "scale": 2, "justification": "zero" })).unwrap();
let mline = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
hide(&mut s, mline);
let (a, b) = viewports(&mut s);
let call = json!({ "window": [[9.9, -0.1], [10.1, 0.1]], "delta": [0, 5] });
enter(&mut s, &a);
s.execute("stretch", &call).unwrap();
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 0.0), 1e-9), "a multiline the viewport does not draw was stretched");
enter(&mut s, &b);
s.execute("stretch", &call).unwrap();
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 5.0), 1e-9), "{:?}", vertices(&s, mline));
}
/// TRIM's fence and crossing window trim the line the viewport draws and leave the frozen one.
#[test]
fn trim_by_fence_and_crossing_leaves_what_the_viewport_does_not_draw() {
for call in [json!({ "fence": [[8, -1], [8, 1]] }), json!({ "crossing": [[7.5, -1], [8.5, 1]] })] {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
// The cutting edge, on a layer every viewport draws.
line(&mut s, [5.0, -5.0], [5.0, 5.0]);
hide(&mut s, hidden);
let (a, b) = viewports(&mut s);
enter(&mut s, &a);
s.execute("trim", &call).unwrap();
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must be trimmed");
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw was trimmed: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.execute("trim", &call).unwrap();
assert!(!untouched(&s, hidden), "{call}: where both are drawn both are trimmed");
}
}
/// A prompt that picks one object (BREAK's "Select object") finds the one the viewport draws.
#[test]
fn a_prompt_for_one_object_picks_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
run(&mut s, "BREAK 4,0 6,0\n");
assert!(untouched(&s, hidden), "a line the viewport does not draw was broken: {:?}", ends(&s, hidden));
assert!(!untouched(&s, shown), "the line the viewport draws must be broken");
enter(&mut s, &b);
run(&mut s, "BREAK 4,0 6,0\n");
assert!(!untouched(&s, hidden), "where both are drawn the one on top is broken");
}
/// LEADER's Copy option picks the text to copy: at a point where two texts lie, the one the
/// viewport draws.
#[test]
fn leader_copies_the_text_the_viewport_draws() {
/// The contents of the multiline texts in model space: LEADER's copies.
fn copies(s: &Session) -> Vec<String> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect()
}
let mut s = Session::new();
for text in ["SHOWN", "HIDDEN"] {
s.execute("text", &json!({ "at": [0, 0], "text": text, "height": 2.5 })).unwrap();
}
// The text drawn last lies on top.
let hidden = *s.doc().unwrap().model.handles().last().unwrap();
hide(&mut s, hidden);
let (a, b) = viewports(&mut s);
let leader = "(command \"_.LEADER\" '(20.0 20.0) '(30.0 25.0) \"\" \"\" \"_C\" '(1.0 1.0))";
enter(&mut s, &a);
s.cmdline(leader).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(copies(&s), ["SHOWN"], "the viewport freezes the layer of the text on top");
s.execute("undo", &json!({})).unwrap();
assert!(copies(&s).is_empty());
enter(&mut s, &b);
s.cmdline(leader).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(copies(&s), ["HIDDEN"], "this viewport draws both: the one on top is picked");
}
/// AutoLISP selects through the viewport too: `ssget` with a window, a crossing window, a fence,
/// a polygon, a crossing polygon or a point leaves the frozen line out, and `entsel` picks the
/// line the viewport draws. The sets made earlier (the implied selection, `ssgetfirst`, Previous)
/// come without the frozen line, and Last is the last object the viewport draws.
#[test]
fn lisp_selects_what_the_viewport_draws() {
use cadcraft_engine::lisp::value::Value as Lisp;
/// The objects of the selection set that `code` returns (none for nil), in handle order.
fn set(s: &mut Session, code: &str) -> Vec<Handle> {
match s.lisp_eval(code, false, true, Vec::new()).unwrap() {
Some(Ok(Lisp::PickSet(set))) => sorted(set.items.lock().unwrap().clone()),
Some(Ok(Lisp::Nil)) => Vec::new(),
other => panic!("`{code}` returned {other:?}"),
}
}
/// The object `entsel` returns for a pick at (5, 0).
fn entsel(s: &mut Session) -> Handle {
s.cmdline("(setq picked (car (entsel)))").unwrap();
s.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 0.0))).unwrap();
match s.lisp_var("picked") {
Lisp::Ename(h) => h,
other => panic!("entsel returned {other:?}: {}", s.prompt_text()),
}
}
let Sheet { mut s, shown, hidden, a, b } = sheet();
let areas = [
"(ssget \"W\" '(-1 -1) '(11 1))",
"(ssget \"C\" '(4 -1) '(6 1))",
"(ssget \"F\" '((5 -1) (5 1)))",
"(ssget \"WP\" '((-1 -1) (11 -1) (11 1) (-1 1)))",
"(ssget \"CP\" '((4 -1) (6 -1) (5 1)))",
];
enter(&mut s, &a);
for code in areas {
assert_eq!(set(&mut s, code), [shown], "{code}");
}
assert_eq!(set(&mut s, "(ssget '(5 0))"), [shown], "a point picks the object the viewport draws");
assert_eq!(entsel(&mut s), shown);
enter(&mut s, &b);
for code in areas {
assert_eq!(set(&mut s, code), sorted(vec![shown, hidden]), "{code}");
}
assert_eq!(set(&mut s, "(ssget '(5 0))"), [hidden], "this viewport draws both: the one on top is picked");
assert_eq!(entsel(&mut s), hidden);
let earlier = ["(ssget \"I\")", "(ssget)", "(cadr (ssgetfirst))", "(ssget \"P\")"];
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
enter(&mut s, viewport);
for code in earlier {
s.set_selection(vec![shown, hidden]);
s.remember_selection(&[shown, hidden]);
assert_eq!(set(&mut s, code), expected, "{code}");
}
}
// Last: of two lines made after everything else, the later one is on the layer the first
// viewport freezes, so through that viewport Last is the earlier one.
s.set_selection(Vec::new());
enter(&mut s, &b);
let drawn = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
let last = line(&mut s, [0.0, 3.0], [10.0, 3.0]);
hide(&mut s, last);
assert_eq!(set(&mut s, "(ssget \"L\")"), [last]);
enter(&mut s, &a);
assert_eq!(set(&mut s, "(ssget \"L\")"), [drawn], "Last must pass over an object the viewport does not draw");
}
/// The grips of a selected object the viewport does not draw take no part in a grip edit: a
/// shared grip stretches only the line that is drawn. Through the other viewport both stretch.
#[test]
fn a_selected_line_the_viewport_does_not_draw_is_not_stretched_with_a_shared_grip() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.set_selection(vec![shown, hidden]);
let i = origin_grip(&s, shown);
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
assert!(untouched(&s, hidden), "a line the viewport does not draw moved: {:?}", ends(&s, hidden));
// Its own grip starts no edit there.
let before = s.state().unwrap().doc.clone();
let j = origin_grip(&s, hidden);
assert!(s.start_grip_edit(&[(hidden, j)]).is_err());
assert!(s.running.is_none());
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
s.execute("undo", &json!({})).unwrap();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
assert!(ends(&s, hidden).0.near(Vec2::new(3.0, 4.0), 1e-9), "where both are drawn both stretch: {:?}", ends(&s, hidden));
}
/// A layer frozen in the viewport while its object is selected: from then on the object's grips
/// are not there, and thawing the layer in the viewport brings them back.
#[test]
fn freezing_a_layer_in_the_viewport_removes_the_grips_of_a_selected_object() {
let Sheet { mut s, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![hidden]);
// The typed GRIP command finds a grip of the selected line at its start.
let grip_there = |s: &mut Session| {
s.cmdline("grip").unwrap();
s.cmdline("0,0").unwrap();
let none = s.log.iter().any(|l| l.contains("No grip of a selected object there"));
s.input(cadcraft_engine::Input::Cancel).unwrap();
s.log.clear();
!none
};
assert!(grip_there(&mut s), "the viewport draws the line: its grip is there");
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
assert!(!grip_there(&mut s), "the viewport no longer draws the line");
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
assert!(grip_there(&mut s), "thawed in the viewport: the grip is back");
}
/// What the edited view shows: the frozen layer hides its model-space objects inside that
/// viewport only, and never an object on the sheet.
#[test]
fn only_the_viewport_that_freezes_a_layer_hides_its_model_objects() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// A line on the sheet, on the layer the first viewport freezes.
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
assert_eq!(s.doc().unwrap().space_of(on_sheet), Some(Space::Paper("Layout1".into())));
let shows = |s: &Session, h: Handle| {
let st = s.state().unwrap();
st.shows(st.doc.entity(h).unwrap())
};
assert!(s.viewport_frozen().is_empty(), "paper space: no viewport is active");
assert!(shows(&s, hidden) && shows(&s, shown) && shows(&s, on_sheet));
enter(&mut s, &a);
assert_eq!(s.viewport_frozen(), ["Hidden".to_string()]);
assert!(!shows(&s, hidden), "frozen in the active viewport");
assert!(shows(&s, shown));
assert!(shows(&s, on_sheet), "the viewport draws model space: its frozen layers hide nothing on the sheet");
// A prompt that picks one object on the sheet while the viewport is active finds it.
let sheet_space = Space::Paper("Layout1".into());
assert_eq!(s.pick_object(&sheet_space, Vec2::new(1.5, 1.0), 0.5).unwrap(), Some(on_sheet));
assert_eq!(s.pick_object(&Space::Model, Vec2::new(5.0, 0.0), 0.5).unwrap(), Some(shown), "in model space the frozen line is left out");
enter(&mut s, &b);
assert!(s.viewport_frozen().is_empty());
assert!(shows(&s, hidden));
// Layer names are not case sensitive: frozen under another spelling, the line is hidden too.
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["HIDDEN"] })).unwrap();
assert_eq!(s.viewport_frozen(), ["HIDDEN".to_string()]);
assert!(!shows(&s, hidden));
assert_eq!(select(&mut s, json!({ "at": [5, 0], "aperture": 0.5 })), [shown]);
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["hidden"] })).unwrap();
assert!(shows(&s, hidden));
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
assert!(s.viewport_frozen().is_empty(), "the Model tab has no layout viewport");
assert!(shows(&s, hidden));
}
/// An object that is selected when its layer is frozen in the active viewport stays selected, but
/// nothing that starts from the selection takes it there: a typed command with the selection
/// made before it (PICKFIRST), a JSON command that names no objects, and Delete.
#[test]
fn a_command_leaves_out_a_selected_object_the_viewport_does_not_draw() {
/// One way of editing the selection.
type Edit = fn(&mut Session);
let edits: [(&str, Edit); 5] = [
("typed ERASE", |s: &mut Session| run(s, "ERASE\n")),
("typed MOVE", |s: &mut Session| run(s, "MOVE 0,0 0,5\n")),
("JSON erase", |s: &mut Session| drop(s.execute("erase", &json!({})).unwrap())),
("JSON move", |s: &mut Session| drop(s.execute("move", &json!({ "delta": [0, 5] })).unwrap())),
("Delete", |s: &mut Session| drop(s.execute("erase.selection", &json!({})).unwrap())),
];
for (name, edit) in edits {
// Where both lines are drawn, both are edited.
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
edit(&mut s);
assert!(edited(&s, shown) && edited(&s, hidden), "{name}: where both are drawn both are edited");
// The layer is frozen in the active viewport while both lines are selected.
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "{name}: both lines must still be selected");
assert_eq!(s.selection(), [shown], "{name}: the selection as the viewport has it");
edit(&mut s);
assert!(edited(&s, shown), "{name}: the line the viewport draws must be edited");
assert!(!edited(&s, hidden), "{name}: a line the viewport does not draw was edited: {:?}", ends(&s, hidden));
}
// With only that line selected there is nothing to start from.
let Sheet { mut s, hidden, a, b, .. } = sheet();
enter(&mut s, &a);
s.set_selection(vec![hidden]);
assert!(s.selection().is_empty());
assert!(s.execute("erase.selection", &json!({})).is_err(), "Delete must find nothing selected");
s.script("ERASE\n").unwrap();
assert!(s.running.is_some() && s.prompt_text().contains("Select objects"), "ERASE must ask for objects: {}", s.prompt_text());
s.input(cadcraft_engine::Input::Cancel).unwrap();
assert!(!edited(&s, hidden));
// The line is still selected, and a viewport that draws it has it again.
s.set_selection(vec![hidden]);
s.execute("vplayer", &json!({ "handles": [a], "thaw": ["Hidden"] })).unwrap();
assert_eq!(s.selection(), [hidden], "thawed in the viewport: selected as before");
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
assert!(s.selection().is_empty());
enter(&mut s, &b);
s.set_selection(vec![hidden]);
run(&mut s, "ERASE\n");
assert!(!exists(&s, hidden), "where the line is drawn the selection made before ERASE is erased");
}
/// The Previous and Last options of "Select objects" take what the viewport draws: of the set a
/// command used before, and the last object that is not frozen there.
#[test]
fn previous_and_last_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
for option in ["P", "L"] {
enter(&mut s, &a);
s.set_selection(Vec::new());
s.remember_selection(&[shown, hidden]);
run(&mut s, &format!("ERASE {option}\n"));
assert!(!exists(&s, shown), "`{option}` must erase the line the viewport draws");
assert!(exists(&s, hidden), "`{option}` erased a line the viewport does not draw");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
enter(&mut s, &b);
s.set_selection(Vec::new());
s.remember_selection(&[shown, hidden]);
run(&mut s, &format!("ERASE {option}\n"));
// Previous is both lines; Last is the one made last, the line on the layer "Hidden".
assert_eq!((exists(&s, shown), exists(&s, hidden)), (option == "L", false), "`{option}` where both are drawn");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
}
}
/// The JSON grip edits that cover the selection (`grip.stretch` with hot grips, and
/// [`Session::grip_edit`]) drag the grips of the other selected objects on the same point, but
/// not the grip of a selected line the viewport does not draw.
#[test]
fn the_grip_edits_that_cover_the_selection_leave_out_what_the_viewport_does_not_draw() {
use cadcraft_engine::grips::GripMode;
/// Drags grip `i` of the line `h` to (3, 4).
type Edit = fn(&mut Session, Handle, usize);
let edits: [(&str, Edit); 2] = [
("grip.stretch", |s: &mut Session, h: Handle, i: usize| {
drop(s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i]], "to": [3, 4] })).unwrap())
}),
("grip_edit", |s: &mut Session, h: Handle, i: usize| s.grip_edit(h, i, Vec2::new(3.0, 4.0), GripMode::Stretch).unwrap()),
];
for (name, edit) in edits {
for frozen in [true, false] {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, if frozen { &a } else { &b });
s.set_selection(vec![shown, hidden]);
let i = origin_grip(&s, shown);
edit(&mut s, shown, i);
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{name}: {:?}", ends(&s, shown));
assert_eq!(untouched(&s, hidden), frozen, "{name}, layer frozen in the viewport: {frozen}: {:?}", ends(&s, hidden));
}
}
}
/// What the session reports as the selection is the selection as the viewport has it, while
/// every selected object stays in the stored selection.
#[test]
fn the_reported_selection_leaves_out_what_the_viewport_does_not_draw() {
/// The handles in `list`, a JSON array of hex strings, in handle order.
fn handles(list: &Value) -> Vec<Handle> {
sorted(list.as_array().unwrap().iter().map(|h| Handle::parse_hex(h.as_str().unwrap()).unwrap()).collect())
}
let Sheet { mut s, shown, hidden, a, b } = sheet();
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
enter(&mut s, viewport);
s.set_selection(vec![shown, hidden]);
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "both lines are selected");
assert_eq!(sorted(s.selection()), expected);
assert_eq!(handles(&s.summary()["selection"]), expected);
let inspect = s.execute("drawing.inspect", &json!({})).unwrap();
assert_eq!(handles(&inspect["selection"]), expected);
}
// A selected object on the sheet is never left out, whatever its layer.
s.execute("pspace", &json!({})).unwrap();
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
enter(&mut s, &a);
s.set_selection(vec![shown, hidden, on_sheet]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, on_sheet]));
// Where no viewport is active nothing is left out: in paper space and on the Model tab.
s.execute("pspace", &json!({})).unwrap();
s.set_selection(vec![shown, hidden, on_sheet]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, on_sheet]), "paper space");
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
s.set_selection(vec![shown, hidden]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden]), "the Model tab");
}
/// Adding to the selection or removing from it inside the viewport keeps a selected object the
/// viewport does not draw: nothing drops it behind the user's back, and it counts again once its
/// layer is thawed there.
#[test]
fn changing_the_selection_keeps_a_selected_object_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
// A third line, on layer 0 like `shown`, away from the other two.
let other = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
// Selects `hidden` where it is drawn, then freezes its layer in the active viewport.
let freeze = |s: &mut Session| {
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(selected(s), [hidden], "the line must still be selected");
assert!(s.selection().is_empty(), "the viewport does not draw it");
};
// A click adds (PICKADD), Shift+click removes.
freeze(&mut s);
s.idle_click(Vec2::new(5.0, 5.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a click dropped the selected line the viewport does not draw");
assert_eq!(s.selection(), [other]);
s.idle_click(Vec2::new(5.0, 5.0), true).unwrap();
assert_eq!(selected(&s), [hidden], "Shift+click must remove only the line clicked");
// A window adds.
freeze(&mut s);
s.idle_click(Vec2::new(-1.0, 4.0), false).unwrap();
assert!(s.pending_window.is_some(), "the first click must be on nothing");
s.idle_click(Vec2::new(11.0, 6.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a window dropped the selected line the viewport does not draw");
// The JSON `select` with `add`, and QSELECT appending the objects on layer 0.
freeze(&mut s);
s.execute("select", &json!({ "at": [5, 5], "aperture": 0.5, "add": true })).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with `add`");
// Given nothing to select with, `select` leaves the selection as it is.
s.execute("select", &json!({})).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with nothing to select with");
freeze(&mut s);
s.execute("qselect", &json!({ "layer": "0", "mode": "append" })).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden, other]), "QSELECT appending");
assert_eq!(sorted(s.selection()), sorted(vec![shown, other]));
// Thawed in the viewport, the line counts as selected again.
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, other]));
}
/// [`Session::selection`] looks a few selected objects up one by one and, when there are many,
/// passes over model space once. Both leave out the same objects and keep the order of the
/// selection: in a drawing of some hundreds of lines whose draw order is not the order of their
/// handles, a selection of a few lines and the selection of every line.
#[test]
fn a_small_and_a_large_selection_leave_out_the_same_objects() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// A line on the sheet, on the layer the first viewport freezes: no viewport hides it.
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
assert!(!s.doc().unwrap().model.contains(on_sheet), "the line must be on the sheet");
enter(&mut s, &b);
let more: Vec<Handle> = (0..400).map(|i| line(&mut s, [f64::from(i), 5.0], [f64::from(i), 6.0])).collect();
// Every second one of them goes on the layer the first viewport freezes.
let frozen: Vec<String> = more.iter().step_by(2).map(|h| h.hex()).collect();
s.execute("properties.set", &json!({ "handles": frozen, "layer": "Hidden" })).unwrap();
// The frozen line made last goes to the back: it is now the first object in draw order.
s.execute("draworder", &json!({ "handles": [more[398].hex()], "order": "back" })).unwrap();
assert_eq!(s.doc().unwrap().model.iter().next().map(|e| e.handle), Some(more[398]));
let all: Vec<Handle> = [shown, hidden].into_iter().chain(more.iter().copied()).collect();
let drawn: Vec<Handle> = std::iter::once(shown).chain(more.iter().copied().skip(1).step_by(2)).collect();
assert_eq!(s.doc().unwrap().model.len(), all.len());
enter(&mut s, &a);
s.set_selection(vec![shown, hidden]);
assert_eq!(s.selection(), [shown], "two selected lines of some hundreds");
s.set_selection(vec![hidden, on_sheet, more[0], more[1]]);
assert_eq!(s.selection(), [on_sheet, more[1]], "a line on the sheet is never left out");
s.set_selection(all.clone());
assert_eq!(s.selection(), drawn, "every line selected");
assert_eq!(s.state().unwrap().selection, all, "every line must still be selected");
// The viewport that freezes nothing has them all, few or many.
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
assert_eq!(s.selection(), [shown, hidden]);
s.set_selection(all.clone());
assert_eq!(s.selection(), all);
}
+22
View File
@@ -190,6 +190,28 @@ fn reload_unload_path_and_detach() {
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
// and reload-all loads one of them once.
let mut twin = Block::new("SITE");
twin.xref_path = Some("site.dxf".into());
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
// Detaching one of them, or giving both a new path, is refused before anything changes.
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
assert!(s.doc().unwrap().block("site").is_some());
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
for k in ["site", "SITE"] {
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
}
s.doc_mut().unwrap().blocks.remove("SITE");
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
assert!(s.doc().unwrap().block("site|Tree").is_none());
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
[dependencies]
cadcraft-geom = { workspace = true }
cadcraft-dxf = { workspace = true }
craft-dxf = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
+28 -3
View File
@@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
// The line's text is not repeated: it comes from the checkout and goes to the build output.
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
return Err(format!("malformed manifest line {n}"));
};
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
@@ -73,6 +76,28 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
Ok(out)
}
/// The most a manifest may hold; the real one is a few KiB.
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
use std::io::Read as _;
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !file.metadata().is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let mut text = String::new();
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
}
Ok(text)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
+10 -1
View File
@@ -184,6 +184,9 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
let mut under_start: Option<f64> = None;
let mut over_start: Option<f64> = None;
let mut gap = stroke::GAP * sc * wf;
// Retained fallback-outline points over the line, as the TrueType path caps them: a short string
// repeating one detailed glyph would otherwise copy its outline per occurrence.
let mut points_left = ttf::MAX_SHAPED_POINTS;
// The simple path lays a line out left to right: one asked to run right to left goes through
// the bidi path even without right-to-left letters.
if (complex::needed(s) || base_rtl == Some(true))
@@ -202,7 +205,13 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
Some(g) => {
let (y, scale) = stroke_fallback_metrics(&g, c, h);
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
let contours = match points_left.checked_sub(g.points) {
Some(rest) => {
points_left = rest;
ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear)
}
None => Vec::new(),
};
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
+55 -1
View File
@@ -153,6 +153,40 @@ pub struct MTextLayout {
pub bounds: Bounds2,
/// Plain text of each laid-out line.
pub lines: Vec<String>,
/// Whether the geometry stops short: the layout reached its allowance of retained points
/// ([`Allowance`]) and the segments after that are not drawn.
pub truncated: bool,
}
/// The points all the segments of one layout may keep together, each counted for both copies the
/// layout holds: the allowance one shaped line has (`MAX_SHAPED_POINTS`), so a short repeated detailed glyph over many segments cannot multiply it.
pub(crate) struct Allowance(usize);
impl Allowance {
pub(crate) fn new() -> Self {
Allowance(crate::ttf::MAX_SHAPED_POINTS)
}
/// Whether any allowance is left, before a segment is shaped.
pub(crate) fn left(&self) -> bool {
self.0 > 0
}
/// Charge the points of `sh`; false (and nothing left) when it does not fit.
pub(crate) fn charge(&mut self, sh: &Shaped) -> bool {
// Kept twice: in the piece and again in the layout's flat `strokes`.
let n = 2 * (sh.strokes.iter().map(Vec::len).sum::<usize>() + sh.glyphs.iter().flatten().map(Vec::len).sum::<usize>());
match self.0.checked_sub(n) {
Some(rest) => {
self.0 = rest;
true
}
None => {
self.0 = 0;
false
}
}
}
}
/// Inline character format.
@@ -259,7 +293,7 @@ pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
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}'));
push_literal(&mut out, craft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
i += 8;
continue;
}
@@ -850,6 +884,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = Allowance::new();
for (l, base_y) in lines.iter().zip(baselines) {
let x0 = match col {
0 => 0.0,
@@ -859,7 +894,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for it in &l.items {
let Some(sg) = &it.seg else { continue };
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(sg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(x0, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
@@ -896,6 +939,17 @@ mod tests {
assert_eq!(l.lines[0], "alpha beta gamma");
}
/// The points of all the segments of one layout are charged against one allowance.
#[test]
fn a_layout_shares_one_point_allowance() {
let big = Shaped { strokes: vec![vec![Vec2::ZERO; crate::ttf::MAX_SHAPED_POINTS / 4 + 1]], ..Default::default() };
let mut a = Allowance::new();
assert!(a.left() && a.charge(&big), "the first segment fits");
assert!(!a.charge(&big), "the second does not, though each alone would");
assert!(!a.left(), "and nothing is left to shape another");
assert!(!layout_mtext("plain text", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0).truncated);
}
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
+9
View File
@@ -677,6 +677,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = super::Allowance::new();
for (l, (c, base_y)) in lines.iter().zip(placed) {
let pp = paras.props(l.para);
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
@@ -696,7 +697,15 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
};
for it in &l.items {
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(&it.seg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
+10 -2
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;
@@ -924,7 +932,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
points: usize,
pub(crate) points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
+23 -6
View File
@@ -431,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.
@@ -464,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();
@@ -484,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,
@@ -497,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(());
@@ -598,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();
+3 -3
View File
@@ -12,8 +12,8 @@ workspace = true
cadcraft-geom = { workspace = true }
cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true }
cadcraft-dxf = { workspace = true }
cadcraft-dwg = { workspace = true }
craft-dxf = { workspace = true }
craft-dwg = { workspace = true }
cadcraft-render = { workspace = true }
cadcraft-fonts = { workspace = true }
thiserror = { workspace = true }
@@ -29,7 +29,7 @@ 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
# Inspect DWG output only where craft-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"
+20 -2
View File
@@ -13,8 +13,8 @@ use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
use cadcraft_dxf::{Tag, Version};
use cadcraft_render::Arrowhead;
use craft_dxf::{Tag, Version};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -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;
}
+1 -1
View File
@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle, Image};
use cadcraft_dxf::{Section, Tag};
use cadcraft_geom::{Vec2, Vec3};
use craft_dxf::{Section, Tag};
/// Most clip boundary vertices kept per image (hostile files).
const MAX_CLIP: usize = 100_000;
+1 -1
View File
@@ -13,8 +13,8 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, Handle, Insert, MLeader, MLeaderStyle, MText};
use cadcraft_dxf::Tag;
use cadcraft_geom::Vec3;
use craft_dxf::Tag;
/// Most leader roots (landings) read per multileader.
const MAX_ROOTS: usize = 256;
+1 -1
View File
@@ -12,8 +12,8 @@ use cadcraft_doc::{
Drawing, Entity, EntityKind, EntityStore, MAX_MLINE_CUTS, MAX_MLINE_ELEMENTS, MAX_MLINE_VERTICES, MLine, MLineCaps, MLineCut, MLineElement,
MLineJust, MLineStyle, MLineVertex, STANDARD_MLINE_STYLE,
};
use cadcraft_dxf::{Section, Tag};
use cadcraft_geom::{Vec2, Vec3};
use craft_dxf::{Section, Tag};
/// Most groups 41 kept per element and vertex (start, then break pairs).
const MAX_PARAMS: usize = 2 * MAX_MLINE_CUTS + 2;

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