Compare commits

...
386 Commits
Author SHA1 Message Date
FredApps aa1c896ef2 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
Tanish Manku 3b7eb1b7bc Merge pull request #2842 from storytold/stack/s2
Stage 1 stack 2: land #2587, #2585, #2608
2026-11-05 20:36:21 -07:00
Tanish Manku 1f11d4f701 Stage 1 stack 2: review fixes on #2587, #2585, #2608
- #2587: a frame turned by anything but a quarter turn keeps its size about its turned
  centre; the box of its two mapped corners collapsed it (a 10 x 10 square turned 45° became
  14.14 x 0, and the OLE header with it) for EXPLODE, REFEDIT, paste and array placement
  (austin62jake, CodeRabbit). Moves, scales, mirrors and quarter turns keep the exact
  corner mapping. `recase` reads only the rewritten digits and a few after them, not the
  whole 310 line (austin62jake). The PR's rotated-frame test expects the kept size.
- #2585: an `output` that contradicts the path's extension is refused, as a device that
  does is; it lost silently to the extension.
- #2608: the window's state is dropped when another dialog took its place (it missed a UI
  pass), so reopening reads the drawing instead of an old unsent edit (CodeRabbit); TEXT and
  ATTDEF get a one-line field (Enter commits), so a line break can't turn into a space on
  save; the module comment says the shortcut menu opens it.
2026-11-05 20:16:44 -07:00
Tanish Manku 412eb84282 Merge branch 'stk-2608' into stack/s2
# Conflicts:
#	crates/ui-egui/src/i18n/zh.tsv
2026-11-05 19:22:54 -07:00
Tanish Manku 173aa38f35 Merge branch 'stk-2585' into stack/s2
# Conflicts:
#	crates/engine/src/cmd/layout.rs
#	crates/engine/src/cmd/layout/tests.rs
2026-11-05 19:22:47 -07:00
Tanish Manku 7218285443 Merge branch 'stk-2587' into stack/s2
# Conflicts:
#	crates/doc/src/extents.rs
2026-11-05 19:22:25 -07:00
Tanish Manku 8e899eef63 Merge pull request #2837 from storytold/stack/s1
Stage 1 stack: land #2584, #1626, #2028, #2107, #2471, #1026, #2612, #2545, #2658
2026-11-05 19:16:39 -07:00
Tanish Manku 417f195cc3 Stage 1 stack: CodeRabbit on #2837 (CLASS places, the first *ACTIVE record)
- #2471: an unnamed CLASS record keeps its place in the class list a DWG maps proxies by,
  so the classes after it keep theirs; a proxy of the unnamed class is left out instead of
  taking the next class's name (the ODA refuses such a DWG).
- #1626: the first *ACTIVE VPORT record is the caller's to say. A first record with a
  usable view but an unusable tile kept no tile, so the next *ACTIVE record was taken for
  the first and its view replaced the saved one (also austin62jake on #1626).
2026-11-05 18:57:19 -07:00
Tanish Manku 2b88b22949 Stage 1 stack: review fixes on #2584, #2471, #1626, #2028, #1026, #2658, #2612
- #2584: a tilted LWPOLYLINE/HATCH run is flattened to at most a million vertices, then
  its arcs stay chords (a hostile file of arcs asked for gigabytes; austin62jake). The
  file-format-parity.md hunk is dropped: maintainers update the parity docs.
- #2471: another program's proxy objects are in the DWG loss report as its proxy entities
  are (left out before 2013, or when their class can't be named); they went silently.
- #1626: a drawing saved at a view keeps it in the desktop app and when it has xrefs: the
  app's first frame and the xref pass both fitted the extents over it. OPEN's result says
  `saved_view`; one `view::saved_view` replaces the copy of `usable_view` in files.rs.
  A misplaced doc comment goes back to its test.
- #2028: a plot rotation (73) outside 0-3 is written back as 0, not as read.
- #1026: the binary code-page retry drops the first pass's tags before it runs (the peak
  doubled near the tag limit); a float spelling of an integer group clamps to what the
  width holds unsigned too, so 65535.0 reads as 65535 does, not 32767.
- #2658: the reference names Undo by the last command ("Undo Group of commands"); Undo
  matched nothing and went to the end of Edit. Ordering and its test share one rule.
- #2612: its test's Insert gets `clip: None` (XCLIP landed after it was written).
2026-11-05 18:34:43 -07:00
Tanish Manku c96d6b2db8 Merge branch 'stk-2658' into stack/s1 2026-11-05 17:23:13 -07:00
Tanish Manku 12db7237cc Merge branch 'stk-2545' into stack/s1 2026-11-05 17:23:13 -07:00
Tanish Manku 831a437f1e Merge branch 'stk-2612' into stack/s1 2026-11-05 17:23:13 -07:00
Tanish Manku b676dd5510 Merge branch 'stk-1026' into stack/s1 2026-11-05 17:23:12 -07:00
Tanish Manku 577b2f74d6 Merge branch 'stk-2471' into stack/s1 2026-11-05 17:23:12 -07:00
Tanish Manku 6554ea3dd5 Merge branch 'stk-2107' into stack/s1 2026-11-05 17:23:12 -07:00
Tanish Manku 2c8eaf2901 Merge branch 'stk-2028' into stack/s1 2026-11-05 17:23:11 -07:00
Tanish Manku 214f1b263c Merge branch 'stk-1626' into stack/s1 2026-11-05 17:23:11 -07:00
Tanish Manku 97136cf316 Merge branch 'stk-2584' into stack/s1 2026-11-05 17:23:11 -07:00
Tanish Manku 1b39fbbabe Merge pull request #2835 from storytold/fix/unused-patches
build: drop three egui patches nothing uses
2026-11-05 16:31:42 -07:00
Tanish Manku 2611bc5f5b Merge pull request #2833 from storytold/land/2016
Land #1971 and #2016: Georgian font and Georgian interface language
2026-11-05 16:31:28 -07:00
Tanish Manku deb1468d08 build: drop three egui patches nothing uses
egui_system_font_provider, egui_demo_lib and egui_inspection were patched to the egui fork
with the rest of its family (#2119), but no crate here depends on them: Cargo.lock lists
them as [[patch.unused]] and cargo warns on every build. A vendored offline build (Nix's
importCargoLock) refuses an unused git patch, so the flakes in #794 and #1814 can't build
main. Reported by @mbolling on #1814.
2026-11-05 16:10:30 -07:00
Tanish Manku befb9b6f61 Merge branch 'pr-2016' into land/2016 2026-11-05 14:36:35 -07:00
Tanish Manku ec0decfd5c Merge branch 'pr-1971' into land/2016 2026-11-05 14:36:35 -07:00
Tanish Manku fc157ea91f Merge pull request #2831 from storytold/land/1031
Land #1031: Latin accents in the CADCraft Stroke font
2026-11-05 14:17:44 -07:00
Tanish Manku 0d125d6a8f Merge remote-tracking branch 'origin/main' into land/1031 2026-11-05 13:08:56 -07:00
Tanish Manku 6d39cf2297 Merge #1031 (Latin accents in the stroke font) onto main
#1031's SHX reader, TextFont::Shx wiring and doc edits are superseded by main's SHX support
(#2751, which landed #1944) and resolved to main's. Kept from #1031: the accented Latin
letters of CADCraft Stroke (stroke.rs, composed from its own letter bodies and marks), their
test (fonts/tests/latin_accents.rs) and a synthetic SHX text-through-DXF test
(io/tests/shx_text.rs), which passes against main's reader.
2026-11-05 13:08:55 -07:00
FredApps df65974458 Merge pull request #2818 from ZIDAN44/fix/803-mcp-render-background
fix(mcp): render takes a background and a plot style (#803)
2026-11-05 15:04:04 +01:00
FredApps 74485a71b1 Merge branch 'main' into fix/803-mcp-render-background 2026-11-05 14:52:29 +01:00
FredApps 6ca414c344 Merge pull request #2180 from helenkwok/web/host-page
feat(web): page host mode, so a page can host CADCraft in its own document (#2178)
2026-11-05 14:10:58 +01:00
Zinadin ZidanandClaude Opus 5.5 75175c807d refactor(render): one constant for the dark canvas; the render error limits say they are limits (#803)
render_look's DARK repeated the rasteriser's default background; both now
read RasterOptions::DARK_BACKGROUND. ECHO and LISTED_TABLES are renamed
MAX_ECHO_CHARS and MAX_LISTED_TABLES. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:10:58 +06:00
Hamza c667dfea26 Merge remote-tracking branch 'origin/main' into stored-model-view
main added view twist. Kept from both: a drawing opens on the view it was saved at, with its saved twist; one that stores none fits its extents through main's twisted box. The VPORT reader takes main's twisted view centre and keeps this PR's first-*ACTIVE view; the writer writes the saved view, else main's twisted fit, with the fallback still fitting the width through the record's aspect.
2026-11-05 16:03:19 +05:00
Helen Kwok 0902a13b88 cadcraft-web: speak the host messages through craft-web-host
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); CADCraft keeps the engine delivery hook, the open size limit and the upload inbox.
2026-11-05 17:50:59 +08:00
Helen Kwok afea2be761 packaging/web: document what page host mode trusts, the limits on host files, and the stable event names 2026-11-05 16:59:08 +08:00
Helen Kwok ea79be1309 ROADMAP: leave the roadmap rows to the maintainers (AGENTS.md) 2026-11-05 16:59:08 +08:00
Helen Kwok e5cac11426 packaging/web: the README example reports an init failure 2026-11-05 16:59:08 +08:00
Helen Kwok 1c3975523d feat(web): page host mode, so a page can host CADCraft in its own document (#2178)
data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; the bridge holds a Transport (Parent or Page) with one send, and the open, save, dirty, ready and failure handling is shared. ?host=parent is checked first and unchanged, including no bridge for an unframed page. The hosting guide documents both modes.
2026-11-05 16:59:08 +08:00
Helen Kwok 4b47a04a09 Contributors: credit helenkwok as Helen Kwok 2026-11-05 16:59:08 +08:00
Zinadin ZidanandClaude Opus 5.5 04a279cff0 fix(mcp): a render with a plot style plots on white; "none" turns plot styles off (#803)
A plot style alone drew black lines on the dark canvas: the tables that
change colours print colour 7 (and, in monochrome.ctb, every colour)
black. With `plot_style` and no `background`, the render now draws on
white, as a plot prints on paper; a given background still wins.

`plot_style: "none"` (or "") draws every object with its own
properties, also in a layout that shows its own plot styles. An unknown
table's error lists the built-in tables and at most ten of the
drawing's own. The app's ui.render test now draws a red line, so it
fails if the plot style isn't passed on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:09:31 +06:00
Zinadin ZidanandClaude Opus 5.5 017cc9b1fc fix(mcp): render takes a background and a plot style (#803)
The MCP render tool and ui.render, in the app and headless, always drew
on the dark canvas with no plot style, so a PNG placed on a page needed
the SVG export instead.

They now take `background` ("dark", the default; "white", "paper" or
"#rrggbb") and `plot_style` (a built-in plot style table such as
monochrome.ctb or grayscale.ctb, or one the drawing holds). Colour 7 is
drawn black on a light background and white on a dark one, as on a plot.
An unknown value is refused with what is accepted. Both copies of
ui.render read the options through one function in the engine
(render_look). The render tool's description also says it returns an
image block, not text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:20:52 +06:00
SBaksa 62d1c806f7 OLE2FRAME: a frame with no width or height is picked as the line it is drawn as (review)
The renderer draws a zero-width frame as a line, so leaving it out of the hit geometry made a
visible object unpickable. Also corrects where the corner tolerance switches to ulps (4 ulps
pass 1e-6 near 1.1e9).
2026-11-05 16:06:13 +11:00
Davit MatchakhelidzeandClaude Sonnet 5.5 fe67c048ec Leave ROADMAP.md and docs/localization-parity.md to maintainers
CLAUDE.md: PRs don't edit these docs. The affected rows are named in the PR description.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-11-05 09:01:04 +04:00
Davit MatchakhelidzeandClaude Sonnet 5.5 40f060018d Leave .github/workflows/release.yml to a maintainer
CLAUDE.md: no CI changes in a feature or fix PR. The web-craft-fonts line is described in the PR description.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-11-05 09:01:04 +04:00
Davit Matchakhelidze 2c0fe29a02 Merge remote-tracking branch 'origin/main' into ka-language 2026-11-05 09:00:31 +04:00
Davit Matchakhelidze 7af5718b20 Merge remote-tracking branch 'origin/main' into fonts-georgian 2026-11-05 09:00:31 +04:00
SBaksa 35e85f8694 OLE2FRAME: comments match the tolerance; case lookup in one pass (review on #2587)
The ulp term takes over from CORNER_EPS from about 1.1e9, and the doc now says
so. The case of the nearest letter for each hex digit is found in two linear
passes instead of a search per digit; same result, covered by
moving_an_ole_frame_keeps_each_hex_digits_case.
2026-11-05 15:34:23 +11:00
SBaksa 63f53f6ed6 ARRAY, ARRAYPATH: one OLE frame check, after the matrices (review on #2587)
A matrix every frame accepts (a rectangular array, plain translations) now needs no
look at the objects, and an empty matrix list never touches the drawing. ARRAYPATH
scans the selection once instead of once per item.
Covered by the existing refusal tests; copy_all is not reachable without a drawing
through any command, so there is no no-drawing test.
2026-11-05 15:34:23 +11:00
SBaksa 95396c032d OLE2FRAME: only picking sees the frame edges (review on #2587)
Putting the frame in prims() also made it a TRIM/EXTEND edge, an object snap
target and dimension geometry. prims() is back to nothing for it, the extents
arm is restored, and select::hit_polylines adds the frame's outline for picking
(none for a frame with no width or height or a bad corner).
Tests: clicking_an_ole_frame_edge_selects_it (now also a frame with no width),
ole_frame_is_not_a_trim_edge_or_snap_target.
2026-11-05 15:34:08 +11:00
FredAppsandClaude Opus 5.5 c4d9e6ac64 fix(ui): About credits tables fit without side-scrolling and their columns resize (#2747)
* fix(ui): About credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:26 +01:00
Tanish Manku 2569dbd58f Merge pull request #2803 from storytold/land/2316
Land #2316: Hebrew SHX fonts, font-change redraw, user font folders (fonts crate)
2026-11-04 20:20:01 -07:00
SBaksa 485c74da6b ARRAY: check OLE frames once (review on #2587)
copy_all scanned the selection for an OLE frame once per matrix before copying.
It now looks at the objects once and then only tests each matrix.
Covered by the existing refusal tests (classic_polar_array_refuses_a_turned_frame_but_not_an_upright_one).
2026-11-05 14:00:28 +11:00
Tanish Manku 843ffb6507 Fonts: a registered font outlives a rescan, also beside a file of the same stem (CodeRabbit on #2803) 2026-11-04 19:58:44 -07:00
SBaksa a779dadad5 OLE2FRAME: corner match and rotation bound scale with the coordinates; hex keeps its case (review on #2587)
The corner match was capped at 1e-6, under one ulp above about 8.6e9, so a frame
there never matched its header; a few ulps of the larger value now also match.
The rotation bound had a floor of 1 on the scale, so a small real turn at a tiny
scale passed; it is now purely relative. Hex digits are re-encoded in the case of
the digit at the same place, so untouched bytes keep their text.
Tests: ole_frame_moves_at_2e10_with_a_one_ulp_text_corner, ole_frame_refuses_a_small_turn_at_a_tiny_scale, moving_an_ole_frame_keeps_each_hex_digits_case.
2026-11-05 13:57:06 +11:00
SBaksa c397ab1e9e OLE2FRAME: the frame can be picked by its edges (review on #2587)
An OLE2FRAME is drawn but prims() gave nothing for it, so hit testing never saw
it and a click on its outline picked nothing. prims() now returns the frame's four
edges from the same corner helper the extents use, so the extents arm is gone.
The renderer keeps its closed-polyline arm (one joined outline, not four segments).
Test: clicking_an_ole_frame_edge_selects_it.
2026-11-05 13:55:27 +11:00
Tanish Manku 5b2c7bb0fb Fonts: a font's family name falls back to the family when the typographic one is blank (CodeRabbit on #2803) 2026-11-04 19:34:32 -07:00
Tanish Manku f7fbf41e61 Merge remote-tracking branch 'origin/main' into land/2316 2026-11-04 19:13:47 -07:00
Tanish Manku 1947e4e09e Fonts: review fixes on #2803 (Unix clippy, the degree sign in DOS Hebrew fonts, font-keyed caches)
- The linked-folder test (Unix only) passes a slice without a clone (clippy
  cloned_ref_to_slice_refs, which failed the Linux gate).
- In a DOS 862 Hebrew SHX font, %%d still draws from the old degree shape 127: only 128-129
  are letters there.
- The viewport highlight, drag preview and command preview caches key on the font set too,
  so text in them is drawn again when fonts change.
2026-11-04 19:13:39 -07:00
vangel76andClaude Sonnet 5.5 37eb21effe Merge upstream/main into fix/dxf-hostile-input-hardening
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:59:38 +01:00
3c123fd61c New and redrawn icons (drawn in code); clicks on painted controls keep the keyboard with the drawing (#2319)
* New and redrawn icons (drawn in code); a click on a painted control leaves the keyboard with the drawing

Split out of #2034. No image files: every icon is drawn in code.

- New: Zoom In / Zoom Out / Zoom to Fit, Move to Top / Up / Down /
  Bottom, Text Style and Dimension Style (with a small gear); redrawn:
  Undo, Redo, Plot, Preview, the dimension tools and others.
- icons::button_bare: a button without its hover text, for callers that
  show a richer tooltip.
- Clicking a painted control (a file tab) no longer takes keyboard focus,
  so Ctrl+C in one drawing and Ctrl+V in another keep working; Tab still
  reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V

* Undo/Redo icons of our own design; the clipboard test requires the copied text

Review (CodeRabbit): the Undo/Redo comment named another product's icons as
the model. The icons are redrawn to our own design (a straight run and a
half-turn back, in the icon colour) and the reference is gone.

The cross-drawing clipboard test no longer falls back to placeholder text:
it installs a DXF writer and requires the copy to reach the system
clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V

* Icons: fit upstream's split icon painter (the pen's painter, theme colours, dim flag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V

* Icons: drop the dangling tool_button doc link; describe the tab click focus as it behaves

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 02:56:40 +01:00
Tanish Manku d12c87ca56 Fonts: review fixes on #2316 (load budget, linked font folders, a test in its own binary)
- A face found by family name in the user's font folders is loaded within the font-byte
  budget: no uncharged read past it (CodeRabbit on #2316).
- Linked font folders are followed again, as before #2316, in the system scan, the user
  scan and the SHX folder search; each real folder is read once, so a circle of links
  can't use up the scan's entry budget (CodeRabbit on #2316).
- The user-font-folder test changes process-wide font settings and emptied the font table
  under tests running beside it (2 of 3 runs failed): it moves to its own test binary,
  tests/ttf_font_folders.rs; that a list keeps no font bytes is checked on a local table.
2026-11-04 18:48:06 -07:00
SBaksa b2ded20888 OLE2FRAME: the axis-aligned check scales its tolerance with the matrix (review on #2587)
ALIGN builds its matrix from two angles that agree only to rounding, so a large
scale leaves a rotation part of about scale * 1e-16 that an absolute 1e-12 bound
refused as a turn. The bound now grows with the scale; a real turn is still refused.
Test: ole_frame_accepts_a_large_scale_with_rounding_noise_in_its_rotation.
2026-11-05 12:36:59 +11:00
Tanish Manku c56fcfc419 Merge branch 'pr-2316' into land/2316 2026-11-04 17:53:39 -07:00
Tanish Manku 5bfcee6299 Merge pull request #2795 from storytold/fix/array-extents
fix(doc): a rotated MINSERT array's extents reach its side copies
2026-11-04 17:51:22 -07:00
Dylan M. Taylor 594c30ae2d Pass the Linux signing key to the release workflow (#2798) 2026-11-04 17:45:46 -05:00
Tanish Manku fb6178b178 fix(doc): a rotated MINSERT array's extents reach its side copies
The extents took the first copy and the far corner copy only. Turned, the grid's
other two corner copies lie outside that box, so ZOOM Extents cut them off. All
four corner copies count now, and the counts are clamped to the 10,000 columns
and rows the renderer draws. Found by CodeRabbit on #2756.
2026-11-04 14:31:19 -07:00
Claude 962cebf9f6 Merge upstream main into split/shx-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V
2026-11-04 22:44:02 +02:00
Claude 7ea06a86db SHX fonts: list the ones in the user's font folders, for font lists
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V
2026-11-04 22:43:42 +02:00
Tanish Manku 3083b5b47f Merge pull request #2756 from storytold/land/2354
XCLIP and XCLIPFRAME; invert flag as AutoCAD's group 290 (#2354)
2026-11-04 13:41:12 -07:00
Tanish Manku f554de28b5 XCLIP: ON/OFF checks without copying boundaries and sets each reference once (CodeRabbit on #2756) 2026-11-04 13:02:38 -07:00
ffishman 190a44d3eb Merge main into fix/dwg-render-settings-class-version
main's #2673 runs the DWG writer in a child process. The kept version now goes with the request
(encode_kept_noted, worker protocol 2), so the worker reads the bridge DXF as that version too.
The proxies proxy_classes leaves out are now counted in Unwritten, as ACAD_PROXY_OBJECT and
ACAD_PROXY_ENTITY.
2026-11-04 15:46:24 -03:00
Claude 2c979eb14b Fonts: user font folders, Hebrew SHX fonts, redraw when fonts change (on #1944's SHX)
Rebuilt on main after #1944 (SHX shape fonts, big fonts, unifonts) landed: this part now
keeps only what #1944 doesn't do.

- User font folders (Options > Fonts, fontFolders): TrueType and OpenType files in them are
  found before the system's and listed first (ttf::set_font_dirs, installed, available,
  rescan); a face is found by its family name in any file there. SHX files in them, or in a
  folder under them, are found by the SHX font table too (shx::set_font_dirs), after the
  drawings' folders and before the search path. Folders are cleaned and capped, links to
  folders are not followed.
- Hebrew SHX fonts: a one-byte font with the letters at their Windows-1255 or DOS 862 places
  draws Hebrew text from them; a Cyrillic font with letters at those places is not taken for
  Hebrew. In a DOS 862 font, %%d %%p %%c come from shapes 256-258 only.
- The canvas draws text again when the TrueType or SHX font set changes (a font folder, a
  rescan, a drawing folder), the active viewport's model space included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzTHYMZTCtkW9FExLCKj5V
2026-11-04 20:21:02 +02:00
assamaralandClaude Opus 5.5 2872c05162 fix(io): read VIEWPORT ids outside 1..=32767 as unknown
Group 69 is a 16-bit code. Large values passed the filter and `as u32`
truncated them, so 69 = 4294967297 read as a second id 1. Also drop the
docs/file-format-parity.md row: PRs name affected rows in the description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:47:19 -03:00
assamaralandClaude Opus 5.5 0642d34244 Merge upstream/main into fix/dxf-viewport-id-unknown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:24:42 -03:00
霁伪 e49ece380c Merge upstream/main into feat/text-edit-window
Upstream kept adding catalog strings (viewports, raster images, named views),
which landed next to this branch's textdlg rows in the same catalogs. Both
sides kept, upstream's rows first.
2026-11-04 17:42:02 +08:00
Tanish Manku 371e8b9e02 XCLIP: ON/OFF checks every selected reference before changing any (CodeRabbit on #2756) 2026-11-03 21:56:58 -07:00
Tanish Manku ff5b55842e XCLIP: bound a filter's vertex count before allocating (CodeRabbit on #2756) 2026-11-03 21:34:23 -07:00
Tanish Manku 064f58f252 XCLIP: write an inverted clip as AutoCAD does in a file (no group 290)
AutoCAD 2024 writes no group 290 on a SPATIAL_FILTER in a DXF and discards a file whose
filter has one (measured by 2ez4gcx, #2756). An inverted clip's filter now gets an
extension dictionary with the ACAD_XREC_ROUNDTRIP XRECORD (ACAD_INVERTEDCLIP_ROUNDTRIP,
then ..._COMPARE). Reading takes the ROUNDTRIP list as the boundary while COMPARE matches
the filter's vertices, so AutoCAD's keyhole filters read as the clip drawn; 290 is still
read when present. The record survives DWG, so CADCraft's own CADCRAFT_XCLIP flag is gone.
2026-11-03 21:16:55 -07:00
Tanish Manku ef4b4f7e6c XCLIP: review fixes from CodeRabbit on #2756
- objects are found through a handle index when reading and writing clips, and removed in one pass (no scan per reference)
- the writer's handle seed starts past the clip objects, so a drawing with many clipped references gets no duplicate handles
- a clipped copy that runs out of the block budget marks the list truncated, like an unclipped one
- clipped block contents keep their own layers in plots
2026-11-03 19:34:20 -07:00
Tanish Manku 510b7283cb XCLIP: Chinese labels for Clip Xref, Xref and Xref Clip Frame 2026-11-03 19:11:23 -07:00
Tanish Manku 0d72152142 Merge main into land/2354 2026-11-03 18:21:00 -07:00
Tanish Manku 639c2e9825 XCLIP: clip on DVIEW's preview reference 2026-11-03 18:20:48 -07:00
Tanish Manku 2099288f54 Merge pull request #2751 from storytold/land/1944
SHX fonts, big fonts and unifonts; linetype shapes (#1944)
2026-11-03 18:20:27 -07:00
Tanish Manku 100d812155 Merge main into land/2354 2026-11-03 18:07:17 -07:00
Tanish Manku e90a197365 XCLIP: keep CADCraft's inverted flag beside group 290 for DWG, whose bridge drops 290; the flag is read only when 290 is absent 2026-11-03 18:07:00 -07:00
Tanish Manku de753897c4 Merge main into land/1944 2026-11-03 18:00:03 -07:00
Tanish Manku 39ee2e5960 SHX fonts: review fixes (search the current folder for a bare drawing name, one lock for the font cache generation, no split_paths on wasm, MTEXT \U+ upper case as the layout, a point budget for repeated linetype shapes, web picks report skipped files and cap bytes read at once) 2026-11-03 17:59:52 -07:00
Tanish Manku 5f8999f303 Merge pull request #2748 from storytold/land/1230
View twist: DVIEW, twisted views, tiles and layout viewports (#1230, #2022)
2026-11-03 17:59:40 -07:00
Tanish Manku 75049718c9 View twist: build with raster images from main (an axis rectangle is turned on a twisted screen, so it cuts an image as an outline) 2026-11-03 17:35:23 -07:00
Aldo ZarateandClaude Opus 5.5 8d1ab2aad6 Merge upstream/main into fix/tilted-2d-read
The revision history keeps both rows. Main's new dynamic block test
expected a polyline in a tilted plane to keep that plane's coordinates;
this branch reads it as seen from above, so its stretch now tests the frame
against those points and moves it whole. The slight tilt below the
projection threshold still uses the stored vertex list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:32:44 -06:00
Tanish Manku 76d09aaddf Merge remote-tracking branch 'origin/main' into land/2354 2026-11-03 17:22:36 -07:00
Tanish Manku d1e6f4df96 XCLIP: invert flag as group 290, extents are the boundary's box, Delete with no boundary asks again; clip on main's new Insert literals 2026-11-03 17:22:36 -07:00
Tanish Manku c2d62a2842 Merge main into land/1944 2026-11-03 17:11:42 -07:00
Tanish Manku 68e01dd487 Merge main into land/1230 2026-11-03 17:11:25 -07:00
Tanish Manku 6d3507f98d Merge pull request #2749 from storytold/land/2505
Raster images drawn on screen and in PNG output (#2505)
2026-11-03 17:10:59 -07:00
Tanish Manku 26e96cfb56 View twist: a locked viewport keeps its twist; the untwisted file view fits the extents as the turned view sees them
Review fixes from CodeRabbit on #2748:
- DVIEW TWist and view.set refuse to turn a locked active viewport ("The viewport is locked.")
- the untiled *Active VPORT record fits the extents rotated by the twist it writes, with the twist normalised
2026-11-03 17:10:20 -07:00
Tanish Manku 945c7f456b Merge remote-tracking branch 'origin/main' into land/1944 2026-11-03 16:52:21 -07:00
Tanish Manku f10e41409d Images: read no more than the limit, keep plot bounds to what a viewport shows, free textures with no images left
Review fixes from CodeRabbit on #2749:
- the image file is read through read_capped, so a file that grows after the size check can't exhaust memory
- an image cut by a layout viewport widens the sheet's bounds only by the part inside it, as other primitives do
- uploaded tiles of closed drawings are dropped also when the next list has no images
2026-11-03 16:52:11 -07:00
Tanish Manku 5bd563caac Merge main into land/2354: charge every block visit in the clipped copy too 2026-11-03 16:42:36 -07:00
Tanish Manku bd22363d5e Merge remote-tracking branch 'origin/main' into land/2505 2026-11-03 16:40:52 -07:00
Tanish Manku a6ef259fec Merge remote-tracking branch 'origin/main' into land/1230 2026-11-03 16:40:36 -07:00
Dylan M. Taylor dad7d1c138 fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time (#2724)
* fix(render): charge every block entry, index attribute definitions, and walk dashes and outlines in linear time

Array copies skipped ATTDEF entries before charging the expansion budget, each attribute searched its block for a definition, each dash scanned every centreline sample, and outline chaining searched a vertex's segment list from its start on every visit. Each is now charged, indexed or cursored.

* address review: drop wall-clock bound from outline test
2026-11-03 18:11:23 -05:00
Dylan M. Taylor b1b3754ffa fix(engine): autosave files are owner-only and lock files are never written through a link (#2717)
The heartbeat opened <instance>.lock with a create-and-truncate that followed symlinks, and fell back to std::fs::write, so another account sharing SAVEFILEPATH could plant a link and have the app truncate a file of the victim's. The lock is now opened with O_NOFOLLOW|O_NONBLOCK and must be a plain file, and unlocked writes replace the file through the atomic writer. Autosave files are created 0600 regardless of umask. Lock pruning parses the folder's autosave names once instead of once per lock.
2026-11-03 18:11:06 -05:00
Tanish Manku 0e19f9d1d1 SHX fonts: build on main (a bottom-justified SHX text sits a third of the height down; explicit render imports after #2239) 2026-11-03 15:41:48 -07:00
Tanish Manku 8232290830 View twist: a window too large to turn saves no view, and the saved one stays 2026-11-03 15:40:39 -07:00
Tanish Manku 457fc2319e View twist: review fixes
- Every engine twist (DVIEW, view.set, opening a drawing, the untiled view) goes through norm_angle, so a tiny negative twist is 0, not a full turn.
- VIEW Window in a twisted view stores the window's size along the view's axes (View::twisted_box), so a restore shows all of it.
- A deferred tangent or perpendicular snap on an arc in a twisted layout viewport turns the arc's angles with its centre.
- The active viewport's cached drawing is rebuilt when its twist changes; moving and scaling it can't turn it.
2026-11-03 15:14:10 -07:00
Tanish Manku 15afc47296 Images: Chinese labels for IMAGEATTACH and IMAGE 2026-11-03 15:12:28 -07:00
Tanish Manku d3226e629e Images: the highlight outline uses main's world-point line helper 2026-11-03 15:04:12 -07:00
Tanish Manku 238aa1789c Images: draw an IMAGE in its own function, under the complexity limit 2026-11-03 15:03:06 -07:00
Tanish Manku f1312113d2 Images: format after the merge with main 2026-11-03 15:01:55 -07:00
Tanish Manku 8cdcd6f85c Merge #2354 (XCLIP and XCLIPFRAME) onto main 2026-11-03 15:00:20 -07:00
Tanish Manku 28421dd1d7 Merge #1944 (SHX shape fonts, big fonts and unifonts; linetype shapes) onto main 2026-11-03 14:59:22 -07:00
Tanish Manku 162cc4792b View twist: read and write every twist within one turn, so #2022's hostile-angle checks hold
A twist of -1e-20 degrees read as a full turn (2π), and a huge twist set from JSON or MCP was written as a huge angle. Readers and writers of VIEWPORT 51, VPORT 51 and VIEW 50 now go through norm_angle.
2026-11-03 14:58:37 -07:00
Tanish Manku 4c00b6732f Merge #2505 (raster images drawn on screen and in PNG output) onto main 2026-11-03 14:52:15 -07:00
Tanish Manku 58bb1e065e docs: date the view twist revision rows to the day they land 2026-11-03 14:50:59 -07:00
Tanish Manku 0955078f56 Merge #1230 (view twist: DVIEW, twisted views, tiles and layout viewports) over #2022 2026-11-03 14:50:43 -07:00
mattlafyandClaude Opus 5.5 f04be0548f fix: new Viewport, VportTile and Xf initializers from main set the view twist; DView has its Chinese label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:56:46 -05:00
mattlafy 852688e000 Merge remote-tracking branch 'upstream/main' into wt-dview 2026-11-03 14:17:57 -05:00
ATARYS-LABandClaude Opus 5.5 6d31491952 Viewport twist: the viewport of main's block expansion test has no twist
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XkJ8y75DE7yLZ7utxSRDeP
2026-11-03 18:35:48 +01:00
ATARYS-LABandClaude Opus 5.5 c8e1671eb4 Viewport twist: normalise with norm_angle on read and write; test the untwisted case on a real viewport
From CodeRabbit's review: a tiny negative angle no longer reads as a full turn, a huge finite
twist from JSON or MCP no longer writes an infinite group 51, and the "no group 51" check now
looks at a viewport that exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 18:32:40 +01:00
ATARYS-LABandClaude Opus 5.5 f7494840dc A layout viewport keeps its view twist through DXF and DWG (part of #1316)
VIEWPORT group 51 was not read, so a twisted viewport came back untwisted and a DXF or DWG
saved by CADCraft wrote it without its twist; AutoCAD then showed the sheet rotated. The twist
is now read (finite angles only, brought into one turn) and written when it isn't zero; the DWG
bridge already carries group 51 both ways. The display and plots don't apply it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
2026-11-03 18:32:40 +01:00
assamaralandClaude Opus 5.5 9ef9bdb55e Merge upstream/main into fix/dxf-viewport-id-unknown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:58:24 -03:00
Dylan M. TaylorandBrandon Thomas b639b18f69 fix(io): sort each space once when a DXF has several SORTENTSTABLEs for it (#2709)
Every SORTENTSTABLE rebuilt its whole entity store, so many small tables naming one large space made import quadratic. Each space is now sorted once, by the last table naming it, which already decided its order.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:08:07 -05:00
578da3ba9c fix(io): an ACAD_TABLE with no rows or no columns reads as an empty table (#2708)
* fix(io): an ACAD_TABLE with no rows or no columns reads as an empty table

The table cell budget charges rows x columns, which is zero when either dimension is, so a table declaring 10 000 rows and no columns still allocated row heights and empty row vectors for free, once per record. Such a table now keeps neither dimension.

* Test: assert the empty table is read (CodeRabbit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:07:46 -05:00
Dylan M. Taylor f2afcd4f02 fix(engine): look names up in an index in AUDIT, layer-state restore and CAL (#2712)
AUDIT checked every layer's linetype and every style's text style by scanning the table; LAYERSTATE restore and restoring a named view's layer snapshot scanned the layer table for every saved layer; CAL scanned the variable map for every case-insensitive reference. Each now builds its name index once, keeping the same first-match and exact-match rules.
2026-11-03 09:07:10 -05:00
Dylan M. Taylor 1619e24381 fix(io): DXF import looks up linetype, style and entity layer names in a set (#2707)
LTYPE records scanned every linetype read so far, STYLE records every text style, and each entity's layer went through Drawing::ensure_layer's scan of the layer table, so a DXF with many distinct names read in quadratic time. These now use indexes built once per pass, as LAYER records already did; first-LTYPE-wins and last-STYLE-wins are unchanged.
2026-11-03 09:07:07 -05:00
ffc2c553e8 Drop missing files from the recent list when opening them fails (#2706)
* Drop missing files from the recent list when opening them fails

Fixes #2700

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Keep a recent file whose existence can't be checked (CodeRabbit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:07:02 -05:00
Khuong DoanandClaude Opus 5.5 7bb6c2f06f MINSERT: a block reference repeated in rows and columns, as AutoCAD does (#2698)
* MINSERT: a block reference repeated in rows and columns

MINSERT asks INSERT's questions, then the grid's as AutoCAD 2024 does
(measured with accoreconsole): the numbers of rows and columns (1 to
32767), the distance between rows or a unit cell when there is more than
one row, the distance between columns when there is more than one column,
then the attribute values once. A distance along an axis with one copy is
0, and one row by one column is a plain reference.

EXPLODE leaves a grid whole ("The object is minserted.") instead of
exploding its first copy and dropping the rest. MIRROR mirrors a block
reference as AutoCAD does, negating the X scale factor and a grid's
column distance, so a mirrored grid's rows stay on the right side. An
attribute with no default is asked without "<>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* MINSERT JSON: refuse a distance that is not a number

`rowSpacing` and `columnSpacing` went through `f64_or`, which takes any
value that is not a number as the default, so `"rowSpacing": "15"` stored 0
and put every copy on top of the others without an error. They are now
checked as `rows` and `columns` are: absent or null is 0, a finite number is
kept, anything else is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:58 -05:00
klerandClaude Opus 5.5 e10f394b28 i18n(sv): translate 20 new strings (#2695)
Brings the Swedish catalog up to date with upstream main (b04273e).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:06:54 -05:00
Dylan M. Taylor 9a112830c5 fix(engine): merge another drawing's tables and blocks in linear time (#2689)
INSERT of a file, IMPORT, paste and COMPARE export each checked every incoming layer, linetype, style and block name by scanning the growing destination vector, and restarted the anonymous block name search from 1 at every collision, so a compact drawing with many distinct names made the command quadratic. The four copies of that code become one helper (cmd/merge.rs) that looks names up in a set built once per table and continues each generated prefix from the last number it allocated. Xref loading does the same for its dependent symbols, and no longer rescans the block table after a fetch that imported nothing.
2026-11-03 09:06:49 -05:00
Hamza 44700c5a36 Merge remote-tracking branch 'origin/main' into stored-model-view
# Conflicts:
#	crates/io/src/dxf_read/tables.rs
2026-11-03 18:48:16 +05:00
assamaralandClaude Opus 5.5 714e1db0aa Merge upstream/main into fix/dxf-viewport-id-unknown
Conflict in crates/io/src/tests.rs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:22:10 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 f9cb70500d docs: Georgian font fallback in the Georgian section
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:17:59 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 e8408851cd docs: the Georgian face paragraph describes glyph support only
Choosing ka, its English fallback and the picker name come with the
language PR (#2016), which registers ka; this PR alone doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:17:59 +04:00
SBaksa 457627df33 OLE2FRAME: validate hex strictly, rewrite only the header groups, cheaper corner lookup (review on #2587)
A sign in a hex pair is no longer accepted; only the 310 groups holding the 98-byte header are decoded and rewritten, in place and without cloning the payload; the corner lookup is one allocation-free pass; the io ole_frame test module is not built for wasm like its DWG siblings; Unknown::is_ole_frame replaces five copies of the type check. Tests: a_plus_sign_in_ole_data_is_not_hex, a_large_ole_payload_only_rewrites_its_leading_groups.
2026-11-03 16:44:30 +11:00
SBaksa be413af026 OLE2FRAME: arrays refuse before adding copies; clearer refusal (review on #2587)
ARRAYPATH with align and polar arrays that rotate items refused part way, leaving the earlier copies in the drawing. Every copy's matrix is now checked before the first copy is made. The refusal text names the other unsupported transforms. Test: arraypath_refuses_before_adding_any_copy.
2026-11-03 16:37:51 +11:00
SBaksa 0a0a820759 OLE2FRAME: any transform leaves the frame axis-aligned and its data consistent (review on #2587)
EXPLODE, REFEDIT, paste and array placement call EntityKind::transform with rotations and mirrors, which left groups 10/20 not upper-left and the data header not matching. ole_frame_transform now takes the box of the two mapped corners (upper-left, lower-right) and writes the header as UL, UR, LR, LL of that box. Test: a_rotated_frame_stays_axis_aligned_and_consistent (rotate 30 and mirror).
2026-11-03 16:36:05 +11:00
SBaksa 42e3d964dc Merge remote-tracking branch 'origin/main' into ole-frame 2026-11-03 16:22:22 +11:00
SBaksa 48165e6cd9 io tests: order the ole_frame module as rustfmt wants after the merge 2026-11-03 16:15:10 +11:00
SBaksa 8cc2c4e847 OLE2FRAME: corner match has a fixed ceiling at large coordinates (review on #2587)
The relative tolerance grew with the coordinate: at 1e9 a payload 0.5 off the frame still matched and was rewritten. It is now capped at CORNER_EPS (1e-6), still above an ulp at 1e9. Tests: a_half_unit_offset_at_large_coordinates_is_not_the_frame, exact_corners_at_large_coordinates_still_move.
2026-11-03 15:45:20 +11:00
SBaksa 9bd5c479e3 OLE2FRAME: SCALE carries a frame with a uniform positive factor (review on #2587)
ole_frame_accepts took only a translation, so SCALE on a selection with a frame failed as a whole. A uniform positive scale keeps the frame axis-aligned and upper-left, so the corner rewrite handles it; rotation and mirroring are still refused. Tests: scale_carries_an_ole_frame_with_the_rest_of_the_selection, mocoro_scales_an_ole_frame, ole_frame_accepts_translations_and_uniform_positive_scales_only.
2026-11-03 15:45:05 +11:00
霁伪 faaa12df3f Merge upstream/main into feat/text-edit-window
#2607 (FIND) landed first, so this keeps both sides of the i18n rows: the
finddlg entries upstream brought and the textdlg entries this branch adds.
2026-11-03 12:19:25 +08:00
Davit MatchakhelidzeandClaude Opus 5.5 4b532f6c24 i18n(ka): translate the strings added upstream; prompts without the trailing "or"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:01:13 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 891e87a7db Merge upstream main into ka-language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:01:13 +04:00
Tanish Manku b04273e897 Merge pull request #2685 from storytold/land/1937
Round-trip harness and corpus report: open, save, reopen and compare (#1937, E12)
2026-11-02 21:00:25 -07:00
Davit MatchakhelidzeandClaude Opus 5.5 e931787a64 Merge upstream main into fonts-georgian
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:05 +04:00
5ed192555c EXPLODE: a block reference with different X and Y scales gives ellipses, not circles (#2649)
* EXPLODE: a block reference with different X and Y scales gives ellipses, not circles

EXPLODE of a block reference whose X and Y scales differ kept its circles
as circles with the mean scale, its arcs as circular arcs, its ellipses
with their old ratio and its polyline arcs as circular bulges: not the
shape the reference draws. BURST and INSERT with explode go the same way.

When the reference's transform isn't a similarity, a circle now becomes a
full ELLIPSE, an arc an elliptical arc, an ellipse gets its true axes, and
a polyline with arc segments (and no widths) its lines and elliptical
arcs. Everything else, and every evenly scaled or mirrored reference,
transforms as before.

The exact ellipse transform and the similarity test move unchanged from
snap.rs to cadcraft-geom (Ellipse::transformed, Mat3::similarity_scale)
and are shared by object snaps and EXPLODE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* contributors: add Tyler-Chiriboga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:54:03 -05:00
4c00f8b9e3 FIND: a Find and Replace window, not only the command line (#2607)
* FIND: report the text around each match

The result list needs to show what matched, not just its handle, so each entry now carries a snippet of the text around the first hit, the hit's offset in it and its length. Offsets count characters, not bytes, so a Chinese or Vietnamese drawing never splits a glyph.

Two optional parameters let a window search part of the space: `types` narrows the search by object type name and `handles` limits it to the objects a caller has already listed, which is what makes replacing a single row possible. Both default to the old behaviour, so scripts, LISP and the MCP server get the answer they always got.

* FIND: a Find and Replace window

FIND only had a command line: it asked for the text, ran, and printed a count, and the palette's Find and Replace tile opened the same prompt. The answer lived in the drawing and the place to read it was the command line.

This adds a window behind the same command. The result list shows each object with the hit lit up, a click selects that object and zooms to it, Replace edits the row under the cursor and Replace All the rows on the list. The window stays open while the drawing is edited, because finding and replacing is a loop: look, locate, edit, look again.

A JSON call carries its parameters, so scripts, LISP and the MCP server still get the engine's answer; only the bare command opens the window. Cmd+F reaches it from anywhere, and the twenty labels it adds are in every interface language.

* FIND: clear the window's state when it closes or the drawing changes

The result rows hold handles, and a handle only means something in the
drawing it was found in. The state now lives exactly as long as the window
does, and an epoch change while the window sits open clears the rows instead
of letting Replace All edit an object the search never matched. A successful
replacement keeps the engine's replacement report rather than letting the
re-search overwrite it with a match count.

* FIND: an empty replacement is a deletion, and stale rows die on any change

Replacing with nothing deletes the matched text, which is a legitimate
replacement, so the buttons no longer require text in the Replace field.

The result rows answer one question - this text, with these switches, in this
drawing and space. Editing the search term or either switch now clears the
rows and owes a fresh search, and so does switching between model space and a
layout: a handle means nothing outside the space it was found in.

* rustfmt: sort the module list after the merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:53:24 -05:00
7b6be7835f fix(io): a solid HATCH keeps its angle, scale and double flag (#2665)
* fix(io): a solid HATCH keeps its angle, scale and double flag

The writer wrote groups 52 (angle), 41 (scale) and 77 (double) for
pattern fills only, so a SOLID hatch read with them lost them on save.
Write them for solid fills too, as read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(io): fixture for a solid HATCH keeps angle, scale and double flag

Add a synthetic DXF with a SOLID and an ANSI31 hatch and a test that saves it
and compares groups 52, 41 and 77 of each hatch by handle.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* docs(io): document the functions this change touches

Add doc comments to the functions the diff changes whose complexity is above
one, so the docstring coverage check passes.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* Drop the DXF fixture: the inline test covers the fix; test data belongs in a corpus repo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:53:20 -05:00
455b2a87cb EXTEND: a polyline's arc end segment runs on round its circle (#2656)
EXTEND on an open polyline ran a ray along the chord of an arc end segment
and kept the old bulge, so the arc changed radius and centre, or the
boundary was reported missed when only the circle reached it. The arc end
segment now runs on round its own circle to the nearest crossing ahead,
turning the same way, and its bulge becomes that of the total turn, as
EXTEND does with an ARC. The polyline also keeps its constant width,
elevation and linetype generation, which the rebuilt polyline dropped.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:52:39 -05:00
Dylan M. Taylor 1b7c705fac Dynamic blocks: index kept objects once per listing; copy each value record once (#2678)
* Dynamic blocks: index kept objects once per listing and copy each value record once

Listing blocks rebuilt a handle index of every kept foreign object for each
block with an extension dictionary, multiplying the block count by the object
count. The listing now builds one ObjectIndex and asks is_dynamic_block_in per
block; dynamic_block answers None before indexing when the block has no
extension dictionary.

templates() copied a value record's tags once per dictionary key, so a record
listed under many keys was copied many times, and it looked up each
dictionary entry by scanning every object. It now resolves entries through
its handle index and copies each record at most once (CWE-400, CWE-407).

* Dynamic blocks: list a block as dynamic from its graph object without walking the graph's members
2026-11-02 22:52:35 -05:00
qshh 77142aa79e fonts: scan the Android and HarmonyOS system font directories (#2650)
* fonts: scan the Android system font directories

System fonts are the only fallback for text whose own font lacks a glyph
(CJK in an SHX big font, for example), and Android was falling through to
the XDG directories it does not have: a drawing came out in the stroke font
with missing-glyph boxes for Chinese. Search /system/fonts and the product
and vendor partitions too.

The directory list moves out of scan() so the platform's main location is
tested directly.

* fonts: scan the HarmonyOS system font directories

HarmonyOS targets are target_os = "linux" with target_env = "ohos", so they
fell into the XDG branch and found no fonts at all: Chinese text in a drawing
came out as missing-glyph boxes while the stroke font drew the ASCII around
it. Search /system/fonts there too, as on Android.

* contributors: add qinshah

Credits them by the name on their public GitHub profile.

* fonts: document the platform font directories
2026-11-02 22:52:31 -05:00
Dylan M. Taylor 31e6e16bc0 constraints: evaluate a named expression without copying its text (#2675)
* constraints: evaluate a named expression without copying its text

Env::value cloned the stored expression before parse rejected it as too
long, and failures are not cached, so an oversized imported expression
referenced from many dimensional constraints was copied on every
reference. Parse the stored text in place, and keep only a bounded
prefix of an over-long expression when it is inserted (it still fails
as too long).

* constraints: test repeated evaluation of an oversized named expression
2026-11-02 22:51:07 -05:00
Tanish Manku a4ff30d11e Round-trip harness: never read the kept copies back as corpus files 2026-11-02 20:49:27 -07:00
Tanish Manku 9d83f21910 Round-trip harness: typed tolerance error, report folders it couldn't read, --out=DIR
From review of #2685. The ast-grep string-error rule refused the tolerance
parser's String error. A folder the walk couldn't read was skipped silently, so a
strict run could pass on part of the corpus; it is now said on stderr and fails a
strict run. xtask corpus resolves --out=DIR against the caller's folder, as
--out DIR.
2026-11-02 20:36:01 -07:00
Tanish Manku 7caf369555 Merge #1937: open, save, reopen round-trip harness and corpus report (E12)
Merged onto current main: the DWG writer's per-type counts of what it leaves out
travel back from the DWG worker, the roundtrip command follows the CLI's clap
subcommands, and read_source keeps main's format detection. The docs/gaps.md
edit is dropped; maintainers update the parity rows in batches.
2026-11-02 20:19:17 -07:00
Tanish Manku f691d0b349 Merge pull request #2683 from storytold/land/1976
DXF: read up to 100 million group codes, or a set limit, without running out of memory silently (#1976)
2026-11-02 20:05:13 -07:00
mattlafy cbc9db75b1 Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/engine/src/prompt.rs
#	crates/engine/src/session/execute.rs
#	crates/io/src/dxf_read/entities.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/cmdline.rs
2026-11-02 21:53:26 -05:00
Tanish Manku e24c4553d2 DXF: 100 million group codes by default, and memory for them that can be refused
The tag list for a counted ASCII file is reserved once with a fallible
allocation, so where the system refuses the memory, opening fails with an error
instead of ending the app. Growing it would also take half as much again for a
moment. 100 million rather than 150 bounds a crafted 512 MB file to about 4 GB
of tags; CADCRAFT_MAX_DXF_TAGS raises it, and the error says so.
2026-11-02 19:47:36 -07:00
SBaksa a9dbc50ce2 Merge origin/main into ole-frame (drop parity-checklist change) 2026-11-03 13:46:27 +11:00
Tanish Manku c602a402c3 Merge commit 'refs/pull/1976/head' of github.com:storytold/cadcraft into land/1976 2026-11-02 19:46:17 -07:00
Dylan M. Taylor d180b28ef6 Multilines: find each segment's usable neighbour direction in linear time (#2679)
* Multilines: find each segment's usable neighbour direction in two linear passes

MLine::miters searched the direction array forwards and backwards from every
vertex for the nearest non-degenerate segment, so a long run of coincident
vertices made it quadratic. The nearest usable directions ahead and behind
are now computed once, in one backward and one forward pass (CWE-407).

* Multilines: test that a run of coincident vertices at a corner takes the corner's miter
2026-11-02 21:33:55 -05:00
Tanish Manku 2e9761249a Autosave: keep every folder's lock, and don't let a failure hide or wait (#2676)
From review of #2670. Changing SAVEFILEPATH released the old folder's lock while
files remained there, so another app could offer a running app's drawings. A
write failing while the app was idle waited for the next input to be shown. A
second drawing's failure stayed hidden behind a dismissed notice.
2026-11-02 19:22:30 -07:00
Dylan M. Taylor c956baa349 constraints: share geometry preparation across an operation and budget spline rebuilds (#2677)
Endpoint residuals rebuilt a fit spline through a dense interpolation
solve on every evaluation, so many Tangent/Smooth constraints on one
spline, or numerical differentiation along other objects' parameters,
repeated identical O(n^3) rebuilds; solving had no budget for them.
purge_invalid built a fresh model per constraint, re-sampling the same
block for every reference, and an Insert naming no block scanned every
block definition case-insensitively.

- Models built for one operation share a Prep: block samples are taken
  once per block (in block coordinates, transformed per Insert), block
  names resolve through a lazily built case-insensitive index, and the
  last rebuild of each spline is reused at the same defining points.
- Comp::run and the Fix redundancy check refuse a system whose
  per-iteration residual work, including spline rebuilds, exceeds the
  limit DOF analysis already applied (MAX_EVAL_WORK).
- Spline templates keep only what a rebuild reads: no fit points for a
  control spline, no control net for a fit spline.
- purge_invalid validates every constraint with one Prep.
2026-11-02 21:17:23 -05:00
Dylan M. Taylor 997f152cea Extents: share block-bounds memoization across all the entities measured (#2681)
Each top-level bounds call started with an empty block cache, so measuring a
space's extents or building a spatial-index chunk measured a block definition
again for every reference to it: R references to a block of B entities cost
R x B. BlockBoundsCache keeps the per-depth block bounds (separately for drawn
bounds and extents, which differ on layers turned off) across one bulk
operation; Drawing::extents and the spatial index use it (CWE-407).
2026-11-02 21:16:56 -05:00
Dylan M. Taylor 0d3b3d21c0 Regions: test a loop's probe only against loops whose box holds it, within a budget (#2680)
Region::renest tested every loop's probe point against every other
tessellated loop, scanning all of its edges each time, so the work was the
loop count times the total vertex count. It now skips loops whose bounding
box does not hold the probe, and stops once MAX_RENEST_EDGE_VISITS edge tests
have been spent, leaving the flags unchanged and returning false; UNION,
SUBTRACT and INTERSECT then report the regions as too complex to combine, as
they already do for too many boundary pairs (CWE-407).
2026-11-02 21:16:51 -05:00
Dylan M. Taylor 4482316306 constraints: find the next default parameter name in one pass (#2674)
next_name tested up to 100,000 candidate names, each against every
parameter and constraint name, so a drawing with many occupied names
made adding one dimensional constraint quadratic. Collect the numeric
suffixes in use once and take the first free one.
2026-11-02 21:16:44 -05:00
Tanish Manku f6d84e28f3 DWG reader and writer run in a child process (E7) (#2673)
* DWG reader and writer run in a child process (E7)

An allocation failure or stack overflow in the third-party code ends its
process, which catch_unwind can't stop, so one bad file took every open drawing
with it. The app and CLI now run them in a copy of themselves over pipes. A
worker that dies or runs past 30 minutes costs that one open or save. If no
worker can be started, the work stays in the process, as before.

* DWG worker: keep the reply's bytes instead of copying them; skip the test without a bridge
2026-11-02 18:47:29 -07:00
Tanish Manku 806654aedf Add a security policy (E33) (#2672) 2026-11-02 18:24:51 -07:00
Tanish Manku 6c4984f5ad A panic in the interface saves the work and reports it (E4) (#2671)
The frame now runs under a guard. A panic autosaves unsaved drawings at once,
even with SAVETIME 0, and shows a notice that doesn't block saving. A panic
hook writes a local report for every panic. Panics that keep coming stop the
interface and leave a window that closes, with the recovery copies kept.
2026-11-02 18:14:29 -07:00
Tanish Manku fdfe4d95a8 Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670)
* Autosave: a failed write is shown until one works (E6)

A failure only reached the log, so someone could work for hours with no
recovery copy. The autosaver keeps why each drawing's write failed; the app
says it once at the command line and shows a notice that doesn't block saving.
It ends when a write works or the drawing no longer needs a copy.

* Crash recovery: offered at the first relaunch, not 5 minutes later (E5)

A crashed app's lock file looked alive until it went stale, so a relaunch
within 5 minutes skipped its autosaves, and a suspended app's looked dead.
A running app now holds a system lock on the file, which the OS drops however
the process ends. File age still decides where locks don't work and for lock
files of older versions, which don't record holding one.
2026-11-02 18:06:35 -07:00
ffishman 6c80e04faa Merge main into fix/dwg-render-settings-class-version
main's #2474 and #2582 change not_saved_as next to the proxy class check, and #1725 adds a test next to ours; all are kept.
2026-11-02 21:38:02 -03:00
ffishman 3e3ac42b60 Merge main into fix/page-setup-media-orientation
main's #2623 adds PageSetup::image_origin next to file_paper; both are kept.
2026-11-02 21:37:02 -03:00
Brandon ThomasandClaude Opus 5.5 f389d6a72e Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:26:12 -05:00
Tanish Manku eacdbe43ee Save: a folder that can't be made says so, not that the backup failed (#2669)
The backup ran before the folders were made. On Linux, a path under a file
gave 'the backup copy could not be written' instead of naming the folder.
2026-11-02 17:10:53 -07:00
Tanish Manku 096b3f9be4 Merge pull request #2668 from storytold/land/2260
Saves to a missing folder write the file and say which folder was created (#2260)
2026-11-02 16:51:54 -07:00
Tanish Manku bf4d51bf61 Merge #2260: a save to a missing folder writes the file and says so
The folders are made by a new writer only user-named targets use, so autosave
and a dangling symlink's target keep the strict write_atomic. Save, Save As,
Export, Plot, WBLOCK, layout templates, render and the CLI report the topmost
new folder at the command line and in createdFolder.
2026-11-02 16:48:16 -07:00
Books 2ac500925b DWG: a table cell's text height is an override only when the file flags it (part of #2501) (#2588)
* DWG: a table cell's text height is an override only when the file flags it (#2501)

acadrust's DXF writer writes group 140 for any cell with a style record, even a
default 0.18 with no override flag, and CADCraft read it as a per-cell height,
so cells ignored the table style's height. The bridge now clears the height of a
cell style that has neither the TEXT_HEIGHT property flag nor the content's
height override bit (0 reads as no override).

* Parity checklist: regenerated after merging main

* Tests: inherited table cell heights must stay unset (review on #2588)

A cell that inherits its height must read back as None, not an explicit 0.0625 override.

* Parity checklist: regenerated after merging main

* Test: a cell style with the TEXT_HEIGHT flag, content override bit 128 and a cell with no style record keep the right height (review on #2588)

* DWG: a table cell's content height override (group 144) is kept (review on #2588)

* Parity checklist: regenerated after merging main

* DWG: table cell height takes the first content's override and the style's override flags (review on #2588)

Only the first content's bit-128 height stands for the cell (one height for its text); an
invalid content height no longer keeps the writer's default 0.18; a height marked only in
the style's override flags (newer files) is kept.
2026-11-02 15:01:57 -07:00
STE1200andClaude Opus 5.5 5ec0d215d8 CLI: info and convert report what a damaged DWG left out (read notes, #859) (#2337)
A damaged DWG opens partially since #859 and the drawing's read notes
say how many object records were left out. OPEN echoes them and MCP
`open` returns them as `warnings`, but `cadcraft-cli info` and
`convert` stayed silent: a corpus round trip wrote thousands of skipped
records away without a word.

- convert: each read note goes to stderr as `IN: <note>`, next to the
  skipped-records warning (#872). The exit code stays 0.
- drawing.inspect (and so info, MCP inspect_drawing and the control
  channel): new field `readNotes`, next to `skipped`. Existing fields
  are unchanged.
- run already prints OPEN's notes with the session log.
- README and docs/control-protocol.md say where the notes appear.

Part of #1039.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:01:51 -07:00
midasmatterhorn 3f57f8d3d4 render: a display list past the block-expansion budget says what it left out (#1278)
* render: a display list past the block-expansion budget says what it left out

* Report viewport truncation only after the expansion budget is exceeded

* fmt: keep ui-egui control.rs rustfmt-clean
2026-11-02 15:01:46 -07:00
NetterSilverandClaude Haiku 5.5 4e874d089a Persist current table style (CTABLESTYLE) in DXF header (#2117)
Found while testing crates/io/src/dxf_write.rs:1964-1970 (xrecords written) and crates/io/src/dxf_read.rs:2049-2052 (apply)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 15:01:41 -07:00
Newton Kilgore Sightsinger 3bb515485d Xrefs: a saved path is found regardless of letter case on case-sensitive file systems (#1982) 2026-11-02 15:01:36 -07:00
e4cc7ffc77 DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.6) (#1725)
* DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.5)

AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with one byte after its
three connections that DXF doesn't carry. acadrust 0.6.4 neither read nor
wrote it, so AutoCAD read past the object and refused every DWG CADCraft
wrote with a dynamic block that scales ("Error Decrypting Data"), even an
unchanged one. acadrust 0.6.5 keeps the byte (dwg_tail_byte).

0.6.5 also maps the dimension flags byte's bit 0 to text_user_positioned
both ways, so the bridge no longer inverts it (it would now double-invert);
it still sets bit 1, which acadrust keeps no DXF source for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DWG: acadrust 0.6.6; multi-layout DWGs get unique paper space names

0.6.6 keeps the scale action byte and the dimension flag mapping this branch
relies on, and writes only the active layout as *Paper_Space (others get
unique *Paper_SpaceN names; hakanaktt/acadrust#143), so ACadSharp opens a
DWG CADCraft saves with more than one layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io tests: a scale action survives CADCraft's own DWG save

The cadcraft-dwg test builds its DXF with acadrust's writer; this one goes
through cadcraft_io::write (the bridge DXF, then acadrust) with a scale
action added to the synthetic Door fixture, and checks the DWG holds it
with its connections and reads back as the same action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ui: block description field ids from Ui::scope_id (egui 0.37 deprecates Ui::id)

main's gate fails clippy -D warnings on the one Ui::id() call (#2113);
Ui::scope_id is its new name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DWG: the scale action byte doc names acadrust 0.6.6, the version in use

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Docs: leave the parity docs to the maintainers (AGENTS.md: PRs don't edit them)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:01:31 -07:00
Rahulandrahul05ranjan 03101e8d77 fix(io): a moved DIMENSION draws its block at its insertion point (group 12) (#2634) (#2636)
A DIMENSION moved by an older AutoCAD keeps its *D block contents where
they were and the move in DXF group 12, and is drawn translated by the
insertion point. Group 12 was never read into Dimension, so the block
rendered at its pre-move position and the extents reached there too.

Read group 12 into a new Dimension::insertion_point, draw the block
translated by it, move its bounds with it in the extents, test the
block's text where it draws, and write group 12 back whenever the block
written is the one read from the file. Regenerated geometry (edits drop
the block link) sits at its definition points, so no offset is kept or
written for it.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 15:01:26 -07:00
ZaxaerithandZaxaerith 0bc025965a fix(dwg): preserve MTEXT caret-adjacent whitespace (#2250) (#2314)
* fix(dwg): preserve literal spaces after MTEXT carets (#2250)

* test(dwg): fix MTEXT fixture ownership and boxed entity

---------

Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
2026-11-02 15:01:21 -07:00
Tanish Manku 03db6261e4 Merge pull request #2662 from storytold/land/1978
Plot style and plotter files save atomically (#1978)
2026-11-02 15:01:16 -07:00
Tanish Manku cd19b205b7 Merge remote-tracking branch 'origin/main' into land-1978 2026-11-02 15:01:09 -07:00
Slopapalooza 5155c9aea8 Merge remote-tracking branch 'origin/main' into properties-set-locked
# Conflicts:
#	crates/engine/tests/it/main.rs
2026-11-02 16:54:16 -05:00
Tanish Manku 69e946586f Merge pull request #2661 from storytold/land/2474
DXF: leave SAT-text solids out of 2013+ files, and ask before a save does (#2474)
2026-11-02 14:37:04 -07:00
Tanish Manku fbc47b48e8 Merge remote-tracking branch 'origin/main' into land-2474 2026-11-02 14:36:58 -07:00
Tanish Manku 86f3fdc9e9 Test: the DWG note's wording, and one-line fake bytes 2026-11-02 14:36:58 -07:00
Tanish Manku 7690d36bb0 fmt 2026-11-02 14:36:58 -07:00
7bc76f94ca OVERKILL: objects within the tolerance are duplicates wherever their numbers fall (#2653)
* OVERKILL: objects within the tolerance are duplicates wherever their numbers fall

OVERKILL compared objects by rounding every number to a multiple of the
tolerance. Two numbers closer than the tolerance on either side of a
rounding step round apart, so the objects were kept: at 0.01, circles at
x = 80.001 and 80.003 were merged but circles at 50.004 and 50.006 were
not. Arcs on one circle were bucketed the same way.

Duplicates are now found by shape (the JSON without its floating-point
numbers; counts and flags must match exactly) and a weighted sum of the
numbers on a grid of the tolerance, looking in the neighbouring cells too,
then compared pairwise to within the tolerance. Arcs and circles look in
the neighbouring cells of centre and radius and are grouped with a
union-find. Lines were already grouped by sorting and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OVERKILL: within the tolerance doesn't chain

Arcs and circles were grouped with a union-find over pairwise matches,
so A ~ B and B ~ C put A and C in one group even when A and C are
farther apart than the tolerance: a circle C was deleted for matching
only the arc between it and the kept circle A, and arcs were combined
across the chain. Each object now joins the first group whose every
member is within the tolerance of it, or starts its own.

In the duplicates pass, a followed object displaced an earlier keeper
that later objects had already matched, leaving them deleted up to twice
the tolerance from what stays. Followed objects are now looked at first,
so every deletion is within the tolerance of an object that stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:53 -07:00
RicSchonfelder 6660d88785 i18n(pt-BR): translate the rows the sibling catalogs cover (#2581)
* i18n(pt-BR): translate the rows the sibling catalogs cover

The Brazilian Portuguese catalog covered the reference catalog and every marked UI literal, but 191
strings that the German, French, Swedish and Ukrainian catalogs already translate still fell back to
English: the Hatch and Gradient dialog, the Block Editor and dynamic-block grips, the plot and drawing
units dialogs, multileader editing, selection preview settings and the unit names.

Rows follow the catalog's vocabulary (dimension = cota, hatch = hachura, grip = pinça, layout =
apresentação, multileader = multilinha, xref = referência externa), keep the prompt keywords, SPDX
tokens and technology names in English, and are appended after the existing entries.

* docs: leave localization parity updates to maintainers
2026-11-02 14:36:48 -07:00
2ez4gcxandClaude Opus 5.5 beb6c48a59 AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them (#2552)
* feat(inquiry): AREA, PERIMETER and DISTANCE system variables, as AutoCAD sets them

AREA, DIST and MEASUREGEOM now leave AREA, PERIMETER and DISTANCE for
getvar, read-only and not saved: the area just measured (the running
total in Add and Subtract mode) with the last area's perimeter, and the
distance (the total of Multiple points). After DIST's two points
LASTPOINT stays at the first point, as measured. AREA's Object option
measures splines, closed by their chord like open polylines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: spline area over narrow spans, finite distances, list order

- AREA of a spline samples it span by span (Spline::tessellate_spans), so
  a narrow knot span no longer drops out of the area. The spread over the
  spans is one helper shared with Spline::bounds, whose boxes are
  unchanged.
- DIST refuses a distance that isn't finite on all three paths and leaves
  DISTANCE as it was.
- sysvars::list puts the session's values after the drawing's header, so
  it shows what get returns (AREA, ORTHOMODE, LWDISPLAY...).
- LASTPOINT after DIST's Multiple points is pinned to AutoCAD 2024's
  measured behaviour: the last point entered, also after Length and Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: measure every knot span of a spline in AREA

A spline with more knot spans than half the sample cap fell back to an
even grid over the domain, which can step over a narrow span and measure
the wrong area: a 100 x 100 square as a degree-1 spline, its right and
top edges two spans a millionth wide after 4,500 spans along the bottom,
measured 4,998.75.

Spread the samples over the spans at any span count: at least one
segment per span, within the cap up to MAX_SAMPLES spans and one each
past it, as Spline::length integrates every span. One evaluation per
span costs 85-285 ms on a million spans in a release build, against
32-52 s for the length AREA already takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: share AREA's spline samples by how much each span bends

Past the sample cap, tessellate_spans gave each knot span one segment, so
a bent span among many straight ones was measured by its chord. Example:
a quadratic spline along the x-axis, one control point 1000 above it,
past 4,500 spans. It measured 750; the area is 1750/3.

Each span now keeps one segment. A span whose control points are
collinear is straight, so its chord is exact. tessellate's count is
shared among the spans that bend, by the cube root of a measure of their
control points' convex hull, which holds the span and its chord. The
shares are rounded on the running total, so they use the whole count
however many spans share it. The example now measures 583.33343, below
the cap and past it.

The spreading by parameter width that this replaces goes, and
span_params with it: bounds is main's code again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DIST: a measurement refused as too large leaves LASTPOINT at the first point

Session::feed sets LASTPOINT to the second point before DIST checks the
distance, and the refusal returned before DIST put the first point back,
as it does after a measurement. It now does so before refusing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AREA: a spline too long to measure is too large, not a perimeter of 0

Spline::length reads a length past f64's range as 0, and AREA took that
as the spline's perimeter. Spline::checked_length returns None there,
and AREA reports the perimeter too large, as it does a polyline's.

The overflow was hidden twice: in the sum, and before it in the
derivative, which read as zero speed once past f64's range. The length
integral now takes the derivative before that (Spline::velocity), so an
overflowing speed counts too. param_at_length keeps the speed it had,
and finite lengths are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:42 -07:00
2ez4gcxandClaude Opus 5.5 990ff2ce9f Say "or" once in prompts that offer options (#2563)
* Say "or" once in prompts that offer options

Twelve prompt messages ended in "or", and the command line adds "or"
before a prompt's options, so they read "or or": HELIX height, XLINE angle
and offset, BLEND, four LENGTHEN prompts, two NCOPY prompts, PEDIT and
table breaks. They now read as AutoCAD's do.

The interface catalogs are keyed by these messages: each key loses its
"or" and each translation its trailing word for "or"; where the shorter
key already had a translation, the longer row is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say "or" once in HELIX's radius prompts; rename the Vietnamese keys

HELIX's base and top radius prompts ended in "or" before their Diameter
option too, so they read "Specify base radius or or [Diameter]". They
now read "Specify base radius or [Diameter] <1.0000>:", and their keys
lose "or" in all 17 catalogs, as the other twelve did.

The Vietnamese catalog, merged from main, gets the same renames as the
other catalogs; where the shorter key already had a translation, the
longer row goes. The unused "Specify rotation angle or" row goes from
every catalog too: RECTANG has asked "Specify rotation angle" since
#2554.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:37 -07:00
2ez4gcxandClaude Opus 5.5 8de925e1d3 Write option prompts without "or", as AutoCAD does (#2540)
* Write option prompts without "or", as AutoCAD does

AutoCAD writes " or [" only when a prompt asks for something besides its keywords ("Enter block
name or [?]"); a prompt asking for one of them has none ("Enter an option [...]", "Enter layout
option [...]", "New color [Truecolor/COlorbook]"). The 27 messages read without "or" from
AutoCAD 2024's command line are listed in NO_OR and displayed that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review: the window's command line drops "or" as the prompt does

The command line in the window draws a prompt's keywords itself and
wrote " or [" after every message, so -LAYER still read "Enter an option
or [?/Make/...]" there, and questions such as AUDIT's had "or" too. Only
the prompt text (the control channel, the state JSON, the tests) had
dropped it.

Prompt::keyword_lead gives what goes before the keywords; display() and
the command line both use it, and the command line translates only
" or [". A render test reads what the command line draws, in English and
German, for -LAYER, LINE and AUDIT. Suggested in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:36:32 -07:00
Tanish Manku 97679d8cff Ask before a save drops solids from a 2013+ DXF (#2334)
The interactive save only knew about DWG, so overwriting a 2013+ DXF lost SAT-text
solids on a command-line note. loss_on_save now lists them, and EXPORT reports them
with the DXF wording.
2026-11-02 14:30:30 -07:00
Tanish Manku 9972a739c5 Merge remote-tracking branch 'origin/main' into land-2474
# Conflicts:
#	crates/doc/src/foreign.rs
#	crates/engine/tests/save_reports_not_saved.rs
#	crates/io/src/dxf_raw.rs
#	crates/io/src/dxf_write/sections.rs
2026-11-02 14:29:22 -07:00
Tanish Mankuandassamaral 0f56c16d00 EXPORT and WBLOCK say what a DWG file leaves out (#2240) (#2660)
Co-authored-by: assamaral <217119886+assamaral@users.noreply.github.com>
2026-11-02 14:27:45 -07:00
Tanish MankuandMarekSpodBiedry a60d437f0a WBLOCK: new handles start above those of the copied blocks (#1996) (#2659)
Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com>
2026-11-02 14:27:33 -07:00
Rahulandrahul05ranjan f2cb7e6e8f i18n(zh): translate the 74 strings the report listed as missing (#2638)
The Chinese catalog lacked 74 interface strings: the block editor
prompts and dialog, the alias editor sentences, the selection visual
effects and preview filter, units of new drawings, and the menu names
Dimension, Draw, Modify and View.

Adds them in the catalog's TSV format with the AutoCAD zh-CN vocabulary
the catalog already uses (夹点, 图案填充, 外部参照, 多行文字, 孤岛).
After this the report lists nothing missing for zh.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 14:26:59 -07:00
0e7c71759c test(ui): the IME double-click test passes without craft-fonts (#2652)
`double_clicked_text_takes_ime_composition_and_commit` commits 中文 at the
command line. Without a CJK font (no CRAFT_FONTS_DIR, none installed, as on
a plain Linux CI image) the kittest harness panics on the first missing
glyph: "No glyph for 中 in Proportional". Code and tests must work without
craft-fonts.

The test now asks the harness's own fonts whether they can draw 中文 and,
only when they can't, lets the command line draw boxes for it. The IME
path it checks is unchanged: the pre-edit is shown and not saved, the
commit is inserted once, Enter stores 中文 in the text. With a CJK font
the harness still fails on any missing glyph.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:53 -07:00
SoldierPePe facae4a9a6 plot and exportpdf help name the device parameter (#2557) (#2567)
* plot and exportpdf help name the `device` parameter (#2557)

`cadcraft-cli commands plot` didn't list `device`, so the only way to find it was the `unknown plotter` error.

PLOT's help now lists `device` with the built-in devices (CADCraft PDF, PNG, SVG), PLOTTERMANAGER plotters and "None". It also says the default is the page setup's device and that the device picks the output format and the papers. EXPORTPDF's help names `device` too, and notes that its output stays PDF. A test checks that both help texts name `device` and that PLOT's names every built-in device.

* plot help: say what Model, "None" and an omitted paper do

In model space, and with "None", no device is used and the plot is a PDF. When `paper` is omitted, a layout keeps its page setup's paper and model space takes the device's default.
2026-11-02 14:26:48 -07:00
2ez4gcxandClaude Opus 5.5 ea127d4702 Dynamic blocks: a point on a frame edge of any length is on it (#2640)
* Dynamic blocks: a point on a frame edge of any length is on it

The frame edge test used Line::dist, which takes a segment shorter than
EPS (1e-9) for its first point: a point halfway along a frame edge that
short was neither on the edge nor, by the even-odd test, inside, so its
line moved whole instead of stretching. The distance to an edge is now
the exact one, clamped to the segment.

Tests also cover two rules from #2627 that had none: a point on an
edge's line but past its end is outside the frame, and a polyline
tilted by only 5e-10 (about a unit off at an elevation of 2e9) keeps its
stored vertex list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic blocks: measure a frame edge's residual from its start

segment_dist added the projection back to the edge's start before
measuring, so near x = 36 the residual was rounded to that magnitude's
spacing (7e-15). A point 1.4e-15 off an edge 5e-10 long, outside the
frame, then came out 4e-20 away, within the edge's 5e-19 tolerance, and
was taken as on it. The residual is now measured from the edge's start,
where those digits are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:43 -07:00
Ashok Ramandashokram-si ad95791c20 SVG import: a transform on a shape element moves the shape (#2602)
SVG 1.1 §7.5 lets every graphics element carry a transform. Only groups'
transforms were applied, so a rect, circle, path or other shape with its
own transform came in where it would be without one. on_element now
composes the element's transform after its groups' for that element
only, the way a group around it would.

https://www.w3.org/TR/SVG11/coords.html#TransformAttribute

Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com>
2026-11-02 14:26:38 -07:00
NetterSilverandClaude Haiku 5.5 e6352d955e Reject control characters in layer names (#2569)
Found while testing crates/engine/src/cmd/layer.rs:184 (valid_name, used by run_new)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 14:26:33 -07:00
NetterSilverandClaude Haiku 5.5 4a17ee78c2 Reject invalid fill angles in JSON arraypolar (#2655)
Found while testing crates/engine/src/cmd/array.rs:380 (run_arraypolar, `fill: f64_or(p, "angle", 360.0)`)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 14:26:28 -07:00
c0306b1af3 Remember the main window's size, position and maximized state between sessions (#2595) (#2604)
* Remember the main window's size, position and maximized state between sessions (#2595)

The main window opened at its default size, centred, on every launch: eframe's window
persistence was turned off (persist_window: false), and `centered` is applied over any saved
position. The window state is now saved; on launch, if app.ron holds a saved window, it is
restored (eframe keeps it on the connected monitors) and not centred. Only the first launch,
with nothing saved, opens centred (#1129). Wayland places windows itself, so only size and
maximized state return there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: leave the parity docs to the maintainers

The affected rows are named in the PR description instead (CLAUDE.md: PRs do not edit the parity docs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:23 -07:00
25ca905815 fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500) (#2618)
* fix(fonts): single-line TEXT runs left to right, as AutoCAD lays it out (#2500)

A TEXT "- פרוייקט" took its direction from its first strong character
(Hebrew), so the leading dash went to the right of the word; AutoCAD
shows it on the left. complex::shape now takes the base direction
(shape_stroke_dir, ttf::shape_dir and shape_line_dir pass it on), and
TEXT's layout (fonts::place and its frame) asks for left to right.
MTEXT and the existing entry points keep taking the direction from the
text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fonts): a right-to-left base reaches LTR-only lines; Fit TEXT is measured as placed

From the review of #2618: shape_stroke_dir and ttf::shape_dir took the
simple, left-to-right path whenever a line had no right-to-left letter,
so an explicit right-to-left base did nothing there; such a line now
takes the bidi path. And Fit/Aligned TEXT was measured with the
direction from its content but placed left to right, so a line whose
last glyph changed (a stroke-font gap) missed its second point; it is
measured with the direction it is placed in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:18 -07:00
CAandClaude Fable 5.1 fbd6e676f8 DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved (#2582)
* DWG save keeps OLE objects (OLE2FRAME): stop reporting them as not saved

acadrust reads OLE2FRAME from our DXF into its own entity and writes it to
DWG of every version we save, frame and embedded data unchanged; only the
"Not saved in the DWG file" warning (and the QSAVE loss prompt) listed it.
not_saved_as now skips the types the DWG bridge writes (DWG_WRITES).

Tests: a synthetic OLE2FRAME through DWG (resaved, and as 2000 and 2010
DWG) keeps every byte; OLE objects are not reported at any version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DWG_WRITES matches the type as stored, not case-insensitively

The DXF given to the DWG bridge carries an unknown entity's type as stored, and
acadrust reads only "OLE2FRAME" into its OLE entity: any other spelling becomes
its Unknown and is dropped, so it must stay in the "Not saved" report. The test
adds a lower-case "ole2frame" that is still listed. (Review comment on #2582.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:14 -07:00
whaslbeckandWalter Haslbeck 0674391100 fix(io): read drawings with many groups in linear time (#862) (#2645)
* fix(io): read drawings with many groups in linear time (#862)

Drawings can carry hundreds of thousands of empty anonymous groups
(*A1, *A2, ...). Two places in the DXF reader were quadratic in their
number:

- apply_groups looked up each group by name with a linear search; it now
  uses an index keyed by the lower-case name, so the case-insensitive
  merge (the later definition wins) is unchanged.
- retained_objects called is_xdict(parent) for every object before
  consulting its cache; for a group the parent is the ACAD_GROUP
  dictionary, which head() walks completely. is_xdict is now memoized
  per object index.

Two drawings with 179,124 and 124,362 empty groups go from 388 s and
165 s (DWG to PDF) to 7.3 s and 8.0 s, with byte-identical output.

* test(io): check that reading groups scales linearly (#862)

The content test passes on the quadratic code as well, so add a scaling
check: 16 times the groups (2,500 vs 40,000, best of three reads each)
must take less than 64 times as long. With both fixes the ratio is
about 21; without the name index it is about 218, without the is_xdict
cache about 180. No wall-clock limit is involved.

Also document apply_groups.

---------

Co-authored-by: Walter Haslbeck <8794773+whaslbeck@users.noreply.github.com>
2026-11-02 14:26:09 -07:00
f4c68e18fc macOS: Quit (Cmd+Q), the Dock's Quit and logout ask to save unsaved drawings (#2157) (#2620)
Quit CADCraft in the app menu, the Dock's Quit and logout end the app through AppKit's
terminate, which skipped the "Save changes to ...?" prompt, so unsaved work was lost. The
event loop now turns termination into a close request for the window (the storytold winit
fork's opt-in, as DesignCraft does), so it asks first, as the close button does. Cancel keeps
the app open; Force Quit can't be intercepted.

Co-authored-by: Ethan Dale <ethandale@Ethans-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:04 -07:00
ed08716f30 fix(io): the paper viewport a save adds is the sheet at the layout's plot scale (#2108) (#2624)
A layout without a paper-space viewport (id 1) gets one on save, sized
by the page alone; a custom plot scale (142/143) wasn't applied, so an
A3 layout at 49.6 mm per unit got a 420 x 297 viewport around its
8.4 x 6 unit sheet, and its extents went wrong. paper_view divides the
sheet by the layout's plot scale; a layout scaled to fit, at 1:1 or with
an unusable scale writes what it did.

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:26:00 -07:00
ab15e42c6c fix(io): a layout keeps its limits and paper image origin through a save (#2109) (#2623)
Every LAYOUT was written with limits 0,0 to 12,9 (AcDbLayout groups
10/11) and paper image origin 0,0 (groups 148/149), whatever the file
had, so another program drew the sheet elsewhere and a Layout plot came
out shifted. Layout::limits and PageSetup::image_origin keep what the
file had (finite, within 1e12) and a save writes it back; a layout
without them writes the old defaults, which read back as none.

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:55 -07:00
assamaralandClaude Fable 5.1 c4ac8761e8 fix(io): a ByBlock plot style (group 380 = 1) is written and read (#2242) (#2566)
* test(io): a ByBlock plot style (group 380 = 1) survives a DXF round trip (#2242)

Red: the writer leaves group 380 out and the reader maps it to ByLayer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(io): a ByBlock plot style is group 380 = 1, written and read (#2242)

The writer only knew named styles (group 390) and left ByBlock out like ByLayer; the reader
mapped a record without 390 to ByLayer. Both now use the plot style name type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:25:51 -07:00
a277346c2f fix(io): a viewport looks at its view target plus its centre (#2492) (#2508)
* fix(io): a viewport looks at its view target plus its centre (#2492)

A DXF stores a view's centre in display coordinates, whose origin is the
view target (DXF Reference: VIEWPORT 12/22 and 17/27/37, VPORT 12/22 and
17/27/37, VIEW 10/20 and 12/22/32). The readers took the centre alone, so
a file that keeps the location in the target with a centre of 0,0 (as
ArchiCAD's AutoCAD 2018 export does) showed model point 0,0 in every
layout viewport, on screen and when plotted.

The VIEWPORT entity, VPORT tiles and named views now read the target plus
the centre. A sum that overflows keeps the stored centre. The writers
already store the whole location as the centre with a target of 0, so a
save and reopen keeps the view, through DXF and DWG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(io): a viewport looking along another direction centres on its target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:46 -07:00
3146b47656 fix(engine): WBLOCK writes only what the block or selection uses (#2423) (#2621)
* fix(engine): WBLOCK writes only what the block or selection uses (#2423)

WBLOCK of a block, or of selected objects, copied the source drawing's
whole layer, linetype, style and block tables into the new file, so a
5-object symbol carried 40 unused layers and 20 blocks. The written
drawing is now purged of what nothing in it uses, as AutoCAD's WBLOCK
writes it: purge::purge_unused runs PURGE All's scan on a plain Drawing
until a pass removes nothing (a block can free its layer), keeping the
defaults and the current ones. Writing the entire drawing is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(engine): PURGE's scan counts leaders' dimension styles and styles used by other styles

From the review of #2621: PURGE (and now WBLOCK, which runs its scan)
took as unused a dimension style only a LEADER used, a linetype only a
dimension style or a dimension's override named (DIMLTYPE, DIMLTEX1,
DIMLTEX2), and a text style only a complex linetype's text used, and
removed them while the objects still named them. The scan counts all
three now; text_styles_in_use, which STYLE's delete also asks, includes
complex linetypes' text styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(engine): PURGE's scan counts multiline styles' linetypes and table styles' text styles

From the review of #2621: a linetype only a multiline style's element
used, and a text style only a table style's cell style used, were taken
as unused and purged while the styles still named them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:25:41 -07:00
assamaralandClaude Fable 5.1 dd3ffe7ae7 Menus list their items in the reference menu tree's order
menus::tree sorts every menu, at every level, by xtask/data/menu-catalog.txt
(embedded with include_str!, so the parity oracle is the one source): the
items the reference has come first, in its order; the rest keep registration
order. The in-window menu bar, the native macOS menu and ui.menu.list all
follow. Labels are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 17:38:28 -03:00
assamaralandClaude Fable 5.1 e8e76d2e60 Menus: test that every menu follows the reference menu tree's order
Reads xtask/data/menu-catalog.txt (the parity oracle) into a tree and
checks menus::tree against it at every level. Fails on main: 19 menus
and submenus list their items in command registration order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 17:38:25 -03:00
FredApps dae4b7b2f5 Merge pull request #2335 from starise/codex/it-chamfer
fix(it): correct Chamfer terminology
2026-11-02 18:23:14 +01:00
ffishman 319351578e A proxy whose number names no class is left out of the DWG
dxf_to_dwg_as kept a proxy whose class number names no class of the DXF's CLASSES with that
number, which names some other class of the DWG. It is now left out and counted, as a binary
DXF's proxies are, and as CADCraft's own save already does.
2026-11-02 10:28:26 -03:00
ffishman 5d0b723ddb Merge main into fix/dwg-render-settings-class-version
main's #2619 adds Foreign::tree and adopt_tree next to the proxy class helpers.
2026-11-02 10:27:56 -03:00
Hamza 5224f067e0 Carry the saved view onto the first tile only when there is one
My previous commit put `center` and `height` on the first `*ACTIVE` tile
unconditionally. Those two are the extents fallback when the header holds no
saved view, and a tiled drawing can hold none -- `-VPORTS` splits the current
view into tiles without setting one -- so the override replaced the first tile's
own good view with a fit to the extents, and the drawing opened somewhere else
(noted in review).

Guarded on `saved_view().is_some()` now: with a view it is carried, without one
the tiles are written as they stand, which is what they were before.

`a_tiled_drawing_with_no_saved_view_keeps_its_first_tiles_view` asserts the
first tile keeps its centre and height, the second keeps its own, and that the
header read back is the tile's view rather than the writer's extents fit.
2026-11-02 17:38:20 +05:00
ffishman 946af87458 A proxy whose class the file doesn't name is reported as not saved
From 2013 the DWG keeps another program's proxy entity with the class its group 91 names, so the
loss report skipped every ACAD_PROXY_ENTITY. Saving drops a proxy whose group 91 names no class of
the file's CLASSES, though, so that one is now counted. The group 91 helpers move to
cadcraft_doc::foreign so the engine can ask Foreign::proxy_class.
2026-11-02 08:54:58 -03:00
ffishman 36e8c02682 Leave the parity docs to the maintainers
AGENTS.md: PRs name the affected rows and maintainers update docs/file-format-parity.md in batches.
2026-11-02 08:54:11 -03:00
ebc67fa81d Dynamic blocks: stretch the points inside the frame, as AutoCAD does (#2627)
* Dynamic blocks: stretch the points inside the frame, as AutoCAD does

A dynamic block made by a third-party tool came out distorted when a
distance changed: its stretch action has distance multiplier 0 with
angle offset 1, and stored vertex lists that don't match its objects.
apply turned the 0 into 1 but kept the angle (a 57 degree slant), and
moved exactly the listed points.

AutoCAD 2024, on a copy of that drawing, moved the block straight along
the parameter, and every point of its lines and polylines exactly when
it was inside the frame, whatever the lists said. So:

- a zero multiplier moves as 1, without its angle offset;
- a line's or lightweight polyline's moving points are those inside
  the frame where the dynamic block has them; a selected one wholly
  outside the frame moves whole, as in AutoCAD's own stretched copies;
- a frame of fewer than 2 or more than 1,000 points keeps the stored
  lists, so a huge frame can't make every point test every edge.

Against AutoCAD's own anonymous copies in two library drawings (556
references set back to their block's distances), 249 come back exactly
on main and 258 with this change; none stops matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic blocks: a point on a polygon frame's edge is inside it

in_frame counted a point on a two-corner frame's edge as inside, but
the even-odd test of a polygon frame put one on its right edge outside.
A line whose end lay there, with its other end outside, then had no
point inside the frame and moved whole instead of its end alone.

A polygon frame now counts a point on an edge (within 1e-9, relative
to the edge's length) as inside too, as the two-corner frame does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dynamic blocks: frame edge tolerance by the edge's length; tilted polylines keep their lists

A point counted as on a polygon frame's edge within 1e-9 * (1 + the
edge's length), so never less than 1e-9 drawing units: in a tiny frame,
a point visibly outside was taken as inside. The tolerance is now a
billionth of the edge's length.

A lightweight polyline's vertices are in its own plane. The reader
flips a mirrored one (extrusion along -z) into the block's plane, but
leaves a tilted one's in its OCS, which the frame (block coordinates)
can't be tested against. A tilted polyline keeps the stored vertex
list, as before the frame test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* dynblock: only an exactly untilted polyline is tested against the frame; a tilted one keeps its stored list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 05:31:22 -05:00
fa03bf2a29 feat(engine): INSERT of a drawing file keeps an attribute definition's field (#2619)
* feat(engine): INSERT of a drawing file keeps an attribute definition's field

The attribute definitions of a drawing file read by INSERT name=file lost their
extension dictionary, so the ACAD_FIELD dictionary and the FIELD objects of an attribute
whose value is a field (the file name in a title block) were gone from the block.
adopt() now copies that object tree to the drawing under new handles, with the FIELD
class and a field list if the drawing has none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* foreign: index the kept objects once when walking and copying a dictionary tree

A file can keep many objects; a linear lookup per pointer grew with their product.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: andrewbrake <6925387+andrewbrake@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 05:31:18 -05:00
2ez4gcxandClaude Opus 5.5 b006110dc5 OOPS: bring back what -WBLOCK erased; keep the last erase through REFEDIT; the Block Editor (#2625)
* OOPS brings back what -WBLOCK erased

-WBLOCK erases the objects it writes, and OOPS brings back what the
last ERASE or BLOCK removed; the two landed separately, so OOPS never
saw what -WBLOCK erased. -WBLOCK now erases through erase_kept, as ERASE
and BLOCK do.

As AutoCAD 2024 does (accoreconsole, the drawing's entnext order read
back): OOPS after -WBLOCK brings its objects back, each where it was and
with its handle; only the last erase counts, -WBLOCK's or ERASE's; and
-WBLOCK * erases nothing, so OOPS still brings back the erase before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OOPS: keep the last erase through a refused REFEDIT command; Block Editor

During REFEDIT, a command that removes an object outside the working set
is undone (refedit::after_command), but erase_kept had already replaced
what OOPS kept: the objects came back, and the erase before them was
lost. erase_kept now leaves OOPS as it was when an object it removes is
outside the working set. That covers BLOCK, -WBLOCK, ERASE and the
Delete key.

In the Block Editor, its objects keep the handles they have in the block
definition, so in_blocks found them there and OOPS brought back nothing
erased in the editor. in_blocks now skips the block open there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 05:31:14 -05:00
Hamza 04f06a2f87 A tile's rectangle and the view it shows are judged apart
Two from review, both about one `*ACTIVE` record carrying two separate things:
where the tile sits on the screen (10/11) and the view it shows (12/22, 40).

Reading: the two were one `ok`, and the early return came before the view was
read. A first `*ACTIVE` with a usable view and an unusable rectangle therefore
lost the view, and a file whose header carries none opened on the extent fit
instead of where it was saved. They are judged apart now -- `view_ok` decides
the saved view, `tile_ok` decides the tile -- so the rectangle is still refused
while the view it holds is kept.

Writing: the tiled branch emitted the stored tiles as they stand, so updating
`header.set_saved_view` on a drawing with several tiles wrote the new view in
HEADER and the old one in the first `*ACTIVE`. The read takes its view from
that record, so the update did not survive a round trip. The first record now
carries the saved view while keeping its own place on the screen, and the tiles
after it are untouched.

Three tests: a bad rectangle still gives its view (and the rectangle is still
refused), a record with no usable view leaves the header alone, and a view set
on a two-tile drawing comes back from a round trip with both tiles still there,
the first keeping its half of the screen and the second its own view.
2026-11-02 15:25:55 +05:00
霁伪 322c5e9bbf TEXTEDIT: the field keeps its undo history per object, and the shortcut works on macOS
The multiline field is keyed by drawing and handle, so two edits never share
a Ctrl+Z trail, and the field's persisted state is removed with the window -
otherwise the undo history would offer a previous object's text to whoever
is edited next. The commit shortcut now checks Control rather than the
platform Command modifier, matching the Ctrl+Enter the window advertises.
2026-11-02 07:52:05 +08:00
霁伪 44950708ba TEXTEDIT: the context-menu test follows the window entry, several texts keep the command 2026-11-02 07:23:09 +08:00
霁伪 df26fc5197 TEXTEDIT: the window opens from the shortcut menu; the double-click keeps its in-place editing
The double-click on a text object goes back to starting the in-place TEXTEDIT
(#842), which the text_double_click tests guard. The window is what the
shortcut menu's Edit Text entry opens for a single selected text, mtext or
attribute definition; with several objects selected the command line still
runs.

The window records the drawing it was opened for and refuses to commit into
another one, and its state now lives exactly as long as the window does, so a
cancelled edit cannot resurface.
2026-11-02 06:34:15 +08:00
霁伪 9dc39260b8 Merge upstream/main into feat/text-edit-window 2026-11-02 06:13:11 +08:00
2034d6899e Polyface meshes stay one object through DXF and DWG (#2600)
* Polyface meshes stay one object through DXF and DWG

A polyface mesh (POLYLINE flag 64) was read as loose 3D faces. It is now
read, drawn, exploded into its 3D faces and written back as a POLYLINE.
Hidden edges aren't drawn. A mesh too large for the 16-bit groups 71-74
is saved as 3D faces.

Split from #1005.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A polyface face ends at its first zero index

A face record with a gap ([1, 2, 0, 3]) has two corners and is dropped,
not read as a triangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Polyface meshes: write only faces that name their vertices; ID gives their Z

The writer drops the faces the reader would drop, so no record names a
missing vertex, and the 16-bit check only needs the counts. ID and DIST
give a mesh the Z of its nearest visible edge, as they do for 3D faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:40:30 -05:00
e8bcc38e34 OOPS: bring back the last erase, each object where it was (#2605)
There was no OOPS. It now brings back what the last ERASE removed (the
Delete key too), or BLOCK deleting the objects it makes a block of, as
AutoCAD 2024 does (accoreconsole, the drawing's entnext order read
back):

- Each object keeps its handle and goes back directly above the object
  that was under it, or under the one that was above it when that is
  gone. An erase records both neighbours (EntityStore::neighbours).
- Only the last erase counts; entdel doesn't. OOPS says nothing and is
  one undo step.
- An object stays out when it is already in the drawing, when an undo
  took the drawing back to before it was made, when its layer, block or
  layout is gone, and, during REFEDIT, when it isn't in the working set.
  REFCLOSE forgets the working copies; the Block Editor has an OOPS of
  its own.
- The command's label is in the 17 interface catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:40:15 -05:00
152617f4df Plot: a layout's saved page setup is used by default (part of #2493) (#2586)
* Plot: a layout's saved page setup is used by default (#2493)

A layout whose page setup says plot window, scale to fit or a plot scale was plotted 1:1 from
the sheet origin unless the caller passed fit. plot, exportpdf, the PNG device and preview now
read the layout's saved window, scale to fit, scale and centring when the options don't say
(render::paper::saved_plot); explicit options win, and fit: false without a scale is still the
plain 1:1 sheet. Tests: saved_setup_tests in pdf.rs (four), and
preview_uses_the_layouts_saved_page_setup_by_default.

* Parity checklist: regenerated after merging main

* Parity checklist: regenerated after merging main

* Plot: saved page setup in preview and PNG matches the PDF (review on #2586)

Preview honours the saved centre when fitting a window and an explicit scale beats a saved scale to fit (PDF too); PNG places a saved window without fit and clips to the printable area.

* Preview: a saved window clips to the printable area (review on #2586)

The preview drew the placed plot over the margins while the PDF and PNG blank them. The blanking helper now lives in the render crate and both the PNG plot and the preview use it. Test: preview_clips_a_saved_window_to_the_printable_area.

* Preview: pin hostile saved page setups and the default setup (review on #2586)

Degenerate or far-away saved windows and saved scales of 0 or not finite already fall back to the plain plot in the PDF, PNG and preview; tests now pin that, and that a layout with no saved setup plots as before.

* Preview: the printable clip follows the view's fit (review on #2586)

The clip assumed the sheet filled the image; when the height cap applies
View::fit scales and centres it. The printable corners now go through
View::to_screen. Test: preview_clip_follows_the_view_when_the_height_cap_applies.

* docs: leave the generated parity checklist to maintainers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:34 -05:00
ccbc4fa854 DXF: link dimension sources to their associations (fixes #2221) (#2589)
* DXF: link dimension sources to their associations (#2221)

Source objects lacked the persistent reactor back to their DIMASSOC.
Register validated source and intersection references once through the existing
reactor list, keeping a self-link from duplicating the dimension's own reactor.
Synthetic regressions cover missing links, repeated references and self-links.

* Parity checklist: regenerated for dimension reactor gate

* Parity checklist: regenerated after merging main

* DXF: a dimension source keeps its other reactors next to the DIMASSOC (review on #2589)

The DIMASSOC reactor made the writer emit an ACAD_REACTORS group, so the raw
passthrough skipped the source's retained foreign reactors. They are now merged,
deduplicated, into that one group. Test: a line reactor-linked to a kept object keeps
both links after an associative dimension is added.

* DXF: an unmodelled DIMASSOC source gets the DIMASSOC reactor (review on #2589)

Unknown entities are written by the raw passthrough, which never saw the planned
reactors. The map is now passed to dxf_raw::finish and merged into the entity's
reactor group. Test: an unknown entity with a dimension snapped to it names the DIMASSOC.

* Docs: leave the file-format parity document to maintainers (review on #2589)

* DXF: pass the unknown-entity reactors as one argument (review on #2589)

* DXF: collapse the extension dictionary condition (review on #2589)

* Test: pin the reactor merge for dimension sources (review on #2589)

* DXF: a kept extension dictionary follows the reactors group (review on #2589)

When a written record already had its reactors group, the kept extension dictionary was
inserted right after the handle, ahead of it; the DXF reference puts the reactors first.
The reactor tests now find each group by name, so either order can no longer pass.

* docs: leave the generated parity checklist to maintainers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:31 -05:00
f0b46e34dc Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#2603)
* Hit tests don't lay out dashes: moving the mouse over a block stays smooth (#1285)

Every mouse move hit-tests the block references under the cursor, and
hit_polylines built them with a full-detail display list, one primitive per
dash. A fine pattern on long lines in a block inserted at 0.001 made millions
of dashes per move.

Options::linetypes off draws every line whole and continuous, and
hit_polylines uses it for blocks, dimensions and multileaders. A dashed line in
a block is now hit across its gaps, as one outside a block already was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* render: with linetypes off, a lightweight polyline is one continuous stroke

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Steffen <camperking@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:38:27 -05:00
06c5760ea6 Persist QuickCalc history, variables and last answer (#2572)
* Persist QuickCalc history, variables and last answer

Store bounded calculator data in app preferences for desktop and web. Restore finite numeric and point variables, normalize names and ignore malformed entries without resuming transient drawing input.

Covers gaps F22. Validation: 3079 workspace tests passed; wasm, fmt, assets, layers and i18n passed; UI screenshot inspected. Strict CI remains blocked by pre-existing nonminimal_bool lints in DXF and UI code; scoped UI Clippy passes with only that lint disabled.

* quickcalc: apply the variable limit after validation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:24 -05:00
Andrea FicarraandClaude Opus 5.5 2d02a5faa5 DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings (#2574)
* DXF: escape carets in TEXT, ATTDEF, ATTRIB and DIMENSION strings

AutoCAD reads `^x` in a DXF string as a control character, so a TEXT of
`10^2` opened in AutoCAD showed a control character instead of the caret.
The DXF Reference writes a caret as `^` and a space: the writer now does
for the text (group 1) of TEXT, ATTRIB, ATTDEF and DIMENSION and an
ATTDEF's prompt (group 3), and the reader turns it back. A bare `^x` from
older files stays as it is; single-line strings keep `^J` literal (they
hold no line feeds). AutoLISP's entget shows the plain caret, and a
DIMENSION's style name (also group 3) is left as written.

Tests: crates/io/tests/it/text_carets_roundtrip.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* entget gives TEXT strings as the file has them, like MTEXT

Decoding the carets in entity_groups broke entmod (it reads the groups
as a file, so `A^ B` came back as `A^B`) and reached into an ATTDEF's
embedded MTEXT. entity_groups is left as on main: it gives the file's
groups, as it already does for MTEXT, and entmod reads them back
unchanged (test entmod_of_entget_keeps_the_strings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* entget unescapes carets, entmake and entmod escape them

e61b4dd left entget with the file's escapes, but entmake still read a
plain `p^ q` as `p^q`. Now one rule, map_carets, picks the groups that
hold an escaped string (the text, 1, and an ATTDEF's prompt, 3, up to an
embedded MTEXT at group 101): entity_groups unescapes them and
entity_from_groups escapes them before reading, so entget is plain,
entmake takes plain strings and entmod of entget changes nothing.

Tests: entget_shows_plain_carets_and_entmake_takes_them,
entmod_leaves_the_embedded_text_alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave the parity docs to the maintainers

CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in
the PR description instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Single-line strings read like MTEXT: a tab from AutoCAD survives

AutoCAD writes a tab in a TEXT as `^I`. The reader left it as two
characters and the writer then saved `^ I`, so the tab was lost. Single-line
strings are now read with decode_contents, as MTEXT is (`^ `, `^I`, `^J`;
other bare carets stay), and encode_line writes a caret as `^ ` and a tab
as `^I`. unescape_carets is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename map_carets to map_text_groups (it encodes tabs too)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Single-line strings keep a bare ^J; entity_from_groups doc

A TEXT holding `A^JB` (from a file saved before this change, which wrote
carets bare) was read as a line feed and saved as `A B`. Single-line
strings now decode only `^ ` and `^I` (decode_line, decode_contents
without line feeds), so `^J` stays two characters. entity_from_groups
documents that it takes plain strings, as entity_groups gives them.
The caret test also goes through a DWG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test a caret before a space and at the end of a TEXT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:21 -05:00
Andrea FicarraandClaude Opus 5.5 5cc342b033 DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF) (#2575)
* DXF/DWG: viewport frozen layers as group 331 (341 in an R2000 DXF)

AutoCAD 2004 and later, and the DWG converter, read a VIEWPORT's frozen
layers from group 331; CADCraft wrote only group 341 (the DXF 2000 form),
so a DWG or 2004+ DXF lost the freeze outside CADCraft, and a file frozen
only in group 331 (as AutoCAD 2026 and ezdxf write it) lost it on the
first save. Now: group 341 in an R2000 DXF, 331 otherwise and for the DWG
bridge; both are read, each layer once. CADCraft's own xdata still wins.

Tests in crates/io/src/tests.rs, including
a_331_only_viewport_freeze_survives_open_and_save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave the parity docs to the maintainers

CLAUDE.md: PRs don't edit the parity docs; the affected rows are named in
the PR description instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport frozen layers: group 331 wins over stale xdata

AutoCAD now sees the freeze in group 331, edits it, and keeps CADCraft's
VPFROZEN xdata as it was. Reading preferred the xdata, so a layer thawed
in AutoCAD came back frozen. The handles now win; the xdata still gives
names that have no LAYER record, and the whole list for records without
handles (entget/entmod, older files). The handles are written before
group 90, as in the DXF Reference.

Test: viewport_frozen_331_wins_over_the_xdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport frozen layers: handles for real layers, xdata only for the rest

Simpler and safe against edits in AutoCAD. A layer with a LAYER record is
written only as a handle (331, or 341 in an R2000 DXF); CADCraft's
VPFROZEN xdata now holds only the names with no record (and the whole
list for entget, which writes no handles). Reading takes the handles,
then the xdata names, each layer once: no precedence, and a viewport
thawed in AutoCAD (even its last frozen layer) stays thawed, since no
stale list is left behind. The reader no longer builds a layer-name set
per viewport.

Tests: viewport_frozen_layers_read_from_331_or_341_once_each,
viewport_freeze_edited_in_autocad_is_read_as_edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport frozen layers: skip 102 application groups

A group 331 inside another application's `102 {…}` group isn't a frozen
layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:17 -05:00
SlopapaloozaandClaude Opus 5.5 7e386a2cc4 Review fix: test that an edit only outside the working set changes nothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:15 -05:00
2ez4gcxandClaude Opus 5.5 fb05c78590 Read bearings and compass points as AutoCAD does (#2590)
Measured in AutoCAD 2024 (accoreconsole):
- N, E, S and W are taken on their own in any angle format (LINE to
  @10<N goes north at AUNITS 0, 1 and 4).
- A bearing or a compass point is an angle like a number, measured from
  ANGBASE in the ANGDIR sense: with ANGBASE 90 and ANGDIR 1, @10<N45dE
  draws at 45 degrees and @10<N east; (angtof "N") is 0 there.
- <<< takes them from east counterclockwise; << (degrees) refuses them.

main took the letters in surveyor's units only, and read them and
bearings as absolute directions. One cardinal() now serves every angle
format, and the test that pinned the surveyor-only reading expects the
measured values. -UNITS uses the shared parser.

Also write the -UNITS tests' scripts with \n escapes again; they had
become multi-line strings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:14 -05:00
2ez4gcxandClaude Opus 5.5 5e9c478bfb U inside a LISP routine takes back the routine's last command (#2610)
Inside a routine, commands recorded no undo step (the routine becomes
one when it ends), so U inside a routine took back the step from before
the routine: after drawing a line D, ERASE B then U in one routine
brought B back and removed D.

As AutoCAD 2024 does (accoreconsole, the drawing read back after one
routine), U inside a routine now takes back the routine's last command,
with what the routine did after it (entdel, entmake); U again, UNDO n
and REDO go on through its commands. With none left, U takes back the
drawing's step before the routine, as before, and the routine's step
then starts from there. Once the routine ends it is still one undo step.

The routine's steps live in the session's LISP state (RoutineSteps), not
in the drawing's undo stack, so a long routine doesn't push the drawing's
earlier steps out. A command inside the routine ends what REDO could
bring back; UNDO Control One and None apply. DocState::swap_in is the
one restore step undo, redo and the routine share.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:10 -05:00
SoldierPePe e187c5fecb i18n(ar): translate 129 new strings, use the catalog's terms in 3 (#2564)
The Arabic catalog showed 129 interface strings in English: the Block Editor and REFEDIT, dynamic blocks, multileader editing, hatch islands, origin and boundaries, selection and grip settings, visual effects, command aliases, backup and units options, and the MTEXT ruler. `cargo xtask i18n` now reports `ar: nothing missing`.

Hatch, Leader and Multileader now use the terms the rest of the catalog uses (تهشير, خط الإشارة) instead of تظليل and قائد.
2026-11-01 14:38:07 -05:00
Andrea FicarraandClaude Opus 5.5 c72c0eebfb SAVEAS portableFonts: Windows fonts for Linux text style fonts (#2576)
* SAVEAS portableFonts: Windows fonts for Linux text style fonts

A text style using a Linux TrueType font (Liberation, DejaVu, Open Sans)
shows in AutoCAD on Windows with a substitute it picks. A save now names
those styles on the command line (and as `nonPortableFonts` in the
result), and SAVEAS {"portableFonts": true} writes the Windows equivalent
in the file (Liberation -> Arial, Times New Roman, Courier New; DejaVu
Sans -> Verdana; Open Sans -> Arial) while the drawing keeps its own
fonts. Narrow and condensed families have no Windows stand-in (Arial
Narrow comes with Office) and are kept and named. QSAVE takes no SAVEAS
options.

Tests: stand_in_tests in cmd/file.rs, tests/save_portable_fonts.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts kept in the file, applied by the writer

Reworked after review. SAVEAS `portableFonts` writes a copy of the drawing
with `Drawing::portable_fonts` set, and the DXF/DWG writer turns each
style's Linux font into its Windows font, keeping the style's own font in
the CADCraft FONT xdata already used for the built-in font. Reading the
file gives the Linux fonts back (unless the font was changed since, in
AutoCAD); the drawing itself is never changed.

It applies to that save: a later QSAVE writes the drawing's fonts and
says so, naming the styles and SAVEAS portableFonts. The family table
moves to `cadcraft_fonts::windows`, used by the writer and the note.
Styles from an xref aren't saved and aren't named. The parity docs are
left to the maintainers (CLAUDE.md).

Tests: crates/io/tests/it/portable_fonts.rs (DXF, DWG, a font changed in
AutoCAD), crates/engine/tests/save_portable_fonts.rs, windows.rs tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts: match the Windows font by file name

A STYLE whose Windows font carries a path (C:\Windows\Fonts\…) still
gets its own font back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Portable fonts: say the font list again only when it changes

A save still returns nonPortableFonts, but the command line note isn't
repeated on every QSAVE of the same drawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:38:04 -05:00
NateMarida 4a9371ad2c Thai text: stacked marks, Thai fonts first, MTEXT word wrap (#2591)
* Thai text: stacked marks, Thai fonts first, MTEXT word wrap, Windows fonts under WSL

Thai text in DWG/DXF files decoded correctly but laid out badly. Vowels and tone marks
stacked on one consonant collided (ดิ์), because only right-to-left text went through the
shaper.

- Thai lines go through the harfrust shaper (complex::needed). Shaped left-to-right glyphs
  keep their characters (TextGlyph) for PDF text.
- Thai fallback fonts: Cordia, Angsana and Browallia first (Thai drawings are laid out with
  their widths), then IBM Plex Sans Thai, Noto Sans/Serif Thai and the fonts each OS ships.
- The web build embeds the craft-fonts Thai regular faces.
- MTEXT wraps a run of Thai between words when the run is too wide for the column by itself.
  Text that fits at its spaces keeps those line breaks, so existing drawings lay out as
  before. Word breaks come from icu_segmenter's LSTM model (about 410 KB). ICU's dictionary
  segmenter was not used: after loanwords it breaks inside words, even between a consonant and
  its tone mark. Breaks inside a character cluster are dropped, and results are cached.
- Under WSL the font scan also reads /mnt/c/Windows/Fonts, at the lowest priority, so drawings
  find the Windows fonts they name (Cordia New). The scan reads entry types from the
  directory listing instead of a stat per file.

* Thai fallback: match older Angsana and Browallia file names; document touched functions

Today's Windows ships Angsana New and Browallia New as angsana.ttc and browalia.ttc. Older
Windows used single faces (angsa.ttf, angsab.ttf, browa.ttf, ...), which still turn up when
fonts are copied to Linux machines. The Thai fallback list now matches the shorter stems
`angsa` and `browa`, which cover both. Review feedback on #2591.

Doc comments for scan, plain_lines and the paragraph layout's word_width and place.

* MTEXT: wrap a Thai run after a first-line indent or a tab

A Thai run that fit a line at the left indent, but not the room left after a first-line indent
or a tab stop, wasn't split between its words: the first line of a paragraph can't wrap, so it
ran out of the column. An empty line now measures the run from where it starts. Review feedback
on #2591.

Also restores the space in `image = {` in Cargo.toml.

* Font scan: take the WSL font folder out of this change

Thai text doesn't depend on it: with no Windows or system fonts, the craft-fonts Thai faces
(Noto Sans Thai, IBM Plex Sans Thai) stack marks and wrap MTEXT the same way. Reading the
Windows host's /mnt/c/Windows/Fonts under WSL is a separate decision, so the scan is back to
main's: Linux system and user font folders only.
2026-11-01 14:38:00 -05:00
2ez4gcxandClaude Opus 5.5 0e2bff3b2b LEADER at the command line, as AutoCAD runs it (#2596)
LEADER had only its JSON form, so a script's LEADER drew nothing. This
adds the command-line command with AutoCAD 2024's prompts: the points,
Format (Spline/STraight/Arrow/None) and Undo, then annotation lines, or
the options None, Block, Copy, and Mtext and Tolerance (which come back
to the first line at the command line, as in accoreconsole).

Measured, and followed:
- A hook line, a horizontal last segment DIMASZ long, is added when the
  last segment is straight and more than 15 degrees off horizontal and
  text follows: 14.9 and 15 degrees keep the end, 15.1 hooks. A spline,
  no annotation and a block take none.
- The text sits DIMGAP past the end on the side the leader runs to:
  above it, bottom-attached, with DIMTAD on; centred on it with DIMTAD
  0. Sizes are DIMSCALE times the style's, with SETVAR overrides.
- A copied text keeps its contents, height and width, is centred on its
  line, and with DIMTAD on the leader ends half the text height lower.

The JSON form now draws through the same code. The drawing model has no
link from a leader to its annotation, so the two are separate objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:56 -05:00
2ez4gcxandClaude Opus 5.5 210cfc2a1d -WBLOCK at the command line, as AutoCAD runs it (#2594)
* -WBLOCK at the command line, as AutoCAD runs it

WBLOCK had only its JSON form, so a script's -WBLOCK did nothing: the
objects it should write and erase stayed in the drawing. This adds the
command-line command, with AutoCAD 2024's prompts:

- "Current settings: Object conversion=Delete", then the output file. A
  name without .dwg or .dxf gets .dwg. A DXF file asks for its decimal
  places first. A file that exists asks "... already exists, do you want
  to replace it? [Yes/No] <N>"; No ends the command.
- An existing block by name, "=" (the block named as the file), "*" (the
  whole drawing), or Enter for objects. A name that isn't a block is
  answered Block "NAME" not found. and asked again.
- For objects: the base point (with mOde), then the objects, which are
  written and erased. AutoCAD erases them whatever mOde says, and keeps
  Delete as the setting. Nothing selected writes nothing.

Its JSON form, -wblock, erases what it writes. WBLOCK's JSON form moves
into the new module unchanged, sharing the writer. The web build refuses
-WBLOCK rather than erase objects it can't save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -WBLOCK's JSON form: nothing to write writes no file

With no handles, an empty selection or only handles of objects that
aren't there, the JSON form wrote an empty drawing. It now refuses with
"no objects selected" before writing or erasing anything, as the command
line writes nothing when nothing is selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -WBLOCK: only Yes replaces an existing file

At "... already exists, do you want to replace it? [Yes/No] <N>", any
text starting with "y" counted as Yes, so "yodel" replaced the file, and
any other text ended the command. Now only the Yes keyword replaces it,
No and Enter end the command, and anything else gets "Invalid option
keyword." and the prompt again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WBLOCK and -WBLOCK write their file through a temporary file

Replacing an existing file truncated it first, so a write that failed
part way lost it. The file now goes through atomic_write::write_atomic,
as SAVE, EXPORT and autosave write theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:52 -05:00
2ez4gcxandClaude Opus 5.5 520394858c -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts (#2597)
* -ATTDEF at the command line: attribute modes, AFLAGS, and the text's prompts

-ATTDEF asked for the tag first. A script's mode answer ("_V", "_C",
"_P") became the tag; the attribute kept no mode; AFLAGS never changed.
The definition was made at the start point, so the height and rotation a
script gives next ran as commands. It now asks as AutoCAD 2024 does:

- "Current attribute modes:" from AFLAGS, then "Enter an option to
  change [Invisible/Constant/Verify/Preset/Lock position/Annotative/
  Multiple lines] <done>", each answer toggling a mode, kept in AFLAGS.
- The tag (spaces taken, upper-cased; an empty one gets "Tag cannot be
  null"), the prompt and default value, or a constant attribute's value.
- "Current text style: ...", the point with Justify and Style, the
  height and the rotation.

The definition takes Invisible, Constant, Verify, Preset and Lock
position from the modes; a constant attribute stays in its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -ATTDEF: a typed height is a distance, as TEXT takes it

The height was read with str::parse, so 1'6" (or any distance TEXT
takes) was refused as "Requires a positive height." It now goes through
the shared distance parser, `number`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -ATTDEF: a wrong answer at Justify is asked again

A wrong answer at "Enter an option [Left/Center/...]", or Align or Fit
(not available here), went on to the point prompt with the justification
unchanged, so a script's next answers landed one prompt off. Only a
justification goes on to its point now; anything else is asked again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* -ATTDEF test: the modes start from AFLAGS, not from the default

The other cases start from AFLAGS 16, which is also the default, so
reading the modes from the drawing went untested. This starts from 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:37:49 -05:00
Andrea Brandi 020d81a27e chore: merge main into Italian terminology fix
Keep all contributor entries and align progress docs with main.
2026-11-01 20:18:39 +01:00
SlopapaloozaandClaude Opus 5.5 8b91646795 Review fix: CHPROP says what it left out; the working set is read once
Interactive CHPROP threw away properties.set's message, so an edit to an
object it had just moved onto a locked layer was left out without a word.
It now shows the message.

properties.set looked each object up in REFEDIT's working set, a list,
twice; it now builds the set once and checks the layer directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:10:07 -05:00
SlopapaloozaandClaude Opus 5.5 e82dede24a Review fix: properties.set counts each object once and refuses missing ones first
`targets` keeps a handle listed twice, so it was counted twice as left
out. During REFEDIT, a handle that names no object counted as outside the
working set instead of being refused. properties.set now takes each
object once, and refuses a handle that names no object before anything
changes; before, the objects ahead of it in the list had already been
changed when the error came.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:48:18 -05:00
SlopapaloozaandClaude Opus 5.5 c311ec6b77 Review fix: CHPROP's parameter text says what properties.set now returns
CHPROP's JSON form is properties.set, so its description now names the
REFEDIT working set too and lists `message` in the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:38:01 -05:00
ffishman 02cc5c8853 Merge main into fix/dwg-render-settings-class-version
main now gates the DWG bridge on cfg(no_dwg_bridge) (FreeBSD aarch64 has no acadrust); dxf_to_dwg_kept_as and its tests follow it.
2026-11-01 15:22:27 -03:00
ffishman 582758adc1 Merge main into fix/page-setup-media-orientation
main's conformance matrix (#2237) compares page setups across saves; file_paper is the file's own groups 44/45/73, so it compares the setting without it, as tables_objects_roundtrip already does.
2026-11-01 15:21:45 -03:00
SlopapaloozaandClaude Opus 5.5 5787b3634f Review fix: properties.set checks every value before leaving all objects out
With every object on a locked layer, properties.set returned before it
checked the linetype, the dimension style and the dimension overrides, so
it accepted bad values there. They're now checked first, reading the
drawing without writing to it; the drawing is taken for writing, and a new
layer made, only when there are objects to change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:06:16 -05:00
SlopapaloozaandClaude Opus 5.5 0dcdf8d7a9 properties.set leaves objects on locked layers out and says how many
The Properties palette, Quick Properties and CHPROP's JSON form all edit
through properties.set, which changed objects on locked layers. It now
leaves them out and changes the rest, as CHPROP, MATCHPROP, MOVE and
CUTCLIP already do. During REFEDIT, objects outside the working set are
left out too, instead of the whole edit being refused.

The result gains `locked`, and a `message`, which app.run shows at the
command line, says how many were left out. When every object is left out,
the drawing doesn't change and no undo step is added.

CHPROP's JSON form was properties.set on the unlocked objects; it is now
properties.set itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:02:37 -05:00
霁伪 6b707c89e4 TEXTEDIT: edit text in a window, from a double-click
TEXTEDIT took over the command line: the caret left the drawing, the prompt asked for a point, and the text was edited where you could no longer see it. The shortcut menu offered Edit Text on text, mtext and attribute definitions, and that is exactly where a user expects to type.

A double-click on one of those objects now opens a small window holding the object's text, its type and its handle, with OK and Cancel. Ctrl+Enter applies. Text that has not changed sends no command, so the undo stack stays clean.

The TEXTEDIT command itself is untouched: scripts, LISP and the command line still drive it as before, and the window is a second surface on the same edit.
2026-11-01 23:14:16 +08:00
Aldo ZarateandClaude Opus 5.5 9735b23227 Tilted planes: decide by how far points would move; spread arc widths
An object counts as tilted when drawing its OCS points as plan
coordinates would put some of them off their place seen from above by
more than a billionth of its size and distance from the origin, sampled
along its arcs too. A slight tilt far from the origin is now projected,
and noise in a plan normal still leaves the object (and its arcs) alone.

A flattened tilted arc spreads its start and end widths evenly over its
segments, as a tapered segment does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 06:41:10 -06:00
SBaksa b54adc5976 OLE2FRAME: MOCORO checks before it changes anything; a locked frame doesn't block in-place edits (review on #2587)
MOCORO refuses a rotate or scale step on an OLE frame up front, before an earlier move or copy
is applied. refuse_ole_frames ignores a frame on a locked layer for in-place edits, as
edit_entities does. Tests: mocoro_refuses_before_it_changes_anything,
a_locked_ole_frame_does_not_block_an_in_place_rotation.
2026-11-01 23:32:59 +11:00
SBaksa c9ffc3e394 OLE2FRAME: a frame without usable corners counts as not drawn in an image export (review on #2587)
The renderer draws nothing for it, so it is no longer reported as shown as the frame only.
Test: an_ole_frame_without_corners_is_counted_as_not_drawn.
2026-11-01 23:32:58 +11:00
SBaksa 7fdda542d3 OLE2FRAME: MOVE rewrites the corners in the OLE data only when they are the frame's corners (review on #2587)
The data layout is only known when its corners match the frame's groups; any other
header is left byte-identical while the tags still move. Test: move_leaves_ole_data_that_is_not_the_frames_corners_alone.
2026-11-01 23:32:58 +11:00
Aldo ZarateandClaude Opus 5.5 90806a7476 Tilted LWPOLYLINE: flatten each arc within a tolerance from its own size
A long edge elsewhere in the run made the tolerance larger than a small
arc, which then flattened to its chord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 06:25:21 -06:00
SBaksa 64644ebc1c Merge remote-tracking branch 'origin/main' into plot-device-from-extension 2026-11-01 22:28:12 +11:00
SBaksa 0c486e337a Engine: refuse rotating, scaling or mirroring an OLE frame on main's transform paths (#2587)
Main moved the edit loop into edit_entities and gave SCALE its own scale_entities, so the
merged check no longer compiled (CI: m not in scope). The refusal is now one helper that
transform_entities and scale_entities call before anything changes.
2026-11-01 22:25:36 +11:00
SBaksa d6555e5bdf Plot: an unknown extension needs a real format selector (review on #2585)
device None and a null or unknown output no longer bypass the refusal
and write a PDF under the odd name; only a recognised output or a
device that resolves does. Test: plot_path_extension_picks_the_format_like_export.
2026-11-01 22:20:10 +11:00
SBaksa 0e871873e7 Plot: refuse layout and layouts that name different sheets (review on #2585)
layout picked the rendered space while layouts filled the reply, so a
request could render Model and report Layout1. Both selectors must now
agree, for every output. Test: plot_goes_through_the_hook.
2026-11-01 22:20:09 +11:00
SBaksa 63bc538113 Parity checklist: regenerated after merging main 2026-11-01 22:09:49 +11:00
SBaksa 1c66fa2338 Merge remote-tracking branch 'origin/main' into ole-frame 2026-11-01 22:02:19 +11:00
SBaksa afb24ee6e5 OLE2FRAME: MOVE also moves the corners inside the OLE data (review on #2587)
The OLE data (310 groups) starts with the frame's four corners. MOVE shifted groups 10/20/11/21 only, so
a DXF save wrote new corner groups beside a data header with the old position. The transform now
rewrites those doubles (x, y; z kept) in the decoded hex, keeping each chunk's length; malformed or
short data is left untouched.
2026-11-01 21:08:47 +11:00
SBaksa c1b88a6cbc OLE2FRAME: an image export notes that the OLE content is not drawn (review on #2587) 2026-11-01 19:21:22 +11:00
SBaksa 09b5fb9328 Plot: refuse a path whose extension names no format (review on #2585)
A path like x.txt (or one with no extension) wrote a PDF under that name. Like EXPORT, PLOT now refuses it unless output or a device says what to write. Test: plot_path_extension_picks_the_format_like_export.
2026-11-01 19:13:53 +11:00
SBaksa 87f3c087b2 OLE2FRAME: corners skip application groups; MOVE/COPY carry the frame, rotate/scale/mirror refuse it (review on #2587)
ole_frame read 10/20 and 11/21 from inside 102 groups; it now skips them as the DXF reader does.
A window-selected OLE2FRAME was left in place by MOVE/COPY. A DWG save writes the position from
the corner groups (checked by write + read-back), so MOVE/COPY now shift them; transforms that
would not keep it axis aligned return an error before anything changes.
2026-11-01 18:12:57 +11:00
SBaksa 12ce66889e Plot: a single layouts entry picks the sheet for PNG and SVG (review on #2585)
run_plot rendered the session space for image output while the reply named the layout. The one-entry list now resolves to the render space, as the PDF path does. Test: plot_goes_through_the_hook.
2026-11-01 18:06:41 +11:00
SBaksa 8dc927e01c Format: rustfmt after the clippy fix (review on #2587) 2026-11-01 16:57:17 +11:00
SBaksa b51318003b Parity checklist: regenerated after merging main 2026-11-01 16:21:24 +11:00
Aldo ZarateandClaude Opus 5.5 a49e4ff941 Tilted LWPOLYLINE: straight edges keep their widths next to flattened arcs
Only bulged edges are flattened now, each on its own; the vertices that
start straight edges keep their widths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:45:35 -06:00
SBaksa ad609fab02 OLE2FRAME: the frame counts in the drawing extents; CLI test uses a self-cleaning dir (review on #2587)
bounds_in now takes the frame from its corner tags (shared ole_frame in the doc crate), so zoom extents shows a frame outside other geometry. The undrawn_entities tests clean up on drop.
2026-11-01 15:38:48 +11:00
Aldo ZarateandClaude Opus 5.5 756946c4d6 Tilted LWPOLYLINE keeps its widths; a tilted hatch's gradient turns too
Straight runs carry each vertex's widths onto its projected vertex
(flattened arcs still have none). The gradient direction is projected
like the pattern's. A pattern keeps the spacing and dashes of its plane,
noted in the parity doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:29:03 -06:00
SBaksa c22700d3af Plot: refuse several layouts for PNG and SVG output (review on #2585)
An image holds one sheet, so layouts with more than one entry now report a clear error instead of pages:N with one render.
2026-11-01 15:27:09 +11:00
midasmatterhorn 9c84141e38 style: rustfmt the write_atomic map_err in render 2026-11-01 04:56:59 +03:00
Aldo ZarateandClaude Opus 5.5 d19df49fcf HATCH and LWPOLYLINE in a tilted plane are read as seen from above (#1886)
Their boundary and vertices are in the object's plane (OCS). Only a
mirrored plane (0,0,-1) was handled, by flipping x; a tilted plane whose
normal points down was taken for a mirror too, and one pointing up kept
its plane coordinates, so each face landed turned about the origin by
its own angle. A tilted one is now mapped to the WCS and drawn as seen
from above at its mean height: bulged edges are flattened (they are
elliptical there), and a hatch's pattern direction and origin follow.
Its extrusion becomes Z, since its points are plan coordinates now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:01:28 -06:00
SBaksa 26c24c5bb0 Parity checklist: regenerated after merging main 2026-11-01 11:08:06 +11:00
SBaksa 62e70432e7 Plot: choose the output format from the path extension (#2556)
plot ignored the extension of `path` and wrote PDF unless a device said otherwise.
Without a device, png/svg/pdf is now taken from the extension (any case), like export;
a device that writes another format is refused, and the reply carries `format`.
Tests plot_format.rs check magic bytes, the mismatch error and the reply format.
2026-11-01 11:05:33 +11:00
vangel76andClaude Sonnet 5.5 1a0ef86aeb Merge upstream/main into fix/dxf-hostile-input-hardening
Resolve the dxf ascii() conflict: keep upstream's byte output and
push_string, and the branch's hex chunking so a hostile value can't
inject tags.

The binary reader keeps upstream's policy: pre-R2007 binary strings
are read from the code page, because a binary DXF cannot escape to
UTF-8; the ASCII reader keeps this branch's per-line UTF-8 rule.
The two branch tests that assumed UTF-8 in binary strings are
updated to the code-page reading.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 11:25:10 +01:00
2ez4gcxandClaude Opus 5.5 156d117dd8 fix(modify): BREAK and TRIM keep the object's handle, as AutoCAD does
BREAK, BREAKATPOINT and TRIM erased the object and added every piece as
a new object. AutoCAD 2024 changes the object itself: the piece holding
its start (or the only piece left: a circle becomes an arc, a closed
polyline is opened) keeps its handle, drawing-order place, groups and
extended data; the other pieces are new objects added at the end.

Associative dimensions follow the measured rule: an attachment still at a
point of the object stays, one on a new piece moves to it, any other
follows the object's new end. Constraints on the object are removed, as
before. New pieces go to the object's own space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:34:08 +07:00
SBaksa a8d05d6894 CLI: an OLE2FRAME is not reported as undrawn by an image export (#2422)
Found end to end: convert to PNG drew the frame outline but still said '1 entity
not drawn (OLE2FRAME: 1)'.
2026-10-31 15:14:32 +11:00
SBaksa 74ddfb377f Test: a DWG keeps an OLE2FRAME's data and frame corners (#2422)
The DWG object has no corner fields; the position travels in the start of the OLE
data, which the bridge keeps. The test builds that header with known corners and
checks them at 2000, 2010 and 2018.
2026-10-31 14:49:13 +11:00
SBaksa ada99e613d Parity checklist: regenerated after merging main 2026-10-31 14:31:00 +11:00
SBaksa 4caf26ee60 OLE2FRAME: draw its frame outline; a DWG save no longer lists it as left out (#2422)
The frame is the rectangle from the entity's two corner points (groups 10/20 and
11/21). A DWG write and read-back keeps the OLE data (ole_frame.rs), so the DWG
note leaves the type out of notSaved.
2026-10-31 14:27:48 +11:00
Davit MatchakhelidzeandClaude Opus 5.5 5f6dbede5c docs: Georgian row count after the block-editor prompt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-31 06:00:43 +04:00
SBaksa 57e6a89d10 Parity checklist: regenerated after merging main 2026-10-31 12:31:34 +11:00
SBaksa 282ad11e60 Save note: a DWG loses SAT-text solids from 2013 on, and the note says so (#2334)
A real DWG write and read-back keeps a SAT-text REGION's groups up to 2010 and
returns it without them at 2013 and 2018, so the SAT-text skip in not_saved_as
applies before 2013 only. The DWG note now says solids are kept in a DXF only
when saved as 2010 or earlier.
2026-10-31 12:28:21 +11:00
Davit MatchakhelidzeandClaude Opus 5.5 651615e5c2 docs: Greek and Bosnian in the language lists; Georgian on the web comes with #1971
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-31 05:00:18 +04:00
SBaksa 768d25bb7d Merge origin/main into dxf2000-region 2026-10-31 11:57:51 +11:00
Davit MatchakhelidzeandClaude Opus 5.5 66f65b763e docs: list bs beside ka in the supported interface languages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-31 04:38:42 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 126a734b5c Merge origin/main into ka-language
Keeps Bosnian and Georgian side by side; adds the new block-editor save prompt to ka.tsv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-31 02:52:48 +04:00
Davit Matchakhelidze 6135745a1a Merge upstream main into fonts-georgian 2026-10-31 02:44:27 +04:00
vangel76andClaude Haiku 5.5 c62c302b01 Merge upstream/main into fix/dxf-hostile-input-hardening
Resolve crates/dxf/src/lib.rs: keep upstream's blank-line-aware group-code
count (#2408) using a byte-level line split that also honours lone CR, and
keep the branch's ascii_lines reader.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-30 23:35:40 +01:00
9d4b3bb091 Raster images are drawn on screen and in PNG output (#1756)
An IMAGE whose file exists was drawn as a frame with two diagonals. Now
its pixels are loaded when a drawing opens and drawn on screen and in PNG
output, with rotation, clip boundary, fade and object transparency. A file
that can't be read keeps the crossed-out frame and reports a message.

Ported from the raster commit of #1029 onto the split render and canvas
modules. New here: large images are drawn from a pyramid of levels cut
into tiles uploaded as they come into view, instead of one texture with
pixels skipped, so a large scan stays sharp zoomed in and doesn't shimmer
zoomed out.

Limits are listed in docs/gaps.md (FF10): PDF and SVG output write only
the frame, and on screen images lie under all other objects.

Co-Authored-By: Chanthawat Kiriyadee <enwuft@icloud.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:05:02 +01:00
ffishman 6ae7c156b8 fix(io): take the kept groups' version from the file's own HEADER
A new drawing starts with $ACADVER AC1032, and the reader applies the file's
header over it, so a file without $ACADVER left AC1032 in Foreign::version and
the bridge read its kept groups with 2018 rules. The version now comes from the
file's HEADER section, and is None when the file has no $ACADVER (the bridge
then reads the kept groups as R2000, as before). Reported by CodeRabbit on #2471.
2026-10-30 10:23:06 -03:00
ffishman cef9ee4b10 fix(dwg): read kept objects as the file's version on every save, not just the first
The bridge took the version of the kept groups from $ACADVER, but SAVEAS writes
the saved version there and the kept groups stay as they were read. After a 2018
drawing was saved as a 2004 DWG, the next save read its mental ray render
settings as 2004 (class version 2, which the ODA File Converter rejects) and a
2004 field lost its cached value. Foreign now records the $ACADVER of the file
the kept data was read from (Foreign::version), and the DWG save passes that.

The new declare_version test also moves out of the doc comment it was pasted
into. Both reported by austin62jake on #2471.
2026-10-30 09:42:40 -03:00
ffishman 1a322faa0b fix(dwg): scan the bridge header in one pass; leave out proxies of a binary DXF
header_value kept an offset for every line up to the first ENDSEC: an input
without one cost about 24 bytes per line (0.8 GB for 32 MiB of short lines,
some 25 GB at the 1 GiB input limit). It now reads (group code, value) pairs in
one pass and stops at 0/ENDSEC, so memory no longer grows with the input
(36 MB for the same 32 MiB, the input itself).

dxf_class_names doesn't read a binary DXF's CLASSES, so its proxies kept their
DXF numbers, which name other classes in the DWG. CADCraft's own bridge is always
ASCII; for a binary DXF given to the conversion functions, the proxies are now
left out, as a pre-2013 DWG leaves them, instead of naming the wrong classes.

Both reported by CodeRabbit on #2471.
2026-10-30 09:08:20 -03:00
ffishman c52dd70824 Merge upstream main into fix/dwg-render-settings-class-version
crates/dwg/src/lib.rs: main's current_layer fix-up and guarded() wrapper around the
DXF to DWG fix-ups keep this branch's convert(kept) and run proxy_classes first
inside the guard.
2026-10-30 08:54:03 -03:00
assamaralandClaude Sonnet 5.5 77e26cc1ac Merge upstream/main into fix/dxf-viewport-id-unknown
Only conflict: contributors/people.toml (both sides appended entries; kept both).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:40:02 -03:00
SBaksa 7baa970381 Save note: a data store record stores its first entity; a DWG keeps SAT-text solids (#2334)
The writer keeps an ACDSRECORD by its first 320 only, so data_store_entities now
credits that handle alone (one shared rule, data_store_owner). The DWG writer
converts a solid's SAT text itself at any version, so not_saved_as no longer
lists solids that hold SAT text as left out of a DWG.
2026-10-30 19:18:41 +11:00
SBaksa 590b97a321 Save note: QSAVE reports by the version it writes, not a version it ignores (#2334)
QSAVE takes no version and writes the drawing's own, but the SAT-text note read a `version`
passed to it, so `qsave {version: 2010}` on a 2018 drawing skipped the note while still
leaving the solids out. Only SAVEAS's requested version now picks the reporting branch.
Test: saving_sat_text_solids_as_dxf_2013_reports_them (qsave case fails without the fix).
2026-10-30 18:42:24 +11:00
SBaksa 86d010eaac DXF: SAT text is groups 1 and 3 of the modeler subclass; only ASM_Data records keep geometry (#2334)
sat_text_acis now looks for groups 1 and 3 inside AcDbModelerGeometry only (all
groups when a file has no subclass markers), and the data store counts an
entity as stored only when its ACDSRECORD holds ASM_Data. The writer and the
reader share one record splitter (data_store_records).
2026-10-30 14:59:16 +11:00
SBaksa fc8270d8c5 Parity checklist: regenerated after merging main 2026-10-30 12:47:03 +11:00
SBaksa b301d5782d Merge origin/main into dxf2000-region 2026-10-30 12:43:43 +11:00
SBaksa 1013b482a8 DXF: SAT-text note only for DXF output, from the host drawing, without xref contents (#2334)
The DWG bridge writes R2000 text for the DWG writer, which keeps SAT-text
solids: the skip is now keyed on the DXF's declared version, not the DWG
target. The save note reads the version and count from the host drawing while
the Block Editor is open, and leaves out xref blocks (path or xref| name), which
the DXF writer empties anyway; the xref name rule is shared with the writer.
2026-10-30 12:26:09 +11:00
SBaksa 89771962fc Parity checklist: regenerated after merging main 2026-10-30 11:26:25 +11:00
SBaksa 49fdbff529 Test: saving SAT-text solids as DXF 2013 reports them, 2010 does not (#2334) 2026-10-30 11:23:09 +11:00
SBaksa 2eecaef6c2 Parity checklist: regenerated after merging main 2026-10-30 10:41:07 +11:00
SBaksa b6c1ab74f9 DXF: leave SAT-text solids out of files of 2013 and later, with a save note (#2334)
REGION, 3DSOLID and BODY read from a DXF 2000 hold SAT text; DXF 2013 and later
keep ACIS geometry in the data store section, so writing the text there made a
file other readers reject. Those entities are now left out of 2013+ DXF output
and counted in the save note; 2010 and earlier, and DWG, are unchanged.
Test: sat_text_solids_are_left_out_of_dxf_2013_and_later.
2026-10-30 10:38:00 +11:00
ffishmanandClaude Opus 5.5 bf137e2c44 fix(dwg): give proxies their own class in the DWG
A DXF proxy object or entity names its class by number: 500 plus the class's
place in the DXF's CLASSES (DXF Reference, ACAD_PROXY_OBJECT, group 91).
acadrust's DXF reader puts its default classes first and numbers the DXF's
after them, and its DWG writer keeps the proxy's DXF number, so in the DWG
the number named another class. A 2018 drawing with an MEP plugin's piping
proxies wrote them as LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA
File Converter refused the whole file ("AcDbProxyObject can't be cast to
AcDbBlockTableRecord").

The DWG bridge now reads the DXF's class names in CLASSES order and gives
each proxy the number acadrust writes for its class. Before 2013 (AC1027)
acadrust writes only the classes it knows such a DWG needs, and a proxy's is
not one of them, so the proxy would name a missing class ("No ClassId") or,
by its old number, another one; such a DWG now leaves the proxies out, and
the save report lists the proxy entities as not kept only for those
versions (from 2013 the DWG keeps them).

Tests: #1812's proxy drawing saved as 2018, 2013, 2010 and 2004 DWG keeps
its proxies' classes from 2013 and leaves them out before (fails before
this change: the classes came out as MESH and SCALE); unit test for the
CLASSES order; the not-saved report counts proxy entities only before 2013.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 18:46:31 -03:00
ffishmanandClaude Opus 5.5 33019677a8 fix(dwg): read kept objects as the version they came from when saving DWG
CADCraft saves DWG through an R2000 DXF bridge, and the objects it keeps
without modelling them (render settings, fields, associative and dynamic
block data) are written into that bridge in the groups of the file they were
read from. acadrust reads several of those differently by version, so reading
them as R2000 changed them:

- mental ray render settings from a 2013+ file came out one class version too
  high; the ODA File Converter then refuses the whole DWG ("Object improperly
  read: AcDbMentalRayRenderSettings");
- RapidRT render settings from a 2013 file came out one class version too low;
- a field lost its value flags, format and cached value.

The bridge is now read as the version the kept groups came from (the
drawing's $ACADVER) when that is newer than R2000: cadcraft_dwg gains
dxf_to_dwg_kept_as, which sets $ACADVER before acadrust reads the DXF,
decoding a code page to UTF-8 first when the kept version is 2007 or later.
Drawings without $ACADVER, or from an R2000 or older file, save as before.

Tests: a synthetic DXF (render settings and a field in the named object
dictionary) saved as 2018, 2013 and 2004 DWG must hold the same objects as
acadrust's own conversion of that DXF; three of the four cases fail without
this change. Unit tests cover the $ACADVER rewrite (CRLF, code pages, binary
DXF, a $ACADVER outside the header, unknown versions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 17:57:01 -03:00
midasmatterhorn c2cb5b8610 engine: propagate create_dir_all errors and route render PNG writes through write_atomic 2026-10-29 21:49:48 +03:00
midasmatterhorn c45b49acc7 Merge remote-tracking branch 'upstream/main' into fix-2260 2026-10-29 21:47:55 +03:00
Francesco BVandClaude Opus 5.5 7917afce5d render: explicit imports for XCLIP clipping after #2239
#2239 replaced the glob imports in blocks.rs and viewports.rs with explicit
lists; the XCLIP code relied on the globs for `Clip`, `append_clipped_keep`,
`clip` and `Pen`. Import them by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 11:42:25 +01:00
Francesco BVandClaude Opus 5.5 14296dc4f5 blocks: XCLIP and XCLIPFRAME, clipped references kept through DXF and DWG
XCLIP clips block and xref references to a rectangular, polygonal or
closed-polyline boundary (Invert clip, ON/OFF, Delete, generate Polyline;
Clipdepth is ignored in 2D). The boundary is Insert::clip, in block
coordinates, so it follows its reference; other references stay whole.
Clipped references are drawn, plotted and framed (XCLIPFRAME 0/1/2) cut
to the boundary, attributes included, and their extents are cut to it.
DXF and DWG read and write it as the reference's ACAD_FILTER/SPATIAL
SPATIAL_FILTER; filters the model can't hold stay foreign data. Gap F2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 11:06:23 +01:00
ffishmanandClaude Opus 5.5 42503a8ecd Keep a file's own paper size and plot rotation when saving DXF/DWG
A media named wider than tall is stored two ways: AutoCAD writes a portrait size
and a quarter turn (ISO_A4_(297.00_x_210.00_MM) as 210 x 297, 73 = 1), ZWCAD the
media's own size and no turn (420 x 297, 73 = 0). Deriving the groups from the
name alone turned the AutoCAD form into the ZWCAD one. The reader now keeps
groups 44, 45 and 73 as read (PageSetup::file_paper, not serialized), and the
writer puts them back while they still describe the page setup's size and
orientation; page setups made or changed in CADCraft are derived from the media
name as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 18:46:19 -03:00
Davit Matchakhelidze b5f0890b95 Docs: Georgian needs the embedded craft-fonts Geor face 2026-10-29 00:58:26 +04:00
mattlafy 2a85653976 Split the DVIEW prompt-steps test under the complexity limit 2026-10-28 15:41:01 -04:00
mattlafy 12fd74a60f Use explicit imports in DVIEW 2026-10-28 15:35:20 -04:00
mattlafy e1d0a4747e Fix imports after merging upstream/main 2026-10-28 15:34:34 -04:00
mattlafy b7c62eb93d Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/doc/src/entity.rs
#	crates/engine/src/cmd/layout.rs
#	crates/engine/src/cmd/modify2.rs
#	crates/engine/src/lib.rs
#	crates/io/src/dxf_read.rs
#	crates/io/src/dxf_write.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/gaps.md
2026-10-28 15:30:03 -04:00
Hamza 9244ffda5d Open a drawing on the view it was saved at (#1602)
A drawing opened fitted to its extents, so one whose extents are dominated by
far-off geometry opened at a view hundreds of times its content: the drawing
was sub-pixel and there was no way to zoom in. Every other reader opens on the
view the file was saved at.

The saved view is read from HEADER (`VIEWCTR`/`VIEWSIZE`) and from the first
`*ACTIVE` VPORT record, which is the newer of the two and wins -- as it already
does for snap and grid spacing. A record that leaves the centre out has no view
to give, so the header pair stands. The extents remain the fallback for a
drawing that stores no view, and that fallback now fits the width too, through
the aspect the record is written with: a drawing 100 wide and 10 tall was given
a view 11 tall, a sliver of it, and a horizontal line got 1e-6.

Writing keeps it, so a read-and-write round trip no longer loses the view.

Rebased onto main, 310 commits on: `open_drawing` and `usable_view` are in
engine/src/session/files.rs, the VPORT reading in io/src/dxf_read/tables.rs,
the HEADER pair list in io/src/dxf_write/header.rs and the VPORT writing in
io/src/dxf_write/layout.rs. The two io tests were already carried by their
hunk, so only the engine one needed placing.
2026-10-28 20:50:16 +05:00
assamaralandClaude Fable 5.1 474df8ede8 Resolve a dropped sheet by its paper-space block, not its owner group
A PAPERVIEW sheet inside a *Paper_SpaceN block whose owner group (330) is missing or unknown belongs to that block's layout, where place_blocks puts the block's other viewports; resolving it by owner sent it to the active layout and left the real one open to numbering (CodeRabbit on #2107). The reader now records the enclosing block of each dropped sheet, and a test strips the owner from the sheet of a second layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 10:07:24 -03:00
assamaralandClaude Fable 5.1 d6cc051d99 Keep the generated sheet of a layout whose viewports lack ids
The reader drops the PAPERVIEW sheet viewport the writer adds to a layout with no id-1 viewport. Numbering that layout's remaining viewports from 1 would then make the first of them the sheet on the next save, losing the generated sheet (CodeRabbit, ffishman on #2107). Layouts whose generated sheet was dropped are now left alone, and a write-read-write test with a single id-0 viewport covers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 08:07:05 -03:00
Davit Matchakhelidze 151d30b2f2 Merge upstream main into ka-language
# Conflicts:
#	crates/ui-egui/src/menus.rs
2026-10-28 04:45:10 +04:00
Davit Matchakhelidze 8715e78650 Merge upstream main into fonts-georgian
# Conflicts:
#	crates/ui-egui/src/menus.rs
2026-10-28 04:45:05 +04:00
ffishmanandClaude Opus 5.5 589e9113bc Merge upstream main into fix/page-setup-media-orientation
LAYOUT writing moved into layout_obj (2721aa8): the groups 44/45/73 change and
plot_paper/media_is_landscape follow it in dxf_write/sections.rs. No logic change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 20:02:59 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 f948acd30b i18n(ka): bring the Georgian catalog up to date
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:32:16 +04:00
Davit Matchakhelidze 6967a79c34 Merge upstream main into ka-language 2026-10-27 23:32:16 +04:00
assamaralandClaude Fable 5.1 f42ac67e2a Merge upstream main into fix/dxf-viewport-id-unknown
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 16:21:00 -03:00
Andrea Brandi 4398a1aab1 fix(it): correct CADCraft chamfer terminology 2026-10-27 18:47:15 +01:00
Francesco BVandClaude Opus 5.5 52c1e6de98 fonts: read SHX shape fonts, big fonts and unifonts; draw linetype shapes (FF8, FF9)
SHX files referenced by text styles are found by file name through a
font map, registered bytes, an app provider, the drawing's folder and a
search path (CADCRAFT_SHXPATH); never in a CAD program's install folder.
TEXT, MTEXT, attributes, dimension text and table cells draw with the
style's font, big font and vertical flag; linetype shape elements are
drawn when their file is found. The web build registers .shx files
picked or dropped with a drawing. All test fonts are compiled in code
from shape source.

Plain MTEXT text decodes \U+XXXX escapes as the layout does, and an SHX
file whose name differs in case from the one asked for is found on
case-sensitive file systems too (the folder is looked through).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 18:10:33 +01:00
Francesco BVandClaude Opus 5.5 9107479ade io: open -> save -> reopen round-trip harness with loss report (FF5)
cadcraft_io::roundtrip compares a drawing with itself saved and reopened
(entities by handle, tables, blocks, layouts, unmodelled objects, xdata,
classes, header variables; numbers within a tolerance), and
roundtrip::corpus runs a folder of DXF/DWG files through DXF and DWG into
report.md and report.json; exposed as `cadcraft-cli roundtrip` and
`cargo xtask corpus`. The corpus itself stays outside the repo.

The DWG bridge now says what its writer leaves out, per DXF type
(cadcraft_dwg::dxf_to_dwg_noted, cadcraft_io::write_noted): the report
shows those records as lost on save and a strict corpus run fails on
them. OLE2FRAME, which the writer keeps with its data, is no longer
listed as not saved in a DWG.

CADCRAFT_CORPUS_STRICT=1 makes cadcraft-cli roundtrip (and so cargo xtask
corpus) fail on the same strict failures as the corpus test; xtask makes
a relative corpus folder and --out absolute before running the CLI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:45:34 +01:00
assamaralandClaude Fable 5.1 c3f4a91132 Merge upstream main into fix/dxf-viewport-id-unknown
The VIEWPORT reader moved to dxf_read/entities.rs and the read steps are
separate functions: the 69 filter goes there and the numbering runs after
place_entities. Also covers a layout where only the sheet viewport lacks an
id: with no id 1 present, the first viewport becomes the sheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 12:11:08 -03:00
ffishmanandClaude Opus 5.5 235737d45d Merge upstream main into fix/page-setup-media-orientation
main split dxf_write.rs into dxf_write/ (736a834): plot_paper, media_is_landscape
and the LAYOUT groups 44/45/73 change move to dxf_write/sections.rs. No logic
change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:41:10 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 b98c55c7fa i18n(ka): translate Font (the text editor's font field)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-27 16:09:18 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 53c7d25ee0 i18n(ka): bring the Georgian catalog up to date
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-27 15:43:32 +04:00
Davit Matchakhelidze 0dd5d5af31 Merge upstream main into ka-language
# Conflicts:
#	crates/ui-egui/src/i18n.rs
2026-10-27 15:42:59 +04:00
midasmatterhorn 62b9b5acd4 engine: save, render and export create a missing target folder instead of failing with os error 3 2026-10-27 00:58:10 +03:00
mattlafy 1823eeab19 fix(view): twist follows named views in viewports, undo, screen-direction crossing, clipped DVIEW preview, DCS target
- VIEW Save inside an MSPACE viewport stores the viewport's twist; VIEW Restore into an
  unlocked viewport turns the viewport to the saved twist (one undo step).
- Undo and redo keep the untiled Model view's twist record in step with the live view.
- A selection window is a crossing window when dragged leftwards on screen, also in a
  twisted view (preview and selection alike).
- DVIEW's preview inside a clipped viewport is clipped to the viewport's outline.
- DXF view centres are read relative to the view target (VIEWPORT/VPORT group 17, VIEW
  group 12), the origin of display coordinates.
2026-10-26 16:19:28 -04:00
assamaralandClaude Fable 5.1 36272cdd28 contributors: key the entry by the renamed username (assamaral)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 07:28:29 -03:00
mattlafy 97761eb2bf io: view-centre helpers before the test module 2026-10-25 16:43:05 -04:00
mattlafy 8b97b4f47e Merge remote-tracking branch 'upstream/main' into pr/dview
# Conflicts:
#	crates/engine/src/sysvars.rs
#	crates/io/src/dxf_read.rs
#	crates/io/tests/pdf_layers.rs
#	crates/ui-egui/src/canvas.rs
2026-10-25 16:26:25 -04:00
mattlafyandClaude Opus 5.5 4c61c3c889 A twisted view's centre is read and written in display coordinates
VIEWPORT and VPORT group 12 and VIEW group 10 hold the view centre turned
by the view twist. The reader turns it back to the drawing point at the
middle of the view and the writer turns it again, so a twisted view far
from the origin shows its own part of the drawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:48:01 -04:00
Davit MatchakhelidzeandClaude Opus 5.5 1fba38238f i18n(ka): shorter wording for labels that were cut off
Decided in the native-speaker review of the screens (all-t01).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-25 22:09:24 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 af6ff7f3a2 Merge main into ka-language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-25 21:40:37 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 963267abe8 docs: list tr and el among the ui.language values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-25 21:30:23 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 4cd88a3815 docs: ka joins the supported ui.language values with the language PR, not this one (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:57:06 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 9c8519da2f docs: ka among the supported ui.language values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:57:06 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 8e84e57ac8 theme: the Georgian glyph test asks FontsView::has_glyph (egui 0.37 made its fonts field private)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:17:17 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 d11c418fe6 theme: the Georgian glyph test asks FontsView::has_glyph (egui 0.37 made its fonts field private)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:17:07 +04:00
Davit Matchakhelidze 335e50b6a7 Merge upstream main into ka-language
# Conflicts:
#	crates/ui-egui/src/i18n/tests.rs
2026-10-25 19:01:01 +04:00
Davit Matchakhelidze 90fd1329e9 Merge upstream main into fonts-georgian
# Conflicts:
#	.github/workflows/freebsd.yml
#	.github/workflows/release.yml
2026-10-25 19:00:51 +04:00
assamaralemanandClaude Fable 5.1 9431ad6ad9 contributors: add assamaraleman
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 21:22:23 -03:00
assamaralemanandClaude Fable 5.1 84ba9a8040 DXF read: number viewports in file order when every id is unknown (#1463)
ZWCAD writes every paper-space VIEWPORT with id (69) -1. The reader cast
it to u32, so no viewport was the sheet (id 1); on save paper_views()
added a second sheet viewport ahead of the real one, which DWG readers
then numbered 2: a model viewport showing the whole sheet, inflating
the layout's extents.

A non-positive 69 now reads as 0, and a layout whose viewports all have
id 0 gets them numbered in file order, the first being the sheet. A
layout mixing known ids with -1 is left as before (pinned by a test).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 21:19:43 -03:00
ffishmanandClaude Opus 5.5 b52d5fbdff io: put the CLASS group 91 doc comment back on its function
plot_paper had been inserted between add_class_instance_counts and its doc
comment (review on #2028).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 19:09:52 -03:00
vangel76andClaude Opus 5.5 4ad7ef64e7 Merge upstream/main into fix/dxf-hostile-input-hardening
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:07:08 +02:00
ffishmanandClaude Opus 5.5 e27a839fa9 Merge upstream main into fix/page-setup-media-orientation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 10:09:30 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 2409afe7f0 i18n(ka): translate the six strings main added (Analyze Usage, Implied Loops, units of new drawings)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-24 14:15:14 +04:00
Davit Matchakhelidze 4c2d228d89 Merge remote-tracking branch 'origin/main' into ka-language
# Conflicts:
#	docs/localization-parity.md
2026-10-24 13:58:32 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 6a5e8eda35 fonts: test the Georgian face in the face order and its glyphs in a ka and an en interface
Same tests as #1971: embedded_ui_faces yields the Geor face for ka (and for every interface,
which names the language ქართული in the picker), can_show("ka") follows the embedded face, and
with a craft-fonts that carries Geor every Mkhedruli letter renders in both families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-24 13:16:07 +04:00
ffishmanandClaude Opus 5.5 241aad2425 docs: file-format parity status line for the page setup change
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 23:35:10 -03:00
ffishmanandClaude Opus 5.5 f84e458ccf Keep a landscape media's size order and rotation when saving DXF/DWG
Page setups keep a portrait size and a landscape flag, so a layout read
with 44 = 420, 45 = 297, 73 = 0 and the media
ISO_full_bleed_A3_(420.00_x_297.00_MM) was written back as 297 x 420
with rotation 1. ZWCAD takes the media's size from its name and turns it
a quarter on top, so the sheet plotted as a shrunk portrait page (#1463).

When the media's name gives it wider than tall, write 44/45 in that
order and the rotation relative to it. Other media are written as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 23:18:29 -03:00
Davit MatchakhelidzeandClaude Opus 5.5 a5391d6ebb fonts: load the embedded Georgian face where can_show accepts it; parity status line
The same embedded_ui_faces lines as #1971, so a build whose craft-fonts has the Geor
face draws Georgian instead of boxes whichever PR lands first (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-24 00:12:13 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 c8c160a4b6 Merge upstream main into ka
Georgian joins the shared i18n tests #2013 introduced (locale table, look-alike codes,
font coverage for scripts egui's bundled faces lack) in place of its own copies. The
can_show gate and the language command's no-font message for ka match #1971, so this
branch alone keeps the interface in English without a Georgian face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 23:47:03 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 85df54b88c i18n(ka): apply the second terminology review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 13:33:34 +04:00
Davit Matchakhelidze daa9ab982d Merge remote-tracking branch 'origin/main' into ka-language 2026-10-23 12:28:33 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 096e20629e i18n(ka): apply the native speaker's terminology review
Draw = ხატვა, Bind = მიბმა, Elevation = დონე, zoom In/Out = მოახლოება/დაშორება,
Latin ViewCube compass letters, and the other decided terms; credit the review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 12:28:32 +04:00
MrBeeboh a13870f376 dxf: check the tag limit in a const block 2026-10-22 20:56:55 -07:00
MrBeeboh 6ed822ec30 Plot style and plotter files save atomically through write_atomic (#1928 follow-up) 2026-10-22 20:22:09 -07:00
MrBeeboh a6ba747329 dxf: raise the group-code limit to 150 million and allow CADCRAFT_MAX_DXF_TAGS to override it (#1948) 2026-10-22 20:20:11 -07:00
Davit MatchakhelidzeandClaude Opus 5.5 93e4ff10c4 fonts: draw the Georgian interface with the craft-fonts Georgian face
Bump craft-fonts-ref to a71c20e3, which adds Noto Sans Georgian (`Geor`),
and build the web release with craft-fonts (`web-craft-fonts: true`).
The interface loads the embedded `Geor` face after the CJK faces in every
language (the picker names ქართული), and the web build embeds it too
(232 KB). Without it `can_show("ka")` is false: the saved choice is kept,
the interface stays English and the language command says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 04:39:13 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 6c72999361 i18n: add Georgian (ka) interface language
Add a Georgian catalog (ka.tsv, 1,383 rows): the union of the existing
catalogs, four menu headings (ზომების დატანა for the Dimension menu, ზომა
for the object) and single-form counts, since a Georgian noun stays
singular after a numeral. No marked UI literal falls back to English.
Prompts read "მიუთითეთ …" for Specify and "შეიყვანეთ …" for Enter;
clickable keywords keep the spelling the engine accepts. No Mtavruli.

Register the language (Preference::Georgian, `ka`, ქართული) and add
locale, preference, count, menu-heading, live-switch, dialog and
no-Mtavruli tests. Docs, README and ATTRIBUTION list it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 04:39:13 +04:00
mattlafy 94b6c485b4 DVIEW records a viewport twist as one undo step when cancelled 2026-10-20 00:00:47 -04:00
mattlafy 4c75916c75 Keep the saved view twist when a template opens or xrefs load after open 2026-10-19 22:40:23 -04:00
mattlafy ecd1bdc214 Merge remote-tracking branch 'upstream/main' into fix/dview
# Conflicts:
#	crates/engine/src/prompt.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-10-19 22:19:12 -04:00
mattlafyandClaude Opus 5.5 212f6cc988 View twist: DVIEW, VIEWTWIST, per-tile, named-view and layout-viewport twist
A model space view carries a twist (radians CCW, View.twist, serde default
0). VIEWTWIST reports it read-only. Each VPORTS tile keeps its own twist
(VportTile.twist, DXF VPORT group 51 read and written; a single untiled
Model view's twist is kept as a lone *Active VPORT tile). VIEW Save stores
and Restore applies NamedView::twist. ZOOM fits in the twisted screen frame;
zooms, pans, the 3D mouse and view.set keep it; ZOOM Previous turns it back.

DVIEW starts with "Select objects or <use DVIEWBLOCK>:" (the picked objects,
or the DVIEWBLOCK sample, are the only things drawn while it runs) and
repeats the "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/
TWist/CLip/Hide/Off/Undo]:" prompt. TWist, PAn, Zoom, Off and Undo work on
2D views; the 3D options say they are not available yet (docs/gaps.md F1).

DVIEW works inside an active layout viewport: the doc Viewport gains a view
twist (DXF VIEWPORT group 51), drawn turned on screen and in plots, mapped
through MSPACE views, snaps and CHSPACE; a twist made there is one undo
step. A layout with no active viewport refuses it, as does view.set's twist.

Canvas: the model view and every VPORTS tile draw turned by their twist.
`Xf` gains `twist`: `to_screen` turns (p - centre) by it before scaling,
`to_world` is its exact inverse, `world_bounds` is the box of the four
turned rect corners. The GPU needs no shader change: `Xf::gpu_xform` builds
the callback xform for the drawing, drag, highlight, viewport and mask
layers. Pans map screen deltas back through the twist; clicks, hover, snaps,
grips and wheel zoom go through `to_world`/`to_screen`. Grid lines and axes
stay world-aligned; the selection window is drawn as the world box it
selects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:23:53 -04:00
Leonardo Pires BatistaandCodex 0de6ec8706 fonts: read user SHX text fonts and keep Latin accents single-stroke
Co-Authored-By: Codex <noreply@openai.com>
2026-10-13 16:41:59 -03:00
vangel76andClaude Sonnet 5.5 7716430948 DXF: multi-byte East Asian code pages read from the code page unless the whole file is UTF-8 (#1025)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:18:54 +02:00
vangel76andClaude Sonnet 5.5 043073808f fix(dxf): hostile-input hardening in the DXF reader and writer (#1025)
Binary reals are finite-filtered like ASCII ones; ASCII reads a UTF-8 BOM and
CR-only/mixed line ends (and the tag-count precheck counts the same lines);
text is decoded per line/string, valid UTF-8 first, else the code page, in both
readers; trailing spaces no longer hide SECTION/ENDSEC/EOF; integer groups
reject inf/NaN and clamp to their width; hex groups pad an odd digit and drop
non-hex characters in reader and writers; the ASCII writer sanitises hex and
splits it at 254 digits; write_binary wraps in-range unsigned 16/32-bit values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:14:29 +02:00
421 changed files with 34434 additions and 2320 deletions
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+1
View File
@@ -85,3 +85,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+55 -7
View File
@@ -1,13 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# CADCraft — instructions for agents
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
Read [`ROADMAP.md`](ROADMAP.md) and [`docs/gaps.md`](docs/gaps.md). `plan/` is an optional gitignored notebook (status, execution notes, `plan/autocad/*`). Do not ask for it and do not stop when it is absent. Licence choices stay inside the house-rules allowlist.
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
@@ -28,9 +77,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds.
## Running and looking at the app
- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too.
+2 -1
View File
@@ -71,10 +71,11 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
| `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) |
+1 -52
View File
@@ -1,52 +1 @@
# CADCraft — instructions for agents
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
## Running and looking at the app
- `cargo run --release -p cadcraft -- --sample --control PORT` (sample drawing + control channel). Pick a free port; other crafting apps use control ports too.
- Drive it with JSON lines on `127.0.0.1:PORT`:
- `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` — type at the command line exactly like a user (prompts, keywords, `@dx,dy`, `@d<a`, direct distance entry).
- `{"id":2,"method":"engine.execute","params":{"command":"line","params":{"points":[[0,0],[10,0]]}}}` — JSON form.
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- Methods: `crates/ui-egui/src/control.rs`; protocol doc: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with `plan/autocad/screenshots/`. If no frame is presented (screen locked) use `ui.render` or `cadcraft-cli`.
- Headless: `cadcraft-cli run --sample --script 'LINE 0,0 10,10\n' --export out.png`; `cadcraft-cli info file.dxf`; `cadcraft-cli mcp`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+99 -36
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -632,6 +632,7 @@ dependencies = [
"muda",
"objc2-app-kit 0.3.2",
"rfd",
"ron",
"serde_json",
"sys-locale",
"thiserror 2.0.21",
@@ -643,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -659,14 +660,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -676,7 +677,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -687,14 +688,15 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"acadrust",
"log",
]
[[package]]
name = "cadcraft-dxf"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"encoding_rs",
"log",
@@ -703,7 +705,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -724,24 +726,26 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
"skrifa 0.48.0",
"unicode-bidi",
]
[[package]]
name = "cadcraft-geom"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -752,6 +756,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -762,7 +767,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -771,11 +776,12 @@ dependencies = [
"getrandom 0.4.3",
"log",
"serde_json",
"tiny-skia",
]
[[package]]
name = "cadcraft-render"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -789,7 +795,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -803,6 +809,7 @@ dependencies = [
"craft-window",
"egui",
"egui-wgpu",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -815,11 +822,13 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-host",
"eframe",
"egui",
"log",
@@ -1115,6 +1124,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1214,6 +1232,15 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -1498,6 +1525,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1566,6 +1606,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -2435,6 +2495,29 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -5894,7 +5977,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"chrono",
"serde_json",
@@ -6209,23 +6292,3 @@ dependencies = [
"syn 3.0.9",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+7 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.16.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -51,7 +51,10 @@ skrifa = "0.48"
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
harfrust = "0.14"
unicode-bidi = "0.3.18"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
encoding_rs = "0.8.35"
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
quick-xml = "0.42"
@@ -60,6 +63,8 @@ quick-xml = "0.42"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
@@ -113,8 +118,5 @@ egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+6 -1
View File
@@ -118,7 +118,7 @@ numbers):
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar), Turkish (Türkçe), Greek (Ελληνικά), Bosnian (Bosanski) and Georgian (ქართული), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
## Quick start
@@ -230,6 +230,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
When reading a file leaves something out (records a DXF reader can't place, object records of a
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
`warnings`.
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
+34
View File
@@ -0,0 +1,34 @@
# Security policy
## Reporting a vulnerability
Please report it privately through [GitHub's private reporting](https://github.com/storytold/cadcraft/security/advisories/new), not in a public issue or pull request. Say which version you ran, on which platform, and how to reproduce it. A small sample file helps, as long as it holds nothing private.
Maintainers read reports as they arrive, but there is no fixed response time yet.
## Supported versions
Only the latest release gets security fixes. CADCraft is pre-alpha and moves quickly, so please check that a problem still exists there before reporting it.
## What counts
CADCraft treats these as untrusted, and a way past these defences is a vulnerability:
- **Drawing files.** Opening a DXF, DWG or other file must not run code, write outside the paths the user chose, or crash the app. Panics are bugs too, but memory exhaustion or a decoder abort on a crafted file is the more serious case.
- **AutoLISP in a drawing's folder.** A drawing must not run code that the user didn't choose to load.
- **The control channel.** It listens on loopback only and needs the session token from an owner-only file on every request. Another local account reaching it without that token is a vulnerability.
- **Temporary and autosave files.** Autosave files are unsaved drawings: another account must not be able to read them or redirect a write through them.
- **Updates.** A release must not be replaceable by someone other than the project.
## Known limits
These are known and being worked on, so they don't need a report:
- Agents connected through MCP or the control channel can read and write any path the user can. There are no workspace roots yet.
- A local process can make the control channel buffer up to 512 MiB before its token is checked. The total is capped, so this can slow the app but not exhaust memory without limit.
- The DWG worker process has no memory limit of its own yet, so a crafted file can use a lot of memory before it stops. It ends that one open or save, not the app.
- The web build has no autosave yet.
## What stays on your machine
Crash reports are written to the `crash-reports` folder in the settings folder and are never uploaded. They hold the version, the platform, the panic message and a stack trace, and a message can include a file path, so read a report before you attach it anywhere.
+37
View File
@@ -81,6 +81,7 @@ pub(crate) enum Command {
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
Roundtrip(RoundtripArgs),
}
/// Print a drawing's summary as JSON
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
}
}
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
///
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
/// doesn't open, save or reopen, or loses records on save.
#[derive(Args)]
pub(crate) struct RoundtripArgs {
/// A drawing, or a folder of them
#[arg(value_name = "FILE|FOLDER")]
pub(crate) path: Option<PathBuf>,
/// Where the report goes (default: `report` beside the files)
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<PathBuf>,
/// Keep the saved files, in `saved` under the report folder
#[arg(long)]
pub(crate) keep_saved: bool,
/// Save only in each file's own format
#[arg(long)]
pub(crate) same_format: bool,
/// Don't count what each file holds (faster; no "read lost" column)
#[arg(long)]
pub(crate) no_census: bool,
/// How far numbers may move and still count as equal
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
pub(crate) tolerance: f64,
}
/// `--tolerance`: a finite number, 0 or more.
fn tolerance(v: &str) -> Result<f64, ValueError> {
match v.parse::<f64>() {
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
_ => Err(ValueError::Tolerance(v.to_string())),
}
}
/// Write a built-in sample drawing
#[derive(Args)]
pub(crate) struct SampleArgs {
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
CmdId,
#[error(transparent)]
CmdJson(#[from] serde_json::Error),
#[error("expected a number of 0 or more, got `{0}`")]
Tolerance(String),
}
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
+3 -2
View File
@@ -1,6 +1,6 @@
use super::args::ConvertArgs;
use super::error::Result;
use super::output::{Streams, export_framed, export_window, skipped_message, undrawn_message};
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
use super::{open, unicode};
use cadcraft_engine::Session;
@@ -9,7 +9,8 @@ pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
let framing = a.framing.framing();
let mut s = Session::empty();
open(&mut s, &a.input)?;
if let Some(m) = skipped_message(s.doc()?, i) {
let d = s.doc()?;
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
io.errln(format_args!("{m}"));
}
if cadcraft_io::is_image(&a.output)
+3
View File
@@ -1,5 +1,6 @@
use crate::output::OutputError;
use crate::perf::PerfError;
use crate::roundtrip::RoundtripError;
use crate::run::RunError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
Output(#[from] OutputError),
#[error(transparent)]
Perf(#[from] PerfError),
#[error(transparent)]
Roundtrip(#[from] RoundtripError),
/// The engine and io crates take file names as text.
#[error("{}: the file name is not valid Unicode", .0.display())]
NotUnicode(std::path::PathBuf),
+5 -1
View File
@@ -16,6 +16,7 @@ mod info;
mod mcp;
mod output;
mod perf;
mod roundtrip;
mod run;
mod sample;
@@ -32,6 +33,7 @@ use output::Streams;
#[cfg(test)]
use output::{export_window, model_views, write_atomic};
use perf::perf;
use roundtrip::roundtrip;
use run::run_cmds;
#[cfg(test)]
use run::{fill_last, returned_handle};
@@ -43,6 +45,7 @@ fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
@@ -99,7 +102,7 @@ where
Command::Run(a) => a.settings.apply(),
Command::Mcp(a) => a.settings.apply(),
Command::Sample(a) => a.settings.apply(),
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
@@ -110,6 +113,7 @@ where
Command::Mcp(a) => mcp(a),
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
Command::Sample(a) => sample(a, &mut io),
Command::Roundtrip(a) => roundtrip(a, &mut io),
};
let mut code = 0;
if let Err(e) = result {
+5
View File
@@ -1,6 +1,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// A copy of this program started to read or write a DWG file does only that.
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
std::process::exit(code);
}
cadcraft_io::dwg_worker::use_own_worker();
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
// reported as an error, not panic before anything is printed.
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
+56 -9
View File
@@ -122,10 +122,21 @@ pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
/// leave the file it points to (`--in-place` through a link) unchanged.
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<(), OutputError> {
/// leave the file it points to (`--in-place` through a link) unchanged. Folders missing above `out`
/// are created, and the topmost one returned for the caller to report.
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<Option<std::path::PathBuf>, OutputError> {
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
cadcraft_engine::atomic_write::write_atomic(&target, bytes).map_err(|source| OutputError::Write { path: out.to_path_buf(), source })
let folder = cadcraft_engine::atomic_write::missing_folder(&target);
cadcraft_engine::atomic_write::write_atomic_making_folders(&target, bytes)
.map_err(|source| OutputError::Write { path: out.to_path_buf(), source })?;
Ok(folder)
}
/// Say that `out` went into a folder that didn't exist, so a mistyped path is noticed.
pub(crate) fn report_new_folder(folder: Option<std::path::PathBuf>, io: &mut Streams) {
if let Some(folder) = folder {
io.errln(format_args!("created the folder {}, which did not exist", folder.display()));
}
}
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
@@ -135,7 +146,8 @@ pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
let d = s.doc()?;
let bytes = cadcraft_io::write_framed(d, out, window)?;
write_atomic(out, &bytes)?;
let folder = write_atomic(out, &bytes)?;
report_new_folder(folder, io);
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
if cadcraft_engine::cmd::file::drops_unknown(out) {
let lost = cadcraft_engine::cmd::file::not_saved(d);
@@ -150,19 +162,37 @@ pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
let mut ole = 0usize;
for e in d.model.iter() {
// An OLE2FRAME's frame outline is drawn, so it isn't an undrawn entity; its content is
// not, which gets a note of its own.
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
if u.is_ole_frame() && cadcraft_engine::doc::ole_frame(&u.tags).is_some() {
ole = ole.saturating_add(1);
continue;
}
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
*n = n.saturating_add(1);
}
}
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
if n == 0 {
return None;
let mut lines = Vec::new();
if n > 0 {
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
lines.push(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")));
}
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
let entities = if n == 1 { "entity" } else { "entities" };
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
if ole > 0 {
let objects = if ole == 1 { "object" } else { "objects" };
lines.push(format!("{path}: {ole} OLE {objects} shown as the frame only (the OLE content is not drawn)"));
}
if lines.is_empty() { None } else { Some(lines.join("\n")) }
}
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
/// #859), one warning line each; empty for a complete read.
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
}
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
@@ -175,3 +205,20 @@ pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) ->
let records = if n == 1 { "record" } else { "records" };
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
}
#[cfg(test)]
mod tests {
use super::read_note_messages;
#[test]
fn read_notes_become_one_warning_each() {
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
assert!(read_note_messages(&d, "a.dwg").is_empty());
d.read_notes =
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
assert_eq!(
read_note_messages(&d, "a.dwg"),
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
);
}
}
+93
View File
@@ -0,0 +1,93 @@
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
use super::args::RoundtripArgs;
use super::error::{CliError, Result};
use super::output::Streams;
/// Why `roundtrip` stopped.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RoundtripError {
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
NoPath,
#[error("roundtrip: no such file or folder: {}", .0.display())]
Missing(std::path::PathBuf),
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
Empty(std::path::PathBuf),
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
Report { path: std::path::PathBuf, source: std::io::Error },
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
Strict(Vec<String>),
}
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
/// stderr, and failures of a strict run.
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
if w.truncated {
out.push("left out: files or folders past the harness limits".to_string());
}
out
}
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
use cadcraft_io::roundtrip::corpus;
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
let mut missed = Vec::new();
let (root, files, out) = if path.is_dir() {
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
// The report folder, and the saved copies in it, are never read back as corpus files.
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
missed = left_out(&walk);
for m in &missed {
io.errln(format_args!("roundtrip: {m}"));
}
(path, walk.files, out)
} else if path.is_file() {
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
(root, vec![path], out)
} else {
return Err(RoundtripError::Missing(path).into());
};
if files.is_empty() {
// A folder it couldn't read isn't an empty one.
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
}
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
opts.compare.abs_tol = a.tolerance;
if a.keep_saved {
opts.keep_saved = Some(out.join("saved"));
}
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
for f in &report.files {
let routes: Vec<String> = f
.routes
.iter()
.map(|r| match (&r.save_error, &r.reopen_error) {
(Some(_), _) => format!("{}: save failed", r.route),
(None, Some(_)) => format!("{}: reopen failed", r.route),
(None, None) => {
let t = r.comparison.total();
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
}
})
.collect();
match &f.open_error {
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
}
}
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
io.outln(format_args!("report: {}", md.display()));
io.outln(format_args!(" {}", json.display()));
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
// leaves out, fail the run (the report is written first).
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
let mut failed = report.strict_failures();
failed.extend(missed);
if !failed.is_empty() {
return Err(CliError::from(RoundtripError::Strict(failed)));
}
}
Ok(())
}
+3 -2
View File
@@ -1,6 +1,6 @@
use super::args::{SampleArgs, SampleName};
use super::error::Result;
use super::output::{Streams, write_atomic};
use super::output::{Streams, report_new_folder, write_atomic};
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
let d = match a.name {
@@ -8,7 +8,8 @@ pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
};
let bytes = cadcraft_io::write(&d, &a.output)?;
write_atomic(&a.output, &bytes)?;
let folder = write_atomic(&a.output, &bytes)?;
report_new_folder(folder, io);
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
Ok(())
}
+40
View File
@@ -0,0 +1,40 @@
//! The DWG reader and writer run in a child process, so a decoder that dies ends one open or save,
//! not the app. The worker setting is process-wide, so everything runs in one test.
#![cfg(not(any(target_arch = "wasm32", all(target_os = "freebsd", target_arch = "aarch64"))))]
use cadcraft_io::dwg_worker;
#[test]
fn a_real_worker_reads_and_writes_and_its_death_is_an_error_here() {
let drawing = cadcraft_engine::sample::bracket();
let in_process = cadcraft_io::write(&drawing, "a.dwg").unwrap();
dwg_worker::use_worker(env!("CARGO_BIN_EXE_cadcraft-cli").into(), vec![dwg_worker::WORKER_ARG.into()]);
let written = cadcraft_io::write(&drawing, "a.dwg").unwrap();
assert_eq!(written.get(..6), in_process.get(..6), "the same version of file");
let read = cadcraft_io::read(&written, "a.dwg").unwrap();
assert_eq!(read.model.len(), drawing.model.len());
// A drawing whose kept objects came from a 2018 file: the worker gets that version with the
// request and reads the bridge DXF as it, as this process does.
let mut kept = drawing.clone();
kept.foreign = std::sync::Arc::new(cadcraft_engine::doc::foreign::Foreign { version: Some("AC1032".into()), ..Default::default() });
let written = cadcraft_io::write(&kept, "a.dwg").unwrap();
assert_eq!(cadcraft_io::read(&written, "a.dwg").unwrap().model.len(), drawing.model.len());
// A decode error comes back as that error, not as the worker stopping.
let mut bad = in_process.clone();
bad.truncate(64);
let e = cadcraft_io::read(&bad, "a.dwg").unwrap_err().to_string();
assert!(!e.contains("worker"), "{e}");
// The worker aborts as the third-party code can. This process goes on.
let e = dwg_worker::crash_worker_for_test().unwrap_err();
assert!(e.contains("worker stopped"), "{e}");
assert!(cadcraft_io::read(&written, "a.dwg").is_ok(), "the next file gets a new worker");
// No worker to start: the work happens here, as without one.
dwg_worker::use_worker("no-such-program-here".into(), Vec::new());
assert!(cadcraft_io::read(&written, "a.dwg").is_ok());
}
+54 -3
View File
@@ -7,6 +7,25 @@ fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A fresh scratch directory that is removed on drop, also when an assertion fails.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("cc-cli-{tag}-{}", std::process::id()));
// A directory left behind by an earlier run with the same process id must not leak in.
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).expect("create scratch dir");
Scratch(dir)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
@@ -17,8 +36,8 @@ const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
#[test]
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let scratch = Scratch::new("undrawn");
let dir = scratch.0.clone();
let input = dir.join("proxies.dxf");
std::fs::write(&input, PROXIES).unwrap();
let input = input.to_str().unwrap();
@@ -49,5 +68,37 @@ fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
/// An OLE2FRAME's frame outline is drawn, so an image export doesn't call it an undrawn entity (#2422); it
/// says once that the OLE content inside the frame is not drawn (review on #2587).
#[test]
fn an_ole_frame_is_noted_as_frame_only() {
let scratch = Scratch::new("ole");
let dir = scratch.0.clone();
let input = dir.join("ole.dxf");
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n11\n5\n21\n-3\n31\n0\n0\nENDSEC\n0\nEOF\n";
std::fs::write(&input, ole).unwrap();
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(!err.contains("entity not drawn") && !err.contains("entities not drawn"), "{err}");
let notes: Vec<&str> = err.lines().filter(|l| l.contains("OLE")).collect();
assert_eq!(notes, [format!("{}: 1 OLE object shown as the frame only (the OLE content is not drawn)", input.display())], "{err}");
}
/// An OLE2FRAME without usable corners (here group 11/21 missing) has no outline to draw, so it is
/// an undrawn entity, not "shown as the frame only" (review on #2587).
#[test]
fn an_ole_frame_without_corners_is_counted_as_not_drawn() {
let scratch = Scratch::new("ole-nocorner");
let dir = scratch.0.clone();
let input = dir.join("ole.dxf");
let ole = "0\nSECTION\n2\nENTITIES\n0\nOLE2FRAME\n8\n0\n100\nAcDbOle2Frame\n10\n0\n20\n0\n30\n0\n0\nENDSEC\n0\nEOF\n";
std::fs::write(&input, ole).unwrap();
let out = cli(&["convert", input.to_str().unwrap(), dir.join("out.png").to_str().unwrap()]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(&format!("{}: 1 entity not drawn (OLE2FRAME: 1)", input.display())), "{err}");
assert!(!err.contains("frame only"), "{err}");
}
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("cadcraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
+69
View File
@@ -0,0 +1,69 @@
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
//! the loss. The exit code stays 0.
use std::process::{Command, Output};
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
}
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
for i in 0..200 {
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
}
dxf += "0\nENDSEC\n0\nEOF\n";
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
.step_by(97)
.map(|at| {
let mut b = dwg.clone();
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
b
})
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
.expect("no damaged copy read partially");
(dwg, damaged)
}
#[test]
fn info_lists_and_convert_warns_about_read_notes() {
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (whole, damaged) = dwg_and_damaged_copy();
let path = dir.join("damaged.dwg");
std::fs::write(&path, &damaged).unwrap();
let path = path.to_str().unwrap();
let out = cli(&["info", path]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let notes = v["readNotes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{v}");
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
let copy = dir.join("copy.dxf");
let out = cli(&["convert", path, copy.to_str().unwrap()]);
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
let err = String::from_utf8_lossy(&out.stderr);
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
assert_eq!(warnings.len(), 1, "{err}");
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
assert!(copy.exists());
// A complete read: no notes, and no warning.
let intact = dir.join("whole.dwg");
std::fs::write(&intact, &whole).unwrap();
let out = cli(&["info", intact.to_str().unwrap()]);
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["readNotes"], serde_json::json!([]));
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
let _ = std::fs::remove_dir_all(&dir);
}
+4 -1
View File
@@ -18,6 +18,9 @@ path = "src/main.rs"
cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -30,4 +33,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"]
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] }
+62 -102
View File
@@ -1,28 +1,37 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//! Host modes: a page that runs CADCraft opens drawings in it and stores what it saves instead
//! of the browser downloading. The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `cadcraft:` prefix. This module is CADCraft's part of it.
//!
//! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now.
//! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
//! - `cadcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file CADCraft would otherwise download (SAVE, SAVEAS, exports): it replaces the engine's
//! delivery hook. The parent stores the file and reports failures itself.
//! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `cadcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `cadcraft:ready` once the app is listening; send a drawing now. CADCraft reads bytes, so it
//! says `file: false` and a host sends `bytes`.
//! - `cadcraft:open` (from the host): `{ name, bytes }`. It opens like a file picked with
//! File › Open or dropped on the window; an oversized drawing is refused before it is copied.
//! - `cadcraft:save` (to the host): `{ name, saveAs, bytes }` for every file CADCraft would
//! otherwise download (SAVE, SAVEAS, exports): it replaces the engine's delivery hook. The host
//! stores the file and reports failures itself. CADCraft doesn't tell the host which saves
//! make a file the document, so `saveAs` is always `false`.
//! - `cadcraft:dirty` whenever unsaved changes appear or go away, `cadcraft:failed` when the app
//! couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.
//! In both host modes the app starts on the Start tab rather than an empty Drawing1, and the
//! host guards leaving the page.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use cadcraft_ui_egui::CadApp;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys;
use craft_web_host::web::{FileData, Host, Message};
/// Longest file name taken from the parent page.
const MAX_NAME_CHARS: usize = 255;
/// The app id in the host messages (`cadcraft:ready`).
const APP: &str = "cadcraft";
/// The name a drawing gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Drawing.dxf";
thread_local! {
/// The bridge [`deliver`] posts through (the engine's hook is a plain `fn`).
@@ -31,25 +40,15 @@ thread_local! {
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
/// The unsaved-changes state last reported to the parent.
#[derive(Default)]
pub struct DirtyReport(Cell<Option<bool>>);
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when CADCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Make this the bridge [`deliver`] uses.
@@ -58,94 +57,55 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
/// Hear drawings from the parent; they arrive through the upload inbox.
/// Hear drawings from the host; they arrive through the upload inbox.
pub fn listen(&self, inbox: crate::upload::Inbox) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
receive_open(&name, &data, &inbox);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("cadcraft:open") {
return;
}
let name = string(&data, "name")
.filter(|n| !n.trim().is_empty())
.map(|n| n.chars().take(MAX_NAME_CHARS).collect())
.unwrap_or_else(|| "Drawing.dxf".to_string());
// Refuse an oversized drawing before copying it into the app's memory.
let Some(array) = byte_array(&data) else { return };
if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {
inbox.refuse(e);
return;
}
inbox.receive(name, array.to_vec());
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.0.set(Some(dirty));
}
/// After each logic callback (including a hidden tab's): tell the host when unsaved changes
/// appear or go away. The crate does not repeat a state it already reported, so this is
/// idempotent.
pub fn report_dirty(&self, app: &CadApp) {
self.host.post_dirty(app.session.docs.iter().any(|d| d.is_dirty()));
}
fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
self.host.post_save(name, bytes, None, false)
}
}
/// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand
/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.
/// the file to the host page. `Ok` once it was sent; the engine then counts a drawing as saved.
pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;
bridge.post_save(name, bytes)
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.clone())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes))
} else {
None
/// The `cadcraft:open` payload, `{ name, bytes }` where `bytes` holds a drawing: it opens like a
/// file picked with File › Open or dropped on the window. The crate has cleaned the name (and cut
/// it to 255 characters); a blank one comes as `document` and becomes `Drawing.dxf`. An oversized
/// drawing is refused before it is copied.
fn receive_open(name: &str, data: &FileData, inbox: &crate::upload::Inbox) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
inbox.refuse(format!("Open: {name} came as a File; CADCraft reads bytes"));
return;
};
// Refuse an oversized drawing before copying it into the app's memory (the data's own length).
if let Some(e) = CadApp::open_size_error(&name, data.size() as u64) {
inbox.refuse(e);
return;
}
inbox.receive(name, array.to_vec());
}
+25 -24
View File
@@ -6,7 +6,8 @@
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
//! (`host.rs`).
//! (`host.rs`); a page can also host CADCraft in its own document (no iframe) with
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
@@ -27,16 +28,11 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// host page (`?host=parent`, or `data-host="page"` on the canvas), the browser's
/// `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
struct Shell(CadApp, crate::upload::Inbox, Option<crate::host::Bridge>, Option<web_sys::MediaQueryList>, Option<String>);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
@@ -51,10 +47,10 @@ mod web {
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// The crate suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
if let Some(bridge) = &self.2 {
bridge.report_dirty(&self.0);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
@@ -100,8 +96,10 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
/// The app's canvas: the one element `index.html` and every embedding page must provide,
/// with the id the app looks up.
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
@@ -119,6 +117,7 @@ mod web {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
@@ -127,10 +126,17 @@ mod web {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
let Some(canvas) = app_canvas() else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// drawings in it and stores what it saves. Otherwise `data-host="page"` on the canvas
// opts in a page that runs CADCraft in its own document (no iframe) and talks over DOM
// `CustomEvent`s on the canvas (`host`). With neither, there is no file system here:
// saved files reach the user as browser downloads; an unframed `?host=parent` stays
// that normal app.
let bridge = crate::host::Bridge::connect(&canvas);
match &bridge {
Some(bridge) => {
bridge.install();
@@ -141,10 +147,6 @@ mod web {
let app_bridge = bridge.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@@ -156,7 +158,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let mut app = CadApp::new(session, services());
app.system_languages =
@@ -191,10 +193,9 @@ mod web {
picker.pick();
None
}));
let host = app_bridge.map(|bridge| {
let host = app_bridge.inspect(|bridge| {
bridge.listen(inbox.clone());
bridge.post_ready();
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
+54 -8
View File
@@ -2,6 +2,11 @@
//! a path from, so the picker is a hidden `<input type="file">`; the chosen file (or one dropped on
//! the page) is read in the background and handed to the app on its next frame, which opens it
//! with OPEN `{data, name}` ([`CadApp::open_bytes`]).
//!
//! SHX fonts (text fonts, big fonts, shape files) chosen or dropped with a drawing are the web
//! build's font folder: they are registered with the font table
//! ([`cadcraft_fonts::shx::register`]) under their file name, so the styles and linetypes that
//! name them draw with them, and the drawings are regenerated.
use std::cell::Cell;
use std::rc::Rc;
@@ -12,8 +17,14 @@ use web_sys::js_sys;
/// The hidden file input (one at a time; a cancelled pick leaves it until the next one).
const INPUT_ID: &str = "cadcraft_open_input";
/// What the picker offers: the formats OPEN reads.
const ACCEPT: &str = ".dxf,.dwg";
/// What the picker offers: the formats OPEN reads, and the SHX fonts drawings name.
const ACCEPT: &str = ".dxf,.dwg,.shx";
/// Most files taken from one pick.
const MAX_PICKED: u32 = 64;
/// Most bytes being read at once, all files together: a large pick must not exhaust the page's
/// memory (each file is copied once more into the app).
const MAX_READING_BYTES: u64 = 512 << 20;
/// A file read in the browser (name, contents), or why it couldn't be read.
type Upload = Result<(String, Vec<u8>), String>;
@@ -22,12 +33,14 @@ type Upload = Result<(String, Vec<u8>), String>;
#[derive(Clone)]
pub struct Inbox {
files: Rc<Cell<Vec<Upload>>>,
/// Bytes of the files being read now.
reading: Rc<Cell<u64>>,
ctx: egui::Context,
}
impl Inbox {
pub fn new(ctx: &egui::Context) -> Self {
Inbox { files: Rc::default(), ctx: ctx.clone() }
Inbox { files: Rc::default(), reading: Rc::default(), ctx: ctx.clone() }
}
/// Show the browser's file picker and return at once. A chosen file arrives through
@@ -51,13 +64,23 @@ impl Inbox {
.map_err(|_| "could not create a file input".to_string())?;
input.set_type("file");
input.set_accept(ACCEPT);
// A drawing and the fonts it uses can be chosen together.
input.set_multiple(true);
input.set_id(INPUT_ID);
input.set_attribute("style", "display:none").map_err(js_error)?;
let (inbox, chosen) = (self.clone(), input.clone());
// Called once, when a file was chosen (browsers don't fire `change` on cancel).
// Called once, when files were chosen (browsers don't fire `change` on cancel).
let on_change = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Some(file) = chosen.files().and_then(|f| f.get(0)) {
inbox.read(file);
if let Some(files) = chosen.files() {
for i in 0..files.length().min(MAX_PICKED) {
if let Some(file) = files.get(i) {
inbox.read(file);
}
}
let skipped: Vec<String> = (MAX_PICKED..files.length()).filter_map(|i| files.get(i)).map(|f| f.name()).collect();
if !skipped.is_empty() {
inbox.push(Err(format!("Open: at most {MAX_PICKED} files at a time; not opened: {}", skipped.join(", "))));
}
}
chosen.remove();
});
@@ -87,12 +110,18 @@ impl Inbox {
if let Some(e) = CadApp::open_size_error(&name, len) {
return self.push(Err(e));
}
let reading = self.reading.get();
if reading.saturating_add(len) > MAX_READING_BYTES {
return self.push(Err(format!("Open: {name} was not read: the files being read together are too large; open it on its own")));
}
self.reading.set(reading.saturating_add(len));
let inbox = self.clone();
wasm_bindgen_futures::spawn_local(async move {
let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())),
Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))),
};
inbox.reading.set(inbox.reading.get().saturating_sub(len));
inbox.push(r);
});
}
@@ -118,9 +147,21 @@ impl Inbox {
self.ctx.request_repaint();
}
/// Open what arrived since the last frame (or report why it couldn't be read).
/// Open what arrived since the last frame (or report why it couldn't be read). Fonts are
/// registered first, so drawings opened with them draw with them.
pub fn drain(&self, app: &mut CadApp) {
for upload in self.files.take() {
let (fonts, files): (Vec<Upload>, Vec<Upload>) = self.files.take().into_iter().partition(|u| u.as_ref().is_ok_and(|(n, _)| is_shx(n)));
let mut registered = false;
for (name, bytes) in fonts.into_iter().flatten() {
match cadcraft_fonts::shx::register(&name, &bytes) {
Ok(()) => registered = true,
Err(e) => app.open_failed(format!("{name}: {e}")),
}
}
if registered {
let _ = app.run("regenall", serde_json::json!({}));
}
for upload in files {
match upload {
Ok((name, bytes)) => app.open_bytes(&name, &bytes),
Err(e) => app.open_failed(e),
@@ -129,6 +170,11 @@ impl Inbox {
}
}
/// Whether `name` is an SHX font or shape file.
fn is_shx(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".shx")
}
fn js_error(e: wasm_bindgen::JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
+6
View File
@@ -24,6 +24,9 @@ log = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
# Already in Cargo.lock through eframe; same version so nothing is built twice.
ron = "0.12"
[build-dependencies]
winresource = "0.1"
@@ -50,6 +53,9 @@ muda = { version = "0.21", default-features = false }
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
# (system_animations.rs). Already in Cargo.lock through muda.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
winit = { version = "0.30", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
+70 -14
View File
@@ -86,7 +86,7 @@ impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
self.cad.guarded_logic(ctx);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
@@ -116,7 +116,7 @@ impl eframe::App for App {
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
self.cad.guarded_ui(ui);
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
}
@@ -127,7 +127,8 @@ impl eframe::App for App {
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
/// (`persist_window`); egui state is not kept.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
@@ -145,6 +146,7 @@ pub fn install_io() {
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
});
cadcraft_engine::cmd::file::set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
});
@@ -194,8 +196,20 @@ fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
filters
}
/// The main window: its size, title bar, placement and rendering needs.
fn window_options() -> eframe::NativeOptions {
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
/// state. An unreadable or malformed file counts as none, as it does for eframe.
fn saved_window(app_ron: &str) -> bool {
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
}
/// The window state from the last session is saved and will be restored (#2595).
fn restores_window() -> bool {
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
}
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
/// saved by the last session is restored instead of opening centred.
fn window_options(restoring: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("CADCraft")
@@ -206,10 +220,13 @@ fn window_options() -> eframe::NativeOptions {
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// The window opens centred on its screen, not cascaded from the top-left corner (#1129).
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
// restored window on the monitors that are connected now.
persist_window: true,
// The first launch opens centred on its screen, not cascaded from the top-left corner
// (#1129); later launches go back where the window was (eframe would centre over that).
// Wayland compositors place windows themselves.
centered: true,
centered: !restoring,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
@@ -221,6 +238,9 @@ fn window_options() -> eframe::NativeOptions {
const APP_ID: &str = "ai.storyteller.cadcraft";
/// The key eframe saves the main window state under in its storage.
const WINDOW_KEY: &str = "window";
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
const STENCIL_BITS: u8 = 8;
@@ -239,6 +259,11 @@ fn version_string() -> String {
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
fn main() -> eframe::Result {
// A copy of this program started to read or write a DWG file does only that.
if let Some(code) = cadcraft_io::dwg_worker::serve_if_asked() {
std::process::exit(code);
}
cadcraft_io::dwg_worker::use_own_worker();
// First, so every start-up record is captured; see `logging`.
let logger = logging::install();
// The update helper relaunches this binary after a self-update; it runs and exits here.
@@ -270,6 +295,9 @@ fn main() -> eframe::Result {
}
install_io();
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
if let Some(dir) = &vars.config_dir {
cadcraft_ui_egui::fault::install_report_hook(dir.join(cadcraft_ui_egui::fault::REPORT_DIR));
}
if let Some(name) = &vars.font_alt {
cadcraft_engine::fonts::ttf::set_font_alt(name);
}
@@ -281,7 +309,7 @@ fn main() -> eframe::Result {
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
..window_options()
..window_options(restores_window())
};
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
@@ -310,6 +338,16 @@ fn main() -> eframe::Result {
b.with_x11();
}));
}
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
// close request for the window instead, so it asks first, as the close button does.
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_close_requested_on_terminate(true);
}));
}
eframe::run_native(
"CADCraft",
options,
@@ -393,7 +431,7 @@ fn main() -> eframe::Result {
#[cfg(test)]
mod tests {
use super::{filters_for_save, follow_desktop, window_options};
use super::{filters_for_save, follow_desktop, saved_window, window_options};
#[test]
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
@@ -411,12 +449,30 @@ mod tests {
}
}
/// #1129: the window opens centred, not cascaded from the top-left corner.
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
#[test]
fn the_window_opens_centred() {
let o = window_options();
fn the_first_launch_opens_centred() {
let o = window_options(false);
assert!(o.centered, "centred on launch");
assert!(!o.persist_window, "no saved position to restore instead");
assert!(o.persist_window, "the window state is saved for the next launch");
}
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
#[test]
fn a_saved_window_reopens_where_it_was() {
let o = window_options(true);
assert!(!o.centered, "centring would move a restored window");
assert!(o.persist_window);
}
/// Only a readable `app.ron` with a window entry counts as a saved window state.
#[test]
fn a_saved_window_is_read_from_app_ron() {
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
assert!(!saved_window(""));
assert!(!saved_window("{\"window\": "), "truncated");
assert!(!saved_window("not ron at all"));
}
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
+17
View File
@@ -88,6 +88,10 @@ sync_github_name = false
real_name = "Robert Lee"
sync_github_name = true
[people.assamaral]
real_name = "assamaral"
sync_github_name = false
[people.2ez4gcx]
real_name = "Doan Huynh Huu Khuong"
display_name = "Doan Huynh Huu Khuong"
@@ -99,3 +103,16 @@ sync_github_name = true
[people.GULERTOLGA]
sync_github_name = true
[people.Tyler-Chiriboga]
real_name = "Tyler Chiriboga"
sync_github_name = true
[people.starise]
real_name = "Andrea Brandi"
[people.qinshah]
sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
+36 -8
View File
@@ -11,16 +11,13 @@ use std::collections::HashSet;
use cadcraft_doc::{Constraint, Drawing, Handle};
use serde::Serialize;
use crate::model::Model;
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
use crate::model::{Model, Prep};
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
const MAX_NULL_COLS: usize = 600;
/// Largest Jacobian (rows × columns × min) whose rank is computed.
const MAX_RANK_WORK: f64 = 4e8;
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
const MAX_EVAL_WORK: f64 = 2e9;
/// Most objects listed in one report.
const MAX_OBJECTS: usize = 10_000;
@@ -139,8 +136,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
/// (constrained or not) and the constraint components they belong to.
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut members_left = crate::model::MAX_RIGID_WORK;
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
@@ -179,7 +176,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
if seen.contains(&h) {
continue;
}
let m = Model::build_budgeted(d, &[h], &mut members_left);
let m = Model::build_with(d, &[h], &mut prep);
let Some(s) = m.slots.first() else { continue };
seen.insert(h);
free_objects = true;
@@ -237,5 +234,36 @@ mod budget_tests {
};
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
assert!(analyse(&comp).dof.is_none());
// Solving it is refused for the same reason, before any Jacobian is built.
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
}
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
/// and the solve is refused by its rebuild budget instead of running.
#[test]
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
use cadcraft_doc::{Constraint, Line};
use cadcraft_geom::Vec3;
let mut d = Drawing::default();
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
let l =
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
for id in 1..=200 {
d.constraints.push(Constraint {
id,
kind: ConstraintKind::Tangent,
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
name: String::new(),
expr: String::new(),
});
}
let cons: Vec<&Constraint> = d.constraints.iter().collect();
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
assert!(skipped.is_empty());
let comp = comps.first().unwrap();
assert_eq!(comp.built.len(), 200);
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
assert!(crate::purge_invalid(&mut d).is_empty());
}
}
+26 -2
View File
@@ -284,7 +284,14 @@ impl Env {
pub fn new() -> Self {
Env::default()
}
/// Add or replace a named expression. Text longer than [`MAX_LEN`] could never parse, so
/// only a bounded prefix of it is kept: it still fails (as too long), without the environment
/// holding or copying the whole text.
pub fn insert(&mut self, name: &str, expr: &str) {
let expr = match expr.char_indices().map(|(i, _)| i).find(|i| *i > MAX_LEN) {
Some(end) => expr.get(..end).unwrap_or(expr),
None => expr,
};
self.exprs.insert(name.to_string(), expr.to_string());
self.cache.clear();
}
@@ -309,8 +316,9 @@ impl Env {
if stack.len() >= MAX_DEPTH {
return Err(ExprError::TooDeep);
}
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
let e = parse(&text)?;
// Parse the stored text in place: `parse` refuses an oversized expression before reading
// it, so a long imported expression referenced many times is never copied (CWE-400).
let e = parse(self.exprs.get(name).ok_or_else(|| ExprError::Unknown(name.to_string()))?)?;
stack.push(name.to_string());
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
stack.pop();
@@ -400,6 +408,22 @@ mod tests {
.unwrap();
}
/// An oversized named expression is kept only as a bounded prefix and still fails, however
/// often it is referenced.
#[test]
fn oversized_named_expression_is_bounded_and_fails() {
let mut env = Env::new();
let long = format!("1+{}", "é".repeat(500_000));
env.insert("big", &long);
assert!(env.exprs.get("big").is_some_and(|t| t.len() > MAX_LEN && t.len() <= MAX_LEN + 4));
assert_eq!(env.value("big"), Err(ExprError::TooDeep));
for _ in 0..200 {
assert_eq!(env.eval("big"), Err(ExprError::TooDeep));
}
env.insert("ok", "1+1+1");
assert_eq!(env.value("ok"), Ok(3.0));
}
#[test]
fn rename_replaces_whole_names_only() {
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
+74 -11
View File
@@ -30,7 +30,7 @@ mod lm;
mod model;
mod residual;
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
use cadcraft_geom::Vec2;
@@ -41,7 +41,7 @@ pub use dof::{DofReport, DofStatus, ObjectDof, dof};
pub use expr::{Env, ExprError};
pub use model::{ref_points, rigid_points, supported};
use model::Model;
use model::{Model, Prep};
use residual::{Built, Form};
/// Most constraints one drawing may hold.
@@ -50,6 +50,11 @@ pub const MAX_CONSTRAINTS: usize = 5000;
const MAX_CONFLICT_PROBES: usize = 48;
/// Solver iterations.
const MAX_ITER: usize = 200;
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
/// shared by solving, redundancy checks and DOF analysis.
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
const TRIAL_EVALS: f64 = 14.0;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum SolveError {
@@ -169,10 +174,26 @@ pub fn name_in_use(d: &Drawing, name: &str) -> bool {
d.parametric.parameters.iter().any(|p| p.name == name) || d.constraints.iter().any(|c| c.name == name)
}
/// Highest numbered default name [`next_name`] tries.
const MAX_NAME_SUFFIX: u32 = 100_000;
/// Next free parameter name for a dimensional constraint kind (`d1`, `rad2`, `ang1`, …).
pub fn next_name(d: &Drawing, kind: ConstraintKind) -> String {
let prefix = kind.name_prefix();
(1..=100_000).map(|i| format!("{prefix}{i}")).find(|n| !name_in_use(d, n)).unwrap_or_else(|| format!("{prefix}_x"))
// Collect the suffixes in use in one pass, then take the first free one: checking every
// candidate against every name is quadratic in the number of names (CWE-407). Only a
// suffix spelled as `format!("{prefix}{i}")` spells it counts (`d07` does not take `d7`).
let taken: HashSet<u32> = d
.parametric
.parameters
.iter()
.map(|p| p.name.as_str())
.chain(d.constraints.iter().map(|c| c.name.as_str()))
.filter_map(|n| n.strip_prefix(prefix))
.filter(|s| !s.starts_with('0') && s.bytes().all(|b| b.is_ascii_digit()))
.filter_map(|s| s.parse::<u32>().ok())
.collect();
(1..=MAX_NAME_SUFFIX).find(|i| !taken.contains(i)).map_or_else(|| format!("{prefix}_x"), |i| format!("{prefix}{i}"))
}
/// Next free constraint id.
@@ -251,12 +272,41 @@ impl Comp {
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
}
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
/// are shared by residuals at the same points, see [`Model`]).
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
let rows = self.rows(skip) as f64;
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
let mut splines: Vec<Handle> = Vec::new();
for (i, b) in self.built.iter().enumerate() {
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
}
}
splines.sort_unstable();
splines.dedup();
let rebuilds: f64 = splines
.iter()
.filter_map(|h| self.model.slot(*h))
.map(|s| {
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
})
.sum();
evals * rows + rebuilds
}
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
let free = self.free(skip);
let rows = self.rows(skip);
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
return Err(SolveError::TooLarge);
}
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
// A solution the ellipses would be stored differently from (axes swapped, minor axis
@@ -410,24 +460,24 @@ fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut members_left = model::MAX_RIGID_WORK;
build_budgeted(d, cons, opts, &mut members_left)
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
/// its member budget, spline rebuilds) between every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
pub(crate) fn build_with(
d: &Drawing,
cons: &[&Constraint],
opts: &SolveOptions,
members_left: &mut usize,
prep: &mut Prep,
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
let mut e = env(d);
let mut skipped = Vec::new();
let mut comps = Vec::new();
for group in components(cons) {
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
let mut model = Model::build_budgeted(d, &handles, members_left);
let mut model = Model::build_with(d, &handles, prep);
let mut built = Vec::new();
for c in group {
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
@@ -640,6 +690,11 @@ pub fn all_satisfied(d: &Drawing) -> bool {
/// Check that a constraint's references make sense for its type.
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
}
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
for h in &handles {
match d.entity(*h) {
@@ -648,7 +703,7 @@ pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
_ => {}
}
}
let mut model = Model::build(d, &handles);
let mut model = Model::build_with(d, &handles, prep);
interpret(c, &mut model, 1.0).map(|_| ())
}
@@ -665,7 +720,12 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
/// Returns the removed ids.
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
let mut gone = Vec::new();
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
// One preparation for every check: each block is sampled and each spline rebuilt once,
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
// a constraint is never dropped because earlier ones spent it: every block is still
// examined at most once, up to its own member limit.
let mut prep = Prep::new(usize::MAX);
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
let mut i = 0;
d.constraints.retain(|c| {
let k = keep.get(i).copied().unwrap_or(true);
@@ -752,6 +812,9 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
{
let before = comp.free(Some(pos));
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
return Err(SolveError::TooLarge);
}
let after = comp.free(None);
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
let rows_old = comp.rows(Some(pos));
+194 -62
View File
@@ -14,9 +14,11 @@
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
//! a tangency between curves); they are never written back.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
use crate::curves::{Ell, Frame, clamped, spline_end};
@@ -70,6 +72,9 @@ pub(crate) struct Slot {
pub len: usize,
}
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
#[derive(Clone, Debug, Default)]
pub(crate) struct Model {
pub slots: Vec<Slot>,
@@ -79,20 +84,75 @@ pub(crate) struct Model {
splines: HashMap<Handle, GSpline>,
/// Block reference rigid points relative to the insertion point at rotation 0.
rigid: HashMap<Handle, Vec<Vec2>>,
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
/// same spline at the same defining points (several constraints on one spline, or derivatives
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
rebuilt: Rc<RefCell<SplineCache>>,
}
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
#[derive(Debug)]
pub(crate) struct Prep {
/// Block members that may still be examined across the operation.
members_left: usize,
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
folded: Option<HashMap<String, String>>,
rebuilt: Rc<RefCell<SplineCache>>,
}
impl Prep {
/// Preparation that may examine at most `members` block members in all.
pub(crate) fn new(members: usize) -> Prep {
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
}
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
if let Some(b) = d.blocks.get(name) {
return Some(b);
}
let folded = self.folded.get_or_insert_with(|| {
let mut m = HashMap::with_capacity(d.blocks.len());
for k in d.blocks.keys() {
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
}
m
});
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
}
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
if let Some(s) = self.blocks.get(name) {
return s.clone();
}
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
self.blocks.insert(name.to_string(), sampled.clone());
sampled
}
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
}
}
impl Model {
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
let Some(s) = self.splines.get(&handle) else { return 0.0 };
let n = s.fit.len() as f64;
if fit_mode(s) {
n * n * n
} else {
let n = s.control.len() as f64;
n * n
}
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
let n = n as f64;
if fit { n * n * n } else { n * n }
}
}
@@ -115,23 +175,18 @@ pub fn supported(kind: &EntityKind) -> bool {
/// centres, vertices, nested insertion points) of the block's own objects, at most
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
let mut members_left = MAX_RIGID_MEMBERS;
rigid_points_budgeted(d, ins, &mut members_left)
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
/// Sample a block's finite characteristic points in block coordinates, within the per-block
/// member limit and the caller-shared `members_left`. Members that contribute no points still
/// consume traversal work; nested blocks are not entered.
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
let mut out = Vec::new();
// Bound the members examined, not only the points collected: entities that contribute no
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
// every Insert — would otherwise be unbounded (CWE-407).
let mut examined = 0usize;
for e in block.entities.iter() {
examined += 1;
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
// points (Text, Ray, …) still cost traversal (CWE-407).
for (examined, e) in block.entities.iter().enumerate() {
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
break;
}
*members_left -= 1;
@@ -148,10 +203,10 @@ fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) ->
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
_ => Vec::new(),
};
out.extend(pts.into_iter().map(|p| m.apply(p)).filter(|p| p.is_finite()));
out.extend(pts.into_iter().filter(|p| p.is_finite()));
}
out.truncate(MAX_RIGID_POINTS);
out
out.into()
}
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
@@ -187,13 +242,12 @@ fn fit_mode(s: &GSpline) -> bool {
impl Model {
/// Build a model for the given entities. Unsupported or missing entities are skipped.
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
let mut members_left = MAX_RIGID_WORK;
Self::build_budgeted(d, handles, &mut members_left)
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
}
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
/// left out (too large, degenerate or unsupported).
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, members_left: &mut usize) -> Option<Shape> {
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
let shape = match kind {
EntityKind::Line(l) => {
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
@@ -243,15 +297,22 @@ impl Model {
// fit point; a control polygon's ends are its first and last points when the
// knots are clamped.
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
// Missing weights default to 1 in eval; only excess weights are unused.
let mut kept = sp.clone();
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
// weights default to 1 in eval and only excess weights are unused; a control
// spline's fit points are dropped when its control points move (write_back), and
// a fit spline is rebuilt from its parameters, not from its old control net.
let mut kept = if fit {
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
} else {
GSpline { fit: Vec::new(), ..sp.clone() }
};
kept.weights.truncate(kept.control.len());
m.splines.insert(h, kept);
Shape::Spline { n, fit, ends }
}
EntityKind::Insert(ins) => {
let base = Vec2::new(ins.insert.x, ins.insert.y);
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
let n = pts.len();
m.rigid.insert(h, pts);
@@ -262,16 +323,17 @@ impl Model {
Some(shape)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
/// Build solver parameters for supported entities, sharing block sampling, its member budget
/// and spline rebuilds with every other model built from `prep`.
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
for &h in handles {
if m.index.contains_key(&h) {
continue;
}
let Some(e) = d.entity(h) else { continue };
let off = m.x0.len();
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, members_left) else { continue };
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
m.x0.truncate(off);
continue;
@@ -373,18 +435,20 @@ impl Model {
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
}
/// The spline of a reference with the defining points taken from `x`.
fn spline(&self, x: &[f64], s: &Slot) -> Option<GSpline> {
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
let Shape::Spline { n, fit, .. } = s.shape else { return None };
let t = self.splines.get(&s.handle)?;
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
Some(if fit {
t.refit(&pts, t.closed)
} else {
let mut sp = t.clone();
sp.control = pts;
sp
})
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
&& *at == pts
{
return Some(Rc::clone(sp));
}
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
Some(sp)
}
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
@@ -421,7 +485,7 @@ impl Model {
let e = self.ell_at(x, o, true)?;
e.frame(if at_end { e.t1 } else { e.t0 })
}
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&self.spline(x, s)?, at_end),
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
_ => None,
}
}
@@ -590,7 +654,8 @@ impl Model {
}
let shape = s.shape;
// A control-point spline loses its fit data once its control points move.
let spline = self.spline(x, s).map(|mut sp| {
let spline = self.spline(x, s).map(|sp| {
let mut sp = GSpline::clone(&sp);
if matches!(shape, Shape::Spline { fit: false, .. }) {
sp.fit.clear();
}
@@ -712,21 +777,21 @@ mod budget_tests {
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
let mut block = Block::new("BAR");
for _ in 0..3 {
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
let mut block = Block::new(name);
for _ in 0..lines {
block.entities.push(Entity {
handle: d.new_handle(),
common: Common::default(),
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
});
}
d.blocks.insert("BAR".into(), Arc::new(block));
let ins = Insert {
block: "BAR".into(),
d.blocks.insert(name.into(), Arc::new(block));
}
fn insert(block: &str) -> Insert {
Insert {
block: block.into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
@@ -735,13 +800,80 @@ mod budget_tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
let mut budget = 2;
let first = Model::build_budgeted(&d, &[h], &mut budget);
assert_eq!(first.rigid[&h].len(), 4);
assert_eq!(budget, 0);
let next = Model::build_budgeted(&d, &[h], &mut budget);
assert!(next.rigid[&h].is_empty());
clip: None,
}
}
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
}
/// The member budget is shared by every model of one preparation, and a block it already
/// sampled is reused by later Inserts without examining its members again.
#[test]
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
let mut d = Drawing::default();
add_block(&mut d, "BAR", 3);
add_block(&mut d, "BAZ", 3);
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
let mut prep = Prep::new(2);
let first = Model::build_with(&d, &[bar], &mut prep);
assert_eq!(first.rigid[&bar].len(), 4);
assert_eq!(prep.members_left, 0);
let other = Model::build_with(&d, &[baz], &mut prep);
assert!(other.rigid[&baz].is_empty());
let again = Model::build_with(&d, &[bar2], &mut prep);
assert_eq!(again.rigid[&bar2].len(), 4);
}
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
#[test]
fn block_names_resolve_ignoring_case() {
let mut d = Drawing::default();
add_block(&mut d, "Bar", 2);
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
let mut prep = Prep::new(MAX_RIGID_WORK);
let m = Model::build_with(&d, &[lower, missing], &mut prep);
assert_eq!(m.rigid[&lower].len(), 4);
assert!(m.rigid[&missing].is_empty());
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
}
/// A spline asked for again at the same defining points is not rebuilt, also from another
/// model of the same preparation; moved points rebuild it.
#[test]
fn spline_rebuilds_are_reused_at_the_same_points() {
let mut d = Drawing::default();
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
let mut prep = Prep::new(MAX_RIGID_WORK);
let a = Model::build_with(&d, &[h], &mut prep);
let b = Model::build_with(&d, &[h], &mut prep);
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
let first = a.spline(&a.x0, sa).unwrap();
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
let mut moved = a.x0.clone();
moved[1] += 0.5;
let other = a.spline(&moved, sa).unwrap();
assert!(!Rc::ptr_eq(&first, &other));
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
}
/// A control spline's template keeps no fit points and no excess weights.
#[test]
fn control_template_drops_unused_data() {
let mut d = Drawing::default();
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
sp.fit = vec![Vec2::ZERO; 10_000];
sp.fit_opts.tolerance = 0.5;
sp.weights = vec![1.0; 10_000];
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
let m = Model::build(&d, &[h]);
let t = &m.splines[&h];
assert!(t.fit.is_empty());
assert_eq!(t.weights.len(), 4);
}
}
+27
View File
@@ -489,3 +489,30 @@ fn arc_solved_with_a_negative_radius_keeps_its_points() {
}
}
}
/// The default name is the first free numbered one, found in one pass over many names in use.
#[test]
fn next_name_skips_names_in_use() {
let mut d = Drawing::default();
assert_eq!(next_name(&d, ConstraintKind::Radius), "rad1");
for i in (1..=20_000).filter(|i| *i != 15_000) {
d.parametric.parameters.push(cadcraft_doc::Parameter { name: format!("d{i}"), expr: "1".into(), description: String::new() });
}
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d15000");
d.parametric.parameters.push(cadcraft_doc::Parameter { name: "d15000".into(), expr: "1".into(), description: String::new() });
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d20001");
// A differently spelled suffix does not occupy the plain name.
let mut d = Drawing::default();
for name in ["d01", "d+1", "d1x", "D1"] {
d.parametric.parameters.push(cadcraft_doc::Parameter { name: name.into(), expr: "1".into(), description: String::new() });
}
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d1");
d.constraints.push(Constraint {
id: 1,
kind: ConstraintKind::Distance(DistAxis::Aligned),
refs: Vec::new(),
name: "d1".into(),
expr: "1".into(),
});
assert_eq!(next_name(&d, ConstraintKind::Distance(DistAxis::Aligned)), "d2");
}
+2
View File
@@ -302,6 +302,7 @@ fn block_reference_moves_rigidly() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
);
assert_eq!(obj_dof(&d, ins), Some(3));
@@ -343,6 +344,7 @@ fn hostile_new_geometry_never_panics() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
);
let l = line(&mut d, (0.0, 0.0), (0.0, 0.0));
+1
View File
@@ -228,6 +228,7 @@ impl ArrayParams {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) });
}
+44 -7
View File
@@ -194,7 +194,9 @@ pub struct Action {
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum ActionKind {
/// Moves the vertices inside `frame` of the objects listed (all of an object not listed).
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
/// file lists for each object, used only when the frame can't be tested (see `apply`).
Stretch {
frame: Vec<Vec2>,
#[serde(serialize_with = "hex_vertices")]
@@ -321,8 +323,9 @@ impl<'a> Cursor<'a> {
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
@@ -332,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let graph = evaluation_graph(objects, xdict)?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
@@ -341,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
/// that asks about many blocks (listing them) builds it once and passes it to
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
/// multiplies the block count by the object count (CWE-407).
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
impl<'a> ObjectIndex<'a> {
pub fn new(d: &'a Drawing) -> Self {
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
}
}
/// The extension dictionary of block `name`'s record, if it has one.
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// A block without an extension dictionary is never dynamic: answer before indexing objects.
block_xdict(d, name)?;
dynamic_block_in(d, &ObjectIndex::new(d), name)
}
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
}
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
let xdict = block_xdict(d, name)?;
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
for o in graph_members(&objects.0, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
+93 -9
View File
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
}
/// The object entry `key` (any case) of dictionary object `o` names.
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
}
/// Every entry of dictionary object `o`: (key, object).
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
@@ -403,7 +411,31 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) {
}
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon).
/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame
/// is two corners or a short polygon; testing every point of every object against every edge of a
/// longer one would grow with their product, so the action's own vertex lists are used instead.
const MAX_FRAME_POINTS: usize = 1000;
/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the
/// action selects that lies outside its frame moves whole.
fn inside(frame: &[Vec2], points: impl Iterator<Item = Vec2>) -> Option<Vec<i32>> {
let idx: Vec<i32> = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect();
(!idx.is_empty()).then_some(idx)
}
/// The distance from `p` to the segment `a`-`b`, however short (`Line::dist` takes one shorter than
/// `EPS` for its first point). It is measured from `a`, so a short segment far from the origin keeps
/// the digits rounding there would drop.
fn segment_dist(a: Vec2, b: Vec2, p: Vec2) -> f64 {
let d = b - a;
let w = p - a;
let l2 = d.len2();
let t = if l2 > 0.0 { (w.dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(w - d * t).len()
}
/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is
/// inside, for a polygon as for two corners: within a billionth of the edge's length of it.
fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
match frame {
[a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y),
@@ -412,6 +444,9 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool {
let mut j = f.len() - 1;
for i in 0..f.len() {
let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break };
if segment_dist(*pj, *pi, p) <= 1e-9 * pi.dist(*pj) {
return true;
}
if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x {
inside = !inside;
}
@@ -464,7 +499,10 @@ fn apply(
_ => 0.0,
})
};
let mult = if mult == 0.0 { 1.0 } else { mult };
// A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the
// parameter's change and leaves its angle offset out (measured on a stretch action written
// with multiplier 0, angle offset 1 and group 280 = 1).
let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) };
let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult;
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
continue;
@@ -473,6 +511,8 @@ fn apply(
// this action's selection, so a selection that repeats a handle does not re-scan the
// vertex list and re-clone its index vector per occurrence (CWE-400).
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
// Whether the frame (two corners or a polygon) is tested point by point.
let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len());
let mut handled: HashSet<Handle> = HashSet::new();
for h in &a.selection {
if !handled.insert(*h) {
@@ -502,7 +542,18 @@ fn apply(
continue;
}
let Some(c) = copy_of(h) else { continue };
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
// The points of a line or polyline that move are those inside the frame where the
// dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It
// doesn't go by the vertex lists the action stores, which a block from another program
// can get wrong. A polyline's vertices are in its own plane: in the block's when its
// extrusion is exactly along z (a mirrored one is read flipped into it). Any tilt, however
// small, moves them by as much as the elevation times the tilt, so a tilted one keeps the
// stored list.
let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) {
Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()),
Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)),
_ => vtx_by_handle.get(h).map(|i| (*i).clone()),
};
moves.entry(c).or_default().push((idx, v));
}
}
@@ -649,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
///
/// Each record is copied once at most: a key naming a record an earlier key already took is
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
/// made), so a file listing one large record under many keys would otherwise multiply its size by
/// the key count (CWE-400); AutoCAD gives every key its own record.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// Dictionary entries looked up through the index, not by scanning every object per
// dictionary (which is quadratic in the object count).
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
let mut taken: HashSet<Handle> = HashSet::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
let Some(values) = objects.get(&values) else { continue };
for (k, h) in dictionary_entries(values) {
if out.contains_key(&k) || taken.contains(&h) {
continue;
}
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
taken.insert(h);
out.insert(k, x.tags.clone());
}
}
}
@@ -1001,4 +1066,23 @@ mod tests {
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
#[test]
fn a_value_record_listed_under_many_keys_is_copied_once() {
// One large XRECORD named by many keys of a reference's value dictionary: the templates
// keep one copy, not one per key.
let mut d = Drawing::new_imperial();
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
f.objects.push(dictionary(values, cache, &entries));
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
let t = templates(&d, def_record);
assert_eq!(t.len(), 1, "one record, one template");
assert_eq!(t.get("0").map(Vec::len), Some(1000));
}
}
+25 -7
View File
@@ -48,7 +48,7 @@ mod wipeout;
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, PolyfaceMesh, Polyline3d, RayLine, Solid};
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
pub use fill::{Gradient, Hatch, HatchLoop};
pub use layout::{Image, Table, TableCell, Viewport};
@@ -60,6 +60,13 @@ pub struct Unknown {
pub tags: Vec<RawTag>,
}
impl Unknown {
/// Whether this is an OLE2FRAME (the type name is matched without regard to case).
pub fn is_ole_frame(&self) -> bool {
self.dxf_type.eq_ignore_ascii_case("OLE2FRAME")
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum EntityKind {
@@ -86,6 +93,7 @@ pub enum EntityKind {
Solid(Solid),
Trace(Solid),
Face3d(Face3d),
PolyfaceMesh(PolyfaceMesh),
Region(Region),
Viewport(Viewport),
Image(Image),
@@ -140,6 +148,7 @@ impl EntityKind {
EntityKind::Solid(_) => "2D Solid",
EntityKind::Trace(_) => "Trace",
EntityKind::Face3d(_) => "3D Face",
EntityKind::PolyfaceMesh(_) => "Polyface Mesh",
EntityKind::Region(_) => "Region",
EntityKind::Viewport(_) => "Viewport",
EntityKind::Image(_) => "Raster Image",
@@ -175,6 +184,7 @@ impl EntityKind {
EntityKind::Solid(_) => "SOLID",
EntityKind::Trace(_) => "TRACE",
EntityKind::Face3d(_) => "3DFACE",
EntityKind::PolyfaceMesh(_) => "POLYLINE",
EntityKind::Region(_) => "REGION",
EntityKind::Viewport(_) => "VIEWPORT",
EntityKind::Image(_) => "IMAGE",
@@ -218,10 +228,10 @@ impl EntityKind {
let c = &s.corners;
vec![Prim::Fill(vec![c[0].xy(), c[1].xy(), c[3].xy(), c[2].xy()])]
}
EntityKind::Face3d(f) => {
let c = &f.corners;
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
EntityKind::Face3d(f) => f.visible_edges().into_iter().map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect(),
// Each face's visible edges (an edge flagged hidden, by a negative index, isn't drawn).
EntityKind::PolyfaceMesh(m) => {
m.faces3d().iter().flat_map(Face3d::visible_edges).map(|(a, b)| Prim::Seg(Segment::Line(GLine::new(a.xy(), b.xy())))).collect()
}
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
EntityKind::Hatch(h) => {
@@ -359,9 +369,13 @@ impl EntityKind {
i.scale.y *= s;
i.scale.z *= s;
i.rotation = cadcraft_geom::norm_angle(i.rotation + rot);
// Mirrored as AutoCAD 2024 mirrors a block reference: the X scale factor
// negated, the rotation 180 degrees plus twice the mirror line's angle less the
// old one, and a MINSERT grid's columns running the other way.
if mirror {
i.scale.y = -i.scale.y;
i.rotation = cadcraft_geom::norm_angle(rot - (i.rotation - rot));
i.scale.x = -i.scale.x;
i.rotation = cadcraft_geom::norm_angle(std::f64::consts::PI + rot - (i.rotation - rot));
i.col_spacing = -i.col_spacing;
}
for a in &mut i.attribs {
transform_text(&mut a.text, m, s, rot, false);
@@ -424,6 +438,7 @@ impl EntityKind {
}
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
EntityKind::PolyfaceMesh(pm) => pm.vertices.iter_mut().for_each(t3),
EntityKind::Region(r) => {
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
v.p = m.apply(v.p);
@@ -461,6 +476,7 @@ impl EntityKind {
let a = if mirror { t.rotation() } else { t.rotation() + rot };
t.dir = Vec2::from_angle(cadcraft_geom::norm_angle(a)).to3(0.0);
}
EntityKind::Unknown(u) if u.is_ole_frame() => crate::ole_frame_transform(&mut u.tags, m),
EntityKind::Unknown(_) => {}
}
}
@@ -542,6 +558,7 @@ impl EntityKind {
EntityKind::Hatch(h) => scale(&mut h.elevation),
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z),
EntityKind::PolyfaceMesh(m) => m.vertices.iter_mut().for_each(z),
EntityKind::Viewport(v) => z(&mut v.center),
EntityKind::Image(i) => z(&mut i.insert),
EntityKind::Table(t) => z(&mut t.insert),
@@ -611,6 +628,7 @@ impl EntityKind {
}
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
EntityKind::PolyfaceMesh(m) => m.vertices.iter().map(|v| v.xy()).collect(),
EntityKind::Region(r) => vec![r.bounds().center()],
EntityKind::Viewport(v) => {
let c = v.center.xy();
+19
View File
@@ -161,6 +161,25 @@ pub struct Insert {
pub col_spacing: f64,
#[serde(default)]
pub row_spacing: f64,
/// XCLIP boundary of this reference (other references to the block are not clipped).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<crate::ClipBoundary>,
}
impl Default for Insert {
fn default() -> Self {
Insert {
block: String::new(),
insert: Vec3::ZERO,
scale: unit_scale(),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}
}
fn unit_scale() -> Vec3 {
+46
View File
@@ -69,6 +69,52 @@ pub struct Face3d {
pub hidden_edges: u8,
}
impl Face3d {
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
/// without the zero-length side of a triangle.
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
let c = self.corners;
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
}
}
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
/// that starts at that vertex).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PolyfaceMesh {
pub vertices: Vec<Vec3>,
pub faces: Vec<[i32; 4]>,
}
impl PolyfaceMesh {
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
/// index outside the vertex list are skipped.
pub fn faces3d(&self) -> Vec<Face3d> {
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
}
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
/// or one outside the vertex list.
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
let mut hidden = 0u8;
for &ix in f.iter().take_while(|&&ix| ix != 0) {
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
let p = *at.and_then(|n| self.vertices.get(n))?;
if ix < 0 {
hidden |= 1 << corners.len();
}
corners.push(p);
}
match corners.as_slice() {
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RayLine {
pub base: Vec3,
+5
View File
@@ -52,6 +52,11 @@ pub struct Dimension {
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
#[serde(default)]
pub block: Option<String>,
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
/// Zero once the geometry is regenerated from the definition points.
#[serde(default)]
pub insertion_point: Vec3,
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
/// merged over the named style.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
+39 -2
View File
@@ -1,3 +1,5 @@
use cadcraft_geom::Mat3;
use super::common::Handle;
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
@@ -25,6 +27,39 @@ pub struct Viewport {
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
#[serde(default = "yes")]
pub on: bool,
/// The view twist (DVIEW TWist inside the viewport; DXF group 51): radians counterclockwise,
/// the model view seen through the viewport is turned on the sheet by it. 0 is untwisted.
#[serde(default, skip_serializing_if = "is_zero_twist")]
pub twist: f64,
}
fn is_zero_twist(v: &f64) -> bool {
*v == 0.0
}
impl Viewport {
/// The view twist, usable: 0 when it isn't finite.
pub fn view_twist(&self) -> f64 {
if self.twist.is_finite() { self.twist } else { 0.0 }
}
/// Paper units per model unit.
pub fn scale(&self) -> f64 {
self.height / self.view_height
}
/// Where model point `m` is on the sheet: `center + scale · R(twist)(m − view_center)`.
pub fn model_to_paper(&self, m: Vec2) -> Vec2 {
self.center.xy() + (m - self.view_center).rotate(self.view_twist()) * self.scale()
}
/// The model point at sheet point `p` (the inverse of [`Viewport::model_to_paper`]).
pub fn paper_to_model(&self, p: Vec2) -> Vec2 {
self.view_center + ((p - self.center.xy()) / self.scale()).rotate(-self.view_twist())
}
/// [`Viewport::model_to_paper`] as a matrix.
pub fn model_to_paper_matrix(&self) -> Mat3 {
let s = self.scale();
Mat3::translate(self.center.xy())
.then_before(Mat3::scale(s, s))
.then_before(Mat3::rotate(self.view_twist()))
.then_before(Mat3::translate(-self.view_center))
}
}
impl Default for Viewport {
@@ -41,6 +76,7 @@ impl Default for Viewport {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
}
}
}
@@ -59,8 +95,9 @@ pub struct Image {
/// The image definition's name (its key in the drawing's image dictionary).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
/// Clip boundary in pixel coordinates (y down, counted from pixel centres: the centre of the
/// top-left pixel is the origin, so the whole image is -0.5 to size - 0.5): two opposite
/// corners of a rectangle, or a polygon's vertices. Empty: the whole image.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clip: Vec<Vec2>,
/// The clip boundary is applied.
+98 -10
View File
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
pub fill: Vec<Vec<Vec2>>,
}
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
///
/// Two linear passes: searching from every segment separately is quadratic in a run of
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
return (vec![Vec2::X; n], vec![Vec2::X; n]);
};
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
let mut cur = Vec2::X;
for k in 0..n {
let i = (last + n - k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(a) = ahead.get_mut(i) {
*a = cur;
}
}
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
for k in 0..n {
let i = (first + k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(b) = behind.get_mut(i) {
*b = cur;
}
}
(ahead, behind)
}
impl MLine {
/// A multiline through `pts` in style `def`.
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
@@ -268,16 +303,11 @@ impl MLine {
let dirs = self.directions();
let n = self.vertices.len();
let nseg = dirs.len();
// A degenerate segment takes its neighbour's direction.
let dir = |i: usize| -> Vec2 {
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
};
let dir_back = |i: usize| -> Vec2 {
(0..nseg)
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
.find(|d| *d != Vec2::ZERO)
.unwrap_or(Vec2::X)
};
// A degenerate segment takes its neighbour's direction: the first usable one at or after
// it (`ahead`), or at or before it (`behind`), wrapping around.
let (ahead, behind) = usable_directions(&dirs);
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
let end = |d: Vec2, angle: f64| {
let u = d.rotate(angle);
let s = angle.sin();
@@ -426,3 +456,61 @@ impl crate::Drawing {
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The nearest usable directions found by searching from each segment, as miters did before.
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
(ahead.collect(), behind.collect())
}
#[test]
fn usable_directions_match_a_search_from_each_segment() {
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
for dirs in [
vec![],
vec![z],
vec![a],
vec![z, z, z],
vec![a, z, z, b],
vec![z, a, z, b, z],
vec![z, z, c],
vec![c, z, z],
vec![a, b, c],
vec![z, a, z, z, b, c, z],
] {
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
}
}
#[test]
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
// Every vertex of a run on one point mitres between the segment before the run and the one after.
let style = MLineStyle::default();
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
let (expected, got) = (corner.miters(), run.miters());
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
}
#[test]
fn a_long_run_of_coincident_vertices_takes_linear_time() {
// 200 000 vertices on one point but the last: the miters must not search the run once per
// vertex (quadratic, minutes) to find the one usable direction.
let mut pts = vec![Vec2::ZERO; 200_000];
pts.push(Vec2::new(1.0, 0.0));
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
let t = std::time::Instant::now();
let miters = m.miters();
assert_eq!(miters.len(), pts.len());
assert!(miters.iter().all(|v| v.is_finite()));
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
}
+73 -7
View File
@@ -9,6 +9,9 @@ use super::HatchLoop;
/// Most loops one region has (hostile files and edits).
pub const MAX_REGION_LOOPS: usize = 10_000;
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Closed loops with bulges; `outer` false for islands.
@@ -76,20 +79,49 @@ impl Region {
}
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
/// an outer loop, odd an island.
pub fn renest(&mut self) {
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
#[must_use]
pub fn renest(&mut self) -> bool {
let polys = self.tessellate(self.fine_tol());
let depth: Vec<usize> = polys
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
// such a polygon need not be closed), so its loop is always tested.
let boxes: Vec<Option<Bounds2>> = polys
.iter()
.enumerate()
.map(|(i, l)| {
let Some(p) = probe(l) else { return 0 };
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
.map(|l| {
let b = Bounds2::from_points(l.iter().copied());
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
})
.collect();
let mut visits = 0usize;
let mut depth = Vec::with_capacity(polys.len());
for (i, l) in polys.iter().enumerate() {
let Some(p) = probe(l) else {
depth.push(0);
continue;
};
let mut d = 0;
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
if j == i || b.is_some_and(|b| !b.contains(p)) {
continue;
}
// Each loop against every other is loops x vertices: bounded so a region of many
// large nested loops cannot hold the thread (CWE-407).
visits = visits.saturating_add(o.len());
if visits > MAX_RENEST_EDGE_VISITS {
return false;
}
if point_in_polygon(o, p) {
d += 1;
}
}
depth.push(d);
}
for (l, d) in self.loops.iter_mut().zip(depth) {
l.outer = d % 2 == 0;
}
true
}
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
// `n` vertices along the bottom edge, then the other three corners.
let mut vertices: Vec<PolyVertex> =
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
HatchLoop { vertices, outer, sources: Vec::new() }
}
#[test]
fn renest_sets_depth_parity_and_skips_loops_far_away() {
// Three nested squares, and one beside them.
let mut r =
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
assert!(r.renest());
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
}
#[test]
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
// Concentric loops of many vertices each: each probe is inside the box of every larger
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
let loops = 300;
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
let before = r.clone();
assert!(!r.renest());
assert_eq!(r, before, "flags left as they were");
}
}
+686 -9
View File
@@ -9,6 +9,239 @@ use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim};
/// Nested block references deeper than this are ignored (cyclic or hostile files).
pub const MAX_BLOCK_DEPTH: usize = 16;
/// Positions in `tags` of the groups outside application-defined `102 {` .. `102 }` groups, whose
/// codes are the application's own and not the entity's.
fn outside_app(tags: &[crate::RawTag]) -> impl Iterator<Item = usize> + '_ {
let mut in_app = false;
tags.iter().enumerate().filter_map(move |(i, t)| {
if t.code == 102 {
in_app = t.value.starts_with('{');
None
} else {
(!in_app).then_some(i)
}
})
}
/// The positions of the frame's corner groups 10, 20, 11, 21 (in that order) and their values:
/// the first of each outside application groups, found in one pass that stops at the last.
fn ole_corner_groups(tags: &[crate::RawTag]) -> Option<[(usize, f64); 4]> {
let mut found: [Option<(usize, f64)>; 4] = [None; 4];
for i in outside_app(tags) {
let Some(t) = tags.get(i) else { continue };
let slot = match t.code {
10 => 0,
20 => 1,
11 => 2,
21 => 3,
_ => continue,
};
if let Some(f) = found.get_mut(slot).filter(|f| f.is_none()) {
*f = Some((i, t.value.trim().parse::<f64>().ok().filter(|v| v.is_finite())?));
if found.iter().all(Option::is_some) {
break;
}
}
}
let [Some(a), Some(b), Some(c), Some(d)] = found else { return None };
Some([a, b, c, d])
}
/// The closed outline of an OLE2FRAME from its upper-left (groups 10, 20) and lower-right
/// (groups 11, 21) corners (DXF Reference, OLE2FRAME); `None` when either is missing or not finite.
pub fn ole_frame(tags: &[crate::RawTag]) -> Option<[Vec2; 5]> {
let [(_, x1), (_, y1), (_, x2), (_, y2)] = ole_corner_groups(tags)?;
let (a, b) = (Vec2::new(x1, y1), Vec2::new(x2, y2));
Some([a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y), a])
}
/// The bytes of the OLE data before its corners: a 2-byte marker.
const OLE_HEADER: usize = 2;
/// The corners in the OLE data: four of x, y, z as little-endian doubles (upper left, upper right,
/// lower right, lower left).
const OLE_CORNERS: usize = 12;
/// Replace the four corners at the start of the OLE data (the 310 groups' hex, in order) by `new`
/// (upper left, upper right, lower right, lower left), keeping z and every group's length and
/// letter case. Only the groups that cover the header are decoded and rewritten; the rest of the
/// payload is not touched. Data that is not valid hex, too short, or whose corners are not the old
/// `frame`'s is left alone.
fn ole_data_transform(tags: &mut [crate::RawTag], new: [Vec2; 4], frame: [f64; 4]) {
const END: usize = OLE_HEADER + OLE_CORNERS * 8;
let mut bytes: Vec<u8> = Vec::with_capacity(END);
// The groups read, and how many leading bytes of each belong to the header.
let mut spans: Vec<(usize, usize)> = Vec::new();
for i in outside_app(tags).filter(|&i| tags.get(i).is_some_and(|t| t.code == 310)) {
if bytes.len() >= END {
break;
}
let Some(t) = tags.get(i) else { return };
let s = t.value.trim();
if s.len() % 2 != 0 || !s.is_ascii() {
return;
}
let take = (s.len() / 2).min(END - bytes.len());
for k in 0..take {
// `from_str_radix` also takes a sign, so each digit is checked first.
let Some(h) = s.get(2 * k..2 * k + 2).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) else { return };
let Ok(b) = u8::from_str_radix(h, 16) else { return };
bytes.push(b);
}
spans.push((i, take));
}
if bytes.len() < END {
return;
}
// Only data whose corners are this frame's corners (UL, UR, LR, LL) has the layout above; any
// other header is not ours to rewrite.
let [x1, y1, x2, y2] = frame;
let want = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)];
// Two corners match within a fixed tolerance for ordinary coordinates (CORNER_EPS at most), or
// within a few ulps of the coordinate where that is larger, which is from about 1.1e9 up.
let near = |a: f64, b: f64| {
let m = a.abs().max(b.abs());
(a - b).abs() <= (1e-9 * m.max(1.0)).min(CORNER_EPS).max(4.0 * f64::EPSILON * m)
};
for (c, (wx, wy)) in want.iter().enumerate() {
let at = OLE_HEADER + c * 24;
let rd = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes);
let (Some(x), Some(y)) = (rd(at), rd(at + 8)) else { return };
if !(near(x, *wx) && near(y, *wy)) {
return;
}
}
for (c, p) in new.iter().enumerate() {
let at = OLE_HEADER + c * 24;
if let Some(s) = bytes.get_mut(at..at + 8) {
s.copy_from_slice(&p.x.to_le_bytes());
}
if let Some(s) = bytes.get_mut(at + 8..at + 16) {
s.copy_from_slice(&p.y.to_le_bytes());
}
}
let mut pos = 0;
for (i, n) in spans {
let (Some(t), Some(chunk)) = (tags.get_mut(i), bytes.get(pos..pos + n)) else { return };
// Leave any padding around the digits, and the digits after the header, as they were.
let start = t.value.len() - t.value.trim_start().len();
// Only the rewritten digits and a few after them, for the case of the nearest letter: a
// hostile 310 line can be as long as the file (the value is checked to be ASCII above).
let end = start.saturating_add(2 * n + 64).min(t.value.len());
let hex = recase(chunk, t.value.get(start..end).unwrap_or_default());
t.value.replace_range(start..start + 2 * n, &hex);
pos += n;
}
}
/// The hex of `chunk`, each digit in the case of the digit at the same place in `old`. A digit that
/// was a number and becomes a letter takes the case of the nearest letter in `old`, upper when there
/// is none, so bytes that did not change keep their exact text.
fn recase(chunk: &[u8], old: &str) -> String {
let old = old.as_bytes();
// For each place, whether the letter nearest to it (the one at it, else the closest before or
// after; the one before on a tie) is lower case: one pass for the next letter at or after each
// place, one for the previous, then the nearer of the two.
let mut next: Vec<Option<(usize, bool)>> = vec![None; old.len()];
let mut seen: Option<(usize, bool)> = None;
for (i, c) in old.iter().enumerate().rev() {
if c.is_ascii_alphabetic() {
seen = Some((i, c.is_ascii_lowercase()));
}
if let Some(slot) = next.get_mut(i) {
*slot = seen;
}
}
let mut lower_at = vec![false; old.len()];
let mut prev: Option<(usize, bool)> = None;
for (i, c) in old.iter().enumerate() {
if c.is_ascii_alphabetic() {
prev = Some((i, c.is_ascii_lowercase()));
}
let after = next.get(i).copied().flatten();
let lower = match (prev, after) {
(Some((p, lp)), Some((q, lq))) => {
if i - p <= q - i {
lp
} else {
lq
}
}
(Some((_, l)), None) | (None, Some((_, l))) => l,
(None, None) => false,
};
if let Some(slot) = lower_at.get_mut(i) {
*slot = lower;
}
}
let mut out = String::with_capacity(chunk.len() * 2);
for (k, b) in chunk.iter().enumerate() {
for (j, d) in [b >> 4, b & 15].into_iter().enumerate() {
let digit = char::from_digit(d.into(), 16).unwrap_or('0');
out.push(if lower_at.get(2 * k + j).copied().unwrap_or(false) { digit } else { digit.to_ascii_uppercase() });
}
}
out
}
/// The fixed part of the tolerance when matching the corners in the OLE data to the frame's. The
/// header holds the same f64 the frame groups print, so they agree to a few ulps. Up to about 1.1e9
/// a fixed tolerance of at most this decides, so that distinct geometry does not match; above that,
/// four ulps of the coordinate (`4 * EPSILON * |x|`) exceed it and decide instead.
const CORNER_EPS: f64 = 1e-6;
/// Transform an OLE2FRAME by `m`: its two corners are mapped through `m` (a DWG save writes the
/// frame's position from these groups), and so are the corners at the start of its OLE data (310
/// groups), which a reader of the saved file may trust instead. The frame stays axis aligned with
/// its upper-left corner first. A matrix that keeps the axes or swaps them (a move, scale, mirror or
/// quarter turn) gives the box of the two mapped corners. Any other turn would collapse that box
/// (a square turned 45° maps its two corners level), so the frame's centre is mapped and its size
/// kept, scaled by `m`'s, its sides swapped when the turn is nearer a quarter turn. Commands refuse
/// a rotation or mirror (see [`ole_frame_accepts`]), but internal callers such as EXPLODE, REFEDIT
/// and paste still apply them, and must leave a consistent frame. A frame with no usable corners is
/// left alone.
pub fn ole_frame_transform(tags: &mut [crate::RawTag], m: &cadcraft_geom::Mat3) {
let Some([(ix1, x1), (iy1, y1), (ix2, x2), (iy2, y2)]) = ole_corner_groups(tags) else { return };
let frame = [x1, y1, x2, y2];
let t = 1e-12 * (m.a.abs() + m.b.abs() + m.c.abs() + m.d.abs());
let axes_kept = (m.b.abs() <= t && m.c.abs() <= t) || (m.a.abs() <= t && m.d.abs() <= t);
let (a, b) = if axes_kept {
(m.apply(Vec2::new(x1, y1)), m.apply(Vec2::new(x2, y2)))
} else {
let center = m.apply(Vec2::new((x1 + x2) / 2.0, (y1 + y2) / 2.0));
// A plain turn keeps the size exactly; cos² + sin² can miss 1 by an ulp or two.
let s = (m.a * m.d - m.b * m.c).abs().sqrt();
let scale = if (s - 1.0).abs() < 1e-12 { 1.0 } else { s };
let (mut w, mut h) = ((x2 - x1).abs() * scale, (y2 - y1).abs() * scale);
// Where the frame's x axis goes: nearer to y than to x is nearer a quarter turn.
if m.b.abs() > m.a.abs() {
std::mem::swap(&mut w, &mut h);
}
(center - Vec2::new(w, h) / 2.0, center + Vec2::new(w, h) / 2.0)
};
if !(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return;
}
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
let (ul, lr) = (Vec2::new(lo.x, hi.y), Vec2::new(hi.x, lo.y));
for (i, v) in [(ix1, ul.x), (iy1, ul.y), (ix2, lr.x), (iy2, lr.y)] {
if let Some(t) = tags.get_mut(i) {
t.value = v.to_string();
}
}
ole_data_transform(tags, [ul, Vec2::new(lr.x, ul.y), lr, Vec2::new(ul.x, lr.y)], frame);
}
/// Whether `m` can move an OLE2FRAME: a translation (move, copy, stretch of the whole frame) or a
/// uniform scale with a positive factor, which keep the frame axis aligned and its upper-left corner
/// upper-left.
pub fn ole_frame_accepts(m: &cadcraft_geom::Mat3) -> bool {
const T: f64 = 1e-12;
// The tolerance grows with the scale: a matrix built from two angles (ALIGN) leaves rounding noise
// in b and c of about `scale * 1e-16`.
let t = T * m.a.abs();
m.b.abs() < t && m.c.abs() < t && m.a > 0.0 && (m.a - m.d).abs() <= t
}
/// Bounds of a single-line TEXT / ATTDEF / ATTRIB of `len` characters, following its justification
/// the same way the renderer places it (the stroke font is about 0.88 of the height per character).
fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
@@ -94,25 +327,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
/// The box of what `e` draws (block contents on layers turned off left out).
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
bounds_of(d, e, depth, false)
bounds_of(d, e, depth, false, &mut HashMap::new())
}
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
bounds_of(d, e, 0, true)
bounds_of(d, e, 0, true, &mut HashMap::new())
}
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
/// The bounds of block contents measured so far, kept across the entities of one unchanged
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
/// entity would measure a block again for every reference to it, R references to a block of B
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
#[derive(Default)]
pub struct BlockBoundsCache<'a> {
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
drawn: HashMap<(&'a str, usize), Bounds2>,
/// The same with those contents counted ([`extent_bounds`]).
extents: HashMap<(&'a str, usize), Bounds2>,
}
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, depth, false, &mut cache.drawn)
}
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, 0, true, &mut cache.extents)
}
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
// A top-level annotative object measures as it draws at the current annotation scale.
if depth == 0 {
match d.anno_draw(e) {
crate::AnnoDraw::Plain => {}
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
}
}
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
bounds_in(d, e, depth, blocks, off_counts)
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
@@ -156,11 +412,23 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
}
};
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
// XCLIP: the reference app takes the boundary's box, also where it reaches past the
// block or misses it (an inverted clip can show anything, so it keeps the block's).
if let Some(c) = ins.clip.as_ref().filter(|c| c.active() && !c.inverted)
&& !b.is_empty()
{
b = Bounds2::from_points(c.polygon().into_iter().map(|p| m.apply(p)));
}
if ins.cols > 1 || ins.rows > 1 {
let off =
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
.rotate(ins.rotation);
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
// The grid's four corner copies (as many as are drawn): turned, the far corner
// alone misses the side ones.
let (cols, rows) =
(f64::from(ins.cols.clamp(1, 10_000).saturating_sub(1)), f64::from(ins.rows.clamp(1, 10_000).saturating_sub(1)));
let one = b;
for (c, r) in [(cols, 0.0), (0.0, rows), (cols, rows)] {
let off = Vec2::new(ins.col_spacing * c, ins.row_spacing * r).rotate(ins.rotation);
b = b.union(&Bounds2::from_points(one.corners().map(|p| p + off)));
}
}
}
for a in &ins.attribs {
@@ -212,6 +480,10 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
inner
}
};
// The block draws translated by the insertion point (group 12), so its bounds
// move with it (#2634); the cache above keeps the block's own bounds.
let off = dm.insertion_point.xy();
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
b = b.union(&inner);
}
b
@@ -244,6 +516,8 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
Bounds2::from_points([o, o + u, o + v, o + u + v])
}
EntityKind::LwPolyline(p) => lwpolyline_bounds(p),
// An OLE object's content is not drawn, but its frame is, so the frame counts (#2422).
EntityKind::Unknown(u) if u.is_ole_frame() => ole_frame(&u.tags).map_or(Bounds2::EMPTY, Bounds2::from_points),
kind => {
let mut b = Bounds2::EMPTY;
for p in kind.prims() {
@@ -438,4 +712,407 @@ mod tests {
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
}
/// An OLE2FRAME counts in the extents through its corner tags (#2422); a missing or
/// non-finite corner counts as nothing.
#[test]
fn ole_frame_counts_in_extents() {
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
let frame = |tags: Vec<crate::RawTag>| EntityKind::Unknown(crate::Unknown { dxf_type: "OLE2FRAME".into(), tags });
let b = bounds(frame(vec![tag(10, "30"), tag(20, "-19"), tag(11, "35"), tag(21, "-22")]));
assert_eq!((b.min, b.max), (Vec2::new(30.0, -22.0), Vec2::new(35.0, -19.0)));
assert!(bounds(frame(vec![tag(10, "1"), tag(20, "2")])).is_empty(), "one corner is not a frame");
assert!(bounds(frame(vec![tag(10, "NaN"), tag(20, "0"), tag(11, "1"), tag(21, "inf")])).is_empty());
assert!(bounds(EntityKind::Unknown(crate::Unknown { dxf_type: "OTHER".into(), tags: vec![tag(10, "5"), tag(20, "5")] })).is_empty());
}
/// Groups 10/20 inside an application-defined `102 {...}` group belong to the application, not
/// to the frame: the real corners come after it (review on #2587).
#[test]
fn ole_frame_skips_application_groups() {
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
let tags = vec![
tag(102, "{ACAD_REACTORS"),
tag(10, "900"),
tag(20, "900"),
tag(11, "901"),
tag(21, "901"),
tag(102, "}"),
tag(10, "30"),
tag(20, "-19"),
tag(11, "35"),
tag(21, "-22"),
];
let q = ole_frame(&tags).expect("frame");
assert_eq!((q[0], q[2]), (Vec2::new(30.0, -19.0), Vec2::new(35.0, -22.0)));
assert!(ole_frame(&tags[..6]).is_none(), "corners only inside the group are not a frame");
}
#[test]
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
e.common.layer = layer.into();
e
};
let ins = |block: &str, at: f64| {
Entity::new(
crate::Handle(2),
EntityKind::Insert(crate::Insert {
block: block.into(),
insert: Vec3::new(at, at, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
};
let mut d = Drawing::new_imperial();
d.ensure_layer("OFF");
if let Some(l) = d.layer_mut("OFF") {
l.on = false;
}
// A block with a part on a layer turned off (left out when drawn, counted in extents),
// and a block that nests it.
let mut b = crate::Block::new("B");
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let mut n = crate::Block::new("N");
n.entities.push(ins("B", 10.0));
d.blocks.insert("N".into(), std::sync::Arc::new(n));
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
// One cache for both kinds of measure, interleaved: neither may answer for the other.
let mut cache = BlockBoundsCache::default();
for e in &ents {
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
assert!(extents.width() > drawn.width());
}
}
/// A turned array of references reaches its side copies, not only the first and the last;
/// a hostile count measures only the copies that are drawn.
#[test]
fn a_rotated_array_counts_every_corner_copy() {
let mut d = Drawing::new_metric();
let mut b = crate::Block::new("B");
b.entities.push(Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) })));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let array = |cols: u32, rows: u32, rotation: f64| {
let ins = crate::Insert {
block: "B".into(),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation,
cols,
rows,
col_spacing: 10.0,
row_spacing: 10.0,
..crate::Insert::default()
};
Entity::new(crate::Handle(2), EntityKind::Insert(ins))
};
// Turned 45°: the copies sit at (0, 0), (7.07, 7.07), (-7.07, 7.07) and (0, 14.14).
let b = entity_bounds(&d, &array(2, 2, std::f64::consts::FRAC_PI_4), 0);
let side = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
assert!((b.min.x + side).abs() < 1e-9 && b.max.x >= side, "{b:?}");
assert!((b.max.y - 2.0 * side - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-9, "{b:?}");
// The renderer draws at most 10,000 columns.
let b = entity_bounds(&d, &array(u32::MAX, 1, 0.0), 0);
assert!((b.max.x - (10.0 * 9_999.0 + 1.0)).abs() < 1e-6, "{b:?}");
}
/// An OLE2FRAME at large coordinates, with its OLE data header holding the frame's corners
/// shifted by `dx` in x.
fn big_frame(dx: f64) -> Vec<crate::RawTag> {
let (x1, y1, x2, y2) = (1e9, 1e9, 1e9 + 10.0, 1e9 + 5.0);
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x + dx, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: String| crate::RawTag { code, value };
vec![
tag(10, x1.to_string()),
tag(20, y1.to_string()),
tag(11, x2.to_string()),
tag(21, y2.to_string()),
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
]
}
/// The x of the first corner in the OLE data of a [`big_frame`].
fn data_x(tags: &[crate::RawTag]) -> f64 {
let hex = tags.last().map(|t| t.value.as_str()).unwrap_or_default();
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
bytes.get(2..10).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN)
}
/// The match between the frame and the corners in its OLE data does not loosen with the
/// coordinate: half a unit off at 1e9 is not the frame, so the bytes stay as they are (review
/// on #2587).
#[test]
fn a_half_unit_offset_at_large_coordinates_is_not_the_frame() {
let mut tags = big_frame(0.5);
let before = tags.last().cloned();
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("1000000001"), "the frame tags move");
assert_eq!(tags.last().map(|t| &t.value), before.as_ref().map(|t| &t.value), "the 310 bytes stay identical");
}
#[test]
fn exact_corners_at_large_coordinates_still_move() {
let mut tags = big_frame(0.0);
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1.0, 1.0)));
assert_eq!(data_x(&tags), 1e9 + 1.0);
}
/// A translation, and a uniform positive scale about any point, keep a frame axis aligned and
/// upper-left; a rotation, a mirror, a non-uniform or non-positive scale do not.
#[test]
fn ole_frame_accepts_translations_and_uniform_positive_scales_only() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
assert!(ole_frame_accepts(&Mat3::translate(p)));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 2.5)));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-3)));
assert!(!ole_frame_accepts(&Mat3::scale(2.0, 3.0)));
assert!(!ole_frame_accepts(&Mat3::scale_about(p, -1.0)));
assert!(!ole_frame_accepts(&Mat3::scale_about(p, 0.0)));
assert!(!ole_frame_accepts(&Mat3::rotate_about(p, 0.5)));
assert!(!ole_frame_accepts(&Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0))));
}
/// ALIGN with a scale builds its matrix from two angles that agree only to rounding, so a large
/// scale leaves a rotation part of about `scale * 1e-16`. That is a scale, not a turn: the
/// tolerance for it grows with the scale, while a real turn is still refused (review on #2587).
#[test]
fn ole_frame_accepts_a_large_scale_with_rounding_noise_in_its_rotation() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
let noisy = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 2e-16));
assert!(noisy.b.abs() > 1e-12, "the setup must exceed an absolute tolerance: {}", noisy.b);
assert!(ole_frame_accepts(&noisy));
let turned = Mat3::scale_about(p, 1e6).then_before(Mat3::rotate_about(p, 1e-6));
assert!(!ole_frame_accepts(&turned));
}
/// The corners (x, y) in the OLE data header of a frame built by [`big_frame`]-style tags.
fn header_corners(tags: &[crate::RawTag]) -> Vec<(f64, f64)> {
let hex: String = tags.iter().filter(|t| t.code == 310).map(|t| t.value.as_str()).collect();
let bytes: Vec<u8> = (0..hex.len() / 2).filter_map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
let f = |o: usize| bytes.get(o..o + 8).and_then(|s| <[u8; 8]>::try_from(s).ok()).map(f64::from_le_bytes).unwrap_or(f64::NAN);
(0..4).map(|c| (f(2 + c * 24), f(10 + c * 24))).collect()
}
fn small_frame() -> Vec<crate::RawTag> {
let mut blob = vec![1u8, 0];
for (x, y) in [(30.0f64, -19.0f64), (35.0, -19.0), (35.0, -22.0), (30.0, -22.0)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: &str| crate::RawTag { code, value: value.into() };
vec![
tag(10, "30"),
tag(20, "-19"),
tag(11, "35"),
tag(21, "-22"),
crate::RawTag { code: 310, value: blob.iter().map(|b| format!("{b:02X}")).collect() },
]
}
/// Internal callers (EXPLODE, REFEDIT, paste, array placement) transform a frame by any matrix.
/// It stays axis aligned: groups 10/20 are the upper-left of its box, 11/21 the lower-right,
/// and the OLE data holds the same box (review on #2587). A mirror keeps the axes, so the box
/// is that of the mapped corners; a 30° turn keeps the frame's own 5 x 3 about its turned
/// centre, where the corners' box would flatten it.
#[test]
fn a_rotated_frame_stays_axis_aligned_and_consistent() {
use cadcraft_geom::Mat3;
let rotate = Mat3::rotate_about(Vec2::new(1.0, 2.0), 30f64.to_radians());
let mirror = Mat3::mirror(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0));
for (m, turned) in [(rotate, true), (mirror, false)] {
let mut tags = small_frame();
let mapped = if turned {
let c = m.apply(Vec2::new(32.5, -20.5));
[c - Vec2::new(2.5, 1.5), c + Vec2::new(2.5, 1.5)]
} else {
[m.apply(Vec2::new(30.0, -19.0)), m.apply(Vec2::new(35.0, -22.0))]
};
let (minx, maxx) = (mapped[0].x.min(mapped[1].x), mapped[0].x.max(mapped[1].x));
let (miny, maxy) = (mapped[0].y.min(mapped[1].y), mapped[0].y.max(mapped[1].y));
ole_frame_transform(&mut tags, &m);
let g = |code: i32| tags.iter().find(|t| t.code == code).and_then(|t| t.value.parse::<f64>().ok()).unwrap_or(f64::NAN);
assert_eq!((g(10), g(20), g(11), g(21)), (minx, maxy, maxx, miny));
assert_eq!(header_corners(&tags), vec![(minx, maxy), (maxx, maxy), (maxx, miny), (minx, miny)]);
}
}
/// `from_str_radix` accepts a leading sign, so "+A" must not count as a hex byte (review on
/// #2587): data with one is not valid and MOVE leaves its bytes alone.
#[test]
fn a_plus_sign_in_ole_data_is_not_hex() {
let mut tags = small_frame();
let hex = tags.last().map(|t| t.value.clone()).unwrap_or_default();
// The marker's first byte is 01: "+1" would decode to the same byte.
let signed = format!("+1{}", hex.get(2..).unwrap_or_default());
if let Some(t) = tags.last_mut() {
t.value = signed.clone();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
assert_eq!(tags.first().map(|t| t.value.as_str()), Some("40"), "the frame tags still move");
assert_eq!(tags.last().map(|t| t.value.as_str()), Some(signed.as_str()));
}
/// A payload of 1,000 groups of 127 bytes: only the group(s) holding the 98-byte header are
/// rewritten. Later groups stay as they were, even ones that are not valid hex or have padding
/// (the old code re-encoded the whole payload and gave up on any bad group).
#[test]
fn a_large_ole_payload_only_rewrites_its_leading_groups() {
let mut tags = small_frame();
let first = tags.pop().map(|t| t.value).unwrap_or_default();
let mut head = first.clone();
// Fill the first group up to 127 bytes.
head.push_str(&"ab".repeat(127 - first.len() / 2));
tags.push(crate::RawTag { code: 310, value: head });
for g in 1..1000 {
let value = match g {
2 => format!(" {} ", "Ab".repeat(127)),
500 => "ZZ".repeat(127),
_ => "ab".repeat(127),
};
tags.push(crate::RawTag { code: 310, value });
}
let rest: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
assert_eq!(header_corners(&tags[..5]), vec![(40.0, -14.0), (45.0, -14.0), (45.0, -17.0), (40.0, -17.0)]);
let after: Vec<String> = tags.iter().skip(5).map(|t| t.value.clone()).collect();
assert_eq!(after, rest, "every group after the first is string-identical");
assert_eq!(tags.get(4).map(|t| t.value.len()), Some(254), "the first group keeps its length");
}
/// Above about 8.6e9 one ulp of an f64 is more than the fixed ceiling on the corner match, so a
/// text corner one ulp from the header double must still count as the same corner (review on
/// #2587).
#[test]
fn ole_frame_moves_at_2e10_with_a_one_ulp_text_corner() {
let mut tags = big_frame(0.0);
// Rebuild the frame at 2e10: the header keeps the exact corners, the text is one ulp off.
let (x1, y1, x2, y2) = (2e10f64, 2e10f64, 2e10 + 10.0, 2e10 + 5.0);
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x, y, 7.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let text_x1 = f64::from_bits(x1.to_bits() + 1);
tags[0].value = text_x1.to_string();
tags[1].value = y1.to_string();
tags[2].value = x2.to_string();
tags[3].value = y2.to_string();
if let Some(t) = tags.last_mut() {
t.value = blob.iter().map(|b| format!("{b:02X}")).collect();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(1e4, 1e4)));
assert_eq!(header_corners(&tags)[0].0, text_x1 + 1e4, "the header moved with the frame");
}
/// Below scale 1 the rotation bound is still relative to the scale: a small real turn at a tiny
/// scale is refused, and so is a non-uniform scale whose two factors differ by more than a
/// relative 1e-12 (review on #2587).
#[test]
fn ole_frame_refuses_a_small_turn_at_a_tiny_scale() {
use cadcraft_geom::Mat3;
let p = Vec2::new(3.0, -4.0);
let turned = Mat3::scale_about(p, 1e-9).then_before(Mat3::rotate_about(p, 0.05f64.to_radians()));
assert!(!ole_frame_accepts(&turned));
assert!(ole_frame_accepts(&Mat3::scale_about(p, 1e-9)));
assert!(!ole_frame_accepts(&Mat3::scale(1e-6, 1e-6 + 5e-13)));
}
/// Hex digits keep the case they had: bytes the transform does not change come back as the
/// identical text, also in a group that mixes upper and lower case (review on #2587).
#[test]
fn moving_an_ole_frame_keeps_each_hex_digits_case() {
let mut tags = small_frame();
let old = tags.last().map(|t| t.value.clone()).unwrap_or_default();
let mixed: String = old.chars().enumerate().map(|(i, c)| if i < 24 { c.to_ascii_lowercase() } else { c }).collect();
assert_ne!(mixed, old);
if let Some(t) = tags.last_mut() {
t.value = mixed.clone();
}
ole_frame_transform(&mut tags, &cadcraft_geom::Mat3::translate(Vec2::new(10.0, 5.0)));
let new = tags.last().map(|t| t.value.clone()).unwrap_or_default();
assert_eq!(new.len(), mixed.len());
let byte = |s: &str, i: usize| s.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
let mut same = 0;
for i in 0..mixed.len() / 2 {
if byte(&mixed, i) == byte(&new, i) {
same += 1;
assert_eq!(mixed.get(2 * i..2 * i + 2), new.get(2 * i..2 * i + 2), "byte {i}");
}
}
assert!(same > 40, "most of the header is unchanged: {same}");
assert_eq!(header_corners(&tags)[0], (40.0, -14.0));
}
/// A frame from `(x1, y1)` (upper left) to `(x2, y2)` (lower right), its OLE header holding the
/// same corners.
fn frame_at(x1: f64, y1: f64, x2: f64, y2: f64) -> Vec<crate::RawTag> {
let mut blob = vec![1u8, 0];
for (x, y) in [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] {
for v in [x, y, 0.0] {
blob.extend_from_slice(&v.to_le_bytes());
}
}
let tag = |code: i32, value: String| crate::RawTag { code, value };
vec![
tag(10, x1.to_string()),
tag(20, y1.to_string()),
tag(11, x2.to_string()),
tag(21, y2.to_string()),
tag(310, blob.iter().map(|b| format!("{b:02X}")).collect()),
]
}
fn size(tags: &[crate::RawTag]) -> (f64, f64, Vec2) {
let o = ole_frame(tags).unwrap();
let b = Bounds2::from_points(o);
(b.width(), b.height(), b.center())
}
/// A frame turned by an angle that isn't a quarter turn keeps its size about its turned
/// centre instead of collapsing to the box of its two corners (a 10 x 10 square turned 45°
/// became 14.14 x 0), and the OLE header follows (#2587 review).
#[test]
fn a_frame_turned_off_the_axes_keeps_its_size() {
use cadcraft_geom::Mat3;
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
let mut tags = frame_at(0.0, 10.0, 10.0, 0.0);
ole_frame_transform(&mut tags, &Mat3::rotate(std::f64::consts::FRAC_PI_4));
let (w, h, c) = size(&tags);
assert!(close(w, 10.0) && close(h, 10.0), "{w} x {h}");
assert!(close(c.x, 0.0) && close(c.y, 50f64.sqrt()), "{c:?}");
let (ul, lr) = (header_corners(&tags)[0], header_corners(&tags)[2]);
assert!(close(ul.0, -5.0) && close(lr.1, 50f64.sqrt() - 5.0), "the header follows: {ul:?} {lr:?}");
// Nearer a quarter turn, the sides swap: a 10 wide, 4 tall frame turned 100° is 4 x 10.
let mut tags = frame_at(0.0, 4.0, 10.0, 0.0);
ole_frame_transform(&mut tags, &Mat3::rotate(100f64.to_radians()));
let (w, h, _) = size(&tags);
assert!(close(w, 4.0) && close(h, 10.0), "{w} x {h}");
// A turn and its inverse bring the frame back.
let mut tags = frame_at(2.0, 9.0, 7.0, 3.0);
let m = Mat3::rotate(0.3);
ole_frame_transform(&mut tags, &m);
ole_frame_transform(&mut tags, &m.inverse().unwrap());
let (w, h, c) = size(&tags);
assert!(close(w, 5.0) && close(h, 6.0) && close(c.x, 4.5) && close(c.y, 6.0), "{w} x {h} at {c:?}");
}
}
+201 -3
View File
@@ -6,7 +6,7 @@
//! named object dictionary, a table record, a block, an entity); references to objects that are
//! gone are dropped. Commands don't edit it.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use crate::{Handle, RawTag};
@@ -75,9 +75,19 @@ pub struct Foreign {
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
pub data_store: Vec<RawTag>,
/// The `$ACADVER` of the file all this was read from: its groups are in that version's form.
/// The drawing's own `$ACADVER` is the version it is saved as, which SAVEAS changes, so a
/// later save must not take it for this one (#2471).
pub version: Option<String>,
}
impl Foreign {
/// The class of the file's CLASSES a proxy's group 91 names (see [`class_number`]), if any.
/// Saving as DWG keeps a proxy with that class and leaves out one whose class it can't name.
pub fn proxy_class(&self, tags: &[RawTag]) -> Option<&str> {
class_index(class_number(tags.iter().map(|r| (r.code, r.value.as_str())))).and_then(|i| self.class_order.get(i)).map(String::as_str)
}
pub fn is_empty(&self) -> bool {
self.objects.is_empty()
&& self.dictionary.is_empty()
@@ -89,8 +99,196 @@ impl Foreign {
&& self.data_store.is_empty()
}
/// The entities the data store has records for (their 320 handles).
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
self.data_store.iter().filter(|t| t.code == 320).filter_map(|t| Handle::parse_hex(t.value.trim())).collect()
let mut out = std::collections::BTreeSet::new();
for rec in data_store_records(&self.data_store) {
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
}
}
out
}
}
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
}
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
let stored = d.foreign.data_store_entities();
let mut out = BTreeMap::new();
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let crate::EntityKind::Unknown(u) = &e.kind
&& sat_text_acis(u, e.handle, &stored)
{
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
*n = n.saturating_add(1);
}
}
out
}
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
pub fn xref_dependent(name: &str) -> bool {
name.contains('|')
}
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
/// (the section's own groups before the first record are not part of one).
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
let mut out = Vec::new();
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
while start < tags.len() {
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
out.push(tags.get(start..end).unwrap_or(&[]));
start = end;
}
out
}
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
/// groups (before the extended data) are the entity's.
pub fn has_sat_text(tags: &[RawTag]) -> bool {
let own = tags.iter().take_while(|r| r.code < 1000);
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
let mut in_modeler = !marked;
for r in own {
if r.code == 100 {
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
} else if in_modeler && (r.code == 1 || r.code == 3) {
return true;
}
}
false
}
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
/// or dropped by this handle alone, in the writer and wherever the store is read.
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
record.iter().find(|t| t.code == 320)
}
impl Foreign {
/// The kept objects by handle (the first of any that share one).
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
let mut m = BTreeMap::new();
for o in &self.objects {
m.entry(o.handle).or_insert(o);
}
m
}
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
/// and child pointers, groups 350 and 360), `root` first.
pub fn tree(&self, root: Handle) -> Vec<Handle> {
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
// their product.
let by_handle = self.object_index();
let mut out = vec![root];
let mut seen = BTreeSet::from([root]);
let mut i = 0;
while let Some(&h) = out.get(i) {
i += 1;
let Some(o) = by_handle.get(&h) else { continue };
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
out.push(c);
}
}
}
out
}
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
/// root's owner. References to objects outside the tree become null. Fields keep a field
/// list: this one gets a copy of the source's if it has none.
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
if fresh.len() < tree.len() + 2 {
return;
}
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
let by_handle = src.object_index();
let mut kinds = Vec::new();
for (n, h) in tree.iter().enumerate() {
let Some(o) = by_handle.get(h) else { continue };
let mut c = (*o).clone();
let Some(&new) = fresh.get(n) else { continue };
c.handle = new;
for t in &mut c.tags {
if t.code == 5 {
t.value = new.hex();
} else if matches!(t.code, 320..=369 | 390..=399) {
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
}
}
kinds.push(c.kind.clone());
self.objects.push(c);
}
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
let mut c = list.clone();
c.handle = h;
for t in &mut c.tags {
match t.code {
5 => t.value = h.hex(),
330 => t.value = "0".to_string(),
_ => {}
}
}
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
t.value = nod.hex();
}
self.targets.insert(nod, ("NOD".to_string(), String::new()));
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
kinds.push(c.kind.clone());
self.objects.push(c);
}
for k in kinds {
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
if !self.classes.iter().any(named) {
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
}
}
}
}
/// Where a proxy's class number is: its first group 91 outside the `102 {NAME` … `102 }` groups
/// (an application's can hold a 91 of its own) and before any xdata.
pub fn class_group<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)>) -> Option<usize> {
let mut in_group = false;
for (i, (code, value)) in groups.into_iter().enumerate() {
match code {
1000.. => return None,
102 => in_group = value.as_ref().trim().starts_with('{'),
91 if !in_group => return Some(i),
_ => {}
}
}
None
}
/// A proxy's class number (see [`class_group`]).
pub fn class_number<S: AsRef<str>>(groups: impl IntoIterator<Item = (i32, S)> + Clone) -> Option<i64> {
let i = class_group(groups.clone())?;
groups.into_iter().nth(i).and_then(|(_, v)| v.as_ref().trim().parse().ok())
}
/// The place in CLASSES a class number names (500 is the first class).
pub fn class_index(number: Option<i64>) -> Option<usize> {
usize::try_from(number?.checked_sub(500)?).ok()
}
+17
View File
@@ -90,6 +90,23 @@ impl Header {
.filter(|p| p.is_finite() && if allow_zero { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 })
.unwrap_or(Vec2::new(0.5, 0.5))
}
/// The model view the drawing was saved at: `$VIEWCTR` (centre) and `$VIEWSIZE` (height, in
/// drawing units), when both are present and usable. A DXF file keeps the pair in HEADER and
/// repeats it in the `*ACTIVE` VPORT record (12/22 and 40); a reader opens the drawing on it
/// rather than on the extents, so a drawing saved on one detail of a large sheet opens there.
pub fn saved_view(&self) -> Option<(Vec2, f64)> {
let center = self.point("VIEWCTR")?.xy();
let height = self.get("VIEWSIZE").and_then(HVal::as_f64)?;
(center.is_finite() && height.is_finite() && height > 1e-9).then_some((center, height))
}
/// Record the view the drawing is saved at (see [`Header::saved_view`]). A view that cannot
/// be shown is not recorded, so a usable stored view is never replaced by an unusable one.
pub fn set_saved_view(&mut self, center: Vec2, height: f64) {
if center.is_finite() && height.is_finite() && height > 1e-9 {
self.set("VIEWCTR", HVal::Point(center.to3(0.0)));
self.set_f64("VIEWSIZE", height);
}
}
pub fn set_f64(&mut self, name: &str, v: f64) {
self.set(name, HVal::Real(v));
}
+20 -2
View File
@@ -16,9 +16,11 @@ mod header;
pub mod library;
mod plotstyle;
mod plotter;
mod raster;
mod refedit;
mod store;
mod tables;
mod xclip;
use std::collections::{BTreeMap, BTreeSet};
@@ -27,15 +29,20 @@ pub use cadcraft_color as color;
pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
pub use extents::{
BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached, ole_frame, ole_frame_accepts,
ole_frame_transform,
};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
pub use plotstyle::*;
pub use plotter::*;
pub use raster::{Raster, Rasters};
pub use refedit::{RefEdit, WorkItem};
pub use store::EntityStore;
pub use tables::*;
pub use xclip::{ClipBoundary, MAX_CLIP_VERTICES};
/// File data CADCraft doesn't model, kept for saving (#528).
pub mod dynblock;
@@ -132,6 +139,12 @@ pub struct Drawing {
/// Records of the file this drawing was read from that the reader could not place (a damaged
/// file), counted by the section they came from. Never written to a file.
pub skipped_records: BTreeMap<String, usize>,
/// A save option, set on the copy SAVEAS `portableFonts` writes: the file gets Windows fonts
/// for the Linux fonts of text styles, and each style keeps its own in CADCraft xdata, so the
/// drawing reopens with it. Never set on an open drawing.
pub portable_fonts: bool,
/// The pixels of the raster images that are loaded (IMAGE). Never written to a file.
pub rasters: Rasters,
}
impl Default for Drawing {
@@ -191,6 +204,8 @@ impl Drawing {
read_notes: Vec::new(),
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
rasters: Rasters::default(),
}
}
@@ -378,10 +393,13 @@ impl Drawing {
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
let mut b = cadcraft_geom::Bounds2::EMPTY;
// One block-bounds cache for the whole space: every reference to a block reuses its
// measured contents instead of measuring the definition again.
let mut cache = BlockBoundsCache::default();
if let Some(store) = self.space(space) {
for e in store.iter() {
if self.in_extents(e) {
b = b.union(&extent_bounds(self, e));
b = b.union(&extent_bounds_cached(self, e, &mut cache));
}
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Decoded raster image pixels (IMAGE entities). The drawing keeps them for the session only:
//! they are loaded from the image files after a drawing opens and are never written to a file.
use std::collections::BTreeMap;
use std::sync::Arc;
/// The pixels of a raster image: 8-bit RGBA, not premultiplied, rows from the top.
#[derive(Clone, PartialEq)]
pub struct Raster {
width: u32,
height: u32,
rgba: Vec<u8>,
opaque: bool,
}
impl Raster {
/// `None` unless both sides are at least one pixel and `rgba` holds exactly four bytes per pixel.
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Option<Raster> {
let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?;
if pixels == 0 || rgba.len() != pixels.checked_mul(4)? {
return None;
}
let opaque = rgba.as_chunks::<4>().0.iter().all(|[_, _, _, alpha]| *alpha == u8::MAX);
Some(Raster { width, height, rgba, opaque })
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
/// Four bytes per pixel (red, green, blue, alpha), `width * height` pixels.
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
/// No pixel is transparent.
pub fn opaque(&self) -> bool {
self.opaque
}
/// The pixel in column `x` of row `y` (from the top): red, green, blue, alpha.
pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
if x >= self.width || y >= self.height {
return None;
}
let (x, y, width) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?, usize::try_from(self.width).ok()?);
let pixels = self.rgba.as_chunks::<4>().0;
pixels.get(y.checked_mul(width)?.checked_add(x)?).copied()
}
}
impl std::fmt::Debug for Raster {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Raster({}x{})", self.width, self.height)
}
}
/// The loaded pixels of a drawing's raster images, by image file path ([`crate::Image::path`]).
/// Session data: two drawings are equal whatever images they have loaded, and copying a drawing
/// (undo) shares the pixels.
#[derive(Clone, Default)]
pub struct Rasters(Arc<BTreeMap<String, Arc<Raster>>>);
impl Rasters {
pub fn get(&self, path: &str) -> Option<&Arc<Raster>> {
self.0.get(path)
}
pub fn insert(&mut self, path: &str, raster: Arc<Raster>) {
Arc::make_mut(&mut self.0).insert(path.to_string(), raster);
}
pub fn remove(&mut self, path: &str) {
if self.0.contains_key(path) {
Arc::make_mut(&mut self.0).remove(path);
}
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl PartialEq for Rasters {
fn eq(&self, _: &Rasters) -> bool {
true
}
}
impl std::fmt::Debug for Rasters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Rasters({})", self.0.len())
}
}
+35
View File
@@ -189,6 +189,19 @@ impl EntityStore {
None => false,
}
}
/// The objects directly under and above `h` in the draw order (None at the bottom or the
/// top), when `h` is here.
pub fn neighbours(&self, h: Handle) -> Option<(Option<Handle>, Option<Handle>)> {
let pos = self.chunk_pos(self.chunk_id(h)?)?;
let items = &self.chunks.get(pos)?.items;
let i = items.iter().position(|e| e.handle == h)?;
let below = match i.checked_sub(1) {
Some(j) => items.get(j),
None => pos.checked_sub(1).and_then(|p| self.chunks.get(p)).and_then(|c| c.items.last()),
};
let above = items.get(i + 1).or_else(|| self.chunks.get(pos + 1).and_then(|c| c.items.first()));
Some((below.map(|e| e.handle), above.map(|e| e.handle)))
}
/// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing
/// moves) when either is absent or they are the same object.
pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool {
@@ -364,4 +377,26 @@ mod tests {
assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h));
}
}
#[test]
fn neighbours_across_chunks() {
let mut s = EntityStore::new();
for h in 1..=3000 {
s.push(ent(h));
}
assert_eq!(s.neighbours(Handle(1)), Some((None, Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(1500)), Some((Some(Handle(1499)), Some(Handle(1501)))));
// The first and last objects of a chunk look into the chunks next to it.
let k = CHUNK_MAX as u64;
assert_eq!(s.neighbours(Handle(k)), Some((Some(Handle(k - 1)), Some(Handle(k + 1)))));
assert_eq!(s.neighbours(Handle(k + 1)), Some((Some(Handle(k)), Some(Handle(k + 2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((Some(Handle(2999)), None)));
assert_eq!(s.neighbours(Handle(9999)), None);
assert!(s.move_next_to(Handle(3000), Handle(1), false));
assert_eq!(s.neighbours(Handle(1)), Some((Some(Handle(3000)), Some(Handle(2)))));
assert_eq!(s.neighbours(Handle(3000)), Some((None, Some(Handle(1)))));
let mut one = EntityStore::new();
one.push(ent(7));
assert_eq!(one.neighbours(Handle(7)), Some((None, None)));
}
}
+22 -2
View File
@@ -799,6 +799,17 @@ pub struct PageSetup {
/// The layout's paper units are millimetres (plot paper units 1 in DXF) whatever the
/// drawing's MEASUREMENT says; otherwise paper units follow MEASUREMENT (#1058).
pub paper_mm: bool,
/// Paper size (DXF groups 44, 45) and plot rotation (group 73) as the file stored them.
/// Saving writes them back while they still describe `width_mm`, `height_mm` and
/// `landscape`, so each program's convention for media defined wider than tall survives a
/// round trip: AutoCAD stores a portrait size and a quarter turn, ZWCAD the media's own size
/// and no turn (#1463). `None` for page setups made in CADCraft.
#[serde(skip)]
pub file_paper: Option<(f64, f64, i64)>,
/// The paper image origin (DXF groups 148/149, in plot paper units) as the file had it, so a
/// save keeps where the sheet sits; `None` for a page CADCraft made, written as 0,0 (#2109).
#[serde(skip_serializing_if = "Option::is_none")]
pub image_origin: Option<Vec2>,
}
impl Default for PageSetup {
@@ -820,6 +831,8 @@ impl Default for PageSetup {
show_plot_styles: false,
plot_transparency: false,
paper_mm: false,
file_paper: None,
image_origin: None,
}
}
}
@@ -835,11 +848,15 @@ pub struct Layout {
/// Saved paper-space view (centre, height).
#[serde(default)]
pub view: Option<(Vec2, f64)>,
/// The layout's limits (LAYOUT groups 10/11, lower left and upper right) as the file had
/// them; `None` for a layout CADCraft made, written as 0,0 to 12,9 (#2109).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<(Vec2, Vec2)>,
}
impl Layout {
pub fn new(name: &str, tab_order: u32) -> Self {
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None }
Layout { name: name.into(), tab_order, page: PageSetup::default(), entities: EntityStore::new(), view: None, limits: None }
}
}
@@ -854,7 +871,7 @@ pub struct NamedView {
/// The layer state restored with the view (its layer snapshot).
#[serde(default)]
pub layer_state: Option<String>,
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
/// View twist (radians, counterclockwise); VIEW Restore applies it to the model space view.
#[serde(default)]
pub twist: f64,
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
@@ -875,6 +892,9 @@ pub struct VportTile {
/// Width / height of the tile on screen when it was saved (0: unknown).
#[serde(default)]
pub aspect: f64,
/// The tile's view twist (radians, counterclockwise; DXF VPORT group 51 in degrees).
#[serde(default)]
pub twist: f64,
}
/// A model space viewport configuration (VPORTS): `*Active` is the one shown, other names are
+44
View File
@@ -55,6 +55,44 @@ fn frozen_layers_are_left_out_of_extents_and_layers_turned_off_are_not() {
assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0));
}
/// DXF group 12: a dimension moved by an older AutoCAD keeps its `*D` block where it was and
/// the move in the insertion point, and the block draws translated by it. Extents follow the
/// drawing, not the stale block contents (#2634).
#[test]
fn dimension_insertion_point_moves_its_block_in_extents() {
let moved = |ins: Vec3| {
let mut d = Drawing::new_metric();
let mut blk = Block::new("*D1");
blk.entities.push(on_layer("0", line((850.0, 920.0), (860.0, 920.0)), 2));
d.blocks.insert("*D1".into(), std::sync::Arc::new(blk));
let dm = Dimension {
kind: DimKind::Aligned,
defpt: Vec3::new(5.0, 1.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::ZERO,
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: Some("*D1".into()),
insertion_point: ins,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
d
};
// Without the offset the block's far-away contents reach the extents.
assert!(moved(Vec3::ZERO).extents(&Space::Model).max.x > 800.0);
// With it, the block draws at the definition points and the extents stay at the drawing.
assert!(moved(Vec3::new(-850.0, -920.0, 0.0)).extents(&Space::Model).max.x < 20.0);
}
/// ZOOM Extents in AutoCAD 2024 (accoreconsole, EXTMIN/EXTMAX read back; AutoCAD pads them by a
/// few hundredths): a layer turned off counts and a frozen one does not, for an entity, for an
/// entity inside a block, and for a block reference itself.
@@ -151,6 +189,7 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
};
d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty")));
@@ -204,6 +243,7 @@ fn visible_attributes_of_a_missing_block_still_count() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
};
@@ -247,6 +287,7 @@ fn insert_bounds_with_cyclic_block_terminate() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
));
b.entities.push(Entity::new(Handle(2), line((0.0, 0.0), (1.0, 1.0))));
@@ -265,6 +306,7 @@ fn insert_bounds_with_cyclic_block_terminate() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap();
@@ -382,6 +424,7 @@ fn plain_insert(block: &str) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
@@ -637,6 +680,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
overrides: Default::default(),
assoc: vec![],
marks: Default::default(),
insertion_point: Vec3::ZERO,
}),
)
.unwrap();
+85
View File
@@ -0,0 +1,85 @@
//! XCLIP: the clipping boundary of one block or xref reference.
//!
//! The boundary is kept in the referenced block's coordinates, so it moves, rotates and scales
//! with its reference; the block definition and other references to it are untouched. In a file
//! it is the reference's `ACAD_FILTER` / `SPATIAL` extension-dictionary entry (a SPATIAL_FILTER
//! object, DXF Reference), read and written by the io crate.
use cadcraft_geom::Vec2;
use serde::{Deserialize, Serialize};
/// Most boundary vertices a clip keeps (hostile files and parameters are capped to this).
pub const MAX_CLIP_VERTICES: usize = 4096;
/// A reference's clipping boundary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipBoundary {
/// Vertices in the block's coordinates: two opposite corners for a rectangle, three or more
/// for a closed polygon (not repeating the first vertex).
pub points: Vec<Vec2>,
/// Whether clipping is on (XCLIP ON/OFF); an off boundary is kept but nothing is clipped.
#[serde(default = "on")]
pub enabled: bool,
/// Inverted clip: what is inside the boundary is hidden, what is outside shows.
#[serde(default)]
pub inverted: bool,
}
fn on() -> bool {
true
}
impl ClipBoundary {
/// A boundary from `points` (block coordinates), on and not inverted.
pub fn new(points: Vec<Vec2>) -> Self {
ClipBoundary { points, enabled: true, inverted: false }
}
/// The closed outline in block coordinates: a rectangle's four corners or the polygon's
/// vertices. Empty when the boundary has fewer than two vertices, more than
/// [`MAX_CLIP_VERTICES`] or a non-finite one.
pub fn polygon(&self) -> Vec<Vec2> {
if self.points.len() > MAX_CLIP_VERTICES || !self.points.iter().all(|p| p.is_finite()) {
return Vec::new();
}
match self.points.as_slice() {
[a, b] => vec![
Vec2::new(a.x.min(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.max(b.y)),
Vec2::new(a.x.min(b.x), a.y.max(b.y)),
],
[_, _, _, ..] => self.points.clone(),
_ => Vec::new(),
}
}
/// Whether the boundary is a rectangle given by two corners.
pub fn is_rectangle(&self) -> bool {
self.points.len() == 2
}
/// Whether drawing the reference is clipped by it now (on, with a usable outline).
pub fn active(&self) -> bool {
self.enabled && self.polygon().len() >= 3
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_and_polygon_outlines() {
let r = ClipBoundary::new(vec![Vec2::new(2.0, 3.0), Vec2::new(0.0, 0.0)]);
assert_eq!(r.polygon(), vec![Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 3.0), Vec2::new(0.0, 3.0)]);
assert!(r.active() && r.is_rectangle());
let bad = ClipBoundary::new(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)]);
assert!(bad.polygon().is_empty() && !bad.active());
let one = ClipBoundary::new(vec![Vec2::ZERO]);
assert!(!one.active());
let off = ClipBoundary { enabled: false, ..r };
assert!(!off.active());
}
}
@@ -0,0 +1,19 @@
//! A 3D face draws only its visible edges: `hidden_edges` bit i hides the side from corner i to
//! the next, as DXF group 70 defines it, and a triangle's zero-length side isn't drawn.
use cadcraft_doc::{EntityKind, Face3d, Prim};
use cadcraft_geom::Vec3;
fn sides(f: Face3d) -> usize {
EntityKind::Face3d(f).prims().iter().filter(|p| matches!(p, Prim::Seg(_))).count()
}
#[test]
fn hidden_edges_are_not_drawn() {
let quad = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 0.0)];
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 0 }), 4);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 1 | 4 }), 2);
assert_eq!(sides(Face3d { corners: quad, hidden_edges: 15 }), 0);
let triangle = [quad[0], quad[1], quad[2], quad[2]];
assert_eq!(sides(Face3d { corners: triangle, hidden_edges: 0 }), 3);
}
+1
View File
@@ -1,2 +1,3 @@
mod face3d_hidden_edges;
mod lin_pat_parse;
mod sub_segment_alias;
+4 -1
View File
@@ -8,9 +8,12 @@ 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.5"
acadrust = "0.6.6"
+674 -21
View File
@@ -19,10 +19,8 @@
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
//! table or section can still make the dependency allocate before any size is known, so the app
//! runs both directions in a child process ([`worker`]): what that kills is the child.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -64,6 +62,11 @@ fn mb(n: u64) -> String {
if m >= 100.0 { format!("{m:.0} MB") } else { format!("{m:.1} MB") }
}
#[cfg(not(no_dwg_bridge))]
pub mod worker;
#[cfg(not(no_dwg_bridge))]
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_kept_noted, encode_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
use std::io::{Cursor, Write};
@@ -110,8 +113,11 @@ mod native {
notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out"));
}
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
let written = std::panic::catch_unwind(AssertUnwindSafe(|| {
acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)?;
mtext_contents(&doc, &mut out)
}))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
if out.exceeded {
return Err(format!(
"DWG: converting this {} file produced more than {} of drawing data (the limit); \
@@ -124,6 +130,94 @@ mod native {
Ok((add_header_vars(out.buf, &doc.header), notes))
}
/// acadrust 0.6.6's ASCII writer treats a caret followed by a literal space as already
/// escaped, and turns line feeds into paragraph codes. Serialize affected MTEXT contents
/// from the native values instead, using its lossless string writer. All other groups,
/// including embedded-object data, remain byte-for-byte as the DXF writer emitted them.
fn mtext_contents(doc: &acadrust::CadDocument, out: &mut CappedWriter) -> acadrust::Result<()> {
use acadrust::io::dxf::{DxfStreamWriter, DxfTextWriter};
let contents: std::collections::HashMap<_, _> = doc
.entities()
.filter_map(|e| match e {
acadrust::EntityType::MText(t) if t.value.contains(['^', '\t', '\n']) => Some((t.common.handle.value(), t.value.as_str())),
_ => None,
})
.collect();
if contents.is_empty() {
return Ok(());
}
let dxf = std::mem::take(&mut out.buf);
let mut lines = dxf.split_inclusive(|b| *b == b'\n');
let (mut mtext, mut text, mut written) = (false, None, false);
while let Some(code) = lines.next() {
let value = lines.next().ok_or_else(|| std::io::Error::other("incomplete DWG bridge DXF pair"))?;
match code.trim_ascii() {
b"0" => {
mtext = value.trim_ascii() == b"MTEXT";
text = None;
written = false;
}
b"5" if mtext => {
text = std::str::from_utf8(value.trim_ascii()).ok().and_then(|s| u64::from_str_radix(s, 16).ok()).and_then(|h| contents.get(&h));
}
b"101" => mtext = false,
b"1" | b"3" if mtext && text.is_some() => {
if let Some(&text) = text
&& !written
{
let mut writer = DxfTextWriter::new(&mut *out);
if doc.version < acadrust::DxfVersion::AC1021
&& let Some(encoding) = acadrust::io::dxf::code_page::encoding_from_code_page(&doc.header.code_page)
{
writer.set_encoding(encoding);
}
// Each character can expand to two caret-escape characters. Keep
// those pairs together and each group within 250 characters.
let mut rest = text;
while let Some((end, _)) = rest.char_indices().nth(125) {
let (chunk, tail) = rest.split_at(end);
writer.write_xrecord_string(3, chunk)?;
rest = tail;
}
writer.write_xrecord_string(1, rest)?;
written = true;
}
continue;
}
_ => {}
}
out.write_all(code)?;
out.write_all(value)?;
}
Ok(())
}
#[cfg(test)]
#[test]
fn mtext_contents_keeps_other_groups_and_the_output_limit() -> acadrust::Result<()> {
for version in [acadrust::DxfVersion::AC1015, acadrust::DxfVersion::AC1032] {
let mut doc = acadrust::CadDocument::new();
doc.version = version;
doc.header.code_page = "ANSI_1252".into();
let mut t = acadrust::MText::with_value("é ^ C", acadrust::Vector3::ZERO);
t.common.handle = acadrust::Handle::new(0x10);
doc.add_entity(acadrust::EntityType::MText(Box::new(t)))?;
// Only the MTEXT's own 1/3 groups change. The embedded object's groups and the
// following TEXT record must survive byte-for-byte, including their spaces.
let prefix = b" 0\r\nMTEXT\r\n 5\r\n10\r\n 8\r\n0\r\n";
let suffix = b"101\r\nEmbedded Object\r\n 1\r\n^ embedded\r\n 3\r\n^ tail\r\n 0\r\nTEXT\r\n 1\r\n^ plain\r\n";
let raw = [prefix.as_slice(), b" 3\r\nold\r\n 1\r\ntext\r\n", suffix.as_slice()].concat();
let mut out = CappedWriter { buf: raw.clone(), limit: 4096, exceeded: false };
mtext_contents(&doc, &mut out)?;
let value: &[u8] = if version == acadrust::DxfVersion::AC1015 { b"\xe9 ^ C" } else { "é ^ C".as_bytes() };
assert_eq!(out.buf, [prefix.as_slice(), b" 1\r\n", value, b"\r\n", suffix.as_slice()].concat());
let mut limited = CappedWriter { buf: raw, limit: 1, exceeded: false };
assert!(mtext_contents(&doc, &mut limited).is_err());
assert!(limited.exceeded);
}
Ok(())
}
/// Most record types named in the skipped-records note.
const NOTE_TYPES: usize = 6;
@@ -241,12 +335,51 @@ mod native {
/// can cover rows × cols cells, so the total is bounded too.
const MAX_MERGE_CELLS: usize = 1 << 24;
/// A table cell's own text height only when the file says it overrides the table style's.
/// acadrust's DXF writer writes a cell style's height (group 140) whenever the cell has a
/// style record, even a default 0.18 with no override flag, and CADCraft reads a 140 as an
/// override (#2501). The override is the cell style's TEXT_HEIGHT property flag or the
/// content's format override bit 128; without either the height is cleared (0 is no
/// override), so the table style's height applies.
fn table_cell_heights(doc: &mut acadrust::CadDocument) {
use acadrust::entities::table::CellStylePropertyFlags;
const CONTENT_TEXT_HEIGHT: i32 = 128;
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
// The first content's own height (group 144) when its format override sets bit 128.
// CADCraft keeps one height for the cell's text, so a later content's override
// must not stand in for the first content's.
let content_height = cell
.contents
.first()
.filter(|c| c.format_override_flags & CONTENT_TEXT_HEIGHT != 0)
.map(|c| c.text_height)
.filter(|h| h.is_finite() && *h > 0.0);
if let Some(h) = content_height {
// CADCraft reads only the style's height (group 140), so carry it there.
cell.style.get_or_insert_with(acadrust::entities::table::CellStyle::new).text_height = h;
continue;
}
// A style overrides through its property flags or, in newer files, its override
// flags; anything else is the writer's default and is cleared.
if let Some(style) = cell.style.as_mut()
&& !style.property_flags.contains(CellStylePropertyFlags::TEXT_HEIGHT)
&& style.override_flags & CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32 == 0
{
style.text_height = 0.0;
}
}
}
}
/// The fix-ups made to a document after the DWG read, returning how many orphaned DGN
/// components were dropped. The document comes from a third-party reader and is hostile.
fn post_process(doc: &mut acadrust::CadDocument) -> usize {
super::fail_in_test("post_process");
current_style_names(doc);
table_merges(doc);
table_cell_heights(doc);
table_style_rows(doc, false);
keep_sort_handles(doc);
drop_orphaned_dgn_components(doc)
@@ -344,7 +477,7 @@ mod native {
/// The DWG dimension flags byte. Its bit 1 is DXF group 70's bit 32 (ODA DWG specification,
/// common dimension data), which acadrust keeps no copy of from DXF: it is set here as
/// CADCraft's DXF writer always sets it (#1094). Bit 0, the opposite of group 70's bit 128,
/// acadrust 0.6.5 reads and writes itself.
/// acadrust 0.6.6 reads and writes itself.
fn dimension_flags(doc: &mut acadrust::CadDocument) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Dimension(d) = e else { continue };
@@ -411,15 +544,22 @@ mod native {
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
/// records of each DXF type were left out, so a save can say so.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
let version = doc.version;
let mut dropped = super::Unwritten::new();
let mut count = |name: &str| {
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
*n = n.saturating_add(1);
};
let unwritable: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Unknown(u)
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(&u.dxf_name);
Some(u.common.handle)
}
_ => None,
@@ -432,9 +572,10 @@ mod native {
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(type_name);
Some(*h)
}
_ => None,
@@ -443,7 +584,7 @@ mod native {
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
dropped
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
@@ -511,13 +652,45 @@ mod native {
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
/// (AC1018).
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
convert(dxf, None)
convert(dxf, None, None).map(|(dwg, _)| dwg)
}
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
/// whatever version the DXF declares.
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
let v = match version.trim().to_ascii_uppercase().as_str() {
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
convert(dxf, Some(writable(version)?), None)
}
/// Like [`dxf_to_dwg_as`], for a DXF that declares an older version than the groups it carries
/// over from another file. CADCraft's R2000 DWG bridge holds the objects it keeps without
/// modelling them (render settings, fields, associative and dynamic block data) as they were
/// read from a file of version `kept` ("AC1032"), and acadrust reads several of those
/// differently by version: read as R2000, an AC1032 file's mental ray render settings came out
/// a class version too high, for which the ODA rejects the whole DWG, and a field lost its
/// cached value. So the DXF is read as `kept` declares. A `kept` version that is unknown, or
/// not newer than the DXF's own, changes nothing.
pub fn dxf_to_dwg_kept_as(dxf: &[u8], version: &str, kept: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_kept_noted(dxf, version, kept).map(|(dwg, _)| dwg)
}
/// [`dxf_to_dwg_kept_as`], also returning what the DWG writer left out, as
/// [`dxf_to_dwg_noted`] does.
pub fn dxf_to_dwg_kept_noted(dxf: &[u8], version: &str, kept: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
let kept = acadrust::DxfVersion::from_version_string(kept.trim().to_ascii_uppercase().as_str());
let kept = (kept != acadrust::DxfVersion::Unknown).then_some(kept);
convert(dxf, Some(writable(version)?), kept)
}
/// The DWG version for a code from [`super::VERSIONS`].
fn writable(version: &str) -> Result<acadrust::DxfVersion, String> {
Ok(match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
"AC1021" => acadrust::DxfVersion::AC1021,
@@ -525,8 +698,72 @@ mod native {
"AC1027" => acadrust::DxfVersion::AC1027,
"AC1032" => acadrust::DxfVersion::AC1032,
_ => return Err(super::unsupported(version)),
})
}
/// The ASCII DXF with its `$ACADVER` set to `kept` when that is newer than the version it
/// declares, or `None` to read it as it is (binary, no `$ACADVER`, or non-ASCII text without a
/// `$DWGCODEPAGE` to decode it by). From AutoCAD 2007 (AC1021) a DXF is UTF-8, so other bytes
/// are first decoded with the code page the DXF declares; `\U+XXXX` escapes read the same in
/// every version, and CADCraft's R2000 DXF is ASCII with such escapes.
pub(super) fn declare_version(dxf: &[u8], kept: acadrust::DxfVersion) -> Option<Vec<u8>> {
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let (_, declared) = header_value(dxf, b"$ACADVER")?;
let declared = acadrust::DxfVersion::from_version_string(std::str::from_utf8(declared).ok()?);
if declared >= kept {
return None;
}
let text: std::borrow::Cow<'_, [u8]> = if dxf.is_ascii() || kept < acadrust::DxfVersion::AC1021 {
std::borrow::Cow::Borrowed(dxf)
} else {
// As acadrust decodes an older DXF: by its code page, Windows-1252 for one it doesn't
// know; a code page that names UTF-8 needs no decoding.
let (_, code_page) = header_value(dxf, b"$DWGCODEPAGE")?;
match acadrust::io::dxf::code_page::encoding_from_code_page(std::str::from_utf8(code_page).ok()?) {
Some(encoding) => {
let (text, errors) = encoding.decode_without_bom_handling(dxf);
if errors {
return None;
}
std::borrow::Cow::Owned(text.into_owned().into_bytes())
}
None => std::borrow::Cow::Borrowed(std::str::from_utf8(dxf).ok()?.as_bytes()),
}
};
convert(dxf, Some(v))
let (at, value) = header_value(&text, b"$ACADVER")?;
let code = kept.to_string();
let mut out = Vec::with_capacity(text.len().saturating_add(code.len()));
out.extend_from_slice(text.get(..at)?);
out.extend_from_slice(code.as_bytes());
out.extend_from_slice(text.get(at.checked_add(value.len())?..)?);
Some(out)
}
/// The byte offset and the trimmed value of header variable `name` in an ASCII DXF: the value
/// of the group that follows the `9` group naming it. The file is read as (group code,
/// value) line pairs, in one pass with no buffering, up to the first `0`/`ENDSEC`, the end
/// of the HEADER section: a large input without one costs time, not memory (a line index of
/// a 32 MiB file used 0.8 GB). Lines may end in `\r\n`.
fn header_value<'a>(dxf: &'a [u8], name: &[u8]) -> Option<(usize, &'a [u8])> {
let mut offset = 0usize;
let mut lines = dxf.split(|b| *b == b'\n').map(|line| {
let at = offset.saturating_add(line.len() - line.trim_ascii_start().len());
offset = offset.saturating_add(line.len()).saturating_add(1);
(at, line.trim_ascii())
});
while let (Some((_, code)), Some((_, value))) = (lines.next(), lines.next()) {
if code == b"0" && value == b"ENDSEC" {
return None;
}
if code == b"9" && value == name {
let (_, code) = lines.next()?;
let (at, value) = lines.next()?;
return (code != b"0" && code != b"9").then_some((at, value));
}
}
None
}
/// The current dimension and text styles' names from their handles: a DWG stores the
@@ -563,6 +800,141 @@ mod native {
}
}
/// The DXF names of an ASCII DXF's CLASS records, in CLASSES order: a proxy's class number
/// (group 91) is 500 plus its class's place here (DXF Reference, ACAD_PROXY_OBJECT). Empty for
/// one without a CLASSES section. `None` for a binary DXF, whose classes this doesn't read:
/// CADCraft's own bridge DXF is always ASCII, and only a binary DXF given to the public
/// conversion functions comes here.
pub(super) fn dxf_class_names(dxf: &[u8]) -> Option<Vec<String>> {
let mut names = Vec::new();
if dxf.starts_with(b"AutoCAD Binary DXF") {
return None;
}
let mut lines = dxf.split(|b| *b == b'\n').map(|l| l.trim_ascii());
let (mut in_classes, mut in_class) = (false, false);
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
match (code, value) {
(b"0", b"ENDSEC") if in_classes => break,
(b"2", b"CLASSES") => in_classes = true,
(b"0", v) if in_classes => {
in_class = v == b"CLASS";
// Every record keeps its place, named or not: a proxy numbers its class by
// place, so a record without a name must not move the ones after it. An empty
// name matches no class, and such a proxy is left out.
if in_class {
names.push(String::new());
}
}
(b"1", v) if in_class => {
if let Some(last) = names.last_mut() {
*last = String::from_utf8_lossy(v).into_owned();
}
in_class = false;
}
_ => {}
}
}
Some(names)
}
/// Proxy objects' and entities' class numbers as the DWG numbers its classes. A DXF proxy
/// names its class by its place in the DXF's CLASSES (`classes`, from 500), but acadrust's
/// reader puts its own default classes first and numbers the DXF's after them, so the DXF
/// number named another class in the DWG: a 2018 drawing's piping proxies became
/// LARGE_RADIAL_DIMENSION and SECTIONOBJECT, and the ODA File Converter refused the file. A
/// proxy whose number names no class of the DXF is left out: kept as it is, its number would
/// name some other class of the DWG, for the same reason.
///
/// Before 2013 (AC1027) acadrust writes only the classes it knows a pre-2013 DWG needs, and a
/// proxy's class is not one of them: the proxy would name a missing class (the ODA refuses
/// the file: "No ClassId") or, by its old number, some other class. Such a DWG leaves the
/// proxies out instead, as it does other programs' data it can't write (`not_saved` reports
/// the entities). So does a DXF whose classes are unknown (`classes` is `None`, a binary
/// DXF): its proxies' numbers can't be mapped, and kept as they are they would name the
/// wrong classes and the ODA would refuse the file. Returns the proxies left out, by DXF type, for
/// what the DWG writer left out ([`super::Unwritten`]).
fn proxy_classes(doc: &mut acadrust::CadDocument, classes: Option<&[String]>) -> super::Unwritten {
let classes = match classes {
Some(c) if doc.version >= acadrust::DxfVersion::AC1027 => c,
_ => {
let entities: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Extended(x) if matches!(x.data, acadrust::entities::ExtendedEntityData::Proxy(_)) => {
Some(x.common.handle)
}
_ => None,
})
.collect();
let objects: Vec<acadrust::Handle> =
doc.objects.iter().filter_map(|(h, o)| matches!(o, acadrust::objects::ObjectType::ProxyObject(_)).then_some(*h)).collect();
for h in &entities {
doc.remove_entity(*h);
}
for h in &objects {
doc.objects.remove(h);
}
return left_out(objects.len(), entities.len());
}
};
let number = |id: i32| -> Option<i32> {
let name = classes.get(usize::try_from(id.checked_sub(500)?).ok()?)?;
doc.classes.get_by_name(name).map(|c| i32::from(c.class_number))
};
let objects: Vec<(acadrust::Handle, Option<i32>)> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::ProxyObject(p) => Some((*h, number(p.class_id))),
_ => None,
})
.collect();
let entities: Vec<(acadrust::Handle, Option<i32>)> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Extended(x) => match &x.data {
acadrust::entities::ExtendedEntityData::Proxy(p) => Some((x.common.handle, number(p.class_id))),
_ => None,
},
_ => None,
})
.collect();
let unmapped = |list: &[(acadrust::Handle, Option<i32>)]| list.iter().filter(|(_, n)| n.is_none()).map(|(h, _)| *h).collect::<Vec<_>>();
let (gone_objects, gone_entities) = (unmapped(&objects), unmapped(&entities));
for h in &gone_objects {
doc.objects.remove(h);
}
for h in &gone_entities {
doc.remove_entity(*h);
}
let objects = objects.into_iter().filter_map(|(h, n)| Some((h, n?)));
let entities = entities.into_iter().filter_map(|(h, n)| Some((h, n?)));
for (h, n) in objects {
if let Some(acadrust::objects::ObjectType::ProxyObject(p)) = doc.objects.get_mut(&h) {
p.class_id = n;
}
}
let entities: std::collections::HashMap<acadrust::Handle, i32> = entities.into_iter().collect();
for e in doc.entities_mut() {
if let acadrust::entities::EntityType::Extended(x) = e {
let Some(n) = entities.get(&x.common.handle).copied() else { continue };
if let acadrust::entities::ExtendedEntityData::Proxy(p) = &mut x.data {
p.class_id = n;
}
}
}
left_out(gone_objects.len(), gone_entities.len())
}
/// Proxy objects and entities left out of the DWG, by their DXF types.
fn left_out(objects: usize, entities: usize) -> super::Unwritten {
[("ACAD_PROXY_OBJECT", objects), ("ACAD_PROXY_ENTITY", entities)]
.into_iter()
.filter(|(_, n)| *n > 0)
.map(|(t, n)| (t.to_string(), n))
.collect()
}
/// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG
/// stores the handle of the layer, and the one read with the DXF needn't be the layer's in
/// the document, so the drawing reopened with layer "0" current (#2252).
@@ -574,7 +946,8 @@ mod native {
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
/// `kept`: the version the DXF's carried-over groups were read from ([`dxf_to_dwg_kept_as`]).
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>, kept: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
@@ -586,7 +959,19 @@ mod native {
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
let data = dxf.to_vec();
// Decoding a code page to UTF-8 can grow the text, so the limit applies again.
let data = match kept.and_then(|k| declare_version(dxf, k)) {
Some(d) if d.len() > Limits::DEFAULT.max_dxf_bytes => {
return Err(format!(
"DXF: the input is {} as UTF-8, larger than the {} CADCraft converts to DWG",
mb(d.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
Some(d) => d,
None => dxf.to_vec(),
};
let classes = dxf_class_names(&data);
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
.map_err(|e| format!("DXF: {e}"))?;
@@ -599,21 +984,28 @@ mod native {
None => {}
}
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
guarded(AssertUnwindSafe(|| {
let unwritten = guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
let proxies = proxy_classes(&mut doc, classes.as_deref());
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
let mut unwritten = drop_unwritable(&mut doc);
for (kind, n) in proxies {
let total = unwritten.entry(kind).or_insert(0);
*total = total.saturating_add(n);
}
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
current_layer(&mut doc);
unwritten
}))
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
.map_err(|e| format!("DWG write: {e}"))?;
Ok((dwg, unwritten))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
@@ -730,6 +1122,34 @@ mod native {
assert_eq!(table_merges_within(&mut doc, usize::MAX), 100);
}
/// A height marked only in the style's override flags (as newer files store it) is kept,
/// and the same height with no flag at all is cleared (review on #2588). Tested on the
/// read document because acadrust's DWG writer doesn't write the override flags.
#[test]
fn a_style_override_flag_keeps_the_cell_height() {
use acadrust::entities::table::{CellStyle, CellStylePropertyFlags};
let height = |override_flags: i32| {
let mut doc = doc_with_table(1, 1, 0);
for e in doc.entities_mut() {
if let EntityType::Table(t) = e {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
let mut s = CellStyle::new();
s.text_height = 0.5;
s.override_flags = override_flags;
cell.style = Some(s);
}
}
}
table_cell_heights(&mut doc);
doc.entities().find_map(|e| match e {
EntityType::Table(t) => t.rows.first()?.cells.first()?.style.as_ref().map(|s| s.text_height),
_ => None,
})
};
assert_eq!(height(CellStylePropertyFlags::TEXT_HEIGHT.bits() as i32), Some(0.5));
assert_eq!(height(0), Some(0.0));
}
/// A panic in the post-read fix-ups is an error, not a crash (#1934).
#[test]
fn a_panic_in_post_processing_is_an_error() {
@@ -742,7 +1162,13 @@ mod native {
}
#[cfg(not(no_dwg_bridge))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
pub use native::{
dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_kept_as, dxf_to_dwg_kept_noted, dxf_to_dwg_noted,
};
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
/// can't write.
pub type Unwritten = std::collections::BTreeMap<String, usize>;
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
// stage then panics on entry, so a test through the public API shows whether it is guarded.
@@ -772,6 +1198,35 @@ fn unsupported(version: &str) -> String {
#[cfg(no_dwg_bridge)]
const NO_BRIDGE_BUILD: &str = if cfg!(target_arch = "wasm32") { "web" } else { "FreeBSD aarch64" };
/// There's no worker without a bridge.
#[cfg(no_dwg_bridge)]
pub const WORKER_ARG: &str = "--dwg-worker";
#[cfg(no_dwg_bridge)]
pub fn serve_if_asked() -> Option<i32> {
None
}
#[cfg(no_dwg_bridge)]
pub fn use_own_worker() {}
#[cfg(no_dwg_bridge)]
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
dwg_to_dxf_noted(bytes, limits)
}
#[cfg(no_dwg_bridge)]
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_as(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
dxf_to_dwg_noted(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, Unwritten), String> {
match kept {
Some(kept) => dxf_to_dwg_kept_noted(dxf, version, kept),
None => dxf_to_dwg_noted(dxf, version),
}
}
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
Err(format!("DWG files can't be opened in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
@@ -792,6 +1247,18 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_kept_as(_dxf: &[u8], _version: &str, _kept: &str) -> Result<Vec<u8>, String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_kept_noted(_dxf: &[u8], _version: &str, _kept: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(test)]
mod tests {
@@ -1076,4 +1543,190 @@ mod tests {
assert!(ls.contains(&"LSDEFINITION") && !ls.contains(&"LSINTERNALCOMPONENT"), "{ls:?}");
assert_eq!(notes, ["DWG: 3 DGN line style components that no line style definition uses were left out"]);
}
/// An R2000 DXF header (`\r\n` lines, as CADCraft writes them) with `extra` after `$ACADVER`.
#[cfg(not(no_dwg_bridge))]
fn r2000(extra: &[u8]) -> Vec<u8> {
let mut dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n 9\r\n$DWGCODEPAGE\r\n 3\r\nANSI_1252\r\n".to_vec();
dxf.extend_from_slice(b" 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\n");
dxf.extend_from_slice(extra);
dxf.extend_from_slice(b"\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n");
dxf
}
/// The kept version replaces only the `$ACADVER` value; everything else stays byte for byte.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_changes_only_acadver() {
let dxf = r2000(b"A\\U+00E7\\U+00E3o");
let out = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), String::from_utf8(dxf).unwrap().replacen("AC1015", "AC1032", 1));
}
/// A kept version not newer than the declared one, a binary DXF and a DXF without `$ACADVER`
/// are read as they are.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_leaves_what_it_need_not_change() {
let dxf = r2000(b"x");
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1015), None);
assert_eq!(native::declare_version(&dxf, acadrust::DxfVersion::AC1009), None);
assert_eq!(native::declare_version(b"AutoCAD Binary DXF\r\n\x1a\0", acadrust::DxfVersion::AC1032), None);
let no_version = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::declare_version(no_version, acadrust::DxfVersion::AC1032), None);
// A `$ACADVER` outside the HEADER section is not the file's version.
let late = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 9\r\n$ACADVER\r\n 1\r\nAC1015\r\n";
assert_eq!(native::declare_version(late, acadrust::DxfVersion::AC1032), None);
}
/// The header is read as group code and value pairs up to the first `0`/`ENDSEC`: a `$ACADVER`
/// name whose value would come after it, or a file that never ends its HEADER, is no version
/// to replace, and `\r\n` lines keep the value's offset right.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_reads_the_header_in_pairs_up_to_endsec() {
let cut = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 0\r\nENDSEC\r\n 1\r\nAC1015\r\n";
assert_eq!(native::declare_version(cut, acadrust::DxfVersion::AC1032), None);
let endless = vec![b'\n'; 1 << 20];
assert_eq!(native::declare_version(&endless, acadrust::DxfVersion::AC1032), None);
let crlf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1015\r\n 0\r\nENDSEC\r\n";
let out = native::declare_version(crlf, acadrust::DxfVersion::AC1032).unwrap();
assert_eq!(out, b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 9\r\n$ACADVER\r\n 1\r\n AC1032\r\n 0\r\nENDSEC\r\n");
}
/// Declared as 2007 or later, a DXF is UTF-8: code page text is decoded first. Declared as an
/// older version, the code page bytes stay as they are.
#[cfg(not(no_dwg_bridge))]
#[test]
fn declare_version_decodes_a_code_page_for_utf8_versions() {
let dxf = r2000(b"A\xe7\xe3o");
let utf8 = native::declare_version(&dxf, acadrust::DxfVersion::AC1032).unwrap();
let text = String::from_utf8(utf8).unwrap();
assert!(text.contains("\r\nAção\r\n") && text.contains("$ACADVER\r\n 1\r\nAC1032\r\n"), "{text}");
let legacy = native::declare_version(&dxf, acadrust::DxfVersion::AC1018).unwrap();
assert!(legacy.windows(4).any(|w| w == b"A\xe7\xe3o"), "the code page bytes stay");
assert!(legacy.windows(6).any(|w| w == b"AC1018"));
// A code page it doesn't know is read as Windows-1252, as acadrust reads an older DXF.
let at = dxf.windows(9).position(|w| w == b"ANSI_1252").unwrap();
let unknown = [&dxf[..at], b"NO_SUCH_CP".as_slice(), &dxf[at + 9..]].concat();
let text = String::from_utf8(native::declare_version(&unknown, acadrust::DxfVersion::AC1032).unwrap()).unwrap();
assert!(text.contains("\r\nAção\r\n"), "{text}");
// Without a `$DWGCODEPAGE` there is nothing to decode it by: it is read as it is.
let none = [&dxf[..at - 21], &dxf[at + 11..]].concat();
assert!(!none.windows(12).any(|w| w == b"$DWGCODEPAGE"), "{}", String::from_utf8_lossy(&none));
assert_eq!(native::declare_version(&none, acadrust::DxfVersion::AC1032), None);
}
/// An unknown kept version converts as [`dxf_to_dwg_as`] does.
#[cfg(not(no_dwg_bridge))]
#[test]
fn unknown_kept_version_converts_as_before() {
let dxf = r2000(b"x");
assert_eq!(dxf_to_dwg_kept_as(&dxf, "AC1032", "AC9999"), dxf_to_dwg_as(&dxf, "AC1032"));
assert!(dxf_to_dwg_kept_as(&dxf, "AC0000", "AC1032").is_err(), "an unwritable version is still an error");
}
/// The CLASS names in CLASSES order; nothing for a binary DXF or one without CLASSES.
#[cfg(not(no_dwg_bridge))]
#[test]
fn dxf_class_names_in_classes_order() {
let dxf = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 1\r\nWIDGETA\r\n 2\r\nAcDbWidgetA\r\n 3\r\nApp\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 2\r\nAcDbWidgetB\r\n 0\r\nENDSEC\r\n 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nTEXT\r\n 1\r\nNOT_A_CLASS\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::dxf_class_names(dxf).unwrap(), ["WIDGETA", "WIDGETB"]);
// A CLASS record without a name keeps its place, so the classes after it keep theirs
// (#2837 review).
let unnamed = b" 0\r\nSECTION\r\n 2\r\nCLASSES\r\n 0\r\nCLASS\r\n 2\r\nAcDbNoName\r\n 0\r\nCLASS\r\n 1\r\nWIDGETB\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
assert_eq!(native::dxf_class_names(unnamed).unwrap(), ["", "WIDGETB"]);
assert_eq!(native::dxf_class_names(b" 0\nSECTION\n 2\nHEADER\n 0\nENDSEC\n 0\nEOF\n"), Some(Vec::new()));
assert_eq!(native::dxf_class_names(b"AutoCAD Binary DXF\r\n\x1a\0"), None);
}
/// AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with a byte after its three
/// connections that DXF doesn't carry. acadrust 0.6.4 neither read nor wrote it, so AutoCAD
/// read past the object and refused every DWG CADCraft wrote with a dynamic block that scales
/// ("Error Decrypting Data"); 0.6.5 added it and 0.6.6, which this uses, still keeps it (0.6.6 also
/// fixes multi-layout DWGs, #1463). The byte reaches the DWG, and a scale action
/// from DXF is written whole, with the byte AutoCAD writes (0).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn scale_actions_keep_the_byte_autocad_reads_after_their_connections() {
use acadrust::objects::*;
let scale_action = |doc: &acadrust::CadDocument| {
doc.objects.values().find_map(|o| match o {
ObjectType::DynamicBlock(DynamicBlockObject { data: DynamicBlockData::ScaleAction(s), .. }) => Some(s.clone()),
_ => None,
})
};
let mut doc = acadrust::CadDocument::new();
let h = doc.allocate_handle();
let conn = |code, name: &str| BlockConnection { code, name: name.into() };
let mut action = DynamicBlockObject::new("BLOCKSCALEACTION", "AcDbBlockScaleAction");
action.handle = h;
action.owner = doc.header.named_objects_dict_handle;
let mut a = BlockBasePointAction::default();
a.action.action.element.name = "Scale".into();
a.action.connections = vec![conn(2, "UpdatedBaseX"), conn(2, "UpdatedBaseY")];
a.action.dependent = true;
a.connections = vec![conn(2, "Scale"), conn(2, "XScale"), conn(2, "YScale")];
a.dwg_tail_byte = 7;
action.data = DynamicBlockData::ScaleAction(a.clone());
doc.objects.insert(h, ObjectType::DynamicBlock(action));
let dwg = acadrust::DwgWriter::write_to_vec(&doc).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
assert_eq!(scale_action(&back).map(|s| s.dwg_tail_byte), Some(7), "the byte isn't written");
let mut dxf = Vec::new();
acadrust::DxfWriter::new(&doc).write_to_writer(&mut dxf).unwrap();
for version in ["AC1018", "AC1032"] {
let dwg = dxf_to_dwg_as(&dxf, version).unwrap();
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let read = scale_action(&back).unwrap_or_else(|| panic!("{version}: the scale action wasn't written"));
assert_eq!(read.connections, a.connections, "{version}");
assert_eq!(read.action.connections, a.action.connections, "{version}");
assert_eq!(read.dwg_tail_byte, 0, "{version}");
}
}
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
/// keeps whole reports nothing.
#[cfg(not(no_dwg_bridge))]
#[test]
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
let dxf = |body: &str| {
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
};
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
assert!(is_dwg(&dwg), "{version}");
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
}
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
assert!(unwritten.is_empty(), "{unwritten:?}");
}
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
/// the DWG with its data: it isn't in the list of what the writer left out.
#[cfg(not(no_dwg_bridge))]
#[test]
fn ole_frames_are_written_with_their_data() {
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
let dxf =
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let frames: Vec<Vec<u8>> = back
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
_ => None,
})
.collect();
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
}
}
}
+378
View File
@@ -0,0 +1,378 @@
//! The DWG reader and writer in a child process.
//!
//! An allocation failure or stack overflow in the third-party code ends the process it runs in, and
//! `catch_unwind` can't stop that. So the app runs them in a copy of itself: [`use_own_worker`] says
//! how to start one, and [`serve_if_asked`], first thing in `main`, turns the copy into a worker
//! that reads one request from standard input and writes one reply to standard output. A file that
//! kills the worker then costs that one open or save, and every other open drawing stays safe.
//!
//! Without a worker (libraries, tests, a build that can't start one) [`decode`] and [`encode_as`]
//! run in the calling process, as they always did. There's no memory limit on the worker yet: that
//! takes per-platform system calls, which `unsafe_code = "forbid"` keeps out of this crate.
//!
//! A reply comes from a process that just handled a possibly hostile file, so it is parsed as
//! untrusted input.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::Duration;
use super::Limits;
/// The argument that makes the app a worker.
pub const WORKER_ARG: &str = "--dwg-worker";
/// Bumped when the messages change: an app updated while running may start a newer copy.
const PROTOCOL: u8 = 2;
const DECODE: u8 = 1;
const ENCODE: u8 = 2;
const CRASH: u8 = 0xff;
/// How long one file may take before the worker is stopped. It can't be interrupted otherwise.
const TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Most bytes accepted for what a reply carries besides the drawing.
const MAX_NOTES_BYTES: u64 = 64 << 20;
/// Most bytes accepted as a written DWG.
const MAX_DWG_REPLY: u64 = 2 << 30;
/// Most bytes one request holds: the largest input, whichever the direction, and its header.
const MAX_REQUEST: u64 = (2 << 30) + 256;
struct Worker {
program: PathBuf,
args: Vec<OsString>,
}
static WORKER: Mutex<Option<Worker>> = Mutex::new(None);
/// Start `program args` as the worker from now on. `program` must call [`serve_if_asked`].
pub fn use_worker(program: PathBuf, args: Vec<OsString>) {
if let Ok(mut w) = WORKER.lock() {
*w = Some(Worker { program, args });
}
}
/// Use a copy of this program as the worker, started with [`WORKER_ARG`]. If its path can't be
/// found, the reader and writer stay in this process.
pub fn use_own_worker() {
match std::env::current_exe() {
Ok(program) => use_worker(program, vec![WORKER_ARG.into()]),
Err(e) => log::warn!("DWG worker: can't find this program: {e}"),
}
}
/// Serve one request and return the exit code if this process was started as a worker.
pub fn serve_if_asked() -> Option<i32> {
(std::env::args_os().nth(1)? == WORKER_ARG).then(|| serve(std::io::stdin().lock(), std::io::stdout().lock()))
}
/// [`super::dwg_to_dxf_noted`] in the worker, when there is one.
pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), String> {
// Before starting a process, and so the same limit holds with or without one.
if bytes.len() > limits.max_dwg_bytes {
return super::dwg_to_dxf_noted(bytes, limits);
}
let reply_limit = (limits.max_dxf_bytes as u64).saturating_add(MAX_NOTES_BYTES);
match call(&request(DECODE, limits, ""), bytes, reply_limit, "reading this file") {
Some(reply) => reply?,
None => super::dwg_to_dxf_noted(bytes, limits),
}
}
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
encode_kept_noted(dxf, version, None)
}
/// [`encode_noted`] for a DXF whose carried-over groups were read from a file of version `kept`
/// ([`super::dxf_to_dwg_kept_noted`]); `None` is [`encode_noted`].
pub fn encode_kept_noted(dxf: &[u8], version: &str, kept: Option<&str>) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
// A kept version too long to send is no version CADCraft knows: it changes nothing.
let kept = kept.filter(|k| u8::try_from(k.len()).is_ok());
let mut header = request(ENCODE, Limits::DEFAULT, version);
push_str(&mut header, kept.unwrap_or(""));
match call(&header, dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&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:?}");
}
}
+78 -10
View File
@@ -129,7 +129,7 @@ pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
/// 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').map(<[u8]>::trim_ascii);
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;
@@ -144,10 +144,35 @@ fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
None
}
/// Decode a whole ASCII DXF file. Valid UTF-8 is read as UTF-8: R2007+ files, pure-ASCII files,
/// and older files from writers that ignored the code page. Anything else is decoded 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.
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);
@@ -162,10 +187,19 @@ pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
}
None => Decoder::Utf8,
};
decode(bytes, d)
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 its header as the tags go by: `$ACADVER`
/// 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)]
@@ -174,6 +208,12 @@ pub(crate) struct BinaryText {
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)]
@@ -183,12 +223,40 @@ enum HeaderVar {
}
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); older
/// files' strings are in their code page.
/// 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 = decode(raw, d).into_owned();
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() {
+176 -30
View File
@@ -23,8 +23,11 @@ pub enum DxfError {
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")]
#[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>;
@@ -62,7 +65,9 @@ impl Value {
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>().map(|f| f as i64).unwrap_or(0)),
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,
}
@@ -154,25 +159,52 @@ fn typed(code: i32, text: &str) -> Value {
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 => {
t.parse::<i64>().map(Value::Int).unwrap_or_else(|_| Value::Int(t.parse::<f64>().map(|f| f as i64).unwrap_or(0)))
// 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(t.to_string()),
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";
/// Most group codes read from one file. A `Tag` takes 40 bytes plus its text, so this bounds the
/// tag list to a few GB; real drawings stay far below (a 10 MB DWG is typically a few million).
pub const MAX_TAGS: usize = 50_000_000;
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_limit(bytes, max_tags())
}
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
@@ -182,8 +214,10 @@ 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);
}
// Count nonblank group-code/value pairs, stopping at EOF just as the parser does.
let mut lines = bytes.split(|&b| b == b'\n');
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();
@@ -199,7 +233,7 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
if tags > max_tags {
return Err(DxfError::TooLarge { tags, limit: max_tags });
}
parse_ascii_with_limit(&codepage::decode_ascii_file(bytes), 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.
@@ -270,14 +304,52 @@ fn unescape_unicode(s: &str) -> String {
out
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_with_limit(text, MAX_TAGS)
/// 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_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
let mut tags = Vec::new();
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
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();
@@ -295,7 +367,7 @@ fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
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() == "EOF") {
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
break;
}
}
@@ -357,12 +429,23 @@ impl<'a> Cur<'a> {
/// Binary DXF (R13+: 2-byte group codes).
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
parse_binary_with_limit(b, MAX_TAGS)
parse_binary_with_limit(b, max_tags())
}
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
let mut c = Cur { b, p: 0 };
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()?);
@@ -371,7 +454,7 @@ fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
}
let v = match kind_of(code) {
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
Kind::Real => Value::Real(c.f64()?),
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()?),
@@ -386,7 +469,7 @@ fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
Value::Hex(s)
}
};
let eof = code == 0 && matches!(&v, Value::Str(s) if s == "EOF");
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 });
@@ -461,7 +544,22 @@ fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
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) => out.extend_from_slice(h.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");
}
@@ -509,13 +607,11 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
}
Kind::Int16 => {
out.extend_from_slice(&code_bytes);
let v = t.value.as_i64().clamp(i64::from(i16::MIN), i64::from(i16::MAX));
out.extend_from_slice(&i16::try_from(v).unwrap_or_default().to_le_bytes());
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
}
Kind::Int32 => {
out.extend_from_slice(&code_bytes);
let v = t.value.as_i64().clamp(i64::from(i32::MIN), i64::from(i32::MAX));
out.extend_from_slice(&i32::try_from(v).unwrap_or_default().to_le_bytes());
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
}
Kind::Int64 => {
out.extend_from_slice(&code_bytes);
@@ -543,12 +639,38 @@ fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
out
}
/// The bytes of hex text, two digits each; anything that isn't a pair of hex digits is skipped.
/// 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> = s.bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
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)
@@ -610,12 +732,12 @@ 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() == "SECTION");
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(Tag::str).unwrap_or_default();
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() == "ENDSEC")) {
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() });
@@ -777,3 +899,27 @@ mod tests {
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}");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
let mut b = SENTINEL.to_vec();
f(&mut b);
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF\0");
b
}
#[test]
fn binary_reals_that_are_not_finite_become_zero() {
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let b = bin_with(|b| {
b.extend_from_slice(&10i16.to_le_bytes());
b.extend_from_slice(&bits.to_le_bytes());
});
let tags = parse(&b).unwrap();
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
}
}
#[test]
fn ascii_accepts_bom_and_cr_only_line_ends() {
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
assert_eq!(parse(text.as_bytes()).unwrap(), want);
let mut bom = b"\xEF\xBB\xBF".to_vec();
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
assert_eq!(parse(&bom).unwrap(), want);
// Mixed terminators.
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
}
#[test]
fn cr_only_files_count_against_the_tag_limit() {
let mut text = String::new();
for _ in 0..50 {
text.push_str("999\rcomment\r");
}
text.push_str("0\rEOF\r");
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
}
#[test]
fn one_bad_byte_spoils_one_line_not_the_file() {
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
b.extend_from_slice("Größe".as_bytes());
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
let t = parse(&b).unwrap();
assert_eq!(t[1].str(), "ANSI_1251");
assert_eq!(t[2].str(), "Größe");
assert_eq!(t[3].str(), "Привет");
}
#[test]
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
let utf8 = "Größe".as_bytes();
let latin = b"Caf\xE9";
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
a.extend_from_slice(utf8);
a.extend_from_slice(b"\n1\n");
a.extend_from_slice(latin);
a.extend_from_slice(b"\n0\nEOF\n");
let from_ascii = parse(&a).unwrap();
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
let from_binary = parse(&b).unwrap();
assert_eq!(from_ascii[2].str(), "Größe");
assert_eq!(from_ascii[3].str(), "Café");
assert_eq!(from_binary[2].str(), "Größe");
assert_eq!(from_binary[3].str(), "Café");
}
#[test]
fn trailing_spaces_keep_sections_and_eof() {
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
// The EOF with a trailing space still ends the file.
assert_eq!(t.len(), 5);
let s = sections(&t);
assert_eq!(s.len(), 1);
assert_eq!(s[0].name, "ENTITIES");
assert_eq!(s[0].tags.len(), 1);
// Binary EOF too.
let mut b = SENTINEL.to_vec();
b.extend_from_slice(&0i16.to_le_bytes());
b.extend_from_slice(b"EOF \0");
b.extend_from_slice(&1i16.to_le_bytes());
b.extend_from_slice(b"after\0");
assert_eq!(parse(&b).unwrap().len(), 1);
}
#[test]
fn integer_groups_reject_non_finite_and_clamp() {
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
assert_eq!(t[0], Tag::i(70, 0));
assert_eq!(t[1], Tag::i(70, 0));
// Clamped to what the group's width holds, unsigned values included (the binary writer
// keeps up to 65535 and 4294967295 as their bit patterns).
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
assert_eq!(t[5], Tag::i(160, i64::MAX));
assert_eq!(t[6], Tag::i(160, 0));
assert_eq!(t[7], Tag::i(62, 12));
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
}
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
/// `65535.0` is 65535 like `65535`, not 32767.
#[test]
fn unsigned_values_read_the_same_in_either_spelling() {
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
assert_eq!(t[0], t[1]);
assert_eq!(t[0], Tag::i(70, 65535));
assert_eq!(t[2], t[3]);
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
}
#[test]
fn hex_groups_pad_odd_digits_and_drop_junk() {
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
assert_eq!(t[2].value, Value::Hex(String::new()));
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
}
#[test]
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
let back = parse(text.as_bytes()).unwrap();
assert_eq!(back.len(), 2, "{text:?}");
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
let long = "AB".repeat(300);
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
assert_eq!(sizes, vec![254, 254, 92]);
// An empty chunk is still one group.
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
assert_eq!(back[0].code, 310);
}
#[test]
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
let tags = [
Tag::i(70, 65535),
Tag::i(70, 40000),
Tag::i(70, 70000),
Tag::i(70, -40000),
Tag::i(90, 0xFFFF_FFFF),
Tag::i(90, 1 << 40),
Tag::i(90, -(1 << 40)),
Tag::s(0, "EOF"),
];
let back = parse(&write_binary(&tags, true)).unwrap();
assert_eq!(back[0], Tag::i(70, -1));
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
assert_eq!(back[4], Tag::i(90, -1));
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
}
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
for s in strings {
a.extend_from_slice(b"1\n");
a.extend_from_slice(s);
a.push(b'\n');
}
a.extend_from_slice(b"0\nEOF\n");
let b = bin_with(|b| {
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
b.extend_from_slice(&code.to_le_bytes());
b.extend_from_slice(s);
b.push(0);
}
});
[parse(&a).unwrap(), parse(&b).unwrap()]
}
#[test]
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
// string is plainly Shift-JIS, so the file is not UTF-8.
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
assert_eq!(t[3].str(), "図面");
}
}
#[test]
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
assert_eq!(t[2].str(), "図面");
assert_eq!(t[3].str(), "ñ");
assert_eq!(t[4].str(), "plain");
}
}
+75 -10
View File
@@ -40,8 +40,12 @@ fn arc_geom(a: &cadcraft_doc::Arc) -> cadcraft_geom::Arc {
/// Where a snap sits on an object now.
pub fn snap_point(d: &Drawing, a: &DimAssoc) -> Option<Vec2> {
let e = d.entity(a.handle)?;
match (&e.kind, &a.snap) {
snap_on(d, &d.entity(a.handle)?.kind, &a.snap)
}
/// Where `snap` sits on an object of kind `kind` (an intersection finds its other line in `d`).
fn snap_on(d: &Drawing, kind: &EntityKind, snap: &AssocSnap) -> Option<Vec2> {
match (kind, snap) {
(EntityKind::Line(l), AssocSnap::Start) => Some(l.a.xy()),
(EntityKind::Line(l), AssocSnap::End) => Some(l.b.xy()),
(EntityKind::Line(l), AssocSnap::Mid) => Some(l.a.xy().mid(l.b.xy())),
@@ -88,6 +92,61 @@ fn candidates(kind: &EntityKind) -> Vec<(AssocSnap, Vec2)> {
}
}
/// Whether two snaps are the same kind of object point: an endpoint or vertex, a centre, or a
/// midpoint.
fn same_kind(a: &AssocSnap, b: &AssocSnap) -> bool {
matches!(
(a, b),
(AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. }, AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. })
| (AssocSnap::Center, AssocSnap::Center)
| (AssocSnap::Mid, AssocSnap::Mid)
)
}
/// BREAK or TRIM reshaped the object `pieces[0]` (which was `old`) and added the other pieces:
/// move the dimension attachments on it as AutoCAD 2024 does. An attachment whose point is still
/// a point of the same kind on the object stays there (a closed polyline opened by BREAK numbers
/// its vertices from the break); one whose point is on a new piece moves to that piece, so a
/// dimension across a line broken in its middle stays where it was; any other is left as it was,
/// so the dimension follows the object's new end when that end was cut off. Attachments at an
/// angle on a circle or arc and at an intersection stay on the object. [`update`] moves the
/// definition points after the command.
pub fn follow_pieces(d: &mut Drawing, old: &EntityKind, pieces: &[Handle]) {
let Some(&h) = pieces.first() else { return };
let mut changes: Vec<(Handle, Vec<DimAssoc>)> = Vec::new();
for store in std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)) {
for e in store.iter() {
let EntityKind::Dimension(dm) = &e.kind else { continue };
if !dm.assoc.iter().any(|a| a.handle == h) {
continue;
}
let assoc: Vec<DimAssoc> = dm.assoc.iter().map(|a| on_piece(d, old, pieces, a).unwrap_or_else(|| a.clone())).collect();
if assoc != dm.assoc {
changes.push((e.handle, assoc));
}
}
}
for (dh, assoc) in changes {
let _ = d.modify_entity(dh, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.assoc = assoc;
}
});
}
}
/// The piece attachment `a` moves to when its object became `pieces` (see [`follow_pieces`]).
fn on_piece(d: &Drawing, old: &EntityKind, pieces: &[Handle], a: &DimAssoc) -> Option<DimAssoc> {
if pieces.first() != Some(&a.handle) || matches!(a.snap, AssocSnap::OnCircle { .. } | AssocSnap::Intersection { .. }) {
return None;
}
let p = snap_on(d, old, &a.snap)?;
pieces.iter().find_map(|&ph| {
let (snap, _) = candidates(&d.entity(ph)?.kind).into_iter().find(|(s, q)| same_kind(&a.snap, s) && close(p, *q))?;
Some(DimAssoc { point: a.point.clone(), handle: ph, snap })
})
}
/// An object point exactly at `p` (an endpoint, vertex, centre or midpoint) to attach to.
pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSnap)> {
if !p.is_finite() {
@@ -244,8 +303,10 @@ pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, aft
let was = prev.unwrap_or(dm);
nd.assoc.retain(|a| {
let Some(old) = def_point(was, &a.point) else { return false };
// Only follow if the point sat on the object before the change.
if let Some(prev) = snap_point(before, a)
// Only follow if the point sat on the object before the change, as attached then (BREAK
// and TRIM may have moved the attachment to a piece of the object since).
let held = was.assoc.iter().find(|o| o.point == a.point).unwrap_or(a);
if let Some(prev) = snap_point(before, held)
&& !close(prev, old.xy())
{
return false;
@@ -304,16 +365,20 @@ pub fn update(after: &mut Arc<Drawing>, before: &Drawing) -> usize {
if dm.assoc.is_empty() {
continue;
}
let touched = dm.assoc.iter().any(|a| {
src_changed(before, d, a.handle) || matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
});
if !touched {
continue;
}
let prev = match before.entity(e.handle).map(|x| &x.kind) {
Some(EntityKind::Dimension(p)) => Some(p),
_ => None,
};
// Its objects changed, or the command moved an attachment (BREAK and TRIM move them
// to new pieces, which a later pick of the same TRIM may reshape again).
let touched = dm.assoc.iter().any(|a| {
src_changed(before, d, a.handle)
|| matches!(a.snap, AssocSnap::Intersection { other } if src_changed(before, d, other))
|| prev.is_some_and(|p| !p.assoc.contains(a))
});
if !touched {
continue;
}
let nd = recompute(dm, prev, before, d);
if nd != *dm {
changes.push((e.handle, nd));
+47
View File
@@ -42,6 +42,27 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_atomic_with(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
/// `None` when the file's folder exists, or when `path` itself exists (a dangling symlink's target
/// folder is not ours to make).
pub fn missing_folder(path: &Path) -> Option<PathBuf> {
if std::fs::symlink_metadata(path).is_ok() {
return None;
}
path.ancestors().skip(1).filter(|a| !a.as_os_str().is_empty()).take_while(|a| std::fs::symlink_metadata(a).is_err()).last().map(Path::to_path_buf)
}
/// [`write_atomic`] to a path the user named, first creating the folders missing above it: a
/// mistyped folder costs less than a drawing that could not be written. The caller reports
/// [`missing_folder`], asked before this call, so the new folder is never a surprise.
pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(folder) = missing_folder(path) {
let dir = path.parent().unwrap_or(&folder);
std::fs::create_dir_all(dir).map_err(|e| io::Error::new(e.kind(), format!("the folder {} could not be created: {e}", dir.display())))?;
}
write_atomic(path, bytes)
}
/// [`write_atomic`] filling the temporary file through `fill` (streamed rather than one buffer),
/// 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.
@@ -159,6 +180,30 @@ fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Resul
mod tests {
use super::*;
#[test]
fn creates_missing_parent_directories() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mkdir-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
let dest = dir.join("new_folder").join("deeper").join("out.bin");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(missing_folder(&dest), Some(dir.join("new_folder")));
assert!(write_atomic(&dest, b"created").is_err(), "the plain writer never makes folders");
write_atomic_making_folders(&dest, b"created").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"created");
assert_eq!(missing_folder(&dest), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_folder_that_cannot_be_made_is_named_in_the_error() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-nomk-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let blocker = dir.join("a_file");
std::fs::write(&blocker, b"x").unwrap();
let e = write_atomic_making_folders(&blocker.join("sub").join("out.bin"), b"new").unwrap_err();
assert!(e.to_string().contains("could not be created"), "{e}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn replaces_contents_atomically() {
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
@@ -259,6 +304,8 @@ mod tests {
let link = dir.join("link.dxf");
symlink("missing/real.dxf", &link).unwrap();
assert!(write_atomic(&link, b"new").is_err());
assert!(write_atomic_making_folders(&link, b"new").is_err());
assert!(!dir.join("missing").exists(), "a link's unavailable target folder must not be created");
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
std::fs::remove_dir_all(dir).unwrap();
}
+5 -4
View File
@@ -234,9 +234,10 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
missing("table style", "Standard", out);
d.table_styles.insert(0, TableStyle::default());
}
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
// Name sets built once: both tables come from the drawing, so a scan per reference is quadratic.
let linetypes: HashSet<String> = d.linetypes.iter().map(|l| l.name.to_ascii_lowercase()).collect();
for l in &mut d.layers {
if !linetypes.iter().any(|n| n.eq_ignore_ascii_case(&l.linetype)) {
if !linetypes.contains(&l.linetype.to_ascii_lowercase()) {
out.push(issue(
"missingLinetype",
"tables",
@@ -247,8 +248,8 @@ fn tables(d: &mut Drawing, out: &mut Vec<Issue>) {
l.linetype = "Continuous".into();
}
}
let styles: Vec<String> = d.text_styles.iter().map(|s| s.name.clone()).collect();
let known = |n: &str| styles.iter().any(|s| s.eq_ignore_ascii_case(n));
let styles: HashSet<String> = d.text_styles.iter().map(|s| s.name.to_ascii_lowercase()).collect();
let known = |n: &str| styles.contains(&n.to_ascii_lowercase());
for s in &mut d.dim_styles {
if !known(&s.text_style) {
out.push(issue(
+194 -23
View File
@@ -4,15 +4,18 @@
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.sv$`. The file goes
//! away when the drawing is saved or closed and when the app exits normally, so a file left behind
//! means the app (or the machine) went down; the next start offers it for recovery. A running app
//! keeps a lock file (`<instance>.lock`) fresh, so another copy running at the same time doesn't
//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from
//! an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//! holds an operating-system lock on its lock file (`<instance>.lock`) and keeps the file fresh, so
//! another copy running at the same time doesn't offer its files. The system drops the lock the
//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once.
//! Where locks aren't available, and for lock files of older versions, a file not refreshed for
//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so
//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted.
//!
//! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop
//! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH
//! are profile settings: process-wide, not saved in the drawing.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
@@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10;
pub const MAX_SAVETIME: u32 = 600;
/// A lock file not refreshed for this long belongs to an app that is no longer running.
pub const STALE_LOCK_SECS: u64 = 5 * 60;
/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app
/// stopped. Older versions write only the process id and never hold one.
#[cfg(not(target_arch = "wasm32"))]
const LOCK_HELD: &str = " held";
/// How often a running app refreshes its lock file.
#[cfg(not(target_arch = "wasm32"))]
const HEARTBEAT_SECS: f64 = 60.0;
@@ -149,13 +156,21 @@ pub struct Autosaver {
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// Writes in progress; each tells whether its file was written.
/// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer
/// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames.
failed: BTreeMap<u64, String>,
/// Writes in progress; each ends with why its file wasn't written, if it wasn't.
#[cfg(not(target_arch = "wasm32"))]
running: Vec<(u64, std::thread::JoinHandle<bool>)>,
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
/// locks don't work and only the file's age counts.
#[cfg(not(target_arch = "wasm32"))]
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
}
impl Autosaver {
@@ -164,12 +179,15 @@ impl Autosaver {
instance: instance.into(),
docs: HashMap::new(),
busy: HashSet::new(),
failed: BTreeMap::new(),
#[cfg(not(target_arch = "wasm32"))]
running: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant::now(),
#[cfg(not(target_arch = "wasm32"))]
last_beat: None,
#[cfg(not(target_arch = "wasm32"))]
locks: Vec::new(),
}
}
@@ -187,6 +205,11 @@ impl Autosaver {
self.docs.values().filter_map(|t| t.file.clone()).collect()
}
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
self.failed.iter().map(|(uid, why)| (*uid, why.as_str()))
}
/// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder
/// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the
/// files of drawings that were saved (no unsaved changes left) or closed.
@@ -220,6 +243,7 @@ impl Autosaver {
jobs.push(Job::Delete { path });
}
*t = Tracked::default();
self.failed.remove(&st.uid);
continue;
}
let same = |w: &Option<Weak<Drawing>>, d: &Arc<Drawing>| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d)));
@@ -248,6 +272,8 @@ impl Autosaver {
t.since = None;
jobs.push(Job::Write { uid: st.uid, path, doc });
}
let docs = &self.docs;
self.failed.retain(|uid, _| docs.contains_key(uid));
jobs
}
@@ -277,12 +303,14 @@ pub use native::*;
#[cfg(not(target_arch = "wasm32"))]
mod native {
use std::fs::{File, OpenOptions, TryLockError};
use std::io::{Read, Write};
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::{
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name,
savefilepath, savetime,
Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name,
parse_lock_name, savefilepath, savetime,
};
/// A new instance id: the process id and the start time.
@@ -307,6 +335,28 @@ mod native {
Some(SystemTime::now().duration_since(m).unwrap_or_default())
}
/// Whether the app instance named by a lock file in `dir` may still be running: its system
/// lock answers at once, and the file's age decides when it can't (no lock support, a lock
/// file from an older version).
fn owner_alive(dir: &Path, instance: &str) -> bool {
let by_age = || instance_alive(lock_age(dir, instance));
let path = lock_path(dir, instance);
// Only a plain file is opened: opening a FIFO planted in a shared folder would block.
if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) {
return by_age();
}
let Ok(file) = File::open(&path) else { return by_age() };
match file.try_lock() {
Err(TryLockError::WouldBlock) => true,
Err(TryLockError::Error(_)) => by_age(),
Ok(()) => {
let mut text = String::new();
let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD);
!held && by_age()
}
}
}
/// The autosave files in `dir` left behind by apps that are no longer running (never those of
/// `own`, this app's instance), oldest first.
pub fn recoverable(dir: &Path, own: &str) -> Vec<Recoverable> {
@@ -316,7 +366,7 @@ mod native {
for e in entries.flatten().take(10_000) {
let name = e.file_name().to_string_lossy().to_string();
let Some((title, instance, _)) = parse_file_name(&name) else { continue };
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) {
if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) {
continue;
}
let Ok(meta) = e.metadata() else { continue };
@@ -355,10 +405,11 @@ mod native {
pub fn prune_locks(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
// The instances that own autosave files, parsed once rather than once per lock file.
let with_files: std::collections::HashSet<&str> = names.iter().filter_map(|f| parse_file_name(f)).map(|(_, i, _)| i).collect();
for n in &names {
let Some(instance) = parse_lock_name(n) else { continue };
let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance));
if !has_files && !instance_alive(lock_age(dir, instance)) {
if !with_files.contains(instance) && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -385,7 +436,22 @@ mod native {
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
// The file holds unsaved work: owner-only from its creation, whatever the umask.
crate::atomic_write::write_atomic_with(Path::new(path), private_mode(), |f| f.write_all(&bytes)).map_err(|e| e.to_string())
}
/// Owner read/write only (`None` where file modes don't exist).
fn private_mode() -> Option<std::fs::Permissions> {
#[cfg(unix)]
return Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
#[cfg(not(unix))]
None
}
/// Write this app's lock file at `path` without following a link planted there: the file is
/// replaced (exclusive temporary file, then rename), never written through (CWE-59).
fn write_lock(path: &Path, id: u32) -> std::io::Result<()> {
crate::atomic_write::write_atomic_with(path, private_mode(), |f| f.write_all(id.to_string().as_bytes()))
}
impl Autosaver {
@@ -423,24 +489,60 @@ mod native {
self.running.iter().any(|(_, h)| !h.is_finished())
}
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
/// another [`Autosaver::tick`] to learn whether it failed.
pub fn results_pending(&self) -> bool {
!self.running.is_empty()
}
/// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing
/// whose write failed is no longer counted as autosaved: the next plan restarts its clock,
/// so it is tried again SAVETIME later even if it doesn't change.
/// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`]
/// says why until a write succeeds.
pub fn reap(&mut self) {
let (done, running) = std::mem::take(&mut self.running).into_iter().partition::<Vec<_>, _>(|(_, h)| h.is_finished());
self.running = running;
for (uid, h) in done {
self.busy.remove(&uid);
if !h.join().unwrap_or(false) {
self.unwritten(uid);
}
self.settle(uid, h);
}
}
fn unwritten(&mut self, uid: u64) {
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
self.busy.remove(&uid);
match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) {
Ok(()) => {
self.failed.remove(&uid);
}
Err(e) => self.unwritten(uid, e),
}
}
/// Write every drawing with changes not yet autosaved and wait until it is done, because
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
/// how many drawings were written.
pub fn emergency_save(&mut self, s: &Session) -> usize {
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
let now = self.start.elapsed().as_secs_f64();
let dir = savefilepath();
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
self.perform(jobs);
self.last_beat = None;
self.heartbeat(&dir, now);
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
}
fn unwritten(&mut self, uid: u64, why: String) {
if let Some(t) = self.docs.get_mut(&uid) {
t.written = None;
}
self.failed.insert(uid, why);
}
/// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now.
@@ -451,16 +553,17 @@ mod native {
Job::Write { uid, path, doc } => {
let Some(write) = crate::cmd::file::io().map(|h| h.write) else {
log::warn!("autosave: file formats are not available in this build");
self.unwritten(uid, "file formats are not available in this build".to_string());
continue;
};
let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) {
Ok(()) => {
log::info!("autosaved {path}");
true
Ok(())
}
Err(e) => {
log::warn!("autosave {path}: {e}");
false
Err(format!("{path}: {e}"))
}
});
match spawned {
@@ -470,7 +573,7 @@ mod native {
}
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid);
self.unwritten(uid, e.to_string());
}
}
}
@@ -485,12 +588,52 @@ mod native {
}
self.last_beat = Some(now);
let dir = Path::new(dir);
let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string()));
if let Err(e) = r {
if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) {
log::warn!("autosave: lock file in {}: {e}", dir.display());
}
}
/// Refresh this app's lock files, and start one at `path` if there isn't one yet, taking
/// the system lock on it if it can. Without that lock the file still works by its age.
/// Only trouble at `path` is an error: an older folder may be gone by now.
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
let id = std::process::id();
let mut current = None;
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => write_lock(held, id),
};
if held == path {
current = Some(refreshed);
}
}
if let Some(refreshed) = current {
return refreshed;
}
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// A symlink planted at the lock path must not be opened (and truncated) through,
// nor a FIFO wait for a reader.
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).mode(0o600);
}
let mut file = options.open(path)?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the lock file is not a plain file"));
}
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return write_lock(path, id);
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
self.locks.push((path.to_path_buf(), Some(file)));
Ok(())
}
/// The app is closing normally: finish writes in progress, then delete this app's autosave
/// files and its lock file.
pub fn shutdown(&mut self) {
@@ -502,12 +645,40 @@ mod native {
discard(&path);
}
self.docs.clear();
for (path, file) in self.locks.drain(..) {
drop(file);
let _ = std::fs::remove_file(path);
}
let dir = savefilepath();
if !dir.is_empty() && is_instance_id(&self.instance) {
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// A symlink planted at the lock path is neither opened through nor written through.
#[test]
fn lock_writes_do_not_follow_a_planted_link() {
let dir = std::env::temp_dir().join(format!("cadcraft-lock-link-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let victim = dir.join("victim.txt");
std::fs::write(&victim, b"precious").unwrap();
let link = dir.join("1-a.lock");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let mut a = Autosaver::new("1-a");
assert!(a.refresh_lock(&link).is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
write_lock(&link, 42).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&link).unwrap(), b"42");
std::fs::remove_dir_all(dir).unwrap();
}
}
}
#[cfg(test)]
+3 -1
View File
@@ -268,11 +268,12 @@ impl DocState {
}
/// Start editing in the Block Editor `be` (the drawing is already the editor's): a fresh
/// undo history, nothing selected, model space.
/// undo history, nothing selected, nothing for OOPS, model space.
fn enter_block_editor(&mut self, be: BlockEdit) {
self.undo.clear();
self.redo.clear();
self.undo_ctl = Default::default();
self.erased = Default::default();
self.selection.clear();
self.previous_selection.clear();
self.space = cadcraft_doc::Space::Model;
@@ -338,6 +339,7 @@ impl DocState {
self.undo = host.undo;
self.redo = host.redo;
self.undo_ctl = host.undo_ctl;
self.erased = host.erased;
// The drawing comes back with nothing selected; Previous is what was selected before.
self.selection.clear();
self.previous_selection = if host.selection.is_empty() { host.previous_selection } else { host.selection };
+4
View File
@@ -18,13 +18,16 @@ mod gripcmds;
mod griphot;
pub(crate) mod groups;
pub(crate) mod hatch;
mod image;
mod inquiry;
mod layer;
mod layout;
mod lisp;
mod merge;
mod mleader_content;
mod mleader_edit;
mod modify;
pub(crate) use modify::Erased;
mod modify2;
mod mtext_opts;
mod overkill;
@@ -198,6 +201,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer::specs());
v.extend(layout::specs());
v.extend(exchange::specs());
v.extend(image::specs());
v.extend(props::specs());
v.extend(inquiry::specs());
v.extend(settings::specs());
+5 -1
View File
@@ -16,6 +16,7 @@ use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions
mod angular;
mod common;
mod edit;
mod leader;
mod linear;
mod mleader;
mod radial;
@@ -88,7 +89,10 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["mld"])
.params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}")
.interactive(|s| Ok(Box::new(MLeaderM::new(s)))),
CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"),
CommandSpec::new("leader", "Leader", run_leader)
.alias(&["lead"])
.params("{points: [[x,y]...], text?}")
.interactive(|_| Ok(Box::new(leader::LeaderM::default()))),
CommandSpec::new("dimstyle.update", "Update", run_update)
.menu(&["Dimension", "Update"])
.params("{handles?}")
+1
View File
@@ -22,6 +22,7 @@ pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
insertion_point: Vec3::ZERO,
})
}
+330
View File
@@ -0,0 +1,330 @@
//! LEADER at the command line, as AutoCAD 2024 runs it (accoreconsole, synthetic drawings): the
//! points, Format and Undo, then annotation text, no annotation, or a block.
//!
//! The leader takes a hook line, a horizontal last segment DIMASZ long, when its last segment is
//! straight and more than 15° off horizontal and text follows it; the text sits DIMGAP past the
//! leader's end, above it (DIMTAD on) or centred on it (DIMTAD 0), on the side the leader runs to.
//! All sizes are scaled by DIMSCALE. The text and the leader are separate objects here: the
//! drawing model has no link from a leader to its annotation.
use super::common::style;
use super::{Accept, EntityKind, Handle, Input, Interactive, MText, Prompt, Result, Session, Step, Vec2, v3};
use crate::cmd::curves::KW;
/// Where LEADER is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Points,
Format,
FirstLine,
NextLine,
Option,
/// Copy: the text or multiline text to copy.
Copy,
BlockName,
BlockPoint,
BlockX,
BlockY,
BlockRotation,
}
#[derive(Default)]
pub(super) struct LeaderM {
ask: Ask,
pts: Vec<Vec2>,
spline: bool,
no_arrow: bool,
lines: Vec<String>,
block: String,
block_at: Vec2,
block_scale: Vec2,
}
/// What follows the leader.
enum Annotation {
Text(Vec<String>),
/// A copy of a text or multiline text: its contents, height, width and style.
Copy(MText),
None,
Block {
name: String,
at: Vec2,
scale: Vec2,
rotation: f64,
},
}
impl Interactive for LeaderM {
fn name(&self) -> &'static str {
"LEADER"
}
fn prompt(&self, _s: &Session) -> Prompt {
let last = self.pts.last().copied();
match self.ask {
Ask::Points => match self.pts.len() {
0 => Prompt::new("Specify leader start point", Accept::POINT),
1 => Prompt::new("Specify next point", Accept::POINT).base_opt(last),
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Annotation", "Format", "Undo"]).default("Annotation").base_opt(last),
},
Ask::Format => Prompt::new("Enter leader format option", KW).kw(&["Spline", "STraight", "Arrow", "None"]).default("Exit"),
// "or <options>": the default is the options, not a keyword list.
Ask::FirstLine => Prompt::new("Enter first line of annotation text or", Accept::TEXT).default("options"),
Ask::NextLine => Prompt::new("Enter next line of annotation text", Accept::TEXT),
Ask::Copy => Prompt::new("Select an object to copy", Accept::SELECT),
Ask::Option => Prompt::new("Enter an annotation option", KW).kw(&["Tolerance", "Copy", "Block", "None", "Mtext"]).default("Mtext"),
Ask::BlockName => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]),
Ask::BlockPoint => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "X", "Y", "Z", "Rotate"]),
Ask::BlockX => Prompt::new("Enter X scale factor, specify opposite corner", Accept::NUMBER).kw(&["Corner", "XYZ"]).default("1"),
Ask::BlockY => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
Ask::BlockRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Points => match i {
Input::Point(p) => self.pts.push(p),
// Enter after one point: no leader.
Input::Enter if self.pts.len() < 2 => return Ok(Step::Done),
Input::Enter => self.ask = Ask::FirstLine,
Input::Keyword(k) => match k.as_str() {
"Annotation" => self.ask = Ask::FirstLine,
"Format" => self.ask = Ask::Format,
_ => {
self.pts.pop();
}
},
_ => {}
},
Ask::Format => {
// Enter, or Exit typed, changes nothing; either way back to the points.
if let Input::Keyword(k) = &i {
match k.as_str() {
"Spline" => self.spline = true,
"STraight" => self.spline = false,
"Arrow" => self.no_arrow = false,
_ => self.no_arrow = true,
}
}
self.ask = Ask::Points;
}
Ask::FirstLine | Ask::NextLine => match i {
Input::Text(t) if !t.is_empty() => {
self.lines.push(t);
self.ask = Ask::NextLine;
}
_ if self.ask == Ask::NextLine => {
let lines = std::mem::take(&mut self.lines);
return self.finish(s, Annotation::Text(lines));
}
_ => self.ask = Ask::Option,
},
Ask::Option => match &i {
Input::Keyword(k) if k == "None" => return self.finish(s, Annotation::None),
Input::Keyword(k) if k == "Block" => self.ask = Ask::BlockName,
Input::Keyword(k) if k == "Copy" => self.ask = Ask::Copy,
Input::Text(_) => s.echo("Invalid option keyword."),
// Mtext (the default) and Tolerance open an editor or a dialog in AutoCAD; at the
// command line, as in accoreconsole, they come back to the first line.
_ => self.ask = Ask::FirstLine,
},
Ask::Copy => {
let picked = match &i {
Input::Pick(hs) => hs.first().copied(),
Input::Point(p) => crate::select::pick(s.doc()?, &s.state()?.edit_space(), *p, s.settings.pickbox.max(1.0) * s.pixel_size()),
_ => None,
};
let text = picked.and_then(|h| s.doc().ok()?.entity(h).cloned()).and_then(|e| match &e.kind {
EntityKind::Text(t) => Some(MText {
insert: t.insert,
height: t.height,
width: 0.0,
attach: 4,
rotation: 0.0,
style: t.style.clone(),
contents: t.value.clone(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}),
EntityKind::MText(m) => Some(m.clone()),
_ => None,
});
match text {
Some(m) => return self.finish(s, Annotation::Copy(m)),
None if picked.is_some() => s.echo("The object is not text."),
None => {}
}
}
Ask::BlockName => {
let name = match i {
Input::Text(t) => t.trim().to_string(),
Input::Keyword(k) => k,
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.blocks.values().filter(|b| !b.anonymous).map(|b| b.name.clone()).collect();
s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") }));
} else if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
} else {
self.block = name;
self.ask = Ask::BlockPoint;
}
}
Ask::BlockPoint => match i {
Input::Point(p) => {
self.block_at = p;
self.ask = Ask::BlockX;
}
Input::Keyword(_) => s.echo("This option is not available here yet."),
_ => {}
},
Ask::BlockX => match scale(&i) {
Some(x) => {
self.block_scale = Vec2::new(x, x);
self.ask = Ask::BlockY;
}
None => s.echo("Requires a nonzero number."),
},
Ask::BlockY => {
match scale(&i) {
Some(y) if !matches!(i, Input::Enter) => self.block_scale.y = y,
Some(_) => {}
None => {
s.echo("Requires a nonzero number.");
return Ok(Step::Continue);
}
}
self.ask = Ask::BlockRotation;
}
Ask::BlockRotation => {
let rotation = match &i {
Input::Enter => 0.0,
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires a valid angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
let a = Annotation::Block { name: self.block.clone(), at: self.block_at, scale: self.block_scale, rotation };
return self.finish(s, a);
}
}
Ok(Step::Continue)
}
}
/// A scale factor typed, or 1 for Enter; `None` for zero or anything that isn't a number.
fn scale(i: &Input) -> Option<f64> {
match i {
Input::Enter => Some(1.0),
Input::Text(t) => t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v != 0.0),
_ => None,
}
}
impl LeaderM {
fn finish(&mut self, s: &mut Session, ann: Annotation) -> Result<Step> {
let pts = std::mem::take(&mut self.pts);
add_leader(s, &pts, self.spline, !self.no_arrow, ann)?;
Ok(Step::Done)
}
}
/// The dimension settings LEADER draws with, from the current dimension style and DIMSCALE.
struct Sizes {
arrow: f64,
gap: f64,
text: f64,
above: bool,
text_style: String,
}
fn sizes(s: &Session) -> Sizes {
// The current style with the drawing's DIM* overrides (what SETVAR DIMTXT… sets).
let st = s.doc().ok().and_then(|d| d.dim_style(&style(s)).map(|st| st.with_overrides(&d.dim_overrides()))).unwrap_or_default();
let k = if st.scale > 0.0 { st.scale } else { 1.0 };
let text_style = if st.text_style.is_empty() { "Standard".to_string() } else { st.text_style.clone() };
Sizes { arrow: st.arrow_size * k, gap: st.text_gap.abs() * k, text: st.text_height * k, above: st.text_above != 0, text_style }
}
/// LEADER's JSON form: a straight leader with an arrow through `pts`, and `text` (lines split at
/// `\n`) placed as the command line places it. The handles of the leader and its text.
pub(super) fn json_leader(s: &mut Session, pts: &[Vec2], text: Option<&str>) -> Result<Vec<Handle>> {
let ann = match text {
Some(t) if !t.is_empty() => Annotation::Text(t.split('\n').map(str::to_string).collect()),
_ => Annotation::None,
};
add_leader(s, pts, false, true, ann)
}
/// Draw the leader through `pts` and its annotation, as AutoCAD 2024 places them; their handles.
fn add_leader(s: &mut Session, pts: &[Vec2], spline: bool, arrow: bool, ann: Annotation) -> Result<Vec<Handle>> {
let (Some(&end), Some(&before)) = (pts.last(), pts.len().checked_sub(2).and_then(|i| pts.get(i))) else { return Ok(Vec::new()) };
let z = sizes(s);
// The side the leader runs to: right unless its last segment runs left.
let dir = if end.x < before.x { -1.0 } else { 1.0 };
let mut vertices = pts.to_vec();
if let Annotation::Text(_) | Annotation::Copy(_) = ann {
let d = end - before;
// A copied text is centred on its line, and with DIMTAD on that line is above the leader:
// the leader ends half the text height lower.
if let (Annotation::Copy(_), true, Some(last)) = (&ann, z.above, vertices.last_mut()) {
last.y -= z.text / 2.0;
}
let end = vertices.last().copied().unwrap_or(end);
// More than 15° off horizontal: a hook line DIMASZ long into the end.
if !spline && d.y.abs() > d.x.abs() * 15f64.to_radians().tan() {
let at = vertices.len() - 1;
vertices.insert(at, Vec2::new(end.x - dir * z.arrow, end.y));
}
}
let leader = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: vertices.iter().map(|p| v3(*p)).collect(),
arrow,
spline,
style: style(s),
}))?;
let annotation = match ann {
Annotation::Text(lines) => {
let (attach, lift) = match (z.above, dir > 0.0) {
(true, true) => (7, z.gap),
(true, false) => (9, z.gap),
(false, true) => (4, 0.0),
(false, false) => (6, 0.0),
};
Some(s.add_entity(EntityKind::MText(MText {
insert: v3(end + Vec2::new(dir * z.gap, lift)),
height: z.text,
width: 0.0,
attach,
rotation: 0.0,
style: z.text_style,
contents: lines.join("\\P"),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?)
}
Annotation::Copy(m) => {
let lift = if z.above { z.gap } else { 0.0 };
let attach = if dir > 0.0 { 4 } else { 6 };
Some(s.add_entity(EntityKind::MText(MText { insert: v3(end + Vec2::new(dir * z.gap, lift)), attach, rotation: 0.0, ..m }))?)
}
Annotation::Block { name, at, scale, rotation } => {
match crate::cmd::blocks::insert_with(s, &name, at, scale, rotation, None, &serde_json::Map::new()) {
Ok(h) => Some(h),
Err(e) => {
s.doc_mut()?.remove_entity(leader);
return Err(e);
}
}
}
Annotation::None => None,
};
Ok(std::iter::once(leader).chain(annotation).collect())
}
+2 -28
View File
@@ -1,4 +1,3 @@
use super::common::style;
use super::{
Accept, EngineError, EntityKind, Input, Interactive, LeaderOrder, MLeader, MLeaderOptions, MText, Prompt, Result, Session, Step, Value, Vec2,
bad, json, points_param, str_param, v3,
@@ -57,33 +56,8 @@ pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Err(bad("leader", "need 2+ points"));
}
let st = style(s);
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
vertices: pts.iter().map(|q| v3(*q)).collect(),
arrow: true,
spline: false,
style: st,
}))?;
let mut out = vec![h.hex()];
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
out.push(
s.add_entity(EntityKind::MText(MText {
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
height: th,
width: 0.0,
attach: 4,
rotation: 0.0,
style: "Standard".into(),
contents: t.into(),
line_spacing: 1.0,
line_spacing_exact: false,
columns: None,
}))?
.hex(),
);
}
Ok(json!({ "handles": out }))
let hs = super::leader::json_leader(s, &pts, str_param(p, "text"))?;
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
/// The MLEADER Options prompt being answered.
+15 -3
View File
@@ -159,6 +159,7 @@ pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: Ar
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}))
}
@@ -247,9 +248,16 @@ fn max_count(s: &Session, others: u64, objs: usize) -> u64 {
}
fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator<Item = Mat3>) -> Result<usize> {
// Every copy is checked before the first is made: a refusal part way would leave the copies
// already added in the drawing (a failed command is not rolled back).
let mats: Vec<Mat3> = mats.collect();
// A matrix every frame accepts needs no look at the objects; otherwise they are looked at once.
if mats.iter().any(|m| !cadcraft_doc::ole_frame_accepts(m)) && super::modify::holds_ole_frame(s, hs, true)? {
return Err(super::modify::ole_refusal());
}
let mut n = 0;
for m in mats {
n += transform_entities(s, hs, &m, true)?.len();
for m in &mats {
n += transform_entities(s, hs, m, true)?.len();
}
Ok(n)
}
@@ -374,10 +382,14 @@ fn run_arraypolar(s: &mut Session, p: &Value) -> Result<Value> {
check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?;
let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c);
let row_spacing = f64_or(p, "rowSpacing", 1.0);
let fill = f64_or(p, "angle", 360.0);
if !(fill.abs() >= 1e-9 && fill.abs() <= 360.0 + 1e-9) {
return Err(other("Requires a fill angle between -360 and 360 degrees (not 0)."));
}
let kind = ArrayKind::Polar(PolarArray {
center: c - base,
items: count32(n),
fill: f64_or(p, "angle", 360.0),
fill: fill.clamp(-360.0, 360.0),
rotate: bool_or(p, "rotate", true),
rows: count32(rows),
row_spacing,
+194 -123
View File
@@ -7,7 +7,7 @@ pub(in crate::cmd) mod purge;
use std::sync::Arc;
use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::helpers::v3;
@@ -17,12 +17,15 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po
use super::has_doc;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod attdef;
mod attedit;
mod burst;
mod description;
mod dyngrip;
mod extract;
pub(crate) mod refedit;
mod wblock;
mod xclip;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -41,14 +44,18 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["i", "-insert", "ddinsert"])
.params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}")
.interactive(|_| Ok(Box::new(InsertM::default()))),
CommandSpec::new("minsert", "Multiple Insert", run_minsert)
.params("{name, file?: DXF/DWG path (as INSERT), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y], attribs?: {TAG: value}, rows?: 1..32767 (default 1), columns?: 1..32767 (default 1), rowSpacing?, columnSpacing? (along the reference's own axes, not scaled with it)}")
.interactive(|_| Ok(Box::new(InsertM { minsert: true, ..InsertM::default() }))),
CommandSpec::new("attdef", "Define Attributes...", run_attdef)
.menu(&["Draw", "Block", "Define Attributes..."])
.alias(&["att", "-attdef"])
.params("{tag, prompt?, default?, at, height?, invisible?}")
.interactive(|_| Ok(Box::new(AttdefM::default()))),
.interactive(|_| Ok(Box::new(attdef::AttdefM::default()))),
attedit::attedit_spec(),
attedit::dash_attedit_spec(),
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"),
wblock::wblock_spec(),
wblock::dash_wblock_spec(),
CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"),
CommandSpec::new("blocks.dynamic", "Dynamic Block Data", run_dynamic)
.params("{name (a block, or \"*Model_Space\" for a drawing that is a dynamic block's own)} → {parameters, actions, other} | null (not a dynamic block)")
@@ -86,6 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
.chain(attribs::attdisp_menu_specs())
.chain(xref::specs())
.chain(refedit::specs())
.chain(xclip::specs())
.chain(bedit::specs())
.collect()
}
@@ -184,9 +192,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle],
b.entities.push(e);
}
doc.blocks.insert(name.to_string(), Arc::new(b));
if keep != Keep::Retain {
for h in hs {
doc.remove_entity(*h);
match keep {
Keep::Retain => {}
Keep::Convert => {
for h in hs {
doc.remove_entity(*h);
}
}
// As after ERASE, OOPS brings them back.
Keep::Delete => {
super::modify::erase_kept(s, hs)?;
}
}
s.set_selection(Vec::new());
@@ -215,6 +230,7 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
if let Some(b) = base {
let landed = ins.transform(blk.base.xy()).apply(b);
@@ -223,6 +239,33 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec3, rotation: f64, base: Option
ins
}
/// MINSERT's rectangular array of a block reference: rows and columns, and the distances between
/// them along the reference's rotated axes (not scaled with it). One row and one column is a plain
/// reference.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Grid {
rows: u32,
cols: u32,
row_spacing: f64,
col_spacing: f64,
}
impl Grid {
const ONE: Grid = Grid { rows: 1, cols: 1, row_spacing: 0.0, col_spacing: 0.0 };
/// The most rows or columns MINSERT takes, as AutoCAD 2024 asks for them.
const MAX: u32 = 32767;
/// A distance is kept only along an axis with more than one copy: AutoCAD 2024 asks for no
/// other and stores 0 for it (one row of three columns: 44 = 20, 45 = 0).
fn new(rows: u32, cols: u32, row_spacing: f64, col_spacing: f64) -> Grid {
Grid { rows, cols, row_spacing: if rows > 1 { row_spacing } else { 0.0 }, col_spacing: if cols > 1 { col_spacing } else { 0.0 } }
}
fn apply(self, ins: &mut Insert) {
(ins.rows, ins.cols, ins.row_spacing, ins.col_spacing) = (self.rows, self.cols, self.row_spacing, self.col_spacing);
}
}
/// [`insert`] with separate X/Y scale factors (Z as X) and an optional base point (see
/// [`insert_entity`]).
pub(crate) fn insert_with(
@@ -234,10 +277,12 @@ pub(crate) fn insert_with(
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
) -> Result<Handle> {
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values)
insert_scaled(s, name, at, Vec3::new(scale.x, scale.y, scale.x), rotation, base, values, Grid::ONE)
}
/// [`insert_with`] with a Z scale factor of its own.
/// [`insert_with`] with a Z scale factor of its own, and MINSERT's grid. A grid has the one set
/// of attributes (AutoCAD 2024 asks for their values once).
#[allow(clippy::too_many_arguments)]
fn insert_scaled(
s: &mut Session,
name: &str,
@@ -246,9 +291,11 @@ fn insert_scaled(
rotation: f64,
base: Option<Vec2>,
values: &serde_json::Map<String, Value>,
grid: Grid,
) -> Result<Handle> {
let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?;
let mut ins = insert_entity(&blk, at, scale, rotation, base);
grid.apply(&mut ins);
let m = ins.transform(blk.base.xy());
for e in blk.entities.iter() {
if let EntityKind::AttDef(ad) = &e.kind {
@@ -300,24 +347,52 @@ fn run_block(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_insert(s: &mut Session, p: &Value) -> Result<Value> {
insert_json(s, p, "insert", Grid::ONE)
}
/// MINSERT's JSON form: INSERT's parameters and the grid.
fn run_minsert(s: &mut Session, p: &Value) -> Result<Value> {
let count = |key: &str| -> Result<u32> {
match p.get(key) {
None | Some(Value::Null) => Ok(1),
Some(v) => v
.as_f64()
.filter(|n| n.fract() == 0.0 && *n >= 1.0 && *n <= f64::from(Grid::MAX))
.map(|n| n as u32)
.ok_or_else(|| bad("minsert", format!("`{key}` must be an integer between 1 and {}", Grid::MAX))),
}
};
// A distance given must be a number: `"15"` is refused, not taken as 0.
let spacing = |key: &str| -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("minsert", format!("`{key}` must be a number"))),
}
};
let grid = Grid::new(count("rows")?, count("columns")?, spacing("rowSpacing")?, spacing("columnSpacing")?);
insert_json(s, p, "minsert", grid)
}
/// INSERT's and MINSERT's JSON form.
fn insert_json(s: &mut Session, p: &Value, cmd: &str, grid: Grid) -> Result<Value> {
let p = &insert_file::json_params(s, p)?;
let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string();
let at = point_req("insert", p, "at")?;
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
let at = point_req(cmd, p, "at")?;
// `scale`: one factor, or [x, y] for different X and Y scale factors.
let scale = match p.get("scale") {
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?,
Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad(cmd, "`scale` must be a number or [x, y]"))?,
_ => {
let f = f64_or(p, "scale", 1.0);
Vec2::new(f, f)
}
};
if scale.x == 0.0 || scale.y == 0.0 {
return Err(bad("insert", "scale cannot be 0"));
return Err(bad(cmd, "scale cannot be 0"));
}
let rot = f64_or(p, "rotation", 0.0).to_radians();
let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default();
let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?;
if bool_or(p, "explode", false) {
let h = insert_scaled(s, &name, at, Vec3::new(scale.x, scale.y, scale.x), rot, point_param(p, "basePoint"), &vals, grid)?;
if cmd == "insert" && bool_or(p, "explode", false) {
// The EXPLODE command body, not `execute`: INSERT records the one undo step.
let explode = find_command("explode").ok_or_else(|| EngineError::UnknownCommand("explode".into()))?;
return (explode.run)(s, &json!({ "handles": [h.hex()] }));
@@ -361,61 +436,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let src = s.doc()?;
let mut d = cadcraft_doc::Drawing::new_imperial();
// The written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its
// units ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches
// and scaled on insert, and its defaults (text size, current styles, annotation scales).
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
// The base point becomes the written drawing's origin, its insertion base: by default 0,0 for
// objects and the block's base point for a block (`base` is then in block coordinates).
let base = point_param(p, "base");
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO));
let ents: Vec<Entity> = if let Some(name) = str_param(p, "name") {
let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
b.entities
.iter()
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
} else {
targets(s, p)?
.iter()
.filter_map(|h| src.entity(*h))
.map(|e| {
let mut e = (**e).clone();
e.kind.transform(&m);
e
})
.collect()
};
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
let hooks = super::file::io().ok_or_else(|| bad("wblock", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, &path).map_err(|e| bad("wblock", e))?;
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(&path, &bytes).map_err(|e| bad("wblock", format!("{path}: {e}")))?;
Ok(json!({ "path": path, "entities": d.model.len(), "bytes": bytes.len() }))
}
fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_req("base", p, "at")?;
s.doc_mut()?.header.set("INSBASE", cadcraft_doc::HVal::Point(v3(at)));
@@ -424,8 +444,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
// One object index for the whole listing: rebuilding it per block multiplies the block count
// by the drawing's kept object count (CWE-407).
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
@@ -602,6 +625,15 @@ enum InsStage {
/// XYZ: the Z scale factor, after X and Y.
ZScale,
Rotation,
/// MINSERT: the number of rows, then of columns.
Rows,
Cols,
/// MINSERT: the distance between rows, or a unit cell's first corner.
RowSpacing,
/// MINSERT: the unit cell's opposite corner (its width between columns, its height between
/// rows).
Cell(Vec2),
ColSpacing,
Attribs,
/// A Verify attribute asked again.
Verify,
@@ -634,6 +666,12 @@ struct InsertM {
explode: bool,
/// The file waiting for the Redefine answer.
file: Option<insert_file::FileRef>,
/// MINSERT: after the rotation, the grid's rows and columns and the distances between them.
minsert: bool,
rows: Option<u32>,
cols: Option<u32>,
row_spacing: Option<f64>,
col_spacing: Option<f64>,
}
impl InsertM {
@@ -643,6 +681,8 @@ impl InsertM {
if self.explode { InsStage::PresetScale } else { InsStage::XScale }
} else if self.rotation.is_none() {
InsStage::Rotation
} else if let Some(g) = self.grid_stage() {
g
} else {
InsStage::Attribs
};
@@ -661,7 +701,7 @@ impl InsertM {
&& self.values.len() >= self.attdefs.len()
&& let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation)
{
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values)?;
let h = insert_scaled(s, &n, a, self.scale3(sc), r, self.base, &self.values, self.grid())?;
if self.explode {
insert_file::explode_insert(s, h)?;
}
@@ -670,6 +710,30 @@ impl InsertM {
Ok(Step::Continue)
}
/// MINSERT's next grid prompt, if any is left (as AutoCAD 2024 asks them): the rows, the
/// columns, then the distance between rows when there is more than one row and between
/// columns when there is more than one column. A unit cell gives both distances.
fn grid_stage(&self) -> Option<InsStage> {
if !self.minsert {
return None;
}
let (Some(rows), Some(cols)) = (self.rows, self.cols) else {
return Some(if self.rows.is_none() { InsStage::Rows } else { InsStage::Cols });
};
if rows > 1 && self.row_spacing.is_none() {
Some(InsStage::RowSpacing)
} else if cols > 1 && self.col_spacing.is_none() {
Some(InsStage::ColSpacing)
} else {
None
}
}
/// The grid answered so far (one copy for INSERT).
fn grid(&self) -> Grid {
Grid::new(self.rows.unwrap_or(1), self.cols.unwrap_or(1), self.row_spacing.unwrap_or(0.0), self.col_spacing.unwrap_or(0.0))
}
/// The Verify attributes asked for.
fn verifying(&self) -> Vec<&(String, String, String, bool)> {
self.attdefs.iter().filter(|a| a.3).collect()
@@ -740,13 +804,18 @@ impl InsertM {
}
}
/// A distance typed at MINSERT's grid prompts: any finite number, negative or 0 too.
fn distance(t: &str) -> Result<f64> {
number(t).filter(|v| v.is_finite()).ok_or_else(|| EngineError::Other("Requires a distance.".into()))
}
fn nonzero(t: &str) -> Result<f64> {
number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))
}
impl Interactive for InsertM {
fn name(&self) -> &'static str {
"INSERT"
if self.minsert { "MINSERT" } else { "INSERT" }
}
fn prompt(&self, s: &Session) -> Prompt {
let at = self.at.unwrap_or_default();
@@ -773,9 +842,16 @@ impl Interactive for InsertM {
InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"),
InsStage::ZScale => Prompt::new("Specify Z scale factor or", Accept::NUMBER).default("use X scale factor"),
InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at),
InsStage::Rows => Prompt::new("Enter number of rows (---)", Accept::NUMBER).default("1"),
InsStage::Cols => Prompt::new("Enter number of columns (|||)", Accept::NUMBER).default("1"),
InsStage::RowSpacing => Prompt::new("Enter distance between rows or specify unit cell (---)", Accept::POINT_OR_NUMBER),
InsStage::Cell(a) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),
InsStage::ColSpacing => Prompt::new("Specify distance between columns (|||)", Accept::NUMBER),
InsStage::Attribs => {
let (_, prompt, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default)
let p = Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT);
// No default shown when there is none ("Value:", AutoCAD 2024), not "<>".
if default.is_empty() { p } else { p.default(default) }
}
InsStage::Verify => {
let (tag, prompt) = self.verifying().get(self.verified).map(|a| (a.0.clone(), a.1.clone())).unwrap_or_default();
@@ -797,9 +873,9 @@ impl Interactive for InsertM {
s.echo(format!("Defined blocks: {}", names.join(", ")));
return Ok(Step::Continue);
}
// `*name`: insert exploded.
self.explode = n.starts_with('*');
let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string();
// `*name`: insert exploded (INSERT only: a grid has no exploded form).
self.explode = !self.minsert && n.starts_with('*');
let n = if self.explode { n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string() } else { n };
match insert_file::file_ref(s, &n) {
Some(f) if f.ask_redefine(s) => {
self.file = Some(f);
@@ -939,6 +1015,45 @@ impl Interactive for InsertM {
});
return self.advance(s);
}
InsStage::Rows | InsStage::Cols => {
let n = match i {
Input::Text(t) => t
.trim()
.parse::<f64>()
.ok()
.filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= f64::from(Grid::MAX))
.map(|v| v as u32)
.ok_or_else(|| EngineError::Other(format!("Requires an integer between 1 and {}.", Grid::MAX)))?,
Input::Enter => 1,
_ => return Ok(Step::Continue),
};
if self.stage == InsStage::Rows {
self.rows = Some(n);
} else {
self.cols = Some(n);
}
return self.advance(s);
}
InsStage::RowSpacing => match i {
Input::Point(a) => self.stage = InsStage::Cell(a),
Input::Text(t) => {
self.row_spacing = Some(distance(&t)?);
return self.advance(s);
}
_ => {}
},
InsStage::Cell(a) => {
if let Input::Point(b) = i {
(self.col_spacing, self.row_spacing) = (Some(b.x - a.x), Some(b.y - a.y));
return self.advance(s);
}
}
InsStage::ColSpacing => {
if let Input::Text(t) = i {
self.col_spacing = Some(distance(&t)?);
return self.advance(s);
}
}
InsStage::Attribs => {
let (tag, _, default, _) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default();
let v = match i {
@@ -979,53 +1094,9 @@ impl Interactive for InsertM {
_ => Vec2::new(1.0, 1.0),
});
let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 });
vec![EntityKind::Insert(insert_entity(b, at, self.scale3(scale), rot, self.base))]
}
}
#[derive(Default)]
struct AttdefM {
tag: Option<String>,
prompt: Option<String>,
default: Option<String>,
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => Prompt::new("Enter attribute tag name", Accept::TEXT),
(Some(_), None, _) => Prompt::new("Enter attribute prompt", Accept::TEXT),
(Some(_), Some(_), None) => Prompt::new("Enter default attribute value", Accept::TEXT),
_ => Prompt::new("Specify start point of text", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let text = match &i {
Input::Text(t) => Some(t.clone()),
Input::Enter => Some(String::new()),
_ => None,
};
match (&self.tag, &self.prompt, &self.default) {
(None, ..) => {
if let Some(t) = text.filter(|t| !t.trim().is_empty() && !t.contains(' ')) {
self.tag = Some(t.to_ascii_uppercase());
}
}
(Some(_), None, _) => self.prompt = text,
(Some(_), Some(_), None) => self.default = text,
(Some(tag), Some(pr), Some(def)) => {
if let Input::Point(p) = i {
let h = s.doc()?.header.f64("TEXTSIZE", 0.2);
let k = attdef_kind(s, tag, pr, def, p, h, false);
s.add_entity(k)?;
return Ok(Step::Done);
}
}
}
Ok(Step::Continue)
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
self.grid().apply(&mut ins);
vec![EntityKind::Insert(ins)]
}
}
+293
View File
@@ -0,0 +1,293 @@
//! -ATTDEF at the command line, as AutoCAD 2024 runs it (accoreconsole): the attribute modes
//! (kept in AFLAGS), the tag, prompt and default value (a constant attribute's value instead),
//! then the text's point, justification, style, height and rotation.
use cadcraft_doc::{AttribModes, HAlign, VAlign};
use super::{Accept, Attrib, EntityKind, Input, Interactive, Prompt, Result, Session, Step, Text, Vec2, number, v3};
use crate::cmd::curves::KW;
use crate::cmd::draw::justify;
/// AFLAGS bits, as AutoCAD numbers the modes.
const INVISIBLE: i64 = 1;
const CONSTANT: i64 = 2;
const VERIFY: i64 = 4;
const PRESET: i64 = 8;
const LOCK: i64 = 16;
const MULTILINE: i64 = 32;
const MODES: [&str; 7] = ["Invisible", "Constant", "Verify", "Preset", "Lock position", "Annotative", "Multiple lines"];
const JUSTIFY: [&str; 15] = ["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"];
/// Where -ATTDEF is in its prompts.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
Modes,
Tag,
Prompt,
Value,
Point,
Justify,
Style,
Height,
Rotation,
}
pub(super) struct AttdefM {
ask: Ask,
flags: i64,
tag: String,
prompt: String,
value: String,
justify: (HAlign, VAlign),
style: String,
at: Vec2,
height: f64,
}
impl Default for AttdefM {
fn default() -> Self {
AttdefM {
ask: Ask::Modes,
flags: LOCK,
tag: String::new(),
prompt: String::new(),
value: String::new(),
justify: (HAlign::Left, VAlign::Baseline),
style: "Standard".into(),
at: Vec2::ZERO,
height: 0.0,
}
}
}
impl AttdefM {
fn echo_modes(&self, s: &mut Session) {
let yn = |bit: i64| if self.flags & bit != 0 { "Y" } else { "N" };
s.echo("Current attribute modes:");
s.echo(format!(
"Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative=N Multiple line={}",
yn(INVISIBLE),
yn(CONSTANT),
yn(VERIFY),
yn(PRESET),
yn(LOCK),
yn(MULTILINE)
));
}
fn echo_style(&self, s: &mut Session) -> Result<()> {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
s.echo(format!("Current text style: \"{}\" Text height: {th:.4} Justify: {}", self.style, justify_name(self.justify)));
Ok(())
}
fn point_prompt(&self) -> &'static str {
match self.justify {
(HAlign::Center, VAlign::Baseline) => "Specify center point of text",
(HAlign::Right, VAlign::Baseline) => "Specify right endpoint of text baseline",
(HAlign::Middle, _) | (HAlign::Center, VAlign::Middle) => "Specify middle point of text",
(HAlign::Left, VAlign::Top) => "Specify top-left point of text",
(HAlign::Center, VAlign::Top) => "Specify top-center point of text",
(HAlign::Right, VAlign::Top) => "Specify top-right point of text",
(HAlign::Left, VAlign::Middle) => "Specify middle-left point of text",
(HAlign::Right, VAlign::Middle) => "Specify middle-right point of text",
(HAlign::Left, VAlign::Bottom) => "Specify bottom-left point of text",
(HAlign::Center, VAlign::Bottom) => "Specify bottom-center point of text",
(HAlign::Right, VAlign::Bottom) => "Specify bottom-right point of text",
_ => "Specify start point of text",
}
}
fn finish(&mut self, s: &mut Session, rotation: f64) -> Result<Step> {
let (h, v) = self.justify;
let left = h == HAlign::Left && v == VAlign::Baseline;
s.add_entity(EntityKind::AttDef(Attrib {
tag: self.tag.to_uppercase(),
text: Text {
insert: v3(self.at),
align_pt: if left { None } else { Some(v3(self.at)) },
height: self.height,
value: std::mem::take(&mut self.value),
rotation,
width_factor: 1.0,
oblique: 0.0,
style: self.style.clone(),
halign: h,
valign: v,
},
invisible: self.flags & INVISIBLE != 0,
constant: self.flags & CONSTANT != 0,
prompt: std::mem::take(&mut self.prompt),
props: Default::default(),
multiline: None,
modes: AttribModes { verify: self.flags & VERIFY != 0, preset: self.flags & PRESET != 0, lock_position: self.flags & LOCK != 0 },
}))?;
Ok(Step::Done)
}
}
/// The justification's name in "Current text style: … Justify: …".
fn justify_name(j: (HAlign, VAlign)) -> &'static str {
JUSTIFY.into_iter().find(|k| justify(k) == Some(j)).unwrap_or("Left")
}
impl Interactive for AttdefM {
fn name(&self) -> &'static str {
"ATTDEF"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let d = s.doc()?;
self.flags = d.header.i64("AFLAGS", LOCK);
self.style = d.header.str("TEXTSTYLE", "Standard");
self.echo_modes(s);
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.ask {
Ask::Modes => Prompt::new("Enter an option to change", KW).kw(&MODES).default("done"),
Ask::Tag => Prompt::new("Enter attribute tag name", Accept::TEXT),
Ask::Prompt => Prompt::new("Enter attribute prompt", Accept::TEXT),
Ask::Value if self.flags & CONSTANT != 0 => Prompt::new("Enter attribute value", Accept::TEXT),
Ask::Value => Prompt::new("Enter default attribute value", Accept::TEXT),
// Once a justification is chosen AutoCAD asks for its point alone.
Ask::Point if self.justify == (HAlign::Left, VAlign::Baseline) => {
Prompt::new(self.point_prompt(), Accept::POINT).kw(&["Justify", "Style"])
}
Ask::Point => Prompt::new(self.point_prompt(), Accept::POINT),
Ask::Justify => Prompt::new("Enter an option", Accept::TEXT).kw(&JUSTIFY),
Ask::Style => Prompt::new("Enter style name", Accept::TEXT).kw(&["?"]).default(self.style.clone()),
Ask::Height => {
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(self.at)
}
Ask::Rotation => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(self.at),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::Modes => match &i {
Input::Keyword(k) => {
let bit = match k.as_str() {
"Invisible" => INVISIBLE,
"Constant" => CONSTANT,
"Verify" => VERIFY,
"Preset" => PRESET,
"Lock position" => LOCK,
_ => {
s.echo(format!("{k} attributes are not available yet."));
return Ok(Step::Continue);
}
};
self.flags ^= bit;
s.doc_mut()?.header.set_i64("AFLAGS", self.flags);
self.echo_modes(s);
}
Input::Enter => self.ask = Ask::Tag,
Input::Text(_) => s.echo("Invalid option keyword."),
_ => {}
},
Ask::Tag => match &i {
// AutoCAD takes spaces in a tag here, and upper-cases it.
Input::Text(t) if !t.trim().is_empty() => {
self.tag = t.trim().to_string();
self.ask = if self.flags & CONSTANT != 0 { Ask::Value } else { Ask::Prompt };
}
Input::Text(_) | Input::Enter => s.echo("Tag cannot be null"),
_ => {}
},
Ask::Prompt | Ask::Value => {
let t = match i {
Input::Text(t) => t,
Input::Enter => String::new(),
_ => return Ok(Step::Continue),
};
if self.ask == Ask::Prompt {
self.prompt = t;
self.ask = Ask::Value;
} else {
self.value = t;
self.echo_style(s)?;
self.ask = Ask::Point;
}
}
Ask::Point => match i {
Input::Point(p) => {
self.at = p;
self.ask = Ask::Height;
}
Input::Keyword(k) if k == "Justify" => self.ask = Ask::Justify,
Input::Keyword(_) => self.ask = Ask::Style,
_ => {}
},
Ask::Justify => {
// A justification goes on to its point; anything else is asked again.
if let Input::Keyword(k) | Input::Text(k) = &i {
match justify(k) {
// Aligned and fitted text take two points: not here yet.
Some((HAlign::Aligned | HAlign::Fit, _)) => s.echo("Align and Fit are not available here yet."),
Some(j) => {
self.justify = j;
self.ask = Ask::Point;
}
None => s.echo("Invalid option keyword."),
}
}
}
Ask::Style => {
let name = match i {
Input::Enter => self.style.clone(),
Input::Text(t) | Input::Keyword(t) => t.trim().to_string(),
_ => return Ok(Step::Continue),
};
if name == "?" {
let names: Vec<String> = s.doc()?.text_styles.iter().map(|st| st.name.clone()).collect();
s.echo(format!("Text styles: {}", names.join(", ")));
return Ok(Step::Continue);
}
match s.doc()?.text_style(&name).map(|st| st.name.clone()) {
Some(n) => {
self.style = n;
self.echo_style(s)?;
self.ask = Ask::Point;
}
None => s.echo(format!("Cannot find text style \"{name}\".")),
}
}
Ask::Height => {
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
let h = match &i {
Input::Enter => th,
Input::Point(p) => self.at.dist(*p),
// A distance, as TEXT takes its height (1'6" in feet and inches, too).
Input::Text(t) => number(t).unwrap_or(0.0),
_ => return Ok(Step::Continue),
};
if !(h.is_finite() && h > 0.0) {
s.echo("Requires a positive height.");
return Ok(Step::Continue);
}
self.height = h;
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
self.ask = Ask::Rotation;
}
Ask::Rotation => {
let r = match &i {
Input::Enter => 0.0,
Input::Point(p) => self.at.angle_to(*p),
Input::Text(t) => match s.angle_settings().direction(t) {
Some(a) => a,
None => {
s.echo("Requires an angle.");
return Ok(Step::Continue);
}
},
_ => return Ok(Step::Continue),
};
return self.finish(s, r);
}
}
Ok(Step::Continue)
}
}
+18 -42
View File
@@ -6,7 +6,7 @@
//! (names this drawing already has keep this drawing's definitions). The file is read through the
//! host's io hooks like OPEN, so the engine stays I/O-agnostic.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle};
@@ -59,7 +59,7 @@ pub(super) fn file_ref(s: &Session, t: &str) -> Option<FileRef> {
}
/// The file name without folders and extension (either separator, so Windows paths work anywhere).
fn stem(path: &str) -> String {
pub(super) fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
@@ -102,14 +102,14 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
// Redefining keeps the existing definition's name (block names are case-insensitive).
let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string());
merge_tables(&mut d, src);
let renames = block_names(&d, src, &name);
let renames = crate::cmd::merge::block_renames(&d, src, Some(&name));
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
@@ -118,7 +118,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default();
let mut ents = Vec::new();
for e in src.model.iter() {
let e = adopt(&mut d, e, &renames);
let e = adopt(&mut d, src, e, &renames);
d.ensure_layer(&e.common.layer);
ents.push(e);
}
@@ -136,13 +136,7 @@ fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result<String> {
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition).
fn merge_tables(d: &mut Drawing, src: &Drawing) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
use crate::cmd::merge::merge_named as merge;
merge(&mut d.layers, &src.layers, |l| &l.name);
merge(&mut d.linetypes, &src.linetypes, |l| &l.name);
merge(&mut d.text_styles, &src.text_styles, |t| &t.name);
@@ -152,40 +146,22 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) {
merge(&mut d.mline_styles, &src.mline_styles, |t| &t.name);
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has (or the block being defined) keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
taken.insert(defining.to_ascii_uppercase());
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
fn adopt(d: &mut Drawing, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
let old = e.handle;
e.handle = d.new_handle();
// An attribute definition keeps its field: its extension dictionary and the objects in it.
if matches!(e.kind, EntityKind::AttDef(_))
&& let Some(root) = src.foreign.entities.get(&old).and_then(|x| x.xdict)
{
let tree = src.foreign.tree(root);
let fresh: Vec<Handle> = (0..tree.len() + 2).map(|_| d.new_handle()).collect();
let foreign = Arc::make_mut(&mut d.foreign);
foreign.adopt_tree(&src.foreign, &tree, &fresh, e.handle);
foreign.entities.entry(e.handle).or_default().xdict = fresh.first().copied();
}
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
+51 -1
View File
@@ -197,7 +197,21 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
.collect()
}
Kind::Linetypes => {
let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone())));
// Objects and layers use linetypes, and so do dimension styles and a dimension's own
// overrides (DIMLTYPE, DIMLTEX1, DIMLTEX2), and multiline styles' elements.
let styles = d.dim_styles.iter().flat_map(|s| [s.dim_linetype.clone(), s.ext_linetype1.clone(), s.ext_linetype2.clone()]);
let overrides = entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm) } else { None }).flat_map(|dm| {
["dimLinetype", "extLinetype1", "extLinetype2"].into_iter().filter_map(|k| dm.overrides.get(k)?.as_str().map(str::to_string))
});
let mlines = d.mline_styles.iter().flat_map(|s| s.elements.iter().map(|el| el.linetype.clone()));
let used = lower(
&mut entities(d)
.map(|e| e.common.linetype.clone())
.chain(d.layers.iter().map(|l| l.linetype.clone()))
.chain(styles)
.chain(overrides)
.chain(mlines),
);
let cur = d.header.str("CELTYPE", "ByLayer");
d.linetypes
.iter()
@@ -218,6 +232,7 @@ pub(in crate::cmd) fn candidates(d: &Drawing, kind: Kind) -> Vec<String> {
let used = lower(&mut entities(d).filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.style.clone()),
EntityKind::Tolerance(t) => Some(t.style.clone()),
EntityKind::Leader(l) => Some(l.style.clone()),
_ => None,
}));
let cur = d.header.str("DIMSTYLE", "Standard");
@@ -299,6 +314,41 @@ fn retain<T>(v: &mut Vec<T>, name: impl Fn(&T) -> &String, n: &str) -> bool {
v.len() != before
}
/// Most passes [`purge_unused`] makes: each can free what the last one's objects used (a nested
/// block, its layer, that layer's linetype).
const MAX_PASSES: usize = 64;
/// Remove every named object drawing `d` doesn't use, as PURGE All would, nested ones included;
/// the defaults and the current ones stay. For a drawing being written (WBLOCK's), not the one
/// being edited: nothing goes through the session or its undo.
pub(in crate::cmd) fn purge_unused(d: &mut Drawing) {
for _ in 0..MAX_PASSES {
let mut removed = false;
for kind in ALL {
for name in candidates(d, *kind) {
removed |= match kind {
Kind::Blocks => {
let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&name)).cloned();
key.is_some_and(|k| d.blocks.remove(&k).is_some())
}
Kind::Layers => retain(&mut d.layers, |l| &l.name, &name),
Kind::TextStyles => retain(&mut d.text_styles, |t| &t.name, &name),
Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, &name),
Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, &name),
Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, &name),
Kind::MlineStyles => retain(&mut d.mline_styles, |t| &t.name, &name),
Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, &name),
Kind::Groups => retain(&mut d.groups, |g| &g.name, &name),
Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false,
};
}
}
if !removed {
break;
}
}
}
/// Model-space and layout objects with no length, or text with no characters.
fn degenerate(d: &Drawing, kind: Kind) -> Vec<Handle> {
let zero = |e: &EntityKind| match e {
+2
View File
@@ -281,6 +281,8 @@ fn close(s: &mut Session, save: bool) -> Result<Value> {
}
nd.end_refedit_discard(&r);
*s.doc_mut()? = nd;
// The objects the edit managed were its own: OOPS doesn't bring them back after it.
s.state_mut()?.erased.forget(|h| r.owns(h));
s.set_selection(Vec::new());
Ok(json!({ "block": r.block, "saved": save, "objects": objects }))
}
+353
View File
@@ -0,0 +1,353 @@
//! WBLOCK (its JSON form) and -WBLOCK at the command line: objects, a block or the whole drawing
//! written to a drawing file.
use std::collections::BTreeMap;
use cadcraft_doc::{Drawing, Entity, Handle};
use cadcraft_geom::{Mat3, Vec2, Vec3};
use serde_json::{Value, json};
use super::super::curves::KW;
use super::super::file::{drops_unknown, new_folder, not_saved, not_saved_message, report_new_folder, report_not_saved};
use super::super::machines::{SelOutcome, SelectPhase};
use super::super::modify::erase_kept;
use super::super::{CommandSpec, bad, point_param, str_param, targets};
use super::insert_file::stem;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn wblock_spec() -> CommandSpec {
CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}")
}
/// -WBLOCK erases the objects it writes, whatever its mOde option says: AutoCAD 2024 does, and
/// keeps "Object conversion=Delete" as the setting. OOPS brings them back, as after ERASE.
pub(super) fn dash_wblock_spec() -> CommandSpec {
CommandSpec::new("-wblock", "Write Block", run_dash_wblock)
.params("{path, name? | handles?, base?} (the objects written are erased)")
.interactive(|_| Ok(Box::new(DashWblockM::default())))
}
/// The web build has no file system: -WBLOCK would erase objects it can't save.
const NO_FILES: &str = "files can't be written in the web build";
/// What WBLOCK writes.
enum Source<'a> {
/// These objects, `base` becoming the written drawing's origin.
Objects(&'a [Handle], Vec2),
/// A block's objects, from its base point or `base` (in block coordinates).
Block(&'a str, Option<Vec2>),
/// The whole drawing.
Whole,
}
fn run_wblock(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "wblock", &path, Source::Block(name, base))?,
None => write(s, "wblock", &path, Source::Objects(&targets(s, p)?, base.unwrap_or(Vec2::ZERO)))?,
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
fn run_dash_wblock(s: &mut Session, p: &Value) -> Result<Value> {
if cfg!(target_arch = "wasm32") {
return Err(bad("-wblock", NO_FILES));
}
let path = str_param(p, "path").ok_or_else(|| bad("-wblock", "`path` is required"))?.to_string();
let base = point_param(p, "base");
let folder = new_folder(&path);
let (entities, bytes, lost) = match str_param(p, "name") {
Some(name) => write(s, "-wblock", &path, Source::Block(name, base))?,
None => {
let hs = targets(s, p)?;
// As at the command line, nothing selected writes no file.
if !hs.iter().any(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())) {
return Err(bad("-wblock", "no objects selected"));
}
let written = write(s, "-wblock", &path, Source::Objects(&hs, base.unwrap_or(Vec2::ZERO)))?;
erase(s, &hs)?;
written
}
};
let mut r = json!({ "path": path, "entities": entities, "bytes": bytes });
report_new_folder(s, &mut r, folder);
report_not_saved(s, &mut r, &lost);
Ok(r)
}
/// Write `what` to `path` through the io hooks; the objects written, the file's size and what a
/// DWG file left out, for the caller to report as a save does. The
/// written drawing keeps the source's settings and styles, as AutoCAD's -WBLOCK does: its units
/// ($INSUNITS, $MEASUREMENT), so a block from a metric drawing is not declared in inches and
/// scaled on insert, and its defaults (text size, current styles, annotation scales).
fn write(s: &Session, cmd: &str, path: &str, what: Source) -> Result<(usize, usize, BTreeMap<String, usize>)> {
let src = s.doc()?;
// The base point becomes the written drawing's origin: by default 0,0 for objects and the
// block's base point for a block.
let d = match what {
Source::Whole => src.clone(),
Source::Block(name, base) => {
let b = src.block(name).ok_or_else(|| bad(cmd, format!("no block `{name}`")))?;
let m = Mat3::translate(-base.unwrap_or(b.base.xy()));
with_objects(src, b.entities.iter().map(|e| moved(e, &m)))
}
Source::Objects(hs, base) => {
let m = Mat3::translate(-base);
with_objects(src, hs.iter().filter_map(|h| src.entity(*h)).map(|e| moved(e, &m)))
}
};
let hooks = super::super::file::io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
let bytes = (hooks.write)(&d, path).map_err(|e| bad(cmd, e))?;
// Through a temporary file, as SAVE writes, so a failed write keeps the file it replaces.
#[cfg(not(target_arch = "wasm32"))]
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let lost = if drops_unknown(path) { not_saved(&d) } else { BTreeMap::new() };
Ok((d.model.len(), bytes.len(), lost))
}
/// Say on the command line what the written DWG file left out.
fn echo_not_saved(s: &mut Session, lost: &BTreeMap<String, usize>) {
if let Some(msg) = not_saved_message(lost) {
s.echo(msg);
}
}
/// A new drawing of `ents`, with `src`'s settings and the layers, linetypes, styles and blocks
/// they use, as AutoCAD's WBLOCK writes them (#2423): what nothing in it uses is purged, and
/// the defaults and the current ones stay. Its insertion base is 0,0.
fn with_objects(src: &Drawing, ents: impl Iterator<Item = Entity>) -> Drawing {
let mut d = Drawing::new_imperial();
d.header = src.header.clone();
d.layers = src.layers.clone();
d.linetypes = src.linetypes.clone();
d.text_styles = src.text_styles.clone();
d.dim_styles = src.dim_styles.clone();
d.mleader_styles = src.mleader_styles.clone();
d.table_styles = src.table_styles.clone();
d.mline_styles = src.mline_styles.clone();
d.scales = src.scales.clone();
d.blocks = src.blocks.clone();
d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO));
// The copied blocks keep their objects' handles, so the new ones start above them.
let kept: Vec<Handle> = d.blocks.values().flat_map(|b| b.entities.iter().map(|e| e.handle)).collect();
for h in kept {
d.bump_handseed(h);
}
for mut e in ents {
e.handle = d.new_handle();
d.model.push(e);
}
super::purge::purge_unused(&mut d);
d
}
fn moved(e: &Entity, m: &Mat3) -> Entity {
let mut e = e.clone();
e.kind.transform(m);
e
}
fn erase(s: &mut Session, hs: &[Handle]) -> Result<()> {
erase_kept(s, hs)?;
s.set_selection(Vec::new());
Ok(())
}
/// The file -WBLOCK writes for a typed name: as named when it ends in .dwg or .dxf, else with
/// .dwg added, as AutoCAD names a drawing.
fn output_path(typed: &str) -> String {
let t = typed.trim().trim_matches('"');
let lower = t.to_ascii_lowercase();
if lower.ends_with(".dwg") || lower.ends_with(".dxf") { t.to_string() } else { format!("{t}.dwg") }
}
/// Whether a file is there to replace; the web has no file system.
fn exists(path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
std::path::Path::new(path).exists()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
false
}
}
/// Where -WBLOCK is in its prompts, as AutoCAD 2024 asks them.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Ask {
#[default]
File,
/// The file exists: "… already exists, do you want to replace it? [Yes/No] <N>".
Replace,
/// A DXF file's decimal places of accuracy.
Precision,
/// An existing block, `=` (the block named as the file), `*` (the whole drawing), or Enter
/// for objects.
Block,
Base,
/// mOde at the base point prompt: asked, but the objects are erased whatever it says.
Mode,
Objects,
}
const SAME_NAME: &str = "= (block=output file)";
const WHOLE: &str = "* (whole drawing)";
#[derive(Default)]
struct DashWblockM {
ask: Ask,
path: String,
base: Vec2,
sel: SelectPhase,
}
impl Interactive for DashWblockM {
fn name(&self) -> &'static str {
"WBLOCK"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if cfg!(target_arch = "wasm32") {
s.echo(format!("-WBLOCK: {NO_FILES}."));
return Ok(Step::Cancel);
}
s.echo("Current settings: Object conversion=Delete");
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.ask {
Ask::File => Prompt::new("Enter name of output file", Accept::TEXT),
Ask::Replace => Prompt::new(format!("{} already exists, do you want to replace it?", self.path), KW).kw(&["Yes", "No"]).default("N"),
Ask::Precision => Prompt::new("Enter decimal places of accuracy (0 to 16)", Accept::NUMBER).kw(&["Binary", "Preview"]).default("16"),
Ask::Block => Prompt::new("Enter name of existing block", Accept::TEXT).kw(&[SAME_NAME, WHOLE]).default("define new drawing"),
Ask::Base => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["mOde"]),
Ask::Mode => {
Prompt::new("Choose mode for selected object conversion", KW).kw(&["Convert to block", "Retain", "Delete"]).default("Delete")
}
Ask::Objects => self.sel.prompt(),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.ask {
Ask::File => {
if let Input::Text(t) = &i
&& !t.trim().trim_matches('"').is_empty()
{
self.path = output_path(t);
self.ask = if exists(&self.path) { Ask::Replace } else { self.after_file(s) };
}
Ok(Step::Continue)
}
Ask::Replace => match &i {
// Yes goes on; No, the default, ends the command; anything else is asked again.
Input::Keyword(k) if k == "Yes" => {
self.ask = self.after_file(s);
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Enter => Ok(Step::Done),
_ => {
s.echo("Invalid option keyword.");
Ok(Step::Continue)
}
},
Ask::Precision => {
match &i {
Input::Enter => self.ask = Ask::Block,
// The file is written at full precision whatever is asked.
Input::Text(t) if t.trim().parse::<u8>().is_ok_and(|n| n <= 16) => self.ask = Ask::Block,
Input::Keyword(k) if k == "Binary" => s.echo("Binary DXF output is not available."),
Input::Keyword(_) => {}
_ => s.echo("Requires an integer between 0 and 16."),
}
Ok(Step::Continue)
}
Ask::Block => {
let name = match &i {
Input::Enter => {
self.ask = Ask::Base;
return Ok(Step::Continue);
}
Input::Keyword(k) if k == WHOLE => return self.write(s, Source::Whole),
Input::Keyword(k) if k == SAME_NAME => stem(&self.path),
Input::Text(t) => t.trim().to_string(),
// A point (from a LISP list) is a name too: AutoCAD answers Block "0,0" not found.
Input::Point(p) => format!("{},{}", p.x, p.y),
_ => return Ok(Step::Continue),
};
if s.doc()?.block(&name).is_none() {
s.echo(format!("Block \"{name}\" not found."));
return Ok(Step::Continue);
}
self.write(s, Source::Block(&name, None))
}
Ask::Base => {
match i {
Input::Keyword(_) => self.ask = Ask::Mode,
Input::Enter | Input::Text(_) => s.echo("Point or option keyword required."),
Input::Point(p) => {
self.base = p;
self.sel = SelectPhase::begin(s);
if self.sel.done {
let picked = self.sel.picked.clone();
return self.write_objects(s, &picked);
}
self.ask = Ask::Objects;
}
_ => {}
}
Ok(Step::Continue)
}
Ask::Mode => {
// Any answer, Enter included, goes back to the base point.
self.ask = Ask::Base;
Ok(Step::Continue)
}
Ask::Objects => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
// Nothing selected: nothing is written.
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.write_objects(s, &hs),
},
}
}
}
impl DashWblockM {
/// The prompt after the file name: a DXF file's precision, else the block.
fn after_file(&self, s: &mut Session) -> Ask {
if self.path.to_ascii_lowercase().ends_with(".dxf") {
s.echo("Current DXF settings: Precision= 16 Format= ASCII Preview= No");
Ask::Precision
} else {
Ask::Block
}
}
fn write(&self, s: &mut Session, what: Source) -> Result<Step> {
match write(s, "-wblock", &self.path, what) {
Ok((.., lost)) => echo_not_saved(s, &lost),
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
fn write_objects(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
if hs.is_empty() {
return Ok(Step::Done);
}
match write(s, "-wblock", &self.path, Source::Objects(hs, self.base)) {
Ok((.., lost)) => {
echo_not_saved(s, &lost);
erase(s, hs)?;
}
Err(e) => s.echo(e.to_string()),
}
Ok(Step::Done)
}
}
+484
View File
@@ -0,0 +1,484 @@
//! XCLIP and XCLIPFRAME: clip block and xref references to a boundary.
//!
//! A boundary belongs to one reference ([`cadcraft_doc::Insert::clip`]), in the block's own
//! coordinates, so it moves, rotates and scales with the reference and other references of the
//! block stay whole. It is a rectangle, a polygon or the outline of a closed polyline, can hide
//! what is inside instead of what is outside (Invert clip), and can be turned off, deleted or
//! turned into a polyline. Clip depth (front and back clipping planes) has no meaning in a 2D
//! drawing and is ignored. XCLIPFRAME says whether the boundary is drawn and plotted.
use cadcraft_doc::{ClipBoundary, EntityKind, Handle, MAX_CLIP_VERTICES};
use cadcraft_geom::{PolyVertex, Vec2};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase};
use crate::cmd::{CommandSpec, bad, bool_or, has_doc, point_value, str_param, targets};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("xclip", "Clip Xref", run)
.menu(&["Modify", "Clip", "Xref"])
.alias(&["xc"])
.params("{handles? | handle? (block or xref references, default the selection), option?: new|on|off|delete|polyline|clipdepth|query (default new with a boundary, else query), rectangle?: [[x,y],[x,y]] | points?: [[x,y],…] (3 to 4096, drawing coordinates) | polyline?: handle of a closed polyline, invert?: bool (hide what is inside)} → {handles, option} (query: {clips: [{handle, boundary, enabled, inverted}]}; polyline: {polylines})")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(XclipM::default()))),
CommandSpec::new("xclipframe", "Xref Clip Frame", run_frame)
.params("{value?: 0 (frames hidden) | 1 (shown and plotted) | 2 (shown, not plotted; the default)} → {XCLIPFRAME}")
.enabled(has_doc)
.interactive(|_| Ok(Box::new(FrameM))),
]
}
/// The block references among `hs` (other objects are ignored, as XCLIP's selection does).
fn references(s: &Session, hs: &[Handle]) -> Vec<Handle> {
let Ok(d) = s.doc() else { return Vec::new() };
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).collect()
}
/// A rectangle's two corners as a closed outline.
fn rectangle(a: Vec2, b: Vec2) -> Vec<Vec2> {
vec![
Vec2::new(a.x.min(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.min(b.y)),
Vec2::new(a.x.max(b.x), a.y.max(b.y)),
Vec2::new(a.x.min(b.x), a.y.max(b.y)),
]
}
/// The boundary a JSON call gives, in drawing coordinates, and whether it is a rectangle.
fn world_boundary(s: &Session, p: &Value) -> Result<(Vec<Vec2>, bool)> {
if let Some(r) = p.get("rectangle") {
let pts: Vec<Vec2> = r.as_array().map(|a| a.iter().filter_map(point_value).collect()).unwrap_or_default();
let [a, b] = pts.as_slice() else { return Err(bad("xclip", "`rectangle` needs two finite corners [[x,y],[x,y]]")) };
if (a.x - b.x).abs() <= 1e-12 || (a.y - b.y).abs() <= 1e-12 {
return Err(bad("xclip", "the clipping rectangle must have a width and a height"));
}
return Ok((vec![*a, *b], true));
}
if let Some(v) = p.get("polyline") {
let h = v
.as_str()
.and_then(Handle::parse_hex)
.or_else(|| v.as_u64().map(Handle))
.ok_or_else(|| bad("xclip", "`polyline` must be an object handle"))?;
let d = s.doc()?;
let e = d.entity(h).ok_or_else(|| bad("xclip", format!("no object {}", h.hex())))?;
let closed = matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) || matches!(&e.kind, EntityKind::Polyline3d(pl) if pl.closed);
if !closed {
return Err(bad("xclip", "select a closed polyline"));
}
let pts = cadcraft_render::clip::boundary_polygon(&e.kind).ok_or_else(|| bad("xclip", "the polyline does not enclose an area"))?;
return Ok((pts, false));
}
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("xclip", "a boundary is required: `rectangle`, `points` or `polyline`"))?;
if !(3..=MAX_CLIP_VERTICES).contains(&pts.len()) {
return Err(bad("xclip", format!("a polygonal boundary needs 3 to {MAX_CLIP_VERTICES} points")));
}
let pts: Vec<Vec2> =
pts.iter().map(|v| point_value(v).ok_or_else(|| bad("xclip", "every boundary point must be a finite [x, y]"))).collect::<Result<_>>()?;
Ok((pts, false))
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let hs = references(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("xclip", "select block or xref references"));
}
let has_boundary = ["rectangle", "points", "polyline"].iter().any(|k| p.get(*k).is_some());
let option = str_param(p, "option").map(str::to_ascii_lowercase).unwrap_or_else(|| if has_boundary { "new".into() } else { "query".into() });
match option.as_str() {
"new" | "n" => {
let (world, rect) = world_boundary(s, p)?;
let outline = if rect { world.first().zip(world.get(1)).map(|(a, b)| rectangle(*a, *b)).unwrap_or_default() } else { world.clone() };
if cadcraft_render::clip::PolyClip::new(&outline).is_none() {
return Err(bad("xclip", "the clipping boundary encloses no area"));
}
let invert = bool_or(p, "invert", false);
let mut clips = Vec::with_capacity(hs.len());
{
let d = s.doc()?;
for h in &hs {
let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue };
let base = d.block(&ins.block).map(|b| b.base.xy()).ok_or_else(|| bad("xclip", format!("block \"{}\" is missing", ins.block)))?;
let inv = ins.transform(base).inverse().ok_or_else(|| bad("xclip", "a reference with a zero scale can't be clipped"))?;
let points: Vec<Vec2> = world.iter().map(|q| inv.apply(*q)).collect();
// A rotated or skewed reference sees the rectangle as a polygon.
let r = ins.rotation.rem_euclid(std::f64::consts::FRAC_PI_2);
let square = r < 1e-9 || std::f64::consts::FRAC_PI_2 - r < 1e-9;
let points = if rect && !square { outline.iter().map(|q| inv.apply(*q)).collect() } else { points };
clips.push((*h, ClipBoundary { points, enabled: true, inverted: invert }));
}
}
for (h, c) in clips {
set_clip(s, h, Some(c))?;
}
}
"on" | "off" => {
let on = option == "on";
// Every reference is checked before any changes: an error leaves the drawing as it was.
let d = s.doc()?;
if !hs.iter().all(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Insert(i)) if i.clip.is_some())) {
return Err(bad("xclip", "no clipping boundary on the selected reference"));
}
// Only the flag changes, once per reference (a selection may name one many times).
let mut seen = std::collections::HashSet::new();
for h in hs.iter().filter(|h| seen.insert(**h)) {
s.doc_mut()?
.modify_entity(*h, |e| {
if let EntityKind::Insert(i) = &mut e.kind
&& let Some(c) = &mut i.clip
{
c.enabled = on;
}
})
.map_err(|e| bad("xclip", e.to_string()))?;
}
}
"delete" | "d" => {
for h in &hs {
set_clip(s, *h, None)?;
}
}
"polyline" | "p" | "generate" => {
let mut made = Vec::new();
for h in &hs {
let Some(world) = world_outline(s, *h) else { continue };
let vertices = world.into_iter().map(|p| PolyVertex { p, bulge: 0.0, start_width: 0.0, end_width: 0.0 }).collect();
let pl = cadcraft_doc::LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false };
made.push(s.add_entity(EntityKind::LwPolyline(pl))?.hex());
}
if made.is_empty() {
return Err(bad("xclip", "no clipping boundary on the selected references"));
}
return Ok(json!({ "handles": hexes(&hs), "option": "polyline", "polylines": made }));
}
// Front and back clipping planes have no meaning in 2D: nothing changes.
"clipdepth" | "c" => return Ok(json!({ "handles": hexes(&hs), "option": "clipdepth", "ignored": true })),
"query" | "?" => {
let clips: Vec<Value> = hs
.iter()
.map(|h| {
let c = clip_of(s, *h);
json!({
"handle": h.hex(),
"boundary": world_outline(s, *h).map(|w| w.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()),
"enabled": c.as_ref().is_some_and(|c| c.enabled),
"inverted": c.as_ref().is_some_and(|c| c.inverted),
})
})
.collect();
return Ok(json!({ "clips": clips }));
}
o => return Err(bad("xclip", format!("unknown option \"{o}\" (new, on, off, delete, polyline, clipdepth or query)"))),
}
Ok(json!({ "handles": hexes(&hs), "option": option }))
}
fn hexes(hs: &[Handle]) -> Vec<String> {
hs.iter().map(|h| h.hex()).collect()
}
fn clip_of(s: &Session, h: Handle) -> Option<ClipBoundary> {
match &s.doc().ok()?.entity(h)?.kind {
EntityKind::Insert(i) => i.clip.clone(),
_ => None,
}
}
/// Reference `h`'s boundary outline in drawing coordinates.
fn world_outline(s: &Session, h: Handle) -> Option<Vec<Vec2>> {
let d = s.doc().ok()?;
let EntityKind::Insert(i) = &d.entity(h)?.kind else { return None };
let c = i.clip.as_ref()?;
let m = i.transform(d.block(&i.block)?.base.xy());
let poly = c.polygon();
(poly.len() >= 3).then(|| poly.into_iter().map(|p| m.apply(p)).collect())
}
fn set_clip(s: &mut Session, h: Handle, c: Option<ClipBoundary>) -> Result<()> {
s.doc_mut()?
.modify_entity(h, |e| {
if let EntityKind::Insert(i) = &mut e.kind {
i.clip = c;
}
})
.map_err(|e| bad("xclip", e.to_string()))
}
// ---------------- XCLIPFRAME ----------------
fn run_frame(s: &mut Session, p: &Value) -> Result<Value> {
let v = match p.get("value").or_else(|| p.get("XCLIPFRAME")) {
None | Some(Value::Null) => return Ok(json!({ "XCLIPFRAME": s.doc()?.header.i64("XCLIPFRAME", 2) })),
Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok())),
};
let v = v.filter(|v| (0..=2).contains(v)).ok_or_else(|| bad("xclipframe", "`value` must be 0, 1 or 2"))?;
s.doc_mut()?.header.set_i64("XCLIPFRAME", v);
Ok(json!({ "XCLIPFRAME": v }))
}
struct FrameM;
impl Interactive for FrameM {
fn name(&self) -> &'static str {
"XCLIPFRAME"
}
fn prompt(&self, s: &Session) -> Prompt {
let cur = s.doc().map(|d| d.header.i64("XCLIPFRAME", 2)).unwrap_or(2);
Prompt::new("Enter new value for XCLIPFRAME", Accept::TEXT).kw(&["0", "1", "2"]).default(cur.to_string())
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let t = match i {
Input::Cancel => return Ok(Step::Cancel),
Input::Enter => return Ok(Step::Done),
Input::Text(t) | Input::Keyword(t) => t,
_ => return Ok(Step::Continue),
};
match run_frame(s, &json!({ "value": t.trim() })) {
Ok(_) => Ok(Step::Done),
Err(_) => {
s.echo("Requires an integer between 0 and 2.");
Ok(Step::Continue)
}
}
}
}
// ---------------- interactive XCLIP ----------------
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Stage {
#[default]
Select,
Option,
DeleteOld,
Boundary,
Corner1,
Corner2,
Polygon,
Polyline,
}
#[derive(Default)]
struct XclipM {
stage: Stage,
sel: SelectPhase,
refs: Vec<Handle>,
invert: bool,
pts: Vec<Vec2>,
}
impl XclipM {
fn selected(&mut self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
self.refs = references(s, hs);
if self.refs.is_empty() {
s.echo("No block or xref references selected.");
return Ok(Step::Done);
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
fn apply(&mut self, s: &mut Session, mut p: Value) -> Result<Step> {
p["handles"] = json!(hexes(&self.refs));
let r = run(s, &p);
s.set_selection(Vec::new());
match r {
Ok(_) => Ok(Step::Done),
Err(e) => {
s.echo(e.to_string());
Ok(Step::Done)
}
}
}
/// Delete: as the reference app, with nothing to delete it says so and asks again.
fn delete(&mut self, s: &mut Session) -> Result<Step> {
if !self.any_clipped(s) {
s.echo("No clip boundary found.");
return Ok(Step::Continue);
}
self.apply(s, json!({ "option": "delete" }))
}
fn any_clipped(&self, s: &Session) -> bool {
self.refs.iter().any(|h| clip_of(s, *h).is_some())
}
}
fn kw(i: &Input) -> Option<String> {
match i {
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
_ => None,
}
}
impl Interactive for XclipM {
fn name(&self) -> &'static str {
"XCLIP"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.selected(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.stage {
Stage::Select => self.sel.prompt(),
Stage::Option => Prompt::new("Enter clipping option", Accept::TEXT)
.kw(&["ON", "OFF", "Clipdepth", "Delete", "generate Polyline", "New boundary"])
.default("New"),
Stage::DeleteOld => Prompt::new("Delete old boundary(s)?", Accept::TEXT).kw(&["Yes", "No"]).default("Yes"),
Stage::Boundary => Prompt::new("Specify clipping boundary or select invert option", Accept::POINT)
.kw(&["Select polyline", "Polygonal", "Rectangular", "Invert clip"])
.default("Rectangular"),
Stage::Corner1 => Prompt::new("Specify first corner", Accept::POINT),
Stage::Corner2 => {
let p = Prompt::new("Specify opposite corner", Accept::POINT);
match self.pts.first() {
Some(a) => p.corner(*a),
None => p,
}
}
Stage::Polygon => {
let msg = if self.pts.is_empty() { "Specify first point" } else { "Specify next point" };
let p = Prompt::new(msg, Accept::POINT).base_opt(self.pts.last().copied());
if self.pts.is_empty() { p } else { p.kw(&["Undo"]) }
}
Stage::Polyline => Prompt::new("Select polyline", Accept::SELECT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if matches!(i, Input::Cancel) {
return Ok(Step::Cancel);
}
match self.stage {
Stage::Select => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => self.selected(s, &hs),
},
Stage::Option => {
let k = if matches!(i, Input::Enter) { Some("new".to_string()) } else { kw(&i) };
match k.as_deref() {
Some("on") => self.apply(s, json!({ "option": "on" })),
Some("off") => self.apply(s, json!({ "option": "off" })),
Some(k) if k.starts_with('c') => {
s.echo("Clip depth does not apply to a 2D drawing: front and back clipping planes are ignored.");
Ok(Step::Done)
}
Some(k) if k.starts_with('d') => self.delete(s),
Some(k) if k.starts_with('p') || k.starts_with('g') => self.apply(s, json!({ "option": "polyline" })),
Some(k) if k.starts_with('n') => {
self.stage = if self.any_clipped(s) { Stage::DeleteOld } else { Stage::Boundary };
Ok(Step::Continue)
}
_ => {
s.echo("Invalid option keyword.");
Ok(Step::Continue)
}
}
}
Stage::DeleteOld => match kw(&i).as_deref() {
None if matches!(i, Input::Enter) => {
self.stage = Stage::Boundary;
Ok(Step::Continue)
}
Some(k) if k.starts_with('y') => {
self.stage = Stage::Boundary;
Ok(Step::Continue)
}
Some(k) if k.starts_with('n') => Ok(Step::Done),
_ => Ok(Step::Continue),
},
Stage::Boundary => match (&i, kw(&i).as_deref()) {
(Input::Enter, _) => {
self.stage = Stage::Corner1;
Ok(Step::Continue)
}
(Input::Point(p), _) => {
// A point starts the default rectangle.
self.pts = vec![*p];
self.stage = Stage::Corner2;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('i') => {
self.invert = !self.invert;
s.echo(if self.invert {
"Inside mode - Objects inside boundary will be hidden."
} else {
"Outside mode - Objects outside boundary will be hidden."
});
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('s') => {
self.stage = Stage::Polyline;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('p') => {
self.pts.clear();
self.stage = Stage::Polygon;
Ok(Step::Continue)
}
(_, Some(k)) if k.starts_with('r') => {
self.stage = Stage::Corner1;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Corner1 => match i {
Input::Point(p) => {
self.pts = vec![p];
self.stage = Stage::Corner2;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Corner2 => match (i, self.pts.first().copied()) {
(Input::Point(b), Some(a)) => self.apply(s, json!({ "option": "new", "rectangle": [[a.x, a.y], [b.x, b.y]], "invert": self.invert })),
_ => Ok(Step::Continue),
},
Stage::Polygon => match i {
Input::Point(p) if self.pts.len() < MAX_CLIP_VERTICES => {
self.pts.push(p);
Ok(Step::Continue)
}
Input::Point(_) => {
s.echo(format!("A boundary has at most {MAX_CLIP_VERTICES} points."));
Ok(Step::Continue)
}
Input::Keyword(_) | Input::Text(_) => {
self.pts.pop();
Ok(Step::Continue)
}
Input::Enter if self.pts.len() >= 3 => {
let pts: Vec<[f64; 2]> = self.pts.iter().map(|p| [p.x, p.y]).collect();
self.apply(s, json!({ "option": "new", "points": pts, "invert": self.invert }))
}
Input::Enter => {
s.echo("A polygonal boundary needs at least three points.");
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
},
Stage::Polyline => match i {
Input::Pick(hs) => match hs.first() {
Some(h) => {
let h = *h;
self.apply(s, json!({ "option": "new", "polyline": h.hex(), "invert": self.invert }))
}
None => Ok(Step::Continue),
},
Input::Enter => Ok(Step::Done),
_ => Ok(Step::Continue),
},
}
}
}
+12 -3
View File
@@ -50,7 +50,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
}
/// Load the xrefs of drawing `i`, just opened (OPEN): the opened state counts as saved, and the
/// view frames the drawing with them.
/// view frames the drawing with them, unless the drawing opened on the view it was saved at.
pub(crate) fn after_open(s: &mut Session, i: usize) {
let px = s.viewport_px;
let Some(st) = s.docs.get_mut(i) else { return };
@@ -61,12 +61,21 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
let out = load::load_all(Arc::make_mut(&mut st.doc), path.as_deref());
st.saved = st.doc.clone();
let ext = st.doc.extents(&cadcraft_doc::Space::Model);
if !ext.is_empty() {
st.set_view_quiet(crate::View { center: ext.center(), height: (ext.height().max(ext.width() * px.1 / px.0.max(1.0)) * 1.1).max(1e-6) });
if !ext.is_empty() && crate::view::saved_view(&st.doc).is_none() {
// Fit in the frame of the saved twist, which open_drawing has restored.
let twist = st.view().twist;
let (center, w, h) = crate::View::twisted_box(ext, twist);
st.set_view_quiet(crate::View { center, height: (h.max(w * px.1 / px.0.max(1.0)) * 1.1).max(1e-6), twist });
}
echo_all(s, &out);
}
/// The file a reference saved as `path` in a drawing at `host` is read from, if there is one
/// (xrefs and raster images look in the same places).
pub(crate) fn find_file(path: &str, host: Option<&str>) -> Option<std::path::PathBuf> {
load::find(path, host.map(std::path::Path::new), false).ok()
}
/// The open drawing's path (xref paths are relative to its folder).
fn host(s: &Session) -> Option<String> {
s.state().ok().and_then(|st| st.path.clone())
+62 -12
View File
@@ -10,6 +10,7 @@
//! loaded further up (a circular reference) is not loaded again. Every failure leaves the xref's
//! block empty with a status saying why; nothing here stops the host drawing from opening.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -208,7 +209,7 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
}
let (files, refused) = candidates(path, host, chosen);
#[cfg(not(target_arch = "wasm32"))]
let found = files.into_iter().find(|p| p.is_file());
let found = files.iter().find(|p| p.is_file()).cloned().or_else(|| files.iter().find_map(|p| case_insensitive(p)));
#[cfg(target_arch = "wasm32")]
let found: Option<PathBuf> = {
let _ = files;
@@ -217,6 +218,43 @@ pub(crate) fn find(path: &str, host: Option<&Path>, chosen: bool) -> Result<Path
found.ok_or(refused.map_or(Missing::NotFound, Missing::Refused))
}
/// Upper bound on directory entries scanned per path component by [`case_insensitive`].
#[cfg(not(target_arch = "wasm32"))]
const MAX_CASE_SCAN: usize = 10_000;
/// The existing file `p` names when its letters' case is ignored, on file systems that don't
/// ignore it themselves. Drawings made on Windows often store a reference as `PLAN.DWG` for
/// `Plan.dwg`; Windows finds it, and so should Linux. Each missing component is matched against
/// its folder's entries; an exact match always wins, and an ambiguous one finds nothing.
#[cfg(not(target_arch = "wasm32"))]
fn case_insensitive(p: &Path) -> Option<PathBuf> {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
let Component::Normal(name) = c else {
out.push(c.as_os_str());
continue;
};
let exact = out.join(name);
if exact.exists() {
out = exact;
continue;
}
let want = name.to_str()?.to_lowercase();
let mut hits = std::fs::read_dir(if out.as_os_str().is_empty() { Path::new(".") } else { &out })
.ok()?
.take(MAX_CASE_SCAN)
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| n.to_lowercase() == want));
let hit = hits.next()?;
if hits.next().is_some() {
return None;
}
out.push(hit.file_name());
}
out.is_file().then_some(out)
}
/// Read a drawing file through the host's io hooks, counting its bytes against the load's total.
fn read(file: &Path, cx: &mut Ctx) -> Result<Drawing, String> {
let hooks = super::super::super::file::io().ok_or("file formats are not available in this build")?;
@@ -302,14 +340,15 @@ fn load_in(d: &mut Drawing, name: &str, host: Option<&Path>, chosen: bool, cx: &
let (status, detail) = match fetch(&path, host, chosen, cx, depth) {
Ok((x, file)) => match import(d, &key, &x, cx) {
Ok(()) => (XrefStatus::Loaded, file.to_string_lossy().into_owned()),
Err(e) => (XrefStatus::Unresolved, e),
Err(e) => {
// Whatever made it in before the failure goes again. A fetch that failed imported
// nothing, so it doesn't pay for a second scan of the block table.
clear(d, &key);
(XrefStatus::Unresolved, e)
}
},
Err(e) => e,
};
if status != XrefStatus::Loaded {
// Whatever made it in before the failure goes again.
clear(d, &key);
}
set_status(d, &key, status);
(status, detail)
}
@@ -353,9 +392,15 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
return Err(format!("more than {MAX_XREF_OBJECTS} objects"));
}
let dep = |sym: Sym, n: &str| (!shared(sym, n)).then(|| format!("{key}|{n}"));
// The host's names per table, looked up in a set built once: both drawings are hostile input,
// so a scan per imported record would be quadratic.
fn names<T>(all: &[T], name: fn(&T) -> &str) -> HashSet<String> {
all.iter().map(|t| name(t).to_ascii_uppercase()).collect()
}
let mut have = names(&d.layers, |l| &l.name);
for l in &x.layers {
if let Some(n) = dep(Sym::Layer, &l.name)
&& d.layer(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
let mut l = l.clone();
l.name = n;
@@ -363,39 +408,44 @@ fn import(d: &mut Drawing, key: &str, x: &Drawing, cx: &mut Ctx) -> Result<(), S
d.layers.push(l);
}
}
let mut have = names(&d.linetypes, |t| &t.name);
for t in &x.linetypes {
if let Some(n) = dep(Sym::Linetype, &t.name)
&& d.linetype(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
d.linetypes.push(cadcraft_doc::Linetype { name: n, ..t.clone() });
}
}
let mut have = names(&d.text_styles, |t| &t.name);
for t in &x.text_styles {
if let Some(n) = dep(Sym::Style, &t.name)
&& d.text_style(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
d.text_styles.push(cadcraft_doc::TextStyle { name: n, ..t.clone() });
}
}
let mut have = names(&d.dim_styles, |t| &t.name);
for t in &x.dim_styles {
if let Some(n) = dep(Sym::DimStyle, &t.name)
&& d.dim_style(&n).is_none()
&& have.insert(n.to_ascii_uppercase())
{
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
d.dim_styles.push(cadcraft_doc::DimStyle { name: n, text_style, ..t.clone() });
}
}
let mut have = names(&d.mleader_styles, |t| &t.name);
for t in &x.mleader_styles {
if let Some(n) = dep(Sym::MLeaderStyle, &t.name)
&& !d.mleader_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
&& have.insert(n.to_ascii_uppercase())
{
let text_style = dep(Sym::Style, &t.text_style).unwrap_or_else(|| t.text_style.clone());
d.mleader_styles.push(cadcraft_doc::MLeaderStyle { name: n, text_style, ..t.clone() });
}
}
let mut have = names(&d.table_styles, |t| &t.name);
for t in &x.table_styles {
if let Some(n) = dep(Sym::TableStyle, &t.name)
&& !d.table_styles.iter().any(|o| o.name.eq_ignore_ascii_case(&n))
&& have.insert(n.to_ascii_uppercase())
{
d.table_styles.push(cadcraft_doc::TableStyle { name: n, ..t.clone() });
}
+3 -37
View File
@@ -10,7 +10,7 @@
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
//! end can put it on the system clipboard and paste it into another CADCraft window.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
@@ -279,20 +279,14 @@ fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
/// their name in `defs` to their name in `d`.
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
use crate::cmd::merge::merge_named as add;
add(&mut d.layers, &defs.layers, |l| &l.name);
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
let renames = block_names(d, defs);
let renames = crate::cmd::merge::block_renames(d, defs, None);
for (from, to) in &renames {
let Some(b) = defs.blocks.get(from) else { continue };
let mut nb = (**b).clone();
@@ -308,34 +302,6 @@ fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
renames
}
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
/// already has keeps `d`'s definition (not copied).
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
for (name, b) in &defs.blocks {
let upper = up(name);
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(up(&to));
out.insert(name.clone(), to);
}
out
}
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
/// objects of the source drawing (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
+1
View File
@@ -33,6 +33,7 @@ use point::{DonutM, PointM, RayM, run_donut, run_point, run_ray};
use polygon::{PolygonM, run_polygon};
use solid::{SolidM, run_solid};
use spline::{SplineM, run_spline};
pub(crate) use text::justify;
use text::{MTextM, TextM, run_mtext, run_text};
/// Every draw command's spec: JSON parameters, menu path, aliases and the interactive prompt
+6 -8
View File
@@ -153,19 +153,17 @@ impl Interactive for HelixM {
}
match (self.center, self.rb, self.rt) {
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
(Some(c), None, _) => {
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c)
}
(Some(c), None, _) => Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default("1.0000")
.base(c),
(Some(c), Some(rb), None) => {
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius" }, Accept::POINT_OR_NUMBER)
.kw(if self.diameter { &[] } else { &["Diameter"] })
.default(format!("{rb:.4}"))
.base(c)
}
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height", Accept::POINT_OR_NUMBER)
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
.default("1.0000")
.base(c),
+2 -2
View File
@@ -119,7 +119,7 @@ impl Interactive for XlineM2 {
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
},
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
XMode::AngAsk => Prompt::new("Enter angle of xline (0)", Accept::NUMBER).kw(&["Reference"]),
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
XMode::Bisect => match self.bis.len() {
@@ -129,7 +129,7 @@ impl Interactive for XlineM2 {
},
XMode::OffDist => {
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
Prompt::new("Specify offset distance", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
"Through".into()
} else {
format!("{d:.4}")
+1 -7
View File
@@ -549,13 +549,7 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
let mut hs = s.selection();
hs.retain(|h| !super::curves::is_locked(s, *h));
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
if d.remove_entity(h).is_some() {
n += 1;
}
}
let n = super::modify::erase_kept(s, &hs)?;
s.set_selection(Vec::new());
Ok(json!({ "erased": n }))
}
+182 -57
View File
@@ -7,7 +7,7 @@
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
//! commands come from a menu, the tool bar or the command line).
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use std::sync::Arc;
use cadcraft_color::Rgb;
@@ -55,11 +55,11 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["printpdf", "pdfb"])
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
.noundo(),
CommandSpec::new("attach", "Attach", |_, _| {
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
CommandSpec::new("attach", "Attach", |s, p| match str_param(p, "path").map(extension) {
Some(e) if super::image::is_image_ext(&e) => super::image::run_imageattach(s, p),
_ => Err(EngineError::Other("ATTACH is not available yet for this file: only raster images (PNG, JPEG, BMP, TIFF; see IMAGEATTACH) can be attached, PDF/DWF/DGN underlays can't be shown yet".into())),
})
.params("not available yet")
.noundo(),
.params("{path: image file, …} as IMAGEATTACH; other file types are not available yet"),
CommandSpec::new("share", "Share", |_, _| {
Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into()))
})
@@ -82,7 +82,7 @@ fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -117,14 +117,19 @@ fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
if !EXPORT_FORMATS.contains(&format.as_str()) {
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
}
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
match path {
let file = format!("export.{format}");
let bytes = (hooks.write)(s.doc()?, &file).map_err(|e| bad("export", e))?;
let folder = path.and_then(super::file::new_folder);
let mut r = match path {
Some(path) => {
write_file("export", path, &bytes)?;
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") })
}
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
}
None => json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() }),
};
super::file::report_new_folder(s, &mut r, folder);
super::file::report_loss(s, &mut r, &file);
Ok(r)
}
// ---------- IMPORT ----------
@@ -161,7 +166,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
let space = s.space();
let d = s.doc_mut()?;
let (layers, styles) = merge_tables(d, src);
let renames = block_names(d, src);
let renames = crate::cmd::merge::block_renames(d, src, None);
for (from, to) in &renames {
let Some(b) = src.blocks.get(from) else { continue };
let mut nb = (**b).clone();
@@ -208,16 +213,7 @@ fn read_file(_path: &str) -> Result<Vec<u8>> {
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
let mut n = 0;
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
n += 1;
}
}
n
}
use crate::cmd::merge::merge_named as merge;
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
@@ -227,35 +223,6 @@ fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
(layers, others)
}
/// Which source blocks to copy, and under which name: new names keep theirs, generated
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
/// already has keeps this drawing's definition (not copied).
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
for (name, b) in &src.blocks {
let upper = name.to_ascii_uppercase();
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let anonymous = b.anonymous || name.starts_with('*');
let to = if !taken.contains(&upper) {
name.clone()
} else if anonymous {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
/// links to other source objects (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
@@ -330,17 +297,23 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
page.landscape = l;
}
// A layout's saved page setup (plot window, scale to fit, scale, centring) applies unless the call says otherwise (#2493).
// An explicit `fit: false` without a `scale` asks for the plain 1:1 plot.
let plain = p.get("fit").and_then(Value::as_bool) == Some(false) && p.get("scale").is_none();
let saved = if space.is_paper() && !plain { cadcraft_render::paper::saved_plot(&page) } else { Default::default() };
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
let given_scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
// A saved scale to fit applies only when the call gives neither `fit` nor `scale`, as in the PDF.
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model) || (saved.fit && given_scale.is_none()));
let scale = given_scale.or(saved.scale);
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
let px_per_paper_unit = width as f64 / sheet.size.x;
// Curves are flattened to about half a pixel.
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
let est = plot_scale(&saved.window.unwrap_or_else(|| d.extents(&space)), &sheet, fit, scale);
let tol = 0.5 / (px_per_paper_unit * est);
let ropts = cadcraft_render::Options {
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
@@ -352,9 +325,13 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
..Default::default()
};
let list = cadcraft_render::build_plot(d, &space, &ropts);
let b = list.bounds;
let b = saved.window.unwrap_or(list.bounds);
// Paper point q shows drawing point (q - to) / k + from.
let (from, k, to) = if fit || space == Space::Model {
let (from, k, to) = if saved.window.is_some() {
// A saved window, fitted or at a saved scale: its centre (or corner) on the printable area's.
let c = saved.center;
(if c { b.center() } else { b.min }, plot_scale(&b, &sheet, fit, scale), if c { sheet.printable.center() } else { sheet.printable.min })
} else if fit || space == Space::Model {
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
} else {
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
@@ -372,7 +349,20 @@ fn run_preview(s: &mut Session, p: &Value) -> Result<Value> {
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
antialias: true,
};
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
let mut pm = cadcraft_render::raster::render(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
// A placed plot (fitted, or a saved window) shows only inside the printable area, as the PDF and PNG plots clip it.
if fit || saved.window.is_some() || space == Space::Model {
// The view may scale and centre the sheet (the height cap), so map the printable corners
// (paper point q shows drawing point (q - to) / k + from) through it.
let screen = |q: Vec2| {
let (x, y) = view.to_screen((q - to) / k + from);
(f64::from(x), f64::from(y))
};
let (min, max) = (sheet.printable.min, sheet.printable.max);
let (a, b) = (screen(Vec2::new(min.x, max.y)), screen(Vec2::new(max.x, min.y)));
cadcraft_render::raster::blank_outside_pixels(&mut pm, a, b);
}
let png = pm.encode_png().map_err(|_| bad("preview", "rendering the preview failed"))?;
let layout = space.tab_name();
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
}
@@ -652,6 +642,7 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
@@ -738,6 +729,140 @@ mod tests {
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
}
/// A layout's saved page setup (plot window, scale to fit) shapes its preview by default, as
/// it shapes the PDF; `fit: false` still gives the plain 1:1 sheet (#2493).
#[test]
fn preview_uses_the_layouts_saved_page_setup_by_default() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let unsaved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 836.0, 589.0];
page.scale_to_fit = true;
let saved = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(saved, unsaved, "the saved window and scale to fit change the preview");
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false })), unsaved, "fit: false is the plain 1:1 sheet");
}
/// With a saved plot window the preview follows the page setup's centring (fitted or not) and an
/// explicit `scale` beats a saved scale to fit, as in the PDF.
/// A placed saved window shows only inside the printable area, as the PDF and PNG plots clip it:
/// geometry in the margin leaves the preview unchanged.
#[test]
fn preview_clips_a_saved_window_to_the_printable_area() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 30.0], [100.0, 30.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-3.0, 0.0], [-3.0, 60.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 60.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 600 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away");
}
/// On a tall sheet the height cap makes the view scale and centre the sheet in the image; the
/// printable clip must follow it, so ink in the margin is still blanked.
#[test]
fn preview_clip_follows_the_view_when_the_height_cap_applies() {
let build = |margin_line: bool| {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[0.0, 50.0], [100.0, 50.0]] })).unwrap();
if margin_line {
s.execute("line", &json!({ "points": [[-2.6, 0.0], [-2.6, 100.0]] })).unwrap();
}
s.execute("pagesetup", &json!({ "layout": "Layout1", "margins": [10, 10, 10, 10] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.width_mm = 210.0;
page.height_mm = 1200.0;
page.landscape = false;
page.plot_area = "window".into();
page.plot_window = [0.0, 0.0, 100.0, 100.0];
page.scale_to_fit = true;
page.center = true;
s.execute("preview", &json!({ "layout": "Layout1", "width": 1000 })).unwrap()["data"].as_str().unwrap_or_default().to_string()
};
assert_eq!(build(true), build(false), "a line in the margin is clipped away on a tall sheet");
}
/// Hostile saved setups (a degenerate or far-away window, a scale of 0 or not finite) fall back
/// to the plain preview and never panic, and a layout with no saved setup previews exactly as
/// the plain 1:1 sheet does.
#[test]
fn preview_survives_hostile_saved_setups_and_default_is_unchanged() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [200.0, 100.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true })).unwrap();
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let call = json!({ "layout": "Layout1", "width": 200 });
let plain = png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false }));
assert_eq!(png(&mut s, call.clone()), plain, "no saved setup: the default preview is the plain sheet");
let set = |s: &mut Session, window: [f64; 4], scale: f64, fit: bool| {
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = window;
page.scale = scale;
page.scale_to_fit = fit;
};
let good = [0.0, 0.0, 100.0, 60.0];
let (nan, inf) = (f64::NAN, f64::INFINITY);
for window in [[5.0, 5.0, 5.0, 40.0], [5.0, 5.0, 40.0, 5.0], [nan, 0.0, 10.0, 10.0], [0.0, 0.0, inf, 10.0]] {
set(&mut s, window, 1.0, true);
assert_eq!(png(&mut s, call.clone()), plain, "{window:?}: a degenerate window changes nothing");
}
for scale in [0.0, -2.0, nan, inf] {
set(&mut s, good, scale, false);
let _ = png(&mut s, call.clone());
set(&mut s, [5.0, 5.0, 5.0, 40.0], scale, false);
assert_eq!(png(&mut s, call.clone()), plain, "{scale}: an unusable saved scale is ignored");
}
set(&mut s, [1e9, 1e9, 1e9 + 10.0, 1e9 + 10.0], 1.0, true);
let _ = png(&mut s, call.clone());
set(&mut s, [-1e300, -1e300, 1e300, 1e300], 1.0, true);
let _ = png(&mut s, call.clone());
}
#[test]
fn preview_honours_saved_centering_and_an_explicit_scale() {
let mut s = Session::new();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
s.execute("line", &json!({ "points": [[20.0, 5.0], [836.0, 589.0]] })).unwrap();
s.execute("pagesetup", &json!({ "layout": "Layout1", "paper": "A4", "landscape": true, "margins": [0, 0, 0, 0] })).unwrap();
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.plot_area = "window".into();
page.plot_window = [20.0, 5.0, 400.0, 200.0];
page.scale_to_fit = true;
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
let set_center = |s: &mut Session, c: bool| s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page.center = c;
set_center(&mut s, true);
let centred = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
set_center(&mut s, false);
// The window is fitted by width, so only the other axis shows where it sits.
let corner = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
assert_ne!(corner, centred, "a saved centre: false puts the fitted window at the printable corner");
let page = &mut s.doc_mut().unwrap().layouts.iter_mut().find(|l| l.name == "Layout1").unwrap().page;
page.center = true;
let by_scale = png(&mut s, json!({ "layout": "Layout1", "width": 200, "scale": 0.5 }));
assert_ne!(by_scale, centred, "an explicit scale is not the fitted plot");
assert_eq!(
by_scale,
png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })),
"scale decides, not the saved fit"
);
}
#[test]
fn pdfbook_collects_frames_into_one_pdf_per_row() {
our_hooks();
+194 -22
View File
@@ -70,6 +70,19 @@ pub fn entity_codec() -> Option<&'static EntityCodec> {
/// COMPARE (drawing comparison).
pub mod compare;
/// Decode a raster image file (PNG, JPEG, BMP, TIFF) to pixels: installed by the app with the
/// image reader, a lower layer.
pub type ImageHook = fn(&[u8]) -> std::result::Result<cadcraft_doc::Raster, String>;
static IMAGE: OnceLock<ImageHook> = OnceLock::new();
pub fn set_image_decoder(h: ImageHook) {
let _ = IMAGE.set(h);
}
pub fn image_decoder() -> Option<ImageHook> {
IMAGE.get().copied()
}
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
@@ -85,12 +98,13 @@ pub fn deliver() -> Option<DeliverHook> {
DELIVER.get().copied()
}
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
/// binary instead of ASCII DXF.
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`), binary
/// instead of ASCII DXF, and Windows fonts for Linux ones ([`cadcraft_doc::Drawing::portable_fonts`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveAs {
pub version: Option<Version>,
pub binary: bool,
pub portable_fonts: bool,
}
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
@@ -131,7 +145,12 @@ fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
};
Ok(SaveAs { version, binary })
let portable_fonts = match p.get("portableFonts") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`portableFonts` must be true or false")),
};
Ok(SaveAs { version, binary, portable_fonts })
}
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
@@ -148,7 +167,9 @@ fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<
}
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
let r = if *opts == SaveAs::default() {
let copy = (opts.portable_fonts && is_drawing_file(path)).then(|| Drawing { portable_fonts: true, ..d.clone() });
let d = copy.as_ref().unwrap_or(d);
let r = if opts.version.is_none() && !opts.binary {
(hooks.write)(d, path)
} else {
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
@@ -178,11 +199,13 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
.menu(&["File", "Save As..."])
.key("Cmd+Shift+S")
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
.params(
"{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool, portableFonts?: bool (Windows fonts for Linux ones, in this file)}",
)
.noundo(),
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
.params("{format?: dxf, version?: 2000…2018, binary?: bool, portableFonts?: bool} → {data: base64}")
.noundo(),
CommandSpec::new("script", "Run Script...", run_script)
.menu(&["Tools", "Run Script..."])
@@ -301,6 +324,7 @@ pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
let ReadFile { drawing, binary_dxf, name, path } = f;
let i = add_read(s, drawing, binary_dxf, &name, path);
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
Ok(opened(s, i, true))
}
@@ -310,6 +334,12 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
// asks where to save instead of exporting over the original.
let path = path.filter(|p| is_drawing_file(p));
// The SHX fonts its text styles name are looked for beside the drawing too.
if let Some(dir) = path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
// A bare file name is in the current folder.
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 })
}
@@ -335,10 +365,12 @@ pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option
fn opened(s: &Session, i: usize, entities: bool) -> Value {
let d = s.docs.get(i).map(|st| &st.doc);
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
// Opened on the view it was saved at: a viewer keeps it instead of fitting the extents.
let saved_view = d.is_some_and(|d| crate::view::saved_view(d).is_some());
if entities {
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings, "saved_view": saved_view })
} else {
json!({ "index": i, "warnings": warnings })
json!({ "index": i, "warnings": warnings, "saved_view": saved_view })
}
}
@@ -353,6 +385,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
return Ok(opened(s, i, true));
}
#[cfg(target_arch = "wasm32")]
@@ -363,6 +396,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
let i = open_bytes(s, &bytes, name, None)?;
super::blocks::xref::after_open(s, i);
super::image::after_open(s, i);
Ok(opened(s, i, false))
}
@@ -395,7 +429,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
}
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
let opts = SaveAs { binary: s.state()?.binary_dxf && is_dxf(path), ..SaveAs::default() };
save_as(s, "save", path, &opts)
}
@@ -430,13 +464,14 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
fn store(path: &str, bytes: &[u8]) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
{
if is_drawing_file(path) && crate::autosave::isavebak() {
// A file in a folder still to be made has nothing to back up.
if is_drawing_file(path) && crate::autosave::isavebak() && crate::atomic_write::missing_folder(std::path::Path::new(path)).is_none() {
backup(std::path::Path::new(path))
.map_err(|e| bad("save", format!("{path}: the backup copy could not be written ({e}); nothing was saved")))?;
}
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
@@ -677,6 +712,7 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
if is_drawing_file(&path) {
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
r["binary"] = json!(opts.binary);
r["portableFonts"] = json!(opts.portable_fonts);
}
Ok(r)
}
@@ -684,8 +720,11 @@ fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
fn drawing_version(s: &Session, path: &str) -> Version {
let v =
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
let v = s
.state()
.ok()
.and_then(|st| st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver))
.unwrap_or_default();
if is_dxf(path) { v } else { v.max(Version::R2004) }
}
@@ -714,6 +753,8 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
if drops_unknown(format!("drawing.{fmt}")) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && is_dxf_file(format!("drawing.{fmt}")) {
report_sat_text_dropped(s, &mut r);
}
Ok(r)
}
@@ -731,21 +772,48 @@ pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
/// store section, which is written back with the drawing (`Foreign::data_store`).
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
/// Entity types CADCraft doesn't model that the DWG bridge writes all the same: acadrust reads
/// them from our DXF into entities of its own and writes them to DWG of every version we save,
/// an OLE object with its frame and its embedded object's bytes unchanged. Matched exactly: the
/// bridge gets the type as stored, and acadrust knows only this spelling of it.
const DWG_WRITES: [&str; 1] = ["OLE2FRAME"];
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
/// store has a record for keeps its geometry and is not counted.
/// store has a record for keeps its geometry and is not counted; neither are [`DWG_WRITES`], nor
/// another program's proxy entity whose class the file names: the DWG writes it with that class.
/// Before 2013, or when its group 91 names no class of the file, the DWG leaves the proxy out and
/// it is counted.
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for e in stores.flat_map(|s| s.iter()) {
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
// A DWG before 2013 writes a solid's SAT text as it is; from 2013 the geometry has to be
// in the data store, and SAT text alone is lost (#2334).
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t))
&& (stored.contains(&e.handle) || (version < Version::R2013 && cadcraft_doc::foreign::has_sat_text(&u.tags)))
{
continue;
}
if DWG_WRITES.contains(&u.dxf_type.as_str()) {
continue;
}
if version >= Version::R2013 && u.dxf_type.eq_ignore_ascii_case("ACAD_PROXY_ENTITY") && d.foreign.proxy_class(&u.tags).is_some() {
continue;
}
let n = out.entry(u.dxf_type.clone()).or_insert(0);
*n = n.saturating_add(1);
}
}
// Another program's proxy objects (in the OBJECTS section) follow the same rule as its proxy
// entities: the DWG writer leaves them out before 2013, or when their class can't be named.
for o in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_PROXY_OBJECT")) {
if version < Version::R2013 || d.foreign.proxy_class(&o.tags).is_none() {
let n = out.entry("ACAD_PROXY_OBJECT".to_string()).or_insert(0);
*n = n.saturating_add(1);
}
}
out
}
@@ -755,20 +823,39 @@ pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Op
return None;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
Some(format!(
"Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them, solids only when saved as 2010 or earlier): {}",
list.join(", ")
))
}
/// What saving the active drawing to `path` would leave out, before anything is written: empty
/// when the format keeps everything. The interactive save asks with this; [`save_reporting`]
/// reports the same list afterwards to callers that never get a dialog.
pub fn loss_on_save(s: &Session, path: &str) -> std::collections::BTreeMap<String, usize> {
let version = drawing_version(s, path);
if is_dxf_file(path) && version >= Version::R2013 {
// The drawing a file holds: the host while the Block Editor is open.
return s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
}
if !drops_unknown(path) {
return Default::default();
}
let version = drawing_version(s, path);
s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default()
}
/// Report what the file `path`, just written at the drawing's own version, left out.
pub(super) fn report_loss(s: &mut Session, r: &mut Value, path: &str) {
if is_dxf_file(path) {
if drawing_version(s, path) >= Version::R2013 {
report_sat_text_dropped(s, r);
}
} else {
let lost = loss_on_save(s, path);
report_not_saved(s, r, &lost);
}
}
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
/// bridge drops types it doesn't know); DXF writes them back as read.
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
@@ -780,8 +867,53 @@ fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path).is_some_and(Format::is_drawing)
}
/// Whether `path` is a DXF file.
fn is_dxf_file(path: impl AsRef<std::path::Path>) -> bool {
Format::from_path(path) == Some(Format::Dxf)
}
/// Say on the command line which solid modeler entities a DXF of 2013 or later left out because
/// they hold SAT-text ACIS data, which such files keep in a data store instead (#2334).
fn report_sat_text_dropped(s: &mut Session, r: &mut Value) {
// The drawing a file holds: the host while the Block Editor is open.
let lost = s.state().map(|st| cadcraft_doc::foreign::sat_text_dropped(st.drawing())).unwrap_or_default();
if lost.is_empty() {
return;
}
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
s.echo(format!(
"Not saved in the DXF file (2013 and later keep ACIS geometry in a data store these entities have no record in; save as 2010 or earlier to keep them): {}",
list.join(", ")
));
if let Some(o) = r.as_object_mut() {
o.insert("notSaved".into(), json!(lost));
}
}
/// The folder a write to `path` has to create, asked before writing so it can be reported after.
/// The web has no folders.
pub(super) fn new_folder(path: &str) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
return crate::atomic_write::missing_folder(std::path::Path::new(path)).map(|f| f.display().to_string());
#[cfg(target_arch = "wasm32")]
{
let _ = path;
None
}
}
/// Say on the command line that the file went into a folder that didn't exist, so a mistyped path
/// is noticed, and name the folder in the result's `createdFolder`.
pub(super) fn report_new_folder(s: &mut Session, r: &mut Value, folder: Option<String>) {
let Some(folder) = folder else { return };
s.echo(format!("Created the folder {folder}, which did not exist. Check the path if you did not mean to."));
if let Some(o) = r.as_object_mut() {
o.insert("createdFolder".into(), json!(folder));
}
}
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
pub(super) fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
let Some(msg) = not_saved_message(lost) else { return };
s.echo(msg);
if let Some(o) = r.as_object_mut() {
@@ -790,24 +922,64 @@ fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTr
}
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>, asked: Option<Version>) -> Result<Value> {
// What the file will hold depends on the version it is saved as (the data store from 2013).
// QSAVE takes no `version` and keeps the drawing's own; SAVEAS writes the one asked for.
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
let version = asked.unwrap_or_else(|| drawing_version(s, &path));
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
let folder = new_folder(&path);
let mut r = run(s, p)?;
report_new_folder(s, &mut r, folder);
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
report_not_saved(s, &mut r, &lost);
} else if version >= Version::R2013 && r.get("path").and_then(Value::as_str).is_some_and(is_dxf_file) {
report_sat_text_dropped(s, &mut r);
}
if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) {
report_fonts(s, &mut r);
}
Ok(r)
}
/// Name the text styles whose font in the saved file AutoCAD on Windows doesn't have: in the
/// result, and at the command line when the list changed since the last save. Styles from an xref
/// (`XREF|name`) aren't saved: AutoCAD takes them from the xref.
fn report_fonts(s: &mut Session, r: &mut Value) {
use cadcraft_fonts::windows::{missing_on_windows, windows_font, written_font};
let portable = r.get("portableFonts").and_then(Value::as_bool).unwrap_or(false);
let Ok(d) = s.state().map(|st| st.drawing()) else { return };
let fonts: Vec<(String, String)> = d
.text_styles
.iter()
.filter(|t| !t.name.contains('|') && missing_on_windows(written_font(&t.font, portable)))
.map(|t| (t.name.clone(), t.font.clone()))
.collect();
let list = fonts.iter().map(|(style, font)| format!("{style} ({font})")).collect::<Vec<_>>().join(", ");
let changed = s.state_mut().map(|st| std::mem::replace(&mut st.fonts_noted, list.clone()) != list).unwrap_or(true);
if fonts.is_empty() {
return;
}
if changed {
let hint = if fonts.iter().any(|(_, font)| windows_font(font).is_some()) {
" SAVEAS with portableFonts writes Windows fonts for those that have one."
} else {
""
};
s.echo(format!("Fonts AutoCAD on Windows doesn't have: {list}.{hint}"));
}
if let Some(o) = r.as_object_mut() {
o.insert("nonPortableFonts".into(), json!(fonts.iter().map(|(style, font)| json!({ "style": style, "font": font })).collect::<Vec<_>>()));
}
}
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_save)
save_reporting(s, p, run_save, None)
}
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
save_reporting(s, p, run_saveas)
let asked = save_options("saveas", p)?.version;
save_reporting(s, p, run_saveas, asked)
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+7 -11
View File
@@ -20,7 +20,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use cadcraft_doc::color::{Color, Rgb};
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, MAX_BLOCK_DEPTH, TableStyle};
use cadcraft_doc::{Block, CellStyle, DimStyle, Drawing, Entity, EntityKind, EntityStore, Group, Handle, Layer, MAX_BLOCK_DEPTH, TableStyle};
use serde_json::{Value, json};
use super::super::super::{CommandSpec, bad};
@@ -197,6 +197,7 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
d.skipped_records.clear();
d.handseed = d.handseed.max(other.handseed);
use crate::cmd::merge::merge_named as merge;
merge(&mut d.layers, &other.layers, |t| &t.name);
merge(&mut d.linetypes, &other.linetypes, |t| &t.name);
merge(&mut d.text_styles, &other.text_styles, |t| &t.name);
@@ -235,22 +236,17 @@ fn snapshot(cur: &Drawing, c: &Comparison, folders: &Folders) -> Snapshot {
Snapshot { drawing: d, current, compared, counts: (here.len(), there.len()), truncated: diff.truncated, blocks_left_out, uncolored }
}
/// Add the entries of `from` whose names `to` lacks (compared without letter case).
fn merge<T: Clone>(to: &mut Vec<T>, from: &[T], name: impl Fn(&T) -> &String) {
for t in from {
if !to.iter().any(|x| name(x).eq_ignore_ascii_case(name(t))) {
to.push(t.clone());
}
}
}
/// Add copies of `objs` to model space with new handles, their block references renamed by
/// `renames` and their files rebased on `folder`, each painted in its comparison color, and
/// group them as `name` (or `name_2`… when that group name is taken). Returns the group's name.
fn add_group(d: &mut Drawing, name: &str, objs: &[(&Entity, Color)], renames: &Renames, folder: Option<&Path>, tinted: &mut Tinted) -> String {
let mut members = Vec::new();
// The layers this drawing has, looked up once per object rather than scanned.
let mut layers: HashSet<String> = d.layers.iter().map(|l| l.name.to_ascii_uppercase()).collect();
for (e, color) in objs {
d.ensure_layer(&e.common.layer);
if layers.insert(e.common.layer.to_ascii_uppercase()) {
d.layers.push(Layer::new(&e.common.layer));
}
let h = d.new_handle();
let mut copy = Entity { handle: h, ..(*e).clone() };
detach(&mut copy.kind);
+2 -1
View File
@@ -59,7 +59,8 @@ pub(super) fn run_new(s: &mut Session, p: &Value) -> Result<Option<Value>> {
&& hi.y > lo.y
{
let center = cadcraft_geom::Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0);
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6) });
let twist = st.view().twist;
st.set_view_quiet(crate::View { center, height: (hi.y - lo.y).max(1e-6), twist });
}
Ok(Some(json!({ "index": i, "title": title, "template": path })))
}
+353
View File
@@ -0,0 +1,353 @@
//! Raster images (IMAGEATTACH, IMAGE). An IMAGE entity names its file; the pixels are decoded
//! when the drawing opens or the image is attached and kept with the open drawing
//! ([`Drawing::rasters`]), never in the file. An image whose file can't be found or read stays in
//! the drawing as a crossed-out frame.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use cadcraft_doc::{Drawing, EntityKind, Image, Raster};
use cadcraft_geom::Vec2;
use serde_json::{Value, json};
use super::file::base64_decode;
use super::{CommandSpec, bad, f64_or, point_param, str_param};
use crate::{Result, Session};
/// Most image files loaded per drawing.
const MAX_IMAGE_FILES: usize = 1000;
/// Largest image file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_IMAGE_BYTES: u64 = 256 << 20;
/// Most decoded pixel bytes kept per drawing; images past it stay unloaded.
const MAX_LOADED_BYTES: u64 = 2 << 30;
/// Most image files decoded at the same time when a drawing opens.
const MAX_LOADERS: usize = 4;
/// Longest image path taken from a caller.
const MAX_PATH: usize = 4096;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("imageattach", "Raster Image Reference...", run_imageattach).alias(&["iat"]).params(
"{path: PNG/JPEG/BMP/TIFF file (saved as given; a relative path is relative to this drawing's folder), data?: base64 file contents \
(instead of reading `path`), at?: [x,y] lower-left corner (default 0,0), scale?: number (default 1: the image is one drawing unit \
wide), width?: width in drawing units (instead of `scale`), rotation? (degrees), name? (default: the file name)} → {name, handle, \
path, pixels: [w, h], size: [w, h]}",
),
CommandSpec::new("image", "Raster Images", run_image)
.alias(&["im"])
.params("{} → {images: [{name, path, foundAt, status: Loaded|Not Found|Not loaded, references, pixels?: [w, h]}]}")
.noundo(),
]
}
/// Whether `ext` (lower case, no dot) is an image file type IMAGEATTACH reads.
pub(crate) fn is_image_ext(ext: &str) -> bool {
matches!(ext, "png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff")
}
/// Every IMAGE entity of `d`: in model space, on the layouts and in the blocks.
fn images(d: &Drawing) -> impl Iterator<Item = &Image> {
let model = std::iter::once(&d.model);
let layouts = d.layouts.iter().map(|l| &l.entities);
let blocks = d.blocks.values().map(|b| &b.entities);
model.chain(layouts).chain(blocks).flat_map(|store| store.iter()).filter_map(|e| match &e.kind {
EntityKind::Image(i) => Some(i),
_ => None,
})
}
/// The image files `d` names, each once, in the order they are first used.
fn paths(d: &Drawing) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut paths = Vec::new();
for i in images(d) {
if !i.path.trim().is_empty() && seen.insert(i.path.as_str()) {
paths.push(i.path.clone());
}
}
paths
}
/// The contents of the image file saved as `path` in a drawing at `host`.
fn read_file(path: &str, host: Option<&str>) -> std::result::Result<Vec<u8>, String> {
#[cfg(not(target_arch = "wasm32"))]
{
let file = super::blocks::xref::find_file(path, host).ok_or("file not found")?;
let len = std::fs::metadata(&file).map_err(|e| e.to_string())?.len();
if len > MAX_IMAGE_BYTES {
return Err(format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20));
}
// Read no more than the limit: the file can grow or change after the check above.
let limit = usize::try_from(MAX_IMAGE_BYTES).unwrap_or(usize::MAX);
crate::capped_read::read_capped(&file, limit).map_err(|e| {
if e.kind() == std::io::ErrorKind::InvalidData { format!("the file is larger than {} MB", MAX_IMAGE_BYTES >> 20) } else { e.to_string() }
})
}
#[cfg(target_arch = "wasm32")]
{
let _ = (path, host);
Err("files are not available on the web".into())
}
}
/// Decode an image file. A decoder that panics on a damaged file fails this image only: the
/// drawing it belongs to must still open.
fn decode(bytes: &[u8]) -> std::result::Result<Raster, String> {
let hook = super::file::image_decoder().ok_or("image files can't be read in this build")?;
match std::panic::catch_unwind(|| hook(bytes)) {
Ok(r) => r,
Err(_) => Err("the file is damaged and can't be read".into()),
}
}
fn bytes_of(r: &Raster) -> u64 {
u64::try_from(r.rgba().len()).unwrap_or(u64::MAX)
}
/// Load the images of drawing `i`, just opened. Those that can't be loaded are reported and
/// stay crossed-out frames; opening never fails over an image.
pub(crate) fn after_open(s: &mut Session, i: usize) {
let Some(st) = s.docs.get_mut(i) else { return };
let paths = paths(&st.doc);
if paths.is_empty() {
return;
}
let mut rasters = st.doc.rasters.clone();
let notes = load_missing(&paths, st.path.as_deref(), &mut rasters);
if !rasters.is_empty() {
// Loading pixels is not a change to the drawing. A drawing counts as unchanged while
// it is the very one that was saved, so the saved one follows.
let unchanged = Arc::ptr_eq(&st.doc, &st.saved);
Arc::make_mut(&mut st.doc).rasters = rasters;
if unchanged {
st.saved = st.doc.clone();
}
}
for n in notes {
s.echo(n);
}
}
/// Read and decode into `rasters` the files of `paths` that are not in it yet, for a drawing at
/// `host`. Returns one message per file that could not be loaded.
fn load_missing(paths: &[String], host: Option<&str>, rasters: &mut cadcraft_doc::Rasters) -> Vec<String> {
let mut notes = Vec::new();
let mut missing: Vec<&str> = paths.iter().map(String::as_str).filter(|p| rasters.get(p).is_none()).collect();
if missing.len() > MAX_IMAGE_FILES {
missing.truncate(MAX_IMAGE_FILES);
notes.push(format!("Images: only the first {MAX_IMAGE_FILES} image files are loaded"));
}
let decoded = decode_files(&missing, host);
// Kept in the order the drawing uses them, up to the memory limit.
let mut kept_bytes = 0u64;
for (path, result) in missing.iter().zip(decoded) {
match result {
Ok(r) => {
kept_bytes = kept_bytes.saturating_add(bytes_of(&r));
if kept_bytes > MAX_LOADED_BYTES {
notes.push(over_limit(path));
break;
}
rasters.insert(path, Arc::new(r));
}
Err(e) => notes.push(format!("Image {path}: {e}")),
}
}
notes
}
fn over_limit(path: &str) -> String {
format!("Image {path}: not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20)
}
/// Read and decode the image files `paths` of a drawing at `host`, a few at a time (decoding is
/// what opening a drawing with large images waits for). The results are in the order of `paths`.
fn decode_files(paths: &[&str], host: Option<&str>) -> Vec<std::result::Result<Raster, String>> {
// Decoded bytes so far, shared by the loaders: past the limit no further file is started.
let decoded_bytes = AtomicU64::new(0);
let load = |path: &str| -> std::result::Result<Raster, String> {
if decoded_bytes.load(Ordering::Relaxed) > MAX_LOADED_BYTES {
return Err(format!("not loaded (more than {} MB of images)", MAX_LOADED_BYTES >> 20));
}
let r = read_file(path, host).and_then(|bytes| decode(&bytes))?;
decoded_bytes.fetch_add(bytes_of(&r), Ordering::Relaxed);
Ok(r)
};
let mut results: Vec<Option<std::result::Result<Raster, String>>> = paths.iter().map(|_| None).collect();
let loaders = loader_count(paths.len());
if loaders > 1 {
// Each loader takes the next file nobody has started.
let next = AtomicUsize::new(0);
let work = || {
let mut done = Vec::new();
loop {
let index = next.fetch_add(1, Ordering::Relaxed);
let Some(path) = paths.get(index) else { break };
done.push((index, load(path)));
}
done
};
let finished: Vec<_> = std::thread::scope(|scope| {
let started: Vec<_> = (0..loaders).filter_map(|_| std::thread::Builder::new().spawn_scoped(scope, work).ok()).collect();
started.into_iter().filter_map(|loader| loader.join().ok()).flatten().collect()
});
for (index, result) in finished {
if let Some(slot) = results.get_mut(index) {
*slot = Some(result);
}
}
}
// One loader, or files a loader that could not start or finish left behind.
results.into_iter().zip(paths).map(|(result, path)| result.unwrap_or_else(|| load(path))).collect()
}
/// How many files are decoded at once: one per processor, at most [`MAX_LOADERS`] (each large
/// image needs several hundred megabytes while it is decoded) and no more than there are files.
fn loader_count(files: usize) -> usize {
#[cfg(not(target_arch = "wasm32"))]
let processors = std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(1);
#[cfg(target_arch = "wasm32")]
let processors = 1;
processors.min(MAX_LOADERS).min(files)
}
/// The file name without folders and extension (either separator).
fn stem(path: &str) -> String {
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
match file.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.to_string(),
_ => file.to_string(),
}
}
/// The definition name for a new image of `path`: the name that file already has in `d`, else
/// `wanted`, numbered (`wanted_2`, `wanted_3`, …) when another file has taken it.
fn def_name(d: &Drawing, path: &str, wanted: &str) -> String {
if let Some(i) = images(d).find(|i| i.path == path && !i.name.is_empty()) {
return i.name.clone();
}
// The names other files have, compared without regard to case.
let taken: BTreeSet<String> = images(d).filter(|i| i.path != path).map(|i| i.name.to_ascii_lowercase()).collect();
let free = |name: &str| !taken.contains(&name.to_ascii_lowercase());
if free(wanted) {
return wanted.to_string();
}
// One of these is free: there are more candidates than names taken.
let candidates = 2..taken.len().saturating_add(3);
candidates.map(|n| format!("{wanted}_{n}")).find(|name| free(name)).unwrap_or_else(|| wanted.to_string())
}
const ID: &str = "imageattach";
pub(crate) fn run_imageattach(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(ID, "`path` is required"))?.to_string();
if path.len() > MAX_PATH {
return Err(bad(ID, "`path` is too long"));
}
let place = placement(p)?;
let raster = pixels(s, p, &path)?;
let (w, h) = (f64::from(raster.width()), f64::from(raster.height()));
// One pixel in drawing units, along the image's bottom edge (`u`) and its left edge (`v`).
let pixel = place.width / w;
let u = Vec2::from_angle(place.rotation) * pixel;
let v = u.perp();
if !(pixel.is_normal() && (pixel * h).is_normal() && u.x.is_finite() && u.y.is_finite()) {
return Err(bad(ID, "the image would be too small or too large to draw"));
}
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| stem(&path));
let name = def_name(s.doc()?, &path, &wanted);
let image = Image {
insert: place.at.to3(0.0),
u: u.to3(0.0),
v: v.to3(0.0),
size: Vec2::new(w, h),
path: path.clone(),
name: name.clone(),
..Image::default()
};
s.doc_mut()?.rasters.insert(&path, raster);
let handle = s.add_entity(EntityKind::Image(image))?;
Ok(json!({ "name": name, "handle": handle.hex(), "path": path, "pixels": [w, h], "size": [place.width, pixel * h] }))
}
/// Where and how large IMAGEATTACH puts the image.
struct Placement {
/// The lower-left corner.
at: Vec2,
/// Radians, counter-clockwise.
rotation: f64,
/// The image's width in drawing units.
width: f64,
}
/// `at`, `rotation` and the width (`width`, else `scale`: an image is one unit wide at scale 1).
fn placement(p: &Value) -> Result<Placement> {
let at = point_param(p, "at").unwrap_or(Vec2::ZERO);
if !(at.x.is_finite() && at.y.is_finite()) {
return Err(bad(ID, "`at` must be a point"));
}
let width = match p.get("width") {
Some(v) => v.as_f64().filter(|w| w.is_finite()).ok_or_else(|| bad(ID, "`width` must be a number"))?,
None => f64_or(p, "scale", 1.0),
};
if !(width.is_finite() && width > 0.0) {
return Err(bad(ID, "`scale` and `width` must be greater than zero"));
}
Ok(Placement { at, rotation: f64_or(p, "rotation", 0.0).to_radians(), width })
}
/// The pixels of the image at `path`: decoded from `data` when the caller hands the file over,
/// else the ones already loaded for that path, else read from the file.
fn pixels(s: &Session, p: &Value, path: &str) -> Result<Arc<Raster>> {
let bytes = match p.get("data") {
Some(data) => {
let text = data.as_str().ok_or_else(|| bad(ID, "`data` must be base64 text"))?;
base64_decode(text).ok_or_else(|| bad(ID, "`data` is not valid base64"))?
}
None => {
if let Some(loaded) = s.doc()?.rasters.get(path) {
return Ok(loaded.clone());
}
let host = s.state()?.path.clone();
read_file(path, host.as_deref()).map_err(|e| bad(ID, format!("{path}: {e}")))?
}
};
Ok(Arc::new(decode(&bytes).map_err(|e| bad(ID, format!("{path}: {e}")))?))
}
fn run_image(s: &mut Session, _p: &Value) -> Result<Value> {
let host = s.state()?.path.clone();
let d = s.doc()?;
// Per file: its definition name and how many images show it.
let mut uses: BTreeMap<&str, (&str, usize)> = BTreeMap::new();
for i in images(d) {
uses.entry(i.path.as_str()).or_insert((i.name.as_str(), 0)).1 += 1;
}
let rows: Vec<Value> = paths(d)
.iter()
.map(|path| {
let (name, references) = uses.get(path.as_str()).copied().unwrap_or_default();
let mut row = describe(d, path, host.as_deref());
row["name"] = json!(name);
row["references"] = json!(references);
row
})
.collect();
Ok(json!({ "images": rows }))
}
/// The state of image file `path` of drawing `d` (saved at `host`): where it is and whether its
/// pixels are loaded.
fn describe(d: &Drawing, path: &str, host: Option<&str>) -> Value {
let loaded = d.rasters.get(path);
let found_at = super::blocks::xref::find_file(path, host).map(|f| f.to_string_lossy().into_owned());
let status = match (loaded, &found_at) {
(Some(_), _) => "Loaded",
(None, None) => "Not Found",
// The file is there but could not be read as an image.
(None, Some(_)) => "Not loaded",
};
json!({ "path": path, "foundAt": found_at, "status": status, "pixels": loaded.map(|r| json!([r.width(), r.height()])) })
}
+44 -3
View File
@@ -217,6 +217,11 @@ fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
if !d.len().is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.distance = d.len();
s.last_point = a.xy();
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
@@ -285,6 +290,14 @@ fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
// Drawn as its faces' visible edges: the Z on the nearest one.
EntityKind::PolyfaceMesh(m) => m
.faces3d()
.iter()
.flat_map(cadcraft_doc::Face3d::visible_edges)
.map(|(a, b)| on_segment(a, b))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z),
_ => None,
}
}
@@ -422,6 +435,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(what) = too_large(a, per) {
return Err(bad("area", format!("the {what} is too large")));
}
s.area = a;
s.perimeter = per;
Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) }))
}
@@ -508,6 +523,13 @@ pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
total
}
EntityKind::Region(r) => (r.area(), r.perimeter()),
// Like an open polyline: the area closed by the chord, the perimeter its own length.
EntityKind::Spline(sp) if sp.is_valid() => {
let b = sp.bounds();
let pts = sp.tessellate_spans((b.min.dist(b.max) * 1e-7).max(1e-12));
// A length past f64's range is too large to report, as a polyline's is, not 0.
(cadcraft_geom::shoelace(&pts).abs(), sp.checked_length().unwrap_or(f64::INFINITY))
}
_ => return Err(bad("area", "object has no area")),
})
}
@@ -574,6 +596,8 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
"skipped": { "count": d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n)), "sections": d.skipped_records },
// What reading the file left out, in words (a damaged DWG's unreadable object records).
"readNotes": d.read_notes,
});
if bool_or(p, "entities", true)
&& let (Some(o), Some(store)) = (out.as_object_mut(), d.space(&space))
@@ -864,16 +888,25 @@ impl Interactive for DistM {
self.first_source = s.picked_viewport;
Ok(Step::Continue)
}
(Some(a), Input::Point(b)) => {
(Some(first), Input::Point(b)) => {
let (a, b) = match (self.first_source, s.picked_viewport) {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
_ => (first, b),
};
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
let (a, b) = (Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
if !(b - a).len().is_finite() {
// The first point stays LASTPOINT, as after a measurement.
s.last_point = first;
return Err(bad("dist", "the distance is too large"));
}
let t = dist_text(s, a, b);
for l in t.lines() {
s.echo(l.to_string());
}
// As in AutoCAD, the second point doesn't become LASTPOINT: the first stays.
s.distance = (b - a).len();
s.last_point = first;
Ok(Step::Done)
}
(Some(a), Input::Keyword(k)) if k == "Multiple points" => {
@@ -901,7 +934,11 @@ impl DistM {
.as_deref()
.or(self.multi.as_deref())
.map_or(0.0, |pts| if closed { arcs::polyline(pts, &self.bulges, true).len() } else { path_len(pts, &self.bulges) });
if !d.is_finite() {
return Err(bad("dist", "the distance is too large"));
}
s.echo(format!("Distance = {}", fmt_d(s, d)));
s.distance = d;
Ok(Step::Done)
}
}
@@ -1115,10 +1152,14 @@ impl AreaM {
return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue };
}
s.echo(area_text(s, a, per));
// AREA: this area, or in Add and Subtract mode the running total; PERIMETER: this area's.
s.perimeter = per;
if self.mode == AreaMode::Single {
s.area = a;
return Step::Done;
}
self.total = total;
s.area = total;
s.echo(format!("Total area = {}", fmt_d(s, self.total)));
Step::Continue
}
+3 -1
View File
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
}
loops.retain(|l| l.vertices.len() >= 2);
let mut r = Region { loops };
r.renest();
if !r.renest() {
return Err(other("These regions are too complex to combine."));
}
Ok(Some(r))
}
+33 -5
View File
@@ -182,7 +182,7 @@ fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result<Op
}
fn valid_name(n: &str) -> bool {
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
!n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| c.is_control() || "<>/\\\":;?*|,=`".contains(c))
}
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
@@ -408,12 +408,24 @@ fn run_state_restore(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("layerstate.restore", "`name` is required"))?;
let d = s.doc_mut()?;
let st = d.layer_states.iter().find(|st| st.name == name).cloned().ok_or_else(|| bad("layerstate.restore", "no such layer state"))?;
for saved in st.layers {
if let Some(l) = d.layer_mut(&saved.name) {
*l = saved;
restore_layers(d, st.layers);
ok()
}
/// Put the saved layers back over the drawing's layers of the same name (without regard to case);
/// saved layers the drawing no longer has are skipped. Both lists can come from a file, so the
/// drawing's layers are looked up in an index rather than scanned per saved layer.
pub(crate) fn restore_layers(d: &mut cadcraft_doc::Drawing, saved: Vec<Layer>) {
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::with_capacity(d.layers.len());
for (i, l) in d.layers.iter().enumerate() {
// The first of names that differ only in case, as `Drawing::layer_mut` finds it.
index.entry(l.name.to_ascii_lowercase()).or_insert(i);
}
for layer in saved {
if let Some(l) = index.get(&layer.name.to_ascii_lowercase()).and_then(|&i| d.layers.get_mut(i)) {
*l = layer;
}
}
ok()
}
fn run_state_list(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -461,6 +473,22 @@ mod tests {
use crate::Session;
/// Restoring many saved layers, most of them missing, takes one pass and matches without case.
#[test]
fn restore_layers_matches_by_name_in_linear_time() {
let mut d = cadcraft_doc::Drawing::new_imperial();
d.layers = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("L{i}"))).collect();
let mut saved: Vec<cadcraft_doc::Layer> = (0..20_000).map(|i| cadcraft_doc::Layer::new(&format!("missing{i}"))).collect();
let mut back = cadcraft_doc::Layer::new("l7");
back.frozen = true;
saved.push(back);
let started = std::time::Instant::now();
super::restore_layers(&mut d, saved);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(d.layer("L7").is_some_and(|l| l.frozen));
assert_eq!(d.layers.len(), 20_000);
}
#[test]
fn layer_states_save_list_restore_delete() {
let mut s = Session::new();
+35 -8
View File
@@ -84,11 +84,11 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["File", "Print..."])
.key("Cmd+P")
.alias(&["print"])
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", device?: \"CADCraft PDF\"|\"CADCraft PNG\"|\"CADCraft SVG\" | a plotter from PLOTTERMANAGER | \"None\" (default: a layout's page setup device; Model, or \"None\": no device, PDF; a device sets the output format and the papers), paper?: the device's paper (default: a layout keeps its page setup's; Model takes the device's default), landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
.noundo(),
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
.menu(&["File", "Export to PDF..."])
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.params("{path? (else returns base64 `data`), layout?, device? (its papers; the output stays PDF), paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)")
.noundo(),
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
.alias(&["ms"])
@@ -313,7 +313,7 @@ fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
let h = d.new_handle();
store.push(Entity { handle: h, ..Entity::clone(e) });
}
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view });
d.layouts.push(Layout { name: to.clone(), tab_order: order, page: src.page, entities: store, view: src.view, limits: src.limits });
Ok(json!({ "name": to }))
}
@@ -524,6 +524,7 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
"height": v.height,
"viewCenter": [v.view_center.x, v.view_center.y],
"viewHeight": v.view_height,
"twist": v.view_twist().to_degrees(),
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
"locked": v.locked,
"on": v.on,
@@ -785,10 +786,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
// Checked here for a clear error; the plotter resolves the names the same way.
let sheets = p.get("layouts").map(|v| plot_sheets(cmd, s.doc()?, v)).transpose()?;
// `layout` and `layouts` must name the same sheet(s), or the reply would name one sheet and the render another.
if let (Some(n), Some(names)) = (str_param(p, "layout"), sheets.as_deref())
&& !matches!(names, [only] if only.eq_ignore_ascii_case(n))
{
return Err(bad(cmd, "`layout` and `layouts` name different sheets; give only one of them"));
}
let is_image = p.get("output").and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("png") || o.eq_ignore_ascii_case("svg"));
let space = match str_param(p, "layout") {
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
Some(n) => Space::Paper(layout_name(cmd, s.doc()?, n)?),
None => s.space(),
// A PNG or SVG holds one render: a single `layouts` entry names the sheet to draw (the PDF
// path resolves `layouts` itself), not whatever space the session happens to show.
None => match sheets.as_deref() {
Some([only]) if is_image => {
if only.eq_ignore_ascii_case("model") {
Space::Model
} else {
Space::Paper(only.clone())
}
}
_ => s.space(),
},
};
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
if let Some(raw) = p.get("offset") {
@@ -827,12 +846,17 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
None => o.remove("plotTransparency"),
};
}
// A PNG or SVG holds one render: several sheets would report `pages: N` but write only one.
if is_image && sheets.as_ref().is_some_and(|names| names.len() > 1) {
return Err(bad(cmd, "`layouts` with several sheets needs PDF output; PNG and SVG hold one sheet, so plot each layout separately"));
}
let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?;
let layout = match &sheets {
Some(names) => names.first().cloned().unwrap_or_default(),
None => space.tab_name().to_string(),
};
let pages = sheets.as_ref().map_or(1, Vec::len);
let format = opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).unwrap_or_else(|| "pdf".into());
match str_param(p, "path") {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
@@ -840,16 +864,19 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let path = _path;
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
let folder = super::file::new_folder(path);
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let mut r = json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format });
super::file::report_new_folder(s, &mut r, folder);
Ok(r)
}
#[cfg(target_arch = "wasm32")]
{
super::exchange::deliver_file(cmd, _path, &bytes, super::file::deliver())?;
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
Ok(json!({ "path": _path, "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format }))
}
}
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages })),
None => Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len(), "layout": layout, "pages": pages, "format": format })),
}
}
+2 -3
View File
@@ -333,9 +333,8 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
Some(_path) => {
#[cfg(not(target_arch = "wasm32"))]
{
let tmp = format!("{_path}.cadcraft-tmp");
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
crate::atomic_write::write_atomic(std::path::Path::new(_path), text.as_bytes())
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
}
#[cfg(target_arch = "wasm32")]
+3 -1
View File
@@ -104,8 +104,10 @@ pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
let t = template_of(d, &name)?;
let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?;
let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?;
let folder = super::super::file::new_folder(&path);
store(&path, &bytes)?;
let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() });
super::super::file::report_new_folder(s, &mut r, folder);
let lost = not_saved(&t);
if drops_unknown(&path)
&& let Some(msg) = not_saved_message(&lost)
@@ -124,7 +126,7 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
{
// Exclusively create a fresh temporary file and rename it into place so a planted
// `<path>.cadcraft-tmp` symlink cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
{
+80 -1
View File
@@ -230,6 +230,24 @@ fn plot_goes_through_the_hook() {
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err());
// Image output holds one sheet: several `layouts` would report pages:N but write one render.
for out in ["x.png", "x.svg"] {
let e = s.execute("plot", &json!({"path": out, "layouts": ["Layout1", "Layout2"]})).unwrap_err();
assert!(e.to_string().contains("layouts"), "{e}");
}
assert!(s.execute("plot", &json!({"output": "png", "layouts": ["Layout1", "Layout2"]})).is_err());
// `layout` and `layouts` naming different sheets are refused (the reply would name one sheet and the render another); agreeing ones pass.
for p in
[json!({"output": "png", "layout": "Model", "layouts": ["Layout1"]}), json!({"output": "pdf", "layout": "Layout1", "layouts": ["Model"]})]
{
let e = s.execute("plot", &p).unwrap_err().to_string();
assert!(e.contains("different"), "{e}");
}
assert_eq!(s.execute("plot", &json!({"output": "png", "layout": "layout1", "layouts": ["Layout1"]})).unwrap()["layout"], "Layout1");
let one = s.execute("plot", &json!({"output": "png", "layouts": ["Layout1"]})).unwrap();
assert_eq!(one["layout"], "Layout1");
let text = String::from_utf8(super::super::file::base64_decode(one["data"].as_str().unwrap()).unwrap()).unwrap();
assert!(text.contains("Paper(\"Layout1\")"), "one sheet renders that layout, not the session space: {text}");
#[cfg(not(target_arch = "wasm32"))]
{
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
@@ -283,7 +301,7 @@ fn mspace_edits_model_through_viewport() {
s.cmdline("circle 10,10 3").unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
// Zooming changes the viewport's model view, not the sheet.
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0, twist: v.twist });
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
assert_eq!(s.state().unwrap().paper_view(), pv);
@@ -293,3 +311,64 @@ fn mspace_edits_model_through_viewport() {
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
assert!(s.state().unwrap().mspace.is_none());
}
/// PLOT and EXPORTPDF help name the `device` parameter, and PLOT's help every built-in device (#2557).
#[test]
fn plot_help_names_the_device_and_the_built_in_devices() {
for id in ["plot", "exportpdf"] {
let help = crate::find_command(id).map(|c| c.params).unwrap_or_default();
assert!(help.contains("device?"), "{id}: {help}");
}
let help = crate::find_command("plot").map(|c| c.params).unwrap_or_default();
for name in ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"] {
assert!(super::super::plotters::find_device(name).is_some(), "{name} is not a device");
assert!(help.contains(name), "plot help lacks {name}");
}
}
/// The path's extension picks the plot format as EXPORT does, in any case; one that names no
/// format (or none) is refused instead of writing a PDF under that name, and a device that writes
/// another format is refused.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn plot_path_extension_picks_the_format_like_export() {
super::super::file::set_io(super::super::file::IoHooks {
read: |_, _| Err("no reader in tests".into()),
write: |_, _| Err("no writer in tests".into()),
plot: Some(fake_plot),
});
if io().and_then(|h| h.plot).is_none() {
return; // another test installed hooks without a plotter first
}
let mut s = session_with_model();
let dir = std::env::temp_dir().join(format!("cadcraft-plot-ext-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
let upper = s.execute("plot", &json!({"path": path("X.PNG")})).unwrap();
assert_eq!(upper["format"], "png", "an upper-case extension still picks png");
let e = s.execute("plot", &json!({"path": path("x.txt")})).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
let e = s.execute("plot", &json!({"path": path("noext")})).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("noext").exists());
let e = s.execute("plot", &json!({"path": path("y.png"), "device": "CADCraft PDF"})).unwrap_err().to_string();
assert!(e.contains("writes pdf") && e.contains("png"), "{e}");
// A device that resolves to none, or a null/unknown `output`, names no format: the refusal stands.
for p in [
json!({"path": path("x.txt"), "device": "None"}),
json!({"path": path("x.txt"), "output": null}),
json!({"path": path("x.txt"), "output": "bogus"}),
] {
let e = s.execute("plot", &p).unwrap_err().to_string();
assert!(e.contains("png") && e.contains("svg") && e.contains("pdf"), "{e}");
assert!(!dir.join("x.txt").exists(), "nothing written under an unknown extension");
}
// An explicit output still decides for an odd name.
assert_eq!(s.execute("plot", &json!({"path": path("z.txt"), "output": "pdf"})).unwrap()["format"], "pdf");
// But one that contradicts the extension is refused, as a device that does is (#2585 review).
let e = s.execute("plot", &json!({"path": path("w.png"), "output": "svg"})).unwrap_err().to_string();
assert!(e.contains("svg") && e.contains("png"), "{e}");
assert!(!dir.join("w.png").exists(), "nothing written when `output` and the path disagree");
std::fs::remove_dir_all(&dir).ok();
}
+1 -1
View File
@@ -329,7 +329,7 @@ pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
let k = cur.view_height / cur.height.max(1e-12);
let mut v = cur;
let c = b.center();
let view_center = v.view_center + (c - v.center.xy()) * k;
let view_center = v.paper_to_model(c);
let view_height = b.height() * k;
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
v.view_center = view_center;
+1 -1
View File
@@ -109,7 +109,7 @@ fn parse_color(t: &str) -> Result<Color> {
fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
let st = s.state().ok()?;
let at = match st.active_viewport() {
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
Some((_, vp)) => vp.model_to_paper(p),
None => p,
};
super::viewports::viewport_at(s, at)

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