Compare commits

..
Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
MrBeeboh a13870f376 dxf: check the tag limit in a const block 2026-10-22 20:56:55 -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
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
260 changed files with 17023 additions and 931 deletions
+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 }}
Generated
+49 -5
View File
@@ -691,6 +691,7 @@ name = "cadcraft-dwg"
version = "0.17.0"
dependencies = [
"acadrust",
"log",
]
[[package]]
@@ -728,6 +729,7 @@ name = "cadcraft-fonts"
version = "0.17.0"
dependencies = [
"cadcraft-geom",
"encoding_rs",
"harfrust 0.14.0",
"icu_segmenter",
"skrifa 0.48.0",
@@ -754,6 +756,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"craft-pdf",
"image",
"miniz_oxide 0.9.1",
"quick-xml 0.42.0",
"serde",
@@ -773,6 +776,7 @@ dependencies = [
"getrandom 0.4.3",
"log",
"serde_json",
"tiny-skia",
]
[[package]]
@@ -805,6 +809,7 @@ dependencies = [
"craft-window",
"egui",
"egui-wgpu",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -820,8 +825,10 @@ name = "cadcraft-web"
version = "0.17.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-host",
"eframe",
"egui",
"log",
@@ -1225,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"
@@ -1509,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"
@@ -1577,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"
@@ -6249,11 +6298,6 @@ 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"
+3 -1
View File
@@ -54,7 +54,7 @@ unicode-bidi = "0.3.18"
# 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"] }
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"
@@ -63,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"
+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
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());
+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(())
}
+33
View File
@@ -0,0 +1,33 @@
//! 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 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());
}
@@ -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")));
+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:?}"))
}
+11 -2
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);
}
@@ -146,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())
});
@@ -258,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.
@@ -289,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);
}
+10
View File
@@ -100,5 +100,15 @@ 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"
+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)");
+18 -2
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;
@@ -174,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.
+1
View File
@@ -800,6 +800,7 @@ mod budget_tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}
+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) });
}
+41 -6
View File
@@ -323,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();
@@ -334,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
@@ -343,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);
+47 -6
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 {
@@ -692,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());
}
}
}
@@ -1044,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));
}
}
+6 -2
View File
@@ -362,9 +362,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);
+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 {
+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");
}
}
+125 -9
View File
@@ -94,25 +94,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 +179,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 {
@@ -442,4 +477,85 @@ mod tests {
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
}
#[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:?}");
}
}
+12 -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,17 @@ 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};
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;
@@ -136,6 +140,8 @@ pub struct Drawing {
/// 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 {
@@ -196,6 +202,7 @@ impl Drawing {
refedit: None,
skipped_records: BTreeMap::new(),
portable_fonts: false,
rasters: Rasters::default(),
}
}
@@ -383,10 +390,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())
}
}
+4 -1
View File
@@ -863,7 +863,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
@@ -884,6 +884,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
+5
View File
@@ -189,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")));
@@ -242,6 +243,7 @@ fn visible_attributes_of_a_missing_block_still_count() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
};
@@ -285,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))));
@@ -303,6 +306,7 @@ fn insert_bounds_with_cyclic_block_terminate() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
)
.unwrap();
@@ -420,6 +424,7 @@ fn plain_insert(block: &str) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
+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());
}
}
+3
View File
@@ -8,6 +8,9 @@ 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).
+108 -15
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_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
use std::io::{Cursor, Write};
@@ -541,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,
@@ -562,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,
@@ -573,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
@@ -641,12 +652,19 @@ 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).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> {
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> {
let v = match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
@@ -704,7 +722,7 @@ mod native {
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
fn convert(dxf: &[u8], version: 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
@@ -729,21 +747,23 @@ 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");
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
let unwritten = drop_unwritable(&mut doc);
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
@@ -900,7 +920,11 @@ 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_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.
@@ -930,6 +954,28 @@ 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 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"))
@@ -950,6 +996,10 @@ 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(test)]
mod tests {
@@ -1281,4 +1331,47 @@ mod tests {
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}");
}
}
}
+333
View File
@@ -0,0 +1,333 @@
//! 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 = 1;
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> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
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());
r.push(u8::try_from(version.len()).unwrap_or(0));
r.extend(version.as_bytes());
r
}
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 => super::dxf_to_dwg_noted(b, version).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:?}");
}
}
#[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:?}");
}
}
+57 -9
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>;
@@ -163,16 +166,32 @@ fn typed(code: i32, text: &str) -> Value {
}
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;
/// 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
@@ -199,7 +218,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.
@@ -271,12 +290,17 @@ fn unescape_unicode(s: &str) -> String {
}
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
parse_ascii_with_limit(text, MAX_TAGS)
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();
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
loop {
let Some((ln, code_line)) = lines.next() else { break };
@@ -357,7 +381,7 @@ 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>> {
@@ -777,3 +801,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}");
}
}
+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(
+125 -28
View File
@@ -166,9 +166,11 @@ pub struct Autosaver {
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// The lock file this app holds the system lock on, open for as long as the lock is wanted.
/// 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"))]
lock: Option<(std::path::PathBuf, std::fs::File)>,
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
}
impl Autosaver {
@@ -185,7 +187,7 @@ impl Autosaver {
#[cfg(not(target_arch = "wasm32"))]
last_beat: None,
#[cfg(not(target_arch = "wasm32"))]
lock: None,
locks: Vec::new(),
}
}
@@ -203,9 +205,9 @@ impl Autosaver {
self.docs.values().filter_map(|t| t.file.clone()).collect()
}
/// Why a drawing currently has no up-to-date recovery copy, for the host to show the user.
pub fn failure(&self) -> Option<&str> {
self.failed.values().next().map(String::as_str)
/// 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
@@ -403,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 && !owner_alive(dir, instance) {
if !with_files.contains(instance) && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
@@ -433,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 {
@@ -471,6 +489,12 @@ 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, and [`Autosaver::failure`]
@@ -479,16 +503,41 @@ mod native {
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);
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),
}
self.settle(uid, h);
}
}
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;
@@ -544,23 +593,44 @@ mod native {
}
}
/// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't
/// hold it there yet. Without one the file still works by its age.
/// 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<()> {
if let Some((held, file)) = &self.lock
&& held == path
{
return file.set_modified(SystemTime::now());
}
self.lock = None;
let id = std::process::id();
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
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() {
return std::fs::write(path, id.to_string());
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.lock = Some((path.to_path_buf(), file));
self.locks.push((path.to_path_buf(), Some(file)));
Ok(())
}
@@ -575,13 +645,40 @@ mod native {
discard(&path);
}
self.docs.clear();
self.lock = None;
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
View File
@@ -18,10 +18,12 @@ 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;
@@ -199,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());
+1
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,
}))
}
+178 -16
View File
@@ -25,6 +25,7 @@ mod dyngrip;
mod extract;
pub(crate) mod refedit;
mod wblock;
mod xclip;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -43,6 +44,9 @@ 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"])
@@ -89,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()
}
@@ -225,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);
@@ -233,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(
@@ -244,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,
@@ -256,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 {
@@ -310,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()] }));
@@ -379,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<_>>() }))
@@ -557,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,
@@ -589,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 {
@@ -598,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
};
@@ -616,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)?;
}
@@ -625,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()
@@ -695,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();
@@ -728,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();
@@ -752,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);
@@ -894,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 {
@@ -934,7 +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))]
let mut ins = insert_entity(b, at, self.scale3(scale), rot, self.base);
self.grid().apply(&mut ins);
vec![EntityKind::Insert(ins)]
}
}
+3 -38
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};
@@ -102,7 +102,7 @@ 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();
@@ -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,35 +146,6 @@ 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, src: &Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
+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),
},
}
}
}
+10 -1
View File
@@ -62,11 +62,20 @@ pub(crate) fn after_open(s: &mut Session, i: usize) {
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) });
// 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())
+24 -11
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;
@@ -339,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)
}
@@ -390,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;
@@ -400,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 {
+8 -45
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()))
})
@@ -166,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();
@@ -213,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)
@@ -232,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 {
@@ -680,6 +642,7 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
+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>;
@@ -311,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))
}
@@ -320,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 })
}
@@ -363,6 +383,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")]
@@ -373,6 +394,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))
}
+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()])) })
}
+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))
}
+32 -4
View File
@@ -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();
+1
View File
@@ -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,
+1 -1
View File
@@ -283,7 +283,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);
+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)
+99
View File
@@ -0,0 +1,99 @@
//! Bringing another drawing's symbol tables and blocks into this one (INSERT of a file, IMPORT,
//! paste, COMPARE export).
//!
//! Both drawings are hostile input, so the work is linear in their sizes: names are looked up in a
//! set built once per table, and a generated block name continues from the last one allocated for
//! its prefix instead of probing every taken number again (CWE-407).
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use cadcraft_doc::Drawing;
/// Add the entries of `theirs` whose names `ours` lacks (compared without letter case; a name that
/// exists keeps `ours`' definition). Returns how many were added.
pub(crate) fn merge_named<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
let mut have: HashSet<String> = ours.iter().map(|o| name(o).to_ascii_uppercase()).collect();
let mut added = 0;
for t in theirs {
if have.insert(name(t).to_ascii_uppercase()) {
ours.push(t.clone());
added += 1;
}
}
added
}
/// Which blocks of `src` to copy into `d`, 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 (or `defining`, the block being defined from `src`) keeps `d`'s
/// definition (not copied).
pub(crate) fn block_renames(d: &Drawing, src: &Drawing, defining: Option<&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.extend(defining.map(str::to_ascii_uppercase));
// The next number to try per generated prefix: numbers below it are taken.
let mut next: HashMap<String, usize> = HashMap::new();
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();
let cursor = next.entry(prefix.to_ascii_uppercase()).or_insert(1);
match (*cursor..=1_000_000).map(|i| (i, format!("{prefix}{i}"))).find(|(_, n)| !taken.contains(&n.to_ascii_uppercase())) {
Some((i, n)) => {
*cursor = i + 1;
n
}
None => continue,
}
} else {
continue;
};
taken.insert(to.to_ascii_uppercase());
out.insert(name.clone(), to);
}
out
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use cadcraft_doc::{Block, Layer};
use super::*;
#[test]
fn merge_keeps_ours_and_adds_the_rest_without_regard_to_case() {
let mut ours = vec![Layer::new("Walls")];
let theirs = vec![Layer::new("WALLS"), Layer::new("Doors"), Layer::new("doors")];
assert_eq!(merge_named(&mut ours, &theirs, |l| &l.name), 1);
assert_eq!(ours.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["Walls", "Doors"]);
}
/// Many colliding anonymous blocks get consecutive names in one pass, not a rescan each.
#[test]
fn anonymous_collisions_take_consecutive_numbers() {
let mut d = Drawing::new_imperial();
let mut src = Drawing::new_imperial();
for i in 1..=5000 {
let mut b = Block::new(&format!("*U{i}"));
b.anonymous = true;
d.blocks.insert(b.name.clone(), Arc::new(b.clone()));
src.blocks.insert(b.name.clone(), Arc::new(b));
}
let started = std::time::Instant::now();
let renames = block_renames(&d, &src, None);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert_eq!(renames.len(), 5000);
assert_eq!(renames.get("*U1").map(String::as_str), Some("*U5001"));
let distinct: HashSet<&String> = renames.values().collect();
assert_eq!(distinct.len(), 5000);
// Every new name is the next free number: nothing below 5001 is free, nothing above 10000 is needed.
assert!(renames.values().all(|n| n.strip_prefix("*U").and_then(|i| i.parse::<u32>().ok()).is_some_and(|i| (5001..=10000).contains(&i))));
}
}
+1
View File
@@ -54,6 +54,7 @@ pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result<EntityKin
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
if *center {
// Move the block so the centre of its extents lands on the anchor.
+77 -3
View File
@@ -1,19 +1,78 @@
use cadcraft_geom::{Ellipse as GEllipse, Mat3};
use super::{Entity, EntityKind, Handle, PolyVertex, Polyline, Result, Segment, Session, Value, Vec2, arc, json, line, lwpoly, targets, v3};
// ---------------- explode / array / order / break / join ----------------
/// `ell` placed by `m` as an ELLIPSE at elevation `z`; `None` when `m` flattens it.
fn placed_ellipse(ell: GEllipse, m: &Mat3, z: f64) -> Option<EntityKind> {
let g = ell.transformed(m);
let len = g.major.len();
(len.is_finite() && len > 0.0 && g.ratio.is_finite() && g.ratio > 0.0).then(|| {
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: g.center.to3(z), major: g.major.to3(0.0), ratio: g.ratio, start: g.start, end: g.end })
})
}
/// The pieces of block object `be` placed by `m`, a transform that stretches unevenly (a block
/// reference whose X and Y scales differ), where its own kind can't hold the result: a circle
/// or an arc becomes an ellipse or an elliptical arc, a polyline with arc segments (and no
/// widths) its lines and elliptical arcs. `None`: the object transforms as itself.
fn stretched_pieces(be: &Entity, m: &Mat3) -> Option<Vec<Entity>> {
let circular = |center: Vec2, radius: f64, start: f64, end: f64| GEllipse { center, major: Vec2::X * radius, ratio: 1.0, start, end };
let kinds = match &be.kind {
EntityKind::Circle(c) => vec![placed_ellipse(GEllipse::full(c.center.xy(), Vec2::X * c.radius, 1.0), m, c.center.z)?],
EntityKind::Arc(a) => vec![placed_ellipse(circular(a.center.xy(), a.radius, a.start, a.end), m, a.center.z)?],
EntityKind::Ellipse(el) => {
let g = GEllipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
vec![placed_ellipse(g, m, el.center.z)?]
}
EntityKind::LwPolyline(p)
if p.vertices.iter().any(|v| v.bulge != 0.0)
&& p.const_width == 0.0
&& p.vertices.iter().all(|v| v.start_width == 0.0 && v.end_width == 0.0) =>
{
let z = p.elevation;
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
pl.segments()
.into_iter()
.map(|s| match s {
Segment::Line(l) => Some(EntityKind::Line(cadcraft_doc::Line { a: m.apply(l.a).to3(z), b: m.apply(l.b).to3(z) })),
Segment::Arc { arc: a, .. } => placed_ellipse(circular(a.center, a.radius, a.start, a.end), m, z),
})
.collect::<Option<Vec<_>>>()?
}
_ => return None,
};
// The pieces of a polyline are new objects, as when it explodes itself: its xdata goes.
let mut common = be.common.clone();
if matches!(be.kind, EntityKind::LwPolyline(_)) {
common.xdata.clear();
}
Some(kinds.into_iter().map(|kind| Entity { handle: Handle(0), common: common.clone(), kind }).collect())
}
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
if let Some(items) = crate::cmd::array::explode_array(d, e) {
return Some(items);
}
// A MINSERT grid stays whole, as in AutoCAD 2024 (see `explode`).
if minserted(ins) {
return None;
}
let blk = d.block(&ins.block)?;
let m = ins.transform(blk.base.xy());
let stretched = m.similarity_scale().is_none();
Some(
blk.entities
.iter()
.map(|be| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
.flat_map(|be| {
stretched.then(|| stretched_pieces(be, &m)).flatten().unwrap_or_else(|| {
let mut ne = (**be).clone();
ne.kind.transform(&m);
vec![ne]
})
})
.map(|mut ne| {
if ne.common.layer == "0" {
ne.common.layer = e.common.layer.clone();
}
@@ -168,9 +227,24 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
}
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
// AutoCAD 2024 refuses a MINSERT grid: "The object is minserted." (Locked ones are left
// without a word, as `explode_with` leaves them.)
let d = s.doc()?;
let grids = hs
.iter()
.filter(|h| !crate::cmd::curves::is_locked(s, **h) && d.entity(**h).is_some_and(|e| matches!(&e.kind, EntityKind::Insert(i) if minserted(i))))
.count();
for _ in 0..grids {
s.echo("The object is minserted.");
}
explode_with(s, hs, explode_kind)
}
/// A block reference with more than one row or column (MINSERT).
fn minserted(i: &cadcraft_doc::Insert) -> bool {
i.rows > 1 || i.cols > 1
}
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
pub(crate) fn explode_with(
+49 -11
View File
@@ -283,6 +283,28 @@ pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
}
}
/// A polyline arc segment on `ga`'s circle that ends at `end` after turning through `sweep`
/// (signed, positive counterclockwise), run on round the circle to the nearest boundary edge: the
/// new end point and the segment's new bulge. `None` when no edge crosses the rest of the circle.
fn extend_arc_segment(ga: &Arc, end: Vec2, sweep: f64, cut: &[Segment]) -> Option<(Vec2, f64)> {
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*ga }, ccw: true };
let a_end = ga.center.angle_to(end);
let ccw = sweep > 0.0;
// How far past `end` each crossing is, turning the way the segment turns; crossings on the
// segment itself (the last |sweep| of a turn) are not ahead of it.
let (x, more) = cut
.iter()
.flat_map(|c| intersect_ext(&full, c, false))
.map(|x| {
let a = ga.center.angle_to(x);
(x, norm_angle(if ccw { a - a_end } else { a_end - a }))
})
.filter(|(_, more)| *more > 1e-9 && *more + sweep.abs() < TAU - 1e-9)
.min_by(|a, b| a.1.total_cmp(&b.1))?;
let total = sweep.abs() + more;
Some((x, cadcraft_geom::arc_to_bulge(if ccw { total } else { -total })))
}
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
if crate::cmd::curves::is_locked(s, h) {
@@ -326,18 +348,34 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Re
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
// The end segment's bulge, signed for travel from `prev` out to `end`.
let i_seg = if from_end { i_prev } else { i_end };
let bulge = vs.get(i_seg).map_or(0.0, |v| if from_end { v.bulge } else { -v.bulge });
let none = || EngineError::Other("Object does not intersect an edge.".into());
if let Some((ga, _)) = cadcraft_geom::bulge_to_arc(prev, end, bulge) {
// An arc end segment runs on round its circle, as an arc does.
let (np, b) = extend_arc_segment(&ga, end, 4.0 * bulge.atan(), &cut).ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
if let Some(v) = vs.get_mut(i_seg) {
v.bulge = if from_end { b } else { -b };
}
} else {
let dir = (end - prev).normalized();
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
let np = cut
.iter()
.flat_map(|c| intersect_ext(&ray, c, false))
.filter(|x| (*x - end).dot(dir) > 1e-9)
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
.ok_or_else(none)?;
if let Some(v) = vs.get_mut(i_end) {
v.p = np;
}
}
lwpoly(vs, false)
// Width, elevation and linetype generation stay as they were.
EntityKind::LwPolyline(cadcraft_doc::LwPolyline { vertices: vs, ..p.clone() })
}
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
};
+1 -2
View File
@@ -182,8 +182,7 @@ fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>)
};
let m = match viewport_of(s, &layout, vp) {
Some(v) => {
let k = v.view_height / v.height;
let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center));
let to_paper = v.model_to_paper_matrix();
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
}
None => Mat3::IDENTITY,
+1
View File
@@ -411,6 +411,7 @@ fn chspace_json_and_command_line() {
layer_colors: Vec::new(),
clip: None,
on: true,
twist: 0.0,
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
+104 -3
View File
@@ -26,7 +26,10 @@ pub fn specs() -> Vec<CommandSpec> {
.params("{mode: point|distance|angle|intersection, points: [[x,y],...]} → {value} | {point}")
.noundo()
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params(
"{find, replace?, matchCase?: bool, wholeWord?: bool, types?: [\"Text\", \"MText\", ...], handles?: [...]} → \
{matches: [{handle, type, count, snippet, hit, hitLen}], total, message}",
),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
.params("{mode: distance|radius|angle|area|volume, ...}")
.noundo()
@@ -194,6 +197,46 @@ fn run_quickcalc_pick(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------- FIND ----------------
/// Characters of context kept on each side of a hit in the Find dialog's result list.
const SNIPPET_CONTEXT: usize = 18;
/// The text around the first hit of `find` in `text`, for the result list: at most
/// [`SNIPPET_CONTEXT`] characters before the hit and as many after it, the hit's offset in the
/// snippet (in characters) and its length (in characters).
///
/// The offset and length are character counts, not bytes: the dialog slices the snippet by
/// character so a Chinese or Vietnamese drawing never splits a glyph. Lower-casing can change a
/// string's character count in rare scripts, so every slice goes through `get` and a snippet that
/// cannot be cut is reported as absent rather than guessed at.
fn snippet_at(text: &str, find: &str, case: bool, whole: bool) -> Option<(String, usize, usize)> {
if find.is_empty() {
return None;
}
let (hay, needle) = if case { (text.to_string(), find.to_string()) } else { (text.to_lowercase(), find.to_lowercase()) };
let mut at = 0;
let pos = loop {
let rest = hay.get(at..)?;
let p = rest.find(&needle)? + at;
let end = p + needle.len();
let boundary = |i: usize| hay.get(..i).and_then(|s| s.chars().last()).is_none_or(|c| !c.is_alphanumeric());
let after = hay.get(end..).and_then(|s| s.chars().next()).is_none_or(|c| !c.is_alphanumeric());
if !whole || (boundary(p) && after) {
break p;
}
at = end.max(p + 1);
if at >= hay.len() {
return None;
}
};
let start = hay.get(..pos)?.chars().count();
let hit_len = needle.chars().count();
let chars: Vec<char> = text.chars().collect();
let from = start.saturating_sub(SNIPPET_CONTEXT);
let to = start.saturating_add(hit_len).saturating_add(SNIPPET_CONTEXT).min(chars.len());
let snippet: String = chars.get(from..to)?.iter().collect();
Some((snippet, start.saturating_sub(from), hit_len))
}
fn replace_in(text: &str, find: &str, rep: &str, case: bool, whole: bool) -> (String, usize) {
if find.is_empty() {
return (text.to_string(), 0);
@@ -228,21 +271,40 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
let rep = str_param(p, "replace").map(str::to_string);
let case = bool_or(p, "matchCase", false);
let whole = bool_or(p, "wholeWord", false);
// Only the named object types, when the dialog narrows the search; `type_name()` is the same
// name the result list shows, so a caller never has to guess the spelling.
let types: Option<Vec<String>> =
p.get("types").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect());
let space = s.space();
let d = s.doc()?;
let hs: Vec<Handle> = d.space(&space).map(|st| st.handles()).unwrap_or_default();
// The whole current space by default; the Find window narrows it to the objects its result
// list already holds, so Replace All touches exactly what the user can see.
let hs: Vec<Handle> = match p.get("handles").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect(),
None => d.space(&space).map(|st| st.handles()).unwrap_or_default(),
};
let mut found: Vec<Value> = Vec::new();
let mut total = 0;
let mut edits: Vec<(Handle, EntityKind)> = Vec::new();
for h in hs {
let Some(e) = d.entity(h) else { continue };
if let Some(names) = &types
&& !names.iter().any(|n| n == &e.kind.type_name().to_ascii_lowercase())
{
continue;
}
let mut k = e.kind.clone();
let mut hits = 0;
// The first hit's surroundings, taken before a replacement rewrites the text.
let mut snippet: Option<(String, usize, usize)> = None;
let r = rep.as_deref().unwrap_or(&find);
let mut apply = |t: &mut String| {
let (nt, n) = replace_in(t, &find, r, case, whole);
if n > 0 {
hits += n;
if snippet.is_none() {
snippet = snippet_at(t, &find, case, whole);
}
if rep.is_some() {
*t = nt;
}
@@ -263,7 +325,15 @@ fn run_find(s: &mut Session, p: &Value) -> Result<Value> {
}
if hits > 0 {
total += hits;
found.push(json!({ "handle": h.hex(), "type": e.kind.type_name(), "count": hits }));
let (text, at, len) = snippet.unwrap_or_default();
found.push(json!({
"handle": h.hex(),
"type": e.kind.type_name(),
"count": hits,
"snippet": text,
"hit": at,
"hitLen": len,
}));
if rep.is_some() {
edits.push((h, k));
}
@@ -577,4 +647,35 @@ mod tests {
assert_eq!(replace_in("aaa", "a", "b", true, false), ("bbb".to_string(), 3));
assert_eq!(replace_in("x", "", "y", true, false).1, 0);
}
/// `snippet_at` returns the whole text when the hit fills it, with the hit at offset 0.
#[test]
fn snippet_reports_the_hit_offset() {
assert_eq!(snippet_at("BOLT-01", "BOLT-01", true, false), Some(("BOLT-01".to_string(), 0, 7)));
}
/// Matching ignores case but the snippet keeps the text as it is written, so the result list
/// shows the drawing's own spelling.
#[test]
fn snippet_keeps_the_original_case() {
assert_eq!(snippet_at("法兰 bolt-01 安装孔", "BOLT-01", false, false), Some(("法兰 bolt-01 安装孔".to_string(), 3, 7)));
}
/// A long text keeps [`SNIPPET_CONTEXT`] characters on each side of the hit, counted in
/// characters so a CJK hit is never split.
#[test]
fn snippet_is_windowed_in_characters() {
let text = format!("{}命中{}", "前".repeat(30), "后".repeat(30));
let (snippet, hit, len) = snippet_at(&text, "命中", true, false).expect("a hit");
assert_eq!((hit, len), (SNIPPET_CONTEXT, 2));
assert_eq!(snippet.chars().count(), SNIPPET_CONTEXT * 2 + 2);
}
/// A whole-word search skips an embedded hit, and text without a hit has no snippet.
#[test]
fn snippet_respects_whole_word() {
assert_eq!(snippet_at("BOLTS", "BOLT", true, true), None);
assert_eq!(snippet_at("a BOLT b", "BOLT", true, true).map(|s| s.1), Some(2));
assert_eq!(snippet_at("nothing", "BOLT", true, false), None);
}
}
+28 -4
View File
@@ -1,6 +1,8 @@
//! The CAL / QuickCalc evaluator: numbers and points (`[x,y]`, `[d<a]`), arithmetic and vector
//! arithmetic, numeric and geometric functions; unit conversion; values picked from the drawing.
use std::collections::HashMap;
use cadcraft_geom::Vec2;
use serde_json::{Map, Value, json};
@@ -67,6 +69,9 @@ struct Parser<'a> {
s: &'a [u8],
i: usize,
vars: &'a Map<String, Value>,
/// `vars` by ASCII-lower-cased name (the first of names that differ only in case), so a
/// case-insensitive lookup is not a scan of the map per reference.
folded: HashMap<String, &'a Value>,
depth: usize,
unary_depth: usize,
}
@@ -230,7 +235,7 @@ impl Parser<'_> {
match name.as_str() {
"pi" => Ok(Val::Num(std::f64::consts::PI)),
"e" => Ok(Val::Num(std::f64::consts::E)),
_ => var(self.vars, &name).ok_or_else(|| format!("unknown name `{name}`")),
_ => var(self.vars, &self.folded, &name).ok_or_else(|| format!("unknown name `{name}`")),
}
}
_ => Err("expected a number".into()),
@@ -239,8 +244,8 @@ impl Parser<'_> {
}
/// A variable: a number, or a point as `[x, y]`. Names are case-insensitive.
fn var(vars: &Map<String, Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| vars.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?;
fn var(vars: &Map<String, Value>, folded: &HashMap<String, &Value>, name: &str) -> Option<Val> {
let v = vars.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()).copied())?;
if let Some(n) = v.as_f64() {
return Some(Val::Num(n));
}
@@ -411,7 +416,11 @@ pub fn eval_val(expr: &str, vars: &Map<String, Value>) -> Result<Val, String> {
if expr.len() > MAX_LEN {
return Err("expression too long".into());
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, depth: 0, unary_depth: 0 };
let mut folded = HashMap::with_capacity(vars.len());
for (k, v) in vars {
folded.entry(k.to_ascii_lowercase()).or_insert(v);
}
let mut p = Parser { s: expr.as_bytes(), i: 0, vars, folded, depth: 0, unary_depth: 0 };
let v = p.expr()?;
if p.peek().is_some() {
return Err(format!("unexpected `{}`", expr.get(p.i..).unwrap_or("")));
@@ -587,6 +596,21 @@ mod tests {
eval_val(s, &Map::new())
}
/// Variables are found without regard to case; an exact match wins over a case variant.
/// (Names are read lower-cased, so `X` finds `x` too.)
#[test]
fn variables_ignore_case_and_prefer_exact_names() {
let mut vars = Map::new();
for i in 0..10_000 {
vars.insert(format!("v{i}"), json!(i));
}
vars.insert("X".into(), json!(1));
vars.insert("x".into(), json!(2));
assert!(matches!(eval_val("V9999 + 1", &vars), Ok(Val::Num(n)) if n == 10_000.0));
assert!(matches!(eval_val("x", &vars), Ok(Val::Num(n)) if n == 2.0));
assert!(eval_val("nope", &vars).is_err());
}
#[test]
fn points_and_vectors() {
assert_eq!(ev("[1,2]+[3,4]*2"), Ok(Val::Pt(Vec2::new(7.0, 10.0))));
+50 -18
View File
@@ -1,5 +1,5 @@
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
//! switching.
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), the view
//! twist (DVIEW TWist), layout switching.
use cadcraft_doc::{Space, entity_bounds};
use cadcraft_geom::{Bounds2, Vec2};
@@ -10,6 +10,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, ok, point_param, point_r
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
mod dview;
mod named_views;
mod vports;
@@ -95,10 +96,13 @@ pub fn specs() -> Vec<CommandSpec> {
.alias(&["r"])
.noundo()
.transparent(),
CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(),
dview::spec(),
CommandSpec::new("view.set", "Set View", run_view_set)
.params("{center: [x,y], height, twist?: degrees (default: keep the current twist; in a layout viewport it is the viewport's twist, an undoable edit; not in Paper space)}")
.noundo(),
CommandSpec::new("view.get", "Get View", |s, _| {
let v = s.state()?.view();
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "twist": v.twist.to_degrees(), "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
})
.noundo(),
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
@@ -120,8 +124,11 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> {
let (w, h) = s.viewport_px;
let st = s.state_mut()?;
let aspect = st.zoom_aspect(w / h.max(1.0));
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
st.set_zoom_frame(View { center: b.center(), height });
// A twisted view fits the box the bounds make on screen.
let twist = st.zoom_frame().twist;
let (center, bw, bh) = View::twisted_box(b, twist);
let height = bh.max(bw / aspect.max(1e-6)).max(1e-9);
st.set_zoom_frame(View { center, height, twist });
Ok(())
}
@@ -150,18 +157,18 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
let f = if mode == "in" { 2.0 } else { 0.5 };
let st = s.state_mut()?;
let v = st.zoom_frame();
st.set_zoom_frame(View { center: v.center, height: v.height / f });
st.set_zoom_frame(View { height: v.height / f, ..v });
}
"center" | "c" | "dynamic" | "d" => {
let c = point_req("zoom", p, "center")?;
let cur = s.state()?.zoom_frame().height;
let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) };
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
let cur = s.state()?.zoom_frame();
let h = if p.get("factor").is_some() { factor_height(s, p, cur.height)? } else { f64_or(p, "height", cur.height) };
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9), ..cur });
}
"scale" | "s" => {
let v = s.state()?.zoom_frame();
let height = factor_height(s, p, v.height)?;
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
s.state_mut()?.set_zoom_frame(View { height, ..v });
}
"realtime" => {
let v = s.state()?.zoom_frame();
@@ -169,7 +176,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
if f <= 0.0 {
return Err(bad("zoom", "factor must be positive"));
}
s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
s.state_mut()?.set_zoom_frame(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
}
"object" | "o" => {
let hs = targets(s, p)?;
@@ -242,7 +249,8 @@ fn pan_frac(s: &mut Session, f: Vec2) -> Result<Value> {
let st = s.state_mut()?;
let v = st.view();
let ww = v.height * w / h.max(1.0);
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
// Screen directions: in a twisted view the drawing is turned by the twist.
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height).rotate(-v.twist), ..v });
ok()
}
@@ -258,7 +266,7 @@ fn run_pan(s: &mut Session, p: &Value) -> Result<Value> {
let b = point_req("pan", p, "to")?;
v.center - (b - a)
};
st.set_view(View { center: c, height: v.height });
st.set_view(View { center: c, ..v });
ok()
}
@@ -268,9 +276,33 @@ fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
if h <= 0.0 {
return Err(bad("view.set", "height must be positive"));
}
if !s.state_mut()?.set_view(View { center: c, height: h }) {
let twist = match p.get("twist") {
Some(t) => t.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad("view.set", "twist must be a number of degrees"))?.to_radians(),
None => s.state()?.view().twist,
};
// As DVIEW TWist: the Model tab's view or the active layout viewport turns; a layout sheet
// (paper space) is never twisted.
let target = match dview::target(s) {
Ok(t) => Some(t),
Err(_) if twist == 0.0 => None,
Err(e) => return Err(e),
};
let before = (s.state()?.doc.clone(), s.state()?.selection.clone());
let twist = match target {
Some(t @ dview::Target::Viewport(_)) => {
dview::set_twist(s, t, twist)?;
s.state()?.view().twist
}
Some(dview::Target::Model) => cadcraft_geom::norm_angle(twist),
None => 0.0,
};
if !s.state_mut()?.set_view(View { center: c, height: h, twist }) {
s.state_mut()?.doc = before.0;
return Err(bad("view.set", "the view is too large to show. Nothing was changed"));
}
if let Some(dview::Target::Viewport(h)) = target {
dview::record_viewport_edit(s, before, h)?;
}
ok()
}
@@ -294,7 +326,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15, twist: 0.0 });
}
s.touch();
ok()
@@ -441,7 +473,7 @@ impl Interactive for ZoomM {
if st.view_history.last() == Some(&b.screen) {
st.view_history.pop();
}
st.set_zoom_frame(View { center: b.center, height: b.height });
st.set_zoom_frame(View { center: b.center, height: b.height, twist: b.frame.twist });
return Ok(Step::Done);
}
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
@@ -565,7 +597,7 @@ impl Session {
let f = 2f64.powf((-dy / half).clamp(-16.0, 16.0));
let st = self.state_mut()?;
let v = st.view();
st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
st.set_view_quiet(View { height: (v.height / f).clamp(1e-9, 1e12), ..v });
self.touch();
Ok(())
}
+441
View File
@@ -0,0 +1,441 @@
//! DVIEW: the dynamic view, on CADCraft's 2D plan views.
//!
//! It starts with "Select objects or <use DVIEWBLOCK>:": the objects
//! picked are the preview set, and Enter with nothing picked previews the DVIEWBLOCK block (the
//! drawing's own block of that name, else a built-in sample house). While DVIEW runs only the
//! preview set is drawn ([`Interactive::display_only`]); the whole drawing is regenerated when it
//! ends. Then it repeats "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/
//! Off/Undo]:" until Enter. TWist, PAn, Zoom, Off and Undo work; the options that need a 3D
//! camera say they are not available yet and stay at the option prompt.
//!
//! DVIEW works on the Model tab's view, or on the active (double-clicked) layout viewport; on a
//! layout with no active viewport it is not available. A Model tab twist is a view change like
//! ZOOM (view history, not undo). A layout viewport's twist belongs to the viewport entity, so a
//! DVIEW that changed it is one undo step.
use std::sync::Arc;
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, Line, Space};
use cadcraft_geom::{Vec2, Vec3};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, number};
use super::super::{CommandSpec, bad, f64_req};
use crate::{Accept, DisplayOnly, EngineError, Input, Interactive, Prompt, Result, Session, Snapshot, Step, View};
/// What DVIEW (and a view twist asked for anywhere) says on a layout with no active viewport.
pub(super) const PAPER_SPACE: &str = "This command is not available in Paper space.";
/// The option prompt's keywords, in prompt order.
const OPTIONS: &[&str] = &["CAmera", "TArget", "Distance", "POints", "PAn", "Zoom", "TWist", "CLip", "Hide", "Off", "Undo"];
/// The block DVIEW previews when nothing is selected.
const DVIEWBLOCK: &str = "DVIEWBLOCK";
pub(super) fn spec() -> CommandSpec {
CommandSpec::new("dview", "DView", run_dview)
.alias(&["dv"])
.params("{twist: degrees counterclockwise} (DVIEW TWist: the Model tab's view twist, or the active layout viewport's, an undoable edit; not in Paper space)")
.noundo()
.interactive(|s| {
// Refused as it starts on a layout with no active viewport.
target(s)?;
Ok(Box::new(DviewM::default()))
})
}
/// The view DVIEW changes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Target {
/// The Model tab's view.
Model,
/// The active (MSPACE) layout viewport: its own view twist.
Viewport(Handle),
}
/// The view DVIEW changes: the active layout viewport, else the Model tab's view. A layout with
/// no active viewport has none.
pub(super) fn target(s: &Session) -> Result<Target> {
let st = s.state()?;
if let Some((h, _)) = st.active_viewport() {
return Ok(Target::Viewport(h));
}
if st.space == Space::Model {
return Ok(Target::Model);
}
Err(EngineError::Other(PAPER_SPACE.into()))
}
/// A twist kept as files hold it: [0, 2π).
fn normal(twist: f64) -> f64 {
cadcraft_geom::norm_angle(twist)
}
/// The active viewport's twist, if it is `h`.
fn viewport_twist(s: &Session, h: Handle) -> Option<f64> {
s.state().ok()?.active_viewport().filter(|(vh, _)| *vh == h).map(|(_, vp)| vp.view_twist())
}
/// Turn `target`'s view to `twist` radians. The Model tab's twist is a view change (view
/// history, like ZOOM); a viewport's is a change to the viewport entity, which the caller
/// records as an undo step ([`record_viewport_edit`]).
pub(super) fn set_twist(s: &mut Session, target: Target, twist: f64) -> Result<()> {
if !twist.is_finite() {
return Err(bad("dview", "the twist must be a finite angle"));
}
let twist = normal(twist);
match target {
Target::Model => {
let st = s.state_mut()?;
let v = st.view();
st.set_view(View { twist, ..v });
}
Target::Viewport(h) => {
if viewport_twist(s, h) == Some(twist) {
return Ok(());
}
// A locked viewport keeps its view, the twist with it.
if s.state()?.active_viewport().is_some_and(|(vh, vp)| vh == h && vp.locked) {
return Err(EngineError::Other("The viewport is locked.".into()));
}
let space = s.state()?.space.clone();
let d = s.doc_mut()?;
if let Some(store) = d.space_mut(&space) {
store.modify(h, |e| {
if let EntityKind::Viewport(vp) = &mut e.kind {
vp.twist = twist;
}
});
}
}
}
Ok(())
}
/// Record a layout viewport's twist change as one undo step, `before` being the drawing (and
/// selection) before it. Nothing when the twist did not change, and nothing inside a LISP routine
/// (its own single step holds it).
pub(super) fn record_viewport_edit(s: &mut Session, before: (Arc<cadcraft_doc::Drawing>, Vec<Handle>), h: Handle) -> Result<()> {
let old = before.0.entity(h).and_then(|e| match &e.kind {
EntityKind::Viewport(vp) => Some(vp.view_twist()),
_ => None,
});
if old == viewport_twist(s, h) || s.lisp_groups_undo() {
return Ok(());
}
let st = s.state_mut()?;
if !Arc::ptr_eq(&before.0, &st.doc) {
st.push_undo(Snapshot { label: "DView".into(), doc: before.0, selection: before.1 });
st.revision += 1;
}
Ok(())
}
/// The drawing and selection now, for [`record_viewport_edit`].
fn snapshot(s: &Session) -> Result<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)> {
let st = s.state()?;
Ok((st.doc.clone(), st.selection.clone()))
}
fn run_dview(s: &mut Session, p: &Value) -> Result<Value> {
let t = target(s)?;
let deg = f64_req("dview", p, "twist")?;
let before = snapshot(s)?;
set_twist(s, t, deg.to_radians())?;
if let Target::Viewport(h) = t {
record_viewport_edit(s, before, h)?;
}
let v = s.state()?.view();
Ok(json!({ "twist": v.twist.to_degrees() }))
}
/// The built-in DVIEWBLOCK: a house front in the unit square, origin at the lower-left corner
/// (the size and origin a DVIEWBLOCK of one's own is expected to have). CADCraft's own drawing.
fn sample_house() -> Vec<(Vec2, Vec2)> {
let p = Vec2::new;
let mut lines = Vec::new();
let mut poly = |pts: &[Vec2], closed: bool| {
for w in pts.windows(2) {
lines.push((w[0], w[1]));
}
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
lines.push((*a, *b));
}
};
// Walls.
poly(&[p(0.1, 0.0), p(0.9, 0.0), p(0.9, 0.6), p(0.1, 0.6)], true);
// Roof with eaves, and a chimney on its right slope.
poly(&[p(0.0, 0.55), p(0.5, 1.0), p(1.0, 0.55)], false);
poly(&[p(0.7, 0.82), p(0.7, 0.95), p(0.8, 0.95), p(0.8, 0.73)], false);
// Door.
poly(&[p(0.42, 0.0), p(0.42, 0.35), p(0.58, 0.35), p(0.58, 0.0)], false);
// Two windows with their bars.
for x in [0.17, 0.67] {
poly(&[p(x, 0.25), p(x + 0.16, 0.25), p(x + 0.16, 0.45), p(x, 0.45)], true);
poly(&[p(x + 0.08, 0.25), p(x + 0.08, 0.45)], false);
poly(&[p(x, 0.35), p(x + 0.16, 0.35)], false);
}
lines
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Stage {
#[default]
Select,
Option,
Twist,
PanBase,
PanSecond(Vec2),
Zoom,
}
/// DVIEW, interactive.
#[derive(Default)]
struct DviewM {
stage: Stage,
sel: SelectPhase,
/// The preview set; empty with `sample` set: the DVIEWBLOCK.
preview: Vec<Handle>,
/// Where the DVIEWBLOCK preview stands: its lower-left corner and size (drawing units), fixed
/// as the options start so that PAn and Zoom move the view, not the house.
sample: Option<(Vec2, f64)>,
/// The views before each DVIEW action, for Undo.
undo: Vec<View>,
/// What DVIEW works on, and the drawing as it started (a viewport's twist change is one
/// undo step).
target: Option<Target>,
before: Option<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)>,
}
impl DviewM {
fn target(&self, s: &Session) -> Result<Target> {
match self.target {
Some(t) => Ok(t),
None => target(s),
}
}
/// Start the options with this preview set (empty: the DVIEWBLOCK).
fn options(&mut self, s: &mut Session, picked: Vec<Handle>) -> Result<Step> {
// Objects picked at the prompt don't stay selected.
s.set_selection(Vec::new());
if picked.is_empty() {
let v = s.state()?.view();
let size = v.height * 0.5;
self.sample = Some((v.center - Vec2::new(size, size) * 0.5, size));
}
self.preview = picked;
self.stage = Stage::Option;
Ok(Step::Continue)
}
/// Show view `v` (centre, height and twist) on the target.
fn show(&self, s: &mut Session, v: View) -> Result<()> {
let t = self.target(s)?;
let before = s.state()?.doc.clone();
let twist = normal(v.twist);
if let Target::Viewport(_) = t {
// The twist is the viewport's; the view inside it then moves as asked.
set_twist(s, t, twist)?;
}
if !s.state_mut()?.set_view(View { twist, ..v }) {
s.state_mut()?.doc = before;
return Err(EngineError::Other("The view is too large to show.".into()));
}
Ok(())
}
/// Apply a view as one DVIEW action (Undo takes it back).
fn change(&mut self, s: &mut Session, f: impl FnOnce(View) -> View) -> Result<()> {
let v = s.state()?.view();
self.show(s, f(v))?;
self.undo.push(v);
Ok(())
}
/// The command ends: a viewport twist change becomes one undo step, and the whole drawing is
/// regenerated.
fn end(&mut self, s: &mut Session) -> Result<()> {
if let (Some(Target::Viewport(h)), Some(before)) = (self.target, self.before.take()) {
record_viewport_edit(s, before, h)?;
}
s.touch();
Ok(())
}
}
impl Interactive for DviewM {
fn name(&self) -> &'static str {
"DVIEW"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.target = Some(target(s)?);
self.before = Some(snapshot(s)?);
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.stage {
Stage::Select if self.sel.removing => self.sel.prompt(),
Stage::Select => Prompt::new("Select objects or", Accept::SELECT).default("use DVIEWBLOCK"),
Stage::Option => Prompt::new("Enter option", Accept::TEXT).kw(OPTIONS),
Stage::Twist => {
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
let cur = s.state().map(|st| st.view().twist).unwrap_or(0.0);
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
let cur = if s.angle_settings().clockwise { -cur } else { cur };
let center = s.state().map(|st| st.view().center).ok();
Prompt::new("Specify view twist angle", Accept::POINT_OR_NUMBER).default(crate::units::format_angle(cur, au, ap)).base_opt(center)
}
Stage::PanBase => Prompt::new("Specify displacement base point", Accept::POINT),
Stage::PanSecond(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
Stage::Zoom => Prompt::new("Specify zoom scale factor", Accept::NUMBER).default("1"),
}
}
fn cancelled(&mut self, s: &mut Session) {
if let Err(e) = self.end(s) {
s.echo(e.to_string());
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.stage {
Stage::Select => match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => self.options(s, Vec::new()),
SelOutcome::Done(hs) => self.options(s, hs),
},
Stage::Option => {
let word = match &i {
Input::Enter => {
self.end(s)?;
return Ok(Step::Done);
}
Input::Keyword(t) | Input::Text(t) => self.prompt(s).match_keyword(t),
_ => None,
};
match word.as_deref() {
Some("TWist") => self.stage = Stage::Twist,
Some("PAn") => self.stage = Stage::PanBase,
Some("Zoom") => self.stage = Stage::Zoom,
// Perspective is never on in a 2D view: there is nothing to turn off.
Some("Off") => {}
Some("Undo") => {
if let Some(v) = self.undo.pop() {
self.show(s, v)?;
}
}
// CAmera, TArget, Distance, POints, CLip and Hide need a 3D view.
Some(other) => s.echo(format!("DVIEW {other} is not available yet.")),
None => return Err(EngineError::Other("Invalid option keyword.".into())),
}
Ok(Step::Continue)
}
Stage::Twist => {
let twist = match &i {
Input::Enter => None,
Input::Point(p) => {
let c = s.state()?.view().center;
let d = *p - c;
if d.len() < 1e-12 {
return Err(EngineError::Other("Requires a valid angle.".into()));
}
Some(d.y.atan2(d.x))
}
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
Input::Text(t) | Input::Keyword(t) => {
Some(s.angle_settings().rotation(t).ok_or_else(|| EngineError::Other("Requires a valid angle.".into()))?)
}
_ => return Ok(Step::Continue),
};
if let Some(a) = twist {
self.change(s, |v| View { twist: normal(a), ..v })?;
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
Stage::PanBase => match i {
Input::Point(p) => {
self.stage = Stage::PanSecond(p);
Ok(Step::Continue)
}
Input::Enter => {
self.stage = Stage::Option;
Ok(Step::Continue)
}
_ => Err(EngineError::Other("Invalid point.".into())),
},
Stage::PanSecond(b) => match i {
Input::Point(p) => {
self.change(s, |v| View { center: v.center - (p - b), ..v })?;
self.stage = Stage::Option;
Ok(Step::Continue)
}
Input::Enter => {
self.stage = Stage::Option;
Ok(Step::Continue)
}
_ => Err(EngineError::Other("Invalid point.".into())),
},
Stage::Zoom => {
let f = match &i {
Input::Enter => None,
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
Input::Text(t) | Input::Keyword(t) => match number(t) {
Some(f) if f.is_finite() && f > 0.0 => Some(f),
_ => return Err(EngineError::Other("Requires a positive number.".into())),
},
_ => return Ok(Step::Continue),
};
if let Some(f) = f.filter(|f| *f != 1.0) {
self.change(s, |v| View { height: v.height / f, ..v })?;
}
self.stage = Stage::Option;
Ok(Step::Continue)
}
}
}
fn display_only(&self, s: &Session) -> Option<DisplayOnly> {
if self.stage == Stage::Select {
return None;
}
let mut extra = Vec::new();
if let Some((corner, size)) = self.sample {
let common = s.current_common();
let d = s.doc().ok()?;
if d.block(DVIEWBLOCK).is_some() {
// The drawing's own DVIEWBLOCK, defined in a 1 by 1 unit area.
let ins = Insert {
block: DVIEWBLOCK.into(),
insert: corner.to3(0.0),
scale: Vec3::new(size, size, size),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
extra.push(Entity { handle: Handle(0), common, kind: EntityKind::Insert(ins) });
} else {
extra.extend(sample_house().into_iter().map(|(a, b)| Entity {
handle: Handle(0),
common: common.clone(),
kind: EntityKind::Line(Line { a: (corner + a * size).to3(0.0), b: (corner + b * size).to3(0.0) }),
}));
}
}
Some(DisplayOnly { handles: self.preview.clone(), extra })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sample_house_fits_the_unit_square() {
let lines = sample_house();
assert!(lines.len() > 10);
for (a, b) in lines {
for q in [a, b] {
assert!((0.0..=1.0).contains(&q.x) && (0.0..=1.0).contains(&q.y), "{q:?}");
}
}
}
}
+19 -12
View File
@@ -110,15 +110,25 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
(Space::Paper(l), false) => Some(l.clone()),
_ => None,
};
// The view this saves: the viewport's model view inside an MSPACE viewport, else the screen.
let frame = if in_viewport { st.zoom_frame() } else { st.view() };
// The view twist goes with the view (paper space has none).
let twist = if layout.is_some() { 0.0 } else { frame.twist };
let (center, height, width) = match window {
Some(b) => {
if !(b.width() > 1e-12 && b.height() > 1e-12 && b.min.is_finite() && b.max.is_finite()) {
return Err(EngineError::Other("The window has no area.".into()));
}
(b.center(), b.height(), b.width())
// In a twisted view the window's box on screen is larger than its world box.
let (center, width, height) = crate::View::twisted_box(b, twist);
// Turning corners near the number limit can overflow: save no view that can't be restored.
if !(center.is_finite() && width.is_finite() && height.is_finite() && width > 1e-12 && height > 1e-12) {
return Err(EngineError::Other("The window is too large for a twisted view.".into()));
}
(center, height, width)
}
None => {
let f = if in_viewport { st.zoom_frame() } else { st.view() };
let f = frame;
let aspect = if in_viewport { st.zoom_aspect(w / h.max(1.0)) } else { w / h.max(1.0) };
(f.center, f.height, f.height * aspect)
}
@@ -139,7 +149,7 @@ fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) ->
}
d.views.retain(|v| !v.name.eq_ignore_ascii_case(old));
}
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, layout });
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist, layout });
let msg = if replaced.is_some() { format!("View \"{name}\" replaced.") } else { format!("View \"{name}\" saved.") };
Ok(json!({ "name": name, "center": [center.x, center.y], "height": height, "width": width, "message": msg }))
}
@@ -168,7 +178,7 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
let st = s.state_mut()?;
st.mspace = None;
let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9));
st.set_view(View { center: v.center, height });
st.set_view(View { center: v.center, height, twist: 0.0 });
}
None => {
// A model space view: into the active MSPACE viewport, else on the Model tab.
@@ -177,21 +187,18 @@ fn restore(s: &mut Session, name: &str) -> Result<Value> {
}
let (w, h) = s.viewport_px;
let st = s.state_mut()?;
// In an unlocked MSPACE viewport the saved twist turns the viewport (an edit of the
// viewport, recorded as the command's undo step).
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-9);
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
let twist = cadcraft_geom::norm_angle(v.twist);
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect), twist });
}
}
let mut msg = format!("View \"{}\" restored.", v.name);
if let Some(state) = &v.layer_state {
let d = s.doc_mut()?;
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
Some(saved) => {
for layer in saved.layers {
if let Some(l) = d.layer_mut(&layer.name) {
*l = layer;
}
}
}
Some(saved) => super::super::layer::restore_layers(d, saved.layers),
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
}
}
+5 -5
View File
@@ -104,7 +104,7 @@ fn rect(t: &VportTile) -> Rect {
}
fn view_of(t: &VportTile) -> View {
View { center: t.center, height: t.height }
View { center: t.center, height: t.height, twist: if t.twist.is_finite() { t.twist } else { 0.0 } }
}
fn active_config(d: &Drawing) -> Option<&VportConfig> {
@@ -117,7 +117,7 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
let now = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v).unwrap_or_default();
let Some(cfg) = active_config(&st.doc) else {
let aspect = px.0 / px.1.max(1.0);
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0);
return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect, twist: now.twist }], 0);
};
let mut tiles = cfg.tiles.clone();
let synced =
@@ -125,11 +125,11 @@ fn live(st: &DocState, px: (f64, f64)) -> (Vec<VportTile>, usize) {
let active = if synced { st.tiles.active.min(tiles.len() - 1) } else { 0 };
if synced {
for (t, v) in tiles.iter_mut().zip(&st.tiles.views) {
(t.center, t.height) = (v.center, v.height);
(t.center, t.height, t.twist) = (v.center, v.height, v.twist);
}
}
if let Some(t) = tiles.get_mut(active) {
(t.center, t.height) = (now.center, now.height);
(t.center, t.height, t.twist) = (now.center, now.height, now.twist);
}
// Screen aspects from the current tile's size in pixels.
if let Some(cur) = tiles.get(active).copied() {
@@ -171,7 +171,7 @@ fn summary(s: &Session) -> Result<Value> {
let rows: Vec<Value> = tiles
.iter()
.enumerate()
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "current": i == active }))
.map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "twist": t.twist.to_degrees(), "current": i == active }))
.collect();
Ok(json!({ "tiles": rows }))
}
+46 -9
View File
@@ -1,5 +1,5 @@
use super::view::{ModelTiles, View, usable_view};
use super::{Arc, Drawing, Handle, Space, blockedit, undoctl};
use super::{Arc, Drawing, Handle, Space, Vec2, blockedit, undoctl};
#[derive(Clone, Debug)]
pub struct Snapshot {
@@ -80,6 +80,7 @@ impl DocState {
pub(crate) fn swap_in(&mut self, snap: Snapshot) -> Snapshot {
let cur = Snapshot { label: snap.label, doc: std::mem::replace(&mut self.doc, snap.doc), selection: std::mem::take(&mut self.selection) };
self.follow_layout(&cur.doc);
self.reconcile_untiled_twist();
self.revision += 1;
cur
}
@@ -135,7 +136,8 @@ impl DocState {
match self.active_viewport() {
Some((_, vp)) => {
let k = vp.view_height / vp.height;
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
// The viewport's twist turns the model view inside it.
View { center: vp.paper_to_model(pv.center), height: pv.height * k, twist: vp.view_twist() }
}
None => pv,
}
@@ -150,16 +152,18 @@ impl DocState {
if vp.locked {
// A locked viewport keeps its scale: pan/zoom the sheet instead.
let k0 = vp.view_height / vp.height;
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
let paper = View { center: pc, height: v.height / k0 };
let pc = vp.model_to_paper(v.center);
let paper = View { center: pc, height: v.height / k0, twist: pv.twist };
if !usable_view(&paper) {
return false;
}
self.put_paper_view(paper);
} else {
let center = v.center - (pv.center - vp.center.xy()) * k;
// `v`'s twist is the one the viewport shows (a non-finite one keeps its own).
let twist = if v.twist.is_finite() { v.twist } else { vp.view_twist() };
let center = v.center - ((pv.center - vp.center.xy()) * k).rotate(-twist);
let height = vp.height * k;
if !usable_view(&View { center, height }) {
if !usable_view(&View { center, height, twist }) {
return false;
}
let doc = Arc::make_mut(&mut self.doc);
@@ -168,6 +172,7 @@ impl DocState {
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
x.view_center = center;
x.view_height = height;
x.twist = twist;
}
});
}
@@ -182,6 +187,36 @@ impl DocState {
Some((_, slot)) => *slot = v,
None => self.views.push((self.space.clone(), v)),
}
if self.space == Space::Model {
self.keep_untiled_twist(v);
}
}
/// An untiled Model tab keeps its view twist in the drawing as a lone `*Active` VPORT tile
/// (written as that record's group 51 and read back), so it survives save and reopen. Only a
/// change of twist touches the drawing; an untwisted view keeps no record.
/// After undo or redo has put back another drawing: its untiled-twist record follows the
/// Model tab's live view again (views are not part of the drawing's undo).
pub(crate) fn reconcile_untiled_twist(&mut self) {
if let Some(v) = self.views.iter().find(|(s, _)| *s == Space::Model).map(|(_, v)| *v) {
self.keep_untiled_twist(v);
}
}
fn keep_untiled_twist(&mut self, v: View) {
use cadcraft_doc::{VportConfig, VportTile};
let twist = cadcraft_geom::norm_angle(v.twist);
let active = self.doc.vports.iter().find(|c| c.is_active());
if active.is_some_and(|c| c.tiles.len() > 1) {
return;
}
if active.and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist) == twist {
return;
}
let d = Arc::make_mut(&mut self.doc);
d.vports.retain(|c| !c.is_active());
if twist != 0.0 {
let tile = VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: v.center, height: v.height, aspect: 1.0, twist };
d.vports.insert(0, VportConfig { name: VportConfig::ACTIVE.into(), tiles: vec![tile] });
}
}
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
@@ -210,7 +245,7 @@ impl DocState {
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
pub fn zoom_frame(&self) -> View {
match self.zoom_viewport() {
Some(v) => View { center: v.view_center, height: v.view_height },
Some(v) => View { center: v.view_center, height: v.view_height, twist: v.view_twist() },
None => self.view(),
}
}
@@ -225,7 +260,9 @@ impl DocState {
Some(vp) => {
let pv = self.paper_view();
let k = f.height / vp.height;
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
// The frame's twist turns the viewport (a non-finite one keeps the viewport's).
let twist = if f.twist.is_finite() { f.twist } else { vp.view_twist() };
View { center: f.center + ((pv.center - vp.center.xy()) * k).rotate(-twist), height: pv.height * k, twist }
}
None => f,
};
@@ -233,7 +270,7 @@ impl DocState {
}
/// Set without recording view history (realtime pan/zoom frames).
pub fn set_view_quiet(&mut self, v: View) {
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
if !usable_view(&v) {
return;
}
self.store_view(v);
+17
View File
@@ -66,6 +66,14 @@ impl Interactive for Guarded {
fn begin(&mut self, s: &mut Session) -> Result<Step> {
catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p)))
}
fn cancelled(&mut self, s: &mut Session) {
if self.broken.get().is_none()
&& let Err(p) = catch_unwind(AssertUnwindSafe(|| self.inner.cancelled(s)))
{
let e = self.internal(&*p);
s.echo(e.to_string());
}
}
fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {
if self.broken.get().is_some() {
return Vec::new();
@@ -102,6 +110,15 @@ impl Interactive for Guarded {
None
})
}
fn display_only(&self, s: &Session) -> Option<crate::prompt::DisplayOnly> {
if self.broken.get().is_some() {
return None;
}
catch_unwind(AssertUnwindSafe(|| self.inner.display_only(s))).unwrap_or_else(|_| {
self.broken.set(Some("panic in preview"));
None
})
}
}
#[cfg(test)]
+2 -1
View File
@@ -27,6 +27,7 @@ pub mod mtext_edit;
pub mod pointmod;
pub mod prompt;
mod recent;
pub mod render_look;
pub mod sample;
pub mod select;
pub mod settings_dir;
@@ -49,7 +50,7 @@ pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
pub use prompt::{Accept, DisplayOnly, Drag, Input, Interactive, Picker, Prompt, Step};
mod doc_state;
mod error;
+21 -1
View File
@@ -31,7 +31,7 @@ impl Accept {
/// [Truecolor/COlorbook] <BYLAYER>:". A prompt asking for something else besides its keywords has
/// it: "Enter block name or [?]:", "Specify insertion base point or [Basepoint/…]:". Read from
/// AutoCAD's command line over measured runs, where no prompt was written both ways.
const NO_OR: [&str; 27] = [
const NO_OR: [&str; 28] = [
"Bring to front",
"Calculate rectangle dimensions based on",
"Choose mode for selected object conversion",
@@ -47,6 +47,7 @@ const NO_OR: [&str; 27] = [
"Enter leader format option",
"Enter line spacing type",
"Enter object ordering option",
"Enter option",
"Enter polyline linetype generation option",
"Enter property to change",
"Enter the type of array",
@@ -266,6 +267,9 @@ pub trait Interactive: Send {
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
Ok(Step::Continue)
}
/// Called when the command is cancelled from outside (Esc, or another command starting),
/// before it finishes: what it records on every exit path.
fn cancelled(&mut self, _s: &mut Session) {}
/// Rubber-band geometry for the cursor position.
fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {
Vec::new()
@@ -287,6 +291,22 @@ pub trait Interactive: Send {
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
None
}
/// What the drawing area shows in place of the drawing while the command runs (DVIEW draws
/// only its preview set); `None` shows the drawing.
fn display_only(&self, _s: &Session) -> Option<DisplayOnly> {
None
}
}
/// See [`Interactive::display_only`]: the only things drawn in the current view while a command
/// runs. The whole drawing is drawn again when it ends.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DisplayOnly {
/// Objects of the drawing, drawn as they are.
pub handles: Vec<Handle>,
/// Geometry that is not in the drawing (DVIEW's sample house, or an insert of the drawing's
/// DVIEWBLOCK block), with its own properties.
pub extra: Vec<cadcraft_doc::Entity>,
}
/// See [`Interactive::drag`].
+196
View File
@@ -0,0 +1,196 @@
//! How a render request draws: its `background` and `plot_style` (#803). The app's control
//! channel and the headless MCP backend both answer `ui.render`, and both read these here.
//!
//! Colour 7 (white/black) follows the background, as on a plot: the rasteriser draws it white on
//! a dark background and black on a light one ([`cadcraft_color::display_rgb`]).
//!
//! A render with a plot style table draws on white unless `background` says otherwise, as a plot
//! prints on paper: the tables that change colours print colour 7 (and, in `monochrome.ctb`,
//! every colour) black, which can hardly be seen on the dark canvas.
//!
//! `plot_style: "none"` (or `""`) draws every object with its own properties, also in a layout
//! whose page setup has "Display plot styles" on; without `plot_style` such a layout draws with
//! its own table, as on screen.
use std::sync::Arc;
use cadcraft_color::Rgb;
use cadcraft_doc::{Drawing, PlotStyleTable};
use cadcraft_render::raster::RasterOptions;
use serde_json::Value;
/// The background a render draws on without `background`: the dark canvas.
pub const DARK: Rgb = RasterOptions::DARK_BACKGROUND;
/// The background of `"white"` and `"paper"`.
pub const WHITE: Rgb = Rgb(255, 255, 255);
/// What `background` accepts, for the error message and the docs.
pub const BACKGROUNDS: &str = "\"dark\" (the default without a plot style), \"white\" (the default with one), \"paper\" or \"#rrggbb\"";
/// Most table names an unknown `plot_style` error lists from the drawing's own tables (the
/// built-in ones are always listed).
const MAX_LISTED_TABLES: usize = 10;
/// Longest part of a refused value repeated in its error message, in characters.
const MAX_ECHO_CHARS: usize = 40;
/// The look of a render: what it draws on and with which plot style table.
#[derive(Clone, Debug)]
pub struct RenderLook {
pub background: Rgb,
/// `None` = draw with the objects' own properties (or, in a layout showing plot styles, its
/// page setup's table). `plot_style: "none"` is a table that keeps every property, so such a
/// layout's table doesn't apply either.
pub plot_style_table: Option<Arc<PlotStyleTable>>,
}
impl Default for RenderLook {
fn default() -> Self {
RenderLook { background: DARK, plot_style_table: None }
}
}
impl RenderLook {
/// `background` and `plot_style` of a request's params for drawing `d`; absent or `null`
/// keeps the default. `Err` says what is accepted.
pub fn from_params(d: &Drawing, p: &Value) -> Result<RenderLook, String> {
let text = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.as_str())),
Some(_) => Err(format!("`{key}` must be a string")),
};
let mut look = RenderLook::default();
if let Some(name) = text("plot_style")? {
look.plot_style_table = Some(Arc::new(match plot_style(d, name)? {
Some(t) => {
// Plotted on paper, unless `background` says otherwise.
look.background = WHITE;
t
}
None => object_properties(),
}));
}
if let Some(b) = text("background")? {
look.background = background(b).ok_or_else(|| format!("unknown background `{}`: use {BACKGROUNDS}", echo(b)))?;
}
Ok(look)
}
/// The raster options of a render with this look.
pub fn raster(&self) -> RasterOptions {
RasterOptions { background: self.background, ..RasterOptions::default() }
}
}
/// The colour of a `background` value (any case): `dark`, `white`, `paper` or `#rrggbb`.
pub fn background(v: &str) -> Option<Rgb> {
let v = v.trim();
match v.to_ascii_lowercase().as_str() {
"dark" => Some(DARK),
"white" | "paper" => Some(WHITE),
hex if hex.starts_with('#') => Rgb::parse_hex(hex),
_ => None,
}
}
/// The plot style table `name` names: one the drawing holds, or a built-in one (`.ctb` may be
/// left out); `none` or an empty name is no table. `Err` lists the tables there are.
fn plot_style(d: &Drawing, name: &str) -> Result<Option<PlotStyleTable>, String> {
if cadcraft_doc::no_plot_style_table(name) {
return Ok(None);
}
cadcraft_doc::plot_style_table(d, name.trim())
.map(Some)
.ok_or_else(|| format!("unknown plot style table `{}`: use none or one of {}", echo(name), table_list(d)))
}
/// The tables a drawing can use, for an error message: the built-in ones, then at most
/// [`MAX_LISTED_TABLES`] of the drawing's own and how many more there are.
fn table_list(d: &Drawing) -> String {
let builtin = |n: &String| cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().any(|b| b.eq_ignore_ascii_case(n));
let names = cadcraft_doc::plot_style_table_names(d);
let own: Vec<String> = names.iter().filter(|n| !builtin(n)).map(|n| echo(n)).collect();
let mut list: Vec<String> = cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().map(|b| (*b).to_string()).collect();
list.extend(own.iter().take(MAX_LISTED_TABLES).cloned());
let mut text = list.join(", ");
if own.len() > MAX_LISTED_TABLES {
text.push_str(&format!(" and {} more of the drawing's", own.len() - MAX_LISTED_TABLES));
}
text
}
/// A table that prints every object with its own properties: what `plot_style: "none"` draws
/// with, so a layout's own table doesn't apply either.
fn object_properties() -> PlotStyleTable {
PlotStyleTable::color_dependent("None", "Object properties", |_| None)
}
/// The start of a refused value, to repeat in its error message.
fn echo(v: &str) -> String {
v.chars().take(MAX_ECHO_CHARS).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn backgrounds() {
assert_eq!(background("dark"), Some(DARK));
assert_eq!(DARK, RasterOptions::default().background, "the default is the rasteriser's canvas");
assert_eq!(background(" Paper "), Some(WHITE));
assert_eq!(background("WHITE"), Some(WHITE));
assert_eq!(background("#336699"), Some(Rgb(0x33, 0x66, 0x99)));
for bad in ["", "purple", "336699", "#12345", "#1234567", "##336699", "#33669g"] {
assert_eq!(background(bad), None, "{bad}");
}
}
#[test]
fn from_params() {
let d = Drawing::new_imperial();
let look = RenderLook::from_params(&d, &json!({})).unwrap();
assert_eq!(look.background, DARK);
assert!(look.plot_style_table.is_none());
// "none" keeps the dark canvas and draws every object with its own properties, also in a
// layout that shows its own plot styles.
for none in ["none", "", " None "] {
let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": none})).unwrap();
assert_eq!(look.background, DARK, "{none:?}");
let t = look.plot_style_table.unwrap();
assert!(t.is_color_dependent() && t.styles.iter().all(|s| s.keeps_color() && s.lineweight.is_none()), "{none:?}");
}
let look = RenderLook::from_params(&d, &json!({"background": "white", "plot_style": "Monochrome"})).unwrap();
assert_eq!(look.raster().background, WHITE);
assert_eq!(look.plot_style_table.map(|t| t.name.clone()).as_deref(), Some("monochrome.ctb"));
// A plot style plots on paper unless `background` says otherwise.
for table in ["monochrome.ctb", "grayscale.ctb", "default.stb"] {
assert_eq!(RenderLook::from_params(&d, &json!({"plot_style": table})).unwrap().background, WHITE, "{table}");
let look = RenderLook::from_params(&d, &json!({"plot_style": table, "background": "dark"})).unwrap();
assert_eq!(look.background, DARK, "{table}");
}
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb") && e.contains("none"), "{e}");
let e = RenderLook::from_params(&d, &json!({"background": "x".repeat(10_000)})).unwrap_err();
assert!(e.len() < 200 && e.contains("#rrggbb"), "{e}");
assert!(RenderLook::from_params(&d, &json!({"background": 1})).is_err());
assert!(RenderLook::from_params(&d, &json!({"plot_style": {}})).is_err());
}
/// An unknown plot style lists the built-in tables and only some of the drawing's own.
#[test]
fn unknown_plot_style_lists_a_few_tables() {
let mut d = Drawing::new_imperial();
for i in 0..500 {
d.plot_style_tables.push(PlotStyleTable::color_dependent(&format!("{}{i}.ctb", "own".repeat(30)), "", |_| None));
}
let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err();
for b in cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES {
assert!(e.contains(b), "{b}: {e}");
}
assert!(e.contains("and 490 more"), "{e}");
assert!(e.len() < 1000, "{} bytes: {e}", e.len());
assert!(RenderLook::from_params(&d, &json!({"plot_style": d.plot_style_tables[7].name.clone()})).is_ok());
}
}
+1
View File
@@ -527,6 +527,7 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
(d, h)
+8 -6
View File
@@ -6,7 +6,7 @@ impl Session {
self.untitled_counter += 1;
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0, twist: 0.0 } } else { View::default() });
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
@@ -18,11 +18,13 @@ impl Session {
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
let mut st = DocState::new(d, title, path);
let ext = st.doc.extents(&Space::Model);
if !ext.is_empty() {
st.set_view_quiet(View {
center: ext.center(),
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
});
// The current tile (the first), or the lone untiled view, keeps its saved twist.
let twist = cadcraft_geom::norm_angle(st.doc.vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map_or(0.0, |t| t.twist));
if ext.is_empty() {
st.set_view_quiet(View { twist, ..st.view() });
} else {
let (center, w, h) = View::twisted_box(ext, twist);
st.set_view_quiet(View { center, height: (h.max(w * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6), twist });
}
self.end_running();
self.docs.push(st);
+16 -3
View File
@@ -1,3 +1,5 @@
use crate::DisplayOnly;
use super::{
Arc, Bounds2, Drag, Drawing, EngineError, Entity, Handle, Input, PendingWindow, Prompt, Result, Running, Session, Snapshot, Step, Value, Vec2,
assoc, cmd, find_command, guard, pointmod, select, snap, sysvars,
@@ -199,7 +201,7 @@ impl Session {
Input::Point(p) => {
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
if let Some(pw) = self.pending_window.take() {
let crossing = p.x < pw.corner.x;
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
@@ -279,7 +281,10 @@ impl Session {
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
let Some(mut run) = self.running.take() else { return Ok(()) };
let step = match input {
Some(Input::Cancel) => Ok(Step::Cancel),
Some(Input::Cancel) => {
run.machine.cancelled(self);
Ok(Step::Cancel)
}
Some(i) => {
if let Input::Point(p) = &i {
self.last_point = *p;
@@ -438,7 +443,8 @@ impl Session {
}
if self.running.is_some() {
if self.suspended.is_some() {
if let Some(nested) = self.running.take() {
if let Some(mut nested) = self.running.take() {
nested.machine.cancelled(self);
self.finish(nested, true);
}
self.running = self.suspended.take();
@@ -523,6 +529,13 @@ impl Session {
out
}
/// What the drawing area shows in place of the drawing while a command runs (DVIEW's preview
/// set, see [`Interactive::display_only`]), also while a transparent command interrupts it.
/// `None`: the drawing.
pub fn display_only(&self) -> Option<DisplayOnly> {
[&self.running, &self.suspended].into_iter().flatten().find_map(|r| r.machine.display_only(self))
}
/// The active command's preview as a transform of existing objects (see
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
+8 -1
View File
@@ -16,6 +16,13 @@ impl Session {
true
}
/// Whether a selection window from `corner` to `p` is a crossing window: it was dragged
/// leftwards on screen (in a twisted view, not along the drawing's X axis).
pub fn window_is_crossing(&self, corner: Vec2, p: Vec2) -> bool {
let twist = self.state().map_or(0.0, |st| st.view().twist);
(p - corner).rotate(twist).x < 0.0
}
/// A click with no command running: pick/toggle objects or start a selection window.
///
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
@@ -24,7 +31,7 @@ impl Session {
let space = self.space();
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = p.x < pw.corner.x;
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = cmd::groups::expand(self, hs);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
+5 -3
View File
@@ -23,8 +23,10 @@ impl Session {
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
let st = self.state_mut()?;
let aspect = st.zoom_aspect(w / h.max(1.0));
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center: ext.center(), height });
let twist = st.zoom_frame().twist;
let (center, bw, bh) = View::twisted_box(ext, twist);
let height = bh.max(bw / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center, height, twist });
Ok(())
}
@@ -38,7 +40,7 @@ impl Session {
let h = (v.height / factor).clamp(1e-9, 1e12);
let k = h / v.height;
let c = about + (v.center - about) * k;
st.set_view_quiet(View { center: c, height: h });
st.set_view_quiet(View { center: c, height: h, twist: v.twist });
Ok(())
}
}
+6 -32
View File
@@ -714,6 +714,7 @@ impl Sink<'_> {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, frozen, &mut |k, m| self.object(k, Some(m)));
}
@@ -813,22 +814,13 @@ fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool {
b.expand(aperture).intersects(probe)
}
/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None`
/// when it distorts shapes (non-uniform scale or shear).
fn similarity_scale(m: &Mat3) -> Option<f64> {
let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d));
let (lu, lv) = (u.len(), v.len());
let tol = 1e-9 * lu.max(lv);
((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu)
}
/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale
/// become ellipses and elliptical arcs.
fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
let mirror = m.is_mirroring();
match p {
Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))),
Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) {
Prim::Seg(Segment::Arc { arc, ccw }) => match m.similarity_scale() {
Some(s) => {
let c = m.apply(arc.center);
let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point())));
@@ -837,14 +829,14 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
}
None => {
let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end };
Prim::Ellipse(xform_ellipse(&e, m))
Prim::Ellipse(e.transformed(m))
}
},
Prim::Circle(c) => match similarity_scale(m) {
Prim::Circle(c) => match m.similarity_scale() {
Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)),
None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)),
None => Prim::Ellipse(Ellipse::full(c.center, Vec2::X * c.radius, 1.0).transformed(m)),
},
Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)),
Prim::Ellipse(e) => Prim::Ellipse(e.transformed(m)),
Prim::Spline(s) => {
let mut s = s.clone();
s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q));
@@ -856,24 +848,6 @@ fn xform_prim(p: &Prim, m: &Mat3) -> Prim {
}
}
/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate
/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them
/// (and reverse under a mirroring transform).
fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse {
let center = m.apply(e.center);
let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor()));
// |am·cos t + an·sin t| is largest at t0: the new major axis.
let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an));
let (s0, c0) = t0.sin_cos();
let (u, v) = (am * c0 + an * s0, an * c0 - am * s0);
let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 };
if e.is_full() {
return Ellipse::full(center, u, ratio);
}
let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) };
Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) }
}
/// The nearest deferred tangent/perpendicular within `aperture` of `cursor`.
fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32, clip: Option<Bounds2>) -> Option<SnapHit> {
let mut best: Option<(f64, SnapHit)> = None;
+7 -4
View File
@@ -40,7 +40,8 @@ impl<'a> Projection<'a> {
}
let half = Vec2::new(viewport.width / 2.0, viewport.height / 2.0);
let paper_clip = Bounds2::new(center - half, center + half);
let model_clip = Bounds2::new(viewport.view_center - half / scale, viewport.view_center + half / scale);
// The model box the viewport shows (turned, with a view twist).
let model_clip = Bounds2::from_points(paper_clip.corners().map(|p| viewport.paper_to_model(p)));
if ![paper_clip.min, paper_clip.max, model_clip.min, model_clip.max].into_iter().all(Vec2::is_finite) {
return None;
}
@@ -48,11 +49,11 @@ impl<'a> Projection<'a> {
}
pub fn to_model(&self, point: Vec2) -> Vec2 {
self.viewport.view_center + (point - self.viewport.center.xy()) / self.scale
self.viewport.paper_to_model(point)
}
fn to_paper(&self, point: Vec2) -> Vec2 {
self.viewport.center.xy() + (point - self.viewport.view_center) * self.scale
self.viewport.model_to_paper(point)
}
pub fn hit(&self, mut hit: SnapHit) -> Option<SnapHit> {
@@ -76,7 +77,9 @@ impl<'a> Projection<'a> {
if !(center.is_finite() && radius.is_finite() && radius > 0.0) {
return None;
}
SnapCurve::Arc { center, radius, start, end }
// The arc turns on the sheet with the viewport's twist.
let twist = self.viewport.view_twist();
SnapCurve::Arc { center, radius, start: start + twist, end: end + twist }
}
SnapCurve::Line { a, b } => {
let (a, b) = (self.to_paper(a), self.to_paper(b));
+7 -3
View File
@@ -15,7 +15,7 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached};
use cadcraft_geom::Bounds2;
use rstar::primitives::{GeomWithData, Rectangle};
use rstar::{AABB, RTree};
@@ -44,6 +44,8 @@ struct ChunkIx {
impl ChunkIx {
fn build(d: &Drawing, key: usize, items: &[Arc<Entity>]) -> ChunkIx {
let mut infinite = Vec::new();
// One block-bounds cache for the chunk: references to the same block measure it once.
let mut cache = BlockBoundsCache::default();
let bounds = items
.iter()
.enumerate()
@@ -51,7 +53,7 @@ impl ChunkIx {
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
infinite.push(u32::try_from(i).unwrap_or(u32::MAX));
}
entity_bounds(d, e, 0)
entity_bounds_cached(d, e, 0, &mut cache)
})
.collect();
let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@@ -200,7 +202,7 @@ impl SpatialIndex {
let (c, l) = self.locate(i)?;
c.ents.get(l)
}
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
/// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them).
pub fn bounds(&self, i: u32) -> Bounds2 {
self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY)
}
@@ -362,6 +364,7 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}),
8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }),
8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }),
@@ -544,6 +547,7 @@ mod tests {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0));
+3
View File
@@ -184,6 +184,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
json!([v.center.x, v.center.y, 0.0])
}
"VIEWSIZE" => json!(s.state().ok()?.view().height),
// The view twist (DVIEW TWist), in degrees counterclockwise.
"VIEWTWIST" => json!(s.state().ok()?.view().twist.to_degrees()),
"EXTMIN" | "EXTMAX" => {
let d = s.doc().ok()?;
let e = d.extents(&s.space());
@@ -260,6 +262,7 @@ const READ_ONLY: &[&str] = &[
"DISTANCE",
"VIEWCTR",
"VIEWSIZE",
"VIEWTWIST",
"EXTMIN",
"EXTMAX",
"UNDOCTL",
+2
View File
@@ -74,6 +74,8 @@ const HEADER: &[(&str, Kind, Def)] = &[
("TRIMEXTENDMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
("TRIMMODE", Kind::Choice(&[0, 1]), Def::Int(1)),
("WIPEOUTFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(1)),
// XCLIP boundaries: 0 hidden, 1 shown and plotted, 2 shown but not plotted.
("XCLIPFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(2)),
];
/// The standard lineweights in hundredths of a millimetre.
+1 -1
View File
@@ -185,7 +185,7 @@ fn move_with_pickfirst_and_window_selection() {
s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
s.execute("line", &json!({"points": [[5, 5], [6, 5]]})).unwrap();
s.viewport_px = (1000.0, 1000.0);
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0 });
s.state_mut().unwrap().set_view(View { center: Vec2::new(3.0, 3.0), height: 10.0, twist: 0.0 });
// Window selection from left to right picks only the first line.
s.cmdline("move").unwrap();
s.input(Input::Point(Vec2::new(-1.0, -1.0))).unwrap();
+21 -3
View File
@@ -1,20 +1,38 @@
use cadcraft_geom::Bounds2;
use super::{Deserialize, Serialize, Vec2};
/// A 2D view of a space: world centre and visible height in drawing units.
/// A 2D view of a space: world centre and visible height in drawing units, and the view twist
/// (VIEWTWIST, DVIEW TWist): how far the view is turned on screen, in radians counterclockwise
/// (the drawing appears turned by `twist`; 0 is the plain plan view).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct View {
pub center: Vec2,
pub height: f64,
#[serde(default)]
pub twist: f64,
}
impl Default for View {
fn default() -> Self {
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
View { center: Vec2::new(6.0, 4.5), height: 10.0, twist: 0.0 }
}
}
impl View {
/// Box `b` (world) as a view turned by `twist` shows it: the world centre of the box it
/// makes on screen, and that box's width and height (`b`'s own when there is no twist).
pub fn twisted_box(b: Bounds2, twist: f64) -> (Vec2, f64, f64) {
if twist == 0.0 || !twist.is_finite() {
return (b.center(), b.width(), b.height());
}
let r = Bounds2::from_points(b.corners().map(|p| p.rotate(twist)));
(r.center().rotate(-twist), r.width(), r.height())
}
}
pub(crate) fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12 && v.twist.is_finite()
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
+1
View File
@@ -24,6 +24,7 @@ fn insert(block: &str) -> EntityKind {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
})
}
+46 -5
View File
@@ -303,6 +303,46 @@ fn a_crashed_app_is_offered_at_once_and_a_running_one_never() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_emergency_save_finishes_before_it_returns_even_with_savetime_off() {
hooks();
let _turn = folder_turn();
let dir = temp_dir("emergency");
autosave::set_savefilepath(&dir.to_string_lossy());
let before = autosave::savetime();
autosave::set_savetime(0);
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("43-e");
assert_eq!(a.save_now(&s), 0, "an ordinary save honours SAVETIME 0");
assert_eq!(a.emergency_save(&s), 1);
autosave::set_savetime(before);
let file = a.files().pop().unwrap();
assert!(std::fs::read(&file).unwrap().ends_with(b"EOF\n"), "the file is whole on return");
assert_eq!(a.emergency_save(&s), 0, "nothing changed since");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_new_autosave_folder_keeps_the_old_folder_locked() {
hooks();
let _turn = folder_turn();
let (old, new) = (temp_dir("moved-old"), temp_dir("moved-new"));
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new("44-e");
autosave_once(&mut a, &s, &old);
autosave::set_savefilepath(&new.to_string_lossy());
draw(&mut s);
assert_eq!(a.emergency_save(&s), 1);
assert!(new.join("44-e.lock").exists());
assert!(autosave::recoverable(&old, "9-f").is_empty(), "this app is still running");
a.shutdown();
assert!(!old.join("44-e.lock").exists() && !new.join("44-e.lock").exists());
let _ = std::fs::remove_dir_all(&old);
let _ = std::fs::remove_dir_all(&new);
}
#[test]
fn a_normal_exit_removes_the_lock_file() {
hooks();
@@ -346,7 +386,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(rearmed, "a failed autosave must be tried again");
assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure());
assert!(a.failures().next().is_some_and(|(_, why)| why.contains("out")));
std::fs::remove_file(&out).unwrap();
let jobs = a.plan(&s, 180.0, 1, &dir);
@@ -367,7 +407,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(a.next_due(1e4, 1), None);
assert_eq!(a.failure(), None, "a write that worked clears the failure");
assert_eq!(a.failures().next(), None, "a write that worked clears the failure");
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
@@ -387,16 +427,16 @@ fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() {
a.perform(jobs);
for _ in 0..500 {
a.reap();
if a.failure().is_some() {
if a.failures().next().is_some() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(a.failure().is_some());
assert!(a.failures().next().is_some());
let i = s.active;
s.execute("close", &json!({ "index": i })).unwrap();
a.plan(&s, 120.0, 1, &dir);
assert_eq!(a.failure(), None);
assert_eq!(a.failures().next(), None);
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
@@ -447,6 +487,7 @@ fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
d.model.push(Entity::new(h, kind));
assert!(s.state().unwrap().recovery_drawing().is_err());
+1
View File
@@ -28,6 +28,7 @@ fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
});
Entity { handle: d.new_handle(), common: Common::default(), kind }
}
+2
View File
@@ -124,6 +124,7 @@ fn hooks() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?;
return Ok(d);
@@ -175,6 +176,7 @@ fn hostile_inputs_are_errors() {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
b.entities.push(Entity::new(cadcraft_engine::doc::Handle(0x900), EntityKind::Insert(ins.clone())));
let d = s.doc_mut().unwrap();
+381
View File
@@ -0,0 +1,381 @@
//! DVIEW starts with "Select objects or <use DVIEWBLOCK>:", then the option prompt;
//! TWist, PAn, Zoom, Off and Undo on a 2D view; the 3D options not available yet. It works on the
//! Model tab and inside an active layout viewport (whose twist is an undoable edit of the
//! viewport), and not on a layout sheet.
use cadcraft_doc::{EntityKind, Handle, Viewport};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session, View};
use serde_json::json;
const SELECT: &str = "Select objects or <use DVIEWBLOCK>:";
const OPTION: &str = "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/Off/Undo]:";
const PAPER: &str = "This command is not available in Paper space.";
fn session() -> Session {
let mut s = Session::new();
s.viewport_px = (1200.0, 800.0);
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
s.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap();
s
}
fn prompt(s: &Session) -> String {
s.current_prompt().map(|p| p.display()).unwrap_or_default()
}
fn view(s: &Session) -> View {
s.state().unwrap().view()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn twist_deg(s: &Session) -> f64 {
view(s).twist.to_degrees()
}
fn logged(s: &Session, m: &str) -> bool {
s.log.iter().any(|l| l == m)
}
#[test]
fn every_prompt_step_on_the_model_tab() {
let mut s = session();
let undo = s.state().unwrap().undo.len();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
// The whole drawing shows while objects are picked.
assert!(s.display_only().is_none());
// Enter with nothing selected: the DVIEWBLOCK sample (a house of lines) is the preview set.
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert!(only.handles.is_empty());
assert!(only.extra.len() > 10 && only.extra.iter().all(|e| matches!(e.kind, EntityKind::Line(_))));
twist_pan_and_zoom(&mut s);
// The sample house stays where it was put: the view moves over it.
assert_eq!(s.display_only().unwrap(), only);
off_and_unavailable_options(&mut s);
undo_steps(&mut s);
// An unknown option is refused and asked again.
assert!(s.cmdline("bogus").is_ok());
assert_eq!(prompt(&s), OPTION);
// Enter ends DVIEW; the whole drawing shows again, and a Model tab twist is no undo step.
s.cmdline("tw").unwrap();
s.cmdline("45").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
assert!(near(twist_deg(&s), 45.0));
assert_eq!(s.state().unwrap().undo.len(), undo);
}
/// DVIEW's TWist, PAn and Zoom steps from the option prompt of a fresh Model tab session.
fn twist_pan_and_zoom(s: &mut Session) {
// TWist: the current twist is the default; a typed angle turns the view.
s.cmdline("tw").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <0>:");
s.cmdline("30").unwrap();
assert!(near(twist_deg(s), 30.0));
assert_eq!(prompt(s), OPTION);
s.cmdline("TWist").unwrap();
assert_eq!(prompt(s), "Specify view twist angle <30>:");
// Enter keeps it.
s.cmdline("").unwrap();
assert!(near(twist_deg(s), 30.0));
// PAn: base point, second point.
s.cmdline("pa").unwrap();
assert_eq!(prompt(s), "Specify displacement base point:");
s.cmdline("0,0").unwrap();
assert_eq!(prompt(s), "Specify second point:");
s.cmdline("4,0").unwrap();
assert_eq!(view(s).center, Vec2::new(6.0, 10.0));
assert_eq!(prompt(s), OPTION);
// Zoom: a scale factor of the current view.
s.cmdline("z").unwrap();
assert_eq!(prompt(s), "Specify zoom scale factor <1>:");
s.cmdline("2").unwrap();
assert!(near(view(s).height, 10.0));
}
/// DVIEW's Off option and the options that need a 3D camera.
fn off_and_unavailable_options(s: &mut Session) {
// Off: perspective is never on in a 2D view, so nothing changes.
let before = view(s);
s.cmdline("o").unwrap();
assert_eq!(prompt(s), OPTION);
assert_eq!(view(s), before);
// The options that need a 3D camera are not available yet and stay at the option prompt.
for (typed, name) in [("ca", "CAmera"), ("ta", "TArget"), ("d", "Distance"), ("po", "POints"), ("cl", "CLip"), ("h", "Hide")] {
s.cmdline(typed).unwrap();
assert!(logged(s, &format!("DVIEW {name} is not available yet.")), "{name}: {:?}", s.log);
assert_eq!(prompt(s), OPTION);
}
assert!(!s.log.iter().any(|l| l.contains("Only the view twist")));
}
/// DVIEW's Undo after [`twist_pan_and_zoom`].
fn undo_steps(s: &mut Session) {
// Undo takes back one DVIEW action at a time: zoom, pan, twist.
s.cmdline("u").unwrap();
assert!(near(view(s).height, 20.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).center, Vec2::new(10.0, 10.0));
assert!(near(twist_deg(s), 30.0));
s.cmdline("u").unwrap();
assert_eq!(view(s).twist, 0.0);
// Nothing left to undo: still at the option prompt.
s.cmdline("u").unwrap();
assert_eq!(prompt(s), OPTION);
}
#[test]
fn selected_objects_are_the_preview_set() {
let mut s = session();
let line = s.doc().unwrap().model.iter().next().map(|e| e.handle).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.cmdline("dview").unwrap();
s.input(Input::Pick(vec![line])).unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
assert_eq!(prompt(&s), OPTION);
let only = s.display_only().unwrap();
assert_eq!(only.handles, vec![line]);
assert!(only.extra.is_empty(), "no sample house with a selection");
// Picked at the prompt, the objects don't stay selected.
assert!(s.selection().is_empty());
// Escape ends it too, and the drawing shows again.
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(s.display_only().is_none());
}
#[test]
fn a_dviewblock_of_the_drawings_own_is_previewed() {
let mut s = session();
s.execute("circle", &json!({ "center": [0.5, 0.5], "radius": 0.5 })).unwrap();
let c = s.doc().unwrap().model.iter().last().map(|e| e.handle).unwrap();
s.execute("block", &json!({ "name": "DVIEWBLOCK", "base": [0, 0], "handles": [c.hex()], "keep": "delete" })).unwrap();
s.cmdline("dview").unwrap();
s.cmdline("").unwrap();
let only = s.display_only().unwrap();
assert_eq!(only.extra.len(), 1);
match &only.extra[0].kind {
EntityKind::Insert(i) => {
assert_eq!(i.block, "DVIEWBLOCK");
// Scaled from its 1 by 1 unit area to half the view's height.
assert!(near(i.scale.x, 10.0) && near(i.scale.y, 10.0));
}
k => panic!("{k:?}"),
}
s.cmdline("").unwrap();
}
/// A layout with one viewport, `(session, viewport handle)`.
fn layout_session() -> (Session, Handle) {
let mut s = session();
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "p1": [20, 20], "p2": [220, 120] })).unwrap();
let h = Handle::parse_hex(r["viewports"][0].as_str().unwrap()).unwrap();
(s, h)
}
fn vp(s: &Session, h: Handle) -> Viewport {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Viewport(v) => v.clone(),
_ => panic!("not a viewport"),
}
}
#[test]
fn on_a_layout_sheet_it_is_not_available() {
let (mut s, _) = layout_session();
assert_eq!(s.execute("dview", &json!({ "twist": 10 })).unwrap_err().to_string(), PAPER);
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
assert!(s.current_prompt().is_none());
// view.set can't turn the sheet either; an untwisted view is fine.
assert_eq!(s.execute("view.set", &json!({ "center": [0, 0], "height": 5, "twist": 15 })).unwrap_err().to_string(), PAPER);
s.execute("view.set", &json!({ "center": [0, 0], "height": 5 })).unwrap();
}
#[test]
fn inside_an_active_viewport_it_twists_that_viewport_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
let pv = s.state().unwrap().paper_view();
s.cmdline("dview").unwrap();
assert_eq!(prompt(&s), SELECT);
s.cmdline("").unwrap();
s.cmdline("tw").unwrap();
s.cmdline("30").unwrap();
// The viewport entity carries the twist, and the model view inside it is turned by it.
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert!(near(twist_deg(&s), 30.0));
// The sheet itself is not turned.
assert_eq!(s.state().unwrap().paper_view(), pv);
s.cmdline("tw").unwrap();
s.cmdline("50").unwrap();
s.cmdline("").unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 50.0));
// One undo step for the whole DVIEW, which turns the viewport back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
// The JSON form and view.set twist the viewport the same way, each one undo step.
s.execute("dview", &json!({ "twist": -90 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 270.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
let v = view(&s);
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 15 })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Setting a view without a twist keeps the viewport's.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 15.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 2);
// Back on the sheet, DVIEW is not available again.
s.execute("pspace", &json!({})).unwrap();
assert_eq!(s.cmdline("dview").unwrap_err().to_string(), PAPER);
}
#[test]
fn a_locked_viewport_keeps_its_twist() {
let (mut s, h) = layout_session();
s.doc_mut()
.unwrap()
.modify_entity(h, |e| {
if let EntityKind::Viewport(v) = &mut e.kind {
v.locked = true;
}
})
.unwrap();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
assert!(s.execute("dview", &json!({ "twist": 30 })).is_err());
let v = view(&s);
assert!(s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height, "twist": 30 })).is_err());
assert_eq!(vp(&s, h).twist, 0.0);
assert_eq!(s.state().unwrap().undo.len(), undo);
// A view with the twist it already has is fine.
s.execute("view.set", &json!({ "center": [v.center.x, v.center.y], "height": v.height })).unwrap();
}
#[test]
fn escape_inside_an_active_viewport_keeps_the_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
let undo = s.state().unwrap().undo.len();
for l in ["dview", "", "tw", "30"] {
s.cmdline(l).unwrap();
}
s.input(Input::Cancel).unwrap();
assert!(s.current_prompt().is_none());
assert!(near(vp(&s, h).twist.to_degrees(), 30.0));
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn the_view_inside_a_twisted_viewport_maps_the_screen_like_the_sheet() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let st = s.state().unwrap();
let (vpe, pv, v) = (vp(&s, h), st.paper_view(), st.view());
// The model view's centre is the model point at the sheet's view centre.
assert!((vpe.model_to_paper(v.center) - pv.center).len() < 1e-9);
assert!(near(v.twist, vpe.twist));
// Panning the view inside the viewport moves its model view, keeping the twist.
let moved = View { center: v.center + Vec2::new(5.0, -2.0), ..v };
s.state_mut().unwrap().set_view(moved);
let after = view(&s);
assert!((after.center - moved.center).len() < 1e-9, "{after:?}");
assert!(near(after.twist, vpe.twist));
assert_eq!(s.state().unwrap().paper_view(), pv);
// ZOOM Extents fits the turned box of the drawing in the viewport.
s.execute("zoom", &json!({ "mode": "extents" })).unwrap();
assert!(near(view(&s).twist, vpe.twist));
}
#[test]
fn a_viewport_twist_survives_a_dxf_round_trip() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
let dxf = cadcraft_io::write_dxf(s.doc().unwrap());
let back = cadcraft_io::read_dxf(dxf.as_bytes()).unwrap();
let twists: Vec<f64> = back
.layouts
.iter()
.flat_map(|l| l.entities.iter())
.filter_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.id != 1 => Some(v.twist.to_degrees()),
_ => None,
})
.collect();
assert_eq!(twists.len(), 1);
assert!((twists[0] - 30.0).abs() < 1e-9, "{twists:?}");
}
#[test]
fn a_view_saved_in_a_twisted_viewport_restores_its_twist_as_one_undo_step() {
let (mut s, h) = layout_session();
s.execute("mspace", &json!({ "handle": h.hex() })).unwrap();
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("view", &json!({ "option": "save", "name": "Turned" })).unwrap();
let saved = s.doc().unwrap().views.iter().find(|v| v.name == "Turned").map(|v| v.twist.to_degrees()).unwrap();
assert!(near(saved, 30.0), "saved twist {saved}");
let center = vp(&s, h).view_center;
s.execute("dview", &json!({ "twist": 0 })).unwrap();
s.execute("view.set", &json!({ "center": [80, 40], "height": 3 })).unwrap();
let undo = s.state().unwrap().undo.len();
s.execute("view", &json!({ "option": "restore", "name": "Turned" })).unwrap();
assert!(near(vp(&s, h).twist.to_degrees(), 30.0), "viewport twist {}", vp(&s, h).twist.to_degrees());
assert!(near(twist_deg(&s), 30.0));
assert!((vp(&s, h).view_center - center).len() < 1e-6, "{:?} vs {center:?}", vp(&s, h).view_center);
// The viewport's twist changed: one undo step, which turns it back.
assert_eq!(s.state().unwrap().undo.len(), undo + 1);
s.execute("undo", &json!({})).unwrap();
assert_eq!(vp(&s, h).twist, 0.0);
}
#[test]
fn undoing_a_drawing_change_keeps_the_untiled_twist_saved() {
let mut s = session();
s.execute("line", &json!({ "points": [[0, 10], [5, 10]] })).unwrap();
// A Model tab twist is a view change, not an undo step.
s.execute("dview", &json!({ "twist": 30 })).unwrap();
s.execute("undo", &json!({})).unwrap();
assert!(near(twist_deg(&s), 30.0));
let saved = |s: &Session| s.doc().unwrap().vports.iter().find(|c| c.is_active()).and_then(|c| c.tiles.first()).map(|t| t.twist.to_degrees());
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
s.execute("redo", &json!({})).unwrap();
assert!(saved(&s).is_some_and(|t| near(t, 30.0)), "{:?}", saved(&s));
}
#[test]
fn a_leftward_drag_on_a_turned_screen_is_a_crossing_window() {
let mut s = session();
let r = s.execute("line", &json!({ "points": [[200, 0], [260, 0]] })).unwrap();
let line = Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap();
s.execute("dview", &json!({ "twist": 180 })).unwrap();
// Turned half way round, a drag towards +X in the drawing runs leftwards on screen.
assert!(s.window_is_crossing(Vec2::new(210.0, -5.0), Vec2::new(230.0, 5.0)));
assert!(!s.window_is_crossing(Vec2::new(230.0, -5.0), Vec2::new(210.0, 5.0)));
// A crossing window takes the line it only partly covers.
s.idle_click(Vec2::new(210.0, -5.0), false).unwrap();
s.idle_click(Vec2::new(230.0, 5.0), false).unwrap();
assert_eq!(s.selection(), vec![line]);
}
+376
View File
@@ -0,0 +1,376 @@
//! Raster images (#956): IMAGEATTACH from a file and from handed-over bytes, the pixels loaded
//! again when the drawing is reopened, a missing file left as a frame, and bad input refused.
use std::path::{Path, PathBuf};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
use cadcraft_engine::doc::{Drawing, EntityKind, Image, Raster};
use serde_json::{Value, json};
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
set_image_decoder(|b| cadcraft_io::raster::decode(b).map_err(|e| e.to_string()));
}
/// A fresh folder for one test.
fn folder(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cadcraft-image-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A PNG file of 4 x 2 pixels.
fn png() -> Vec<u8> {
let px: Vec<u8> = (0..8u8).flat_map(|i| [i * 30, 255 - i * 30, 7, 255]).collect();
cadcraft_io::raster::encode_png(&Raster::new(4, 2, px).unwrap()).unwrap()
}
fn images(s: &Session) -> Vec<Image> {
let d = s.doc().unwrap();
d.model.iter().filter_map(|e| if let EntityKind::Image(i) = &e.kind { Some(i.clone()) } else { None }).collect()
}
fn open(s: &mut Session, path: &Path) {
s.execute("open", &json!({ "path": path.to_string_lossy() })).unwrap();
}
fn listed(s: &mut Session) -> Value {
s.execute("image", &json!({})).unwrap()["images"].clone()
}
#[test]
fn attach_from_bytes_places_the_image() {
hooks();
let mut s = Session::new();
let r = s.execute("imageattach", &json!({ "path": "maps/site.png", "data": base64_encode(&png()), "at": [10, 20], "width": 100 })).unwrap();
assert_eq!((r["name"].as_str(), r["pixels"].clone(), r["size"].clone()), (Some("site"), json!([4.0, 2.0]), json!([100.0, 50.0])));
let i = images(&s).pop().unwrap();
assert_eq!((i.insert.x, i.insert.y, i.u.x, i.u.y, i.v.x, i.v.y), (10.0, 20.0, 25.0, 0.0, 0.0, 25.0));
assert_eq!((i.size.x, i.size.y, i.path.as_str(), i.name.as_str()), (4.0, 2.0, "maps/site.png", "site"));
assert_eq!(s.doc().unwrap().rasters.get("maps/site.png").map(|r| (r.width(), r.height())), Some((4, 2)));
let l = listed(&mut s);
assert_eq!((l[0]["status"].as_str(), l[0]["references"].as_u64(), l[0]["pixels"].clone()), (Some("Loaded"), Some(1), json!([4, 2])));
// A second reference to the same file shares the definition and the pixels; another file
// with the same file name gets its own name.
s.execute("imageattach", &json!({ "path": "maps/site.png", "at": [200, 0], "rotation": 90, "scale": 8 })).unwrap();
let r = s.execute("attach", &json!({ "path": "other/site.png", "data": base64_encode(&png()) })).unwrap();
assert_eq!(r["name"], "site_2");
let all = images(&s);
assert_eq!(all.len(), 3);
assert_eq!(all[1].name, "site");
assert!((all[1].u.x).abs() < 1e-12 && (all[1].u.y - 2.0).abs() < 1e-12 && (all[1].v.x + 2.0).abs() < 1e-12, "{:?}", all[1]);
assert_eq!(listed(&mut s)[0]["references"], 2);
// Undo takes the reference away; the drawing draws with its pixels.
s.execute("undo", &json!({})).unwrap();
assert_eq!(images(&s).len(), 2);
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
assert_eq!(list.images.len(), 2);
}
#[test]
fn a_file_is_found_next_to_the_drawing_and_loaded_again_on_open() {
hooks();
let dir = folder("file");
std::fs::write(dir.join("map.png"), png()).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&Drawing::new_metric(), "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
open(&mut s, &host);
let r = s.execute("imageattach", &json!({ "path": "map.png" })).unwrap();
assert_eq!(r["size"], json!([1.0, 0.5]), "one drawing unit wide by default");
s.execute("qsave", &json!({})).unwrap();
// Reopened: the pixels come back from the file.
let mut s = Session::new();
open(&mut s, &host);
assert_eq!(images(&s).len(), 1);
assert!(s.doc().unwrap().rasters.get("map.png").is_some());
assert!(!s.state().unwrap().is_dirty(), "loading images is not a change");
let l = listed(&mut s);
assert_eq!(l[0]["status"], "Loaded");
assert!(l[0]["foundAt"].as_str().is_some_and(|f| f.ends_with("map.png")));
// The file gone: the drawing still opens, says so, and keeps the reference.
std::fs::remove_file(dir.join("map.png")).unwrap();
let mut s = Session::new();
open(&mut s, &host);
assert_eq!(images(&s).len(), 1);
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("file not found")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
// A file that is not an image is reported the same way.
std::fs::write(dir.join("map.png"), b"not an image").unwrap();
let mut s = Session::new();
open(&mut s, &host);
assert!(s.doc().unwrap().rasters.get("map.png").is_none());
assert!(s.log.iter().any(|l| l.contains("map.png")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not loaded");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bad_input_is_refused_and_changes_nothing() {
hooks();
let mut s = Session::new();
let data = base64_encode(&png());
for p in [
json!({}),
json!({ "path": " " }),
json!({ "path": "nowhere/missing.png" }),
json!({ "path": "a.png", "data": "***" }),
json!({ "path": "a.png", "data": 5 }),
json!({ "path": "a.png", "data": base64_encode(b"plain text") }),
json!({ "path": "a.png", "data": data, "scale": 0 }),
json!({ "path": "a.png", "data": data, "width": -3 }),
json!({ "path": "a.png", "data": data, "width": "wide" }),
json!({ "path": "a.png", "data": data, "width": 1e-320 }),
json!({ "path": "x".repeat(5000), "data": data }),
] {
assert!(s.execute("imageattach", &p).is_err(), "{p}");
}
assert!(images(&s).is_empty());
assert!(s.execute("attach", &json!({ "path": "plan.pdf" })).unwrap_err().to_string().contains("not available yet"));
assert_eq!(listed(&mut s), json!([]));
}
#[test]
fn an_image_among_many_objects_keeps_its_pixels() {
// More objects than one build task takes: the display list is built in parts and joined,
// and the image must still point at its own pixels.
hooks();
let mut s = Session::new();
for i in 0..700 {
s.execute("line", &json!({ "points": [[i, 0], [i, 1]] })).unwrap();
}
s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [0, 5], "width": 4 })).unwrap();
for i in 0..700 {
s.execute("line", &json!({ "points": [[i, 2], [i, 3]] })).unwrap();
}
s.execute("imageattach", &json!({ "path": "b.png", "data": base64_encode(&png()), "at": [10, 5], "width": 8 })).unwrap();
let list = cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default());
let widths: Vec<f64> = list
.prims
.iter()
.filter_map(|p| match p.kind {
cadcraft_engine::render::Kind::Image { index } => list.images.get(index as usize).map(|i| i.right.x * 4.0),
_ => None,
})
.collect();
assert_eq!(widths, [4.0, 8.0]);
}
fn drawn_images(s: &Session) -> usize {
cadcraft_engine::render::build(s.doc().unwrap(), &cadcraft_engine::doc::Space::Model, &cadcraft_engine::render::Options::default()).images.len()
}
#[test]
fn editing_commands_work_on_images_and_undo_keeps_the_pixels() {
hooks();
let mut s = Session::new();
let r = s.execute("imageattach", &json!({ "path": "a.png", "data": base64_encode(&png()), "at": [10, 0], "width": 4 })).unwrap();
let h = r["handle"].as_str().unwrap().to_string();
// Move, then a quarter turn about the origin: the corner and both axes follow.
s.execute("move", &json!({ "handles": [h], "delta": [0, 5] })).unwrap();
s.execute("rotate", &json!({ "handles": [h], "base": [0, 0], "angle": 90 })).unwrap();
let i = images(&s).pop().unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(close(i.insert.x, -5.0) && close(i.insert.y, 10.0), "{i:?}");
assert!(close(i.u.x, 0.0) && close(i.u.y, 1.0) && close(i.v.x, -1.0) && close(i.v.y, 0.0), "{i:?}");
assert_eq!(drawn_images(&s), 1);
// A copy is a second reference to the same file.
s.execute("copy", &json!({ "handles": [h], "delta": [20, 0] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
assert_eq!(listed(&mut s)[0]["references"], 2);
assert_eq!(listed(&mut s).as_array().map(Vec::len), Some(1));
// Scaled about its corner: twice the size, still the same pixels.
s.execute("scale", &json!({ "handles": [h], "base": [-5, 10], "factor": 2 })).unwrap();
let i = images(&s).into_iter().find(|i| close(i.insert.x, -5.0)).unwrap();
assert!(close(i.u.y, 2.0) && close(i.size.x, 4.0), "{i:?}");
// Erased, then brought back by undo, with its picture; redo erases again.
s.execute("erase", &json!({ "handles": [h] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (1, 1));
s.execute("undo", &json!({})).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
s.execute("redo", &json!({})).unwrap();
assert_eq!(images(&s).len(), 1);
// Everything undone: no image, and nothing left to draw.
for _ in 0..10 {
let _ = s.execute("undo", &json!({}));
}
assert!(images(&s).is_empty());
assert_eq!(drawn_images(&s), 0);
assert_eq!(listed(&mut s), json!([]));
}
#[test]
fn the_alias_and_the_command_line_reach_the_command() {
hooks();
assert_eq!(cadcraft_engine::cmd::resolve_alias("IAT"), "imageattach");
assert_eq!(cadcraft_engine::cmd::resolve_alias("im"), "image");
let spec = |id: &str| cadcraft_engine::cmd::command_specs().iter().find(|c| c.id == id).unwrap();
assert!(!spec("imageattach").params.is_empty() && !spec("image").params.is_empty());
// Typed without a file it has nothing to attach: an error, not a crash or a stuck prompt.
let mut s = Session::new();
let _ = s.cmdline("imageattach");
let _ = s.cmdline("");
s.cancel();
assert!(s.running.is_none() && images(&s).is_empty());
}
#[test]
fn images_in_blocks_and_layouts_are_loaded_on_open() {
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Block, Common, Entity, Space};
hooks();
let dir = folder("spaces");
std::fs::create_dir_all(dir.join("maps")).unwrap();
std::fs::write(dir.join("maps").join("a.png"), png()).unwrap();
std::fs::write(dir.join("b.png"), png()).unwrap();
let image = |path: &str, name: &str| {
EntityKind::Image(Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: path.into(),
name: name.into(),
..Image::default()
})
};
let mut d = Drawing::new_metric();
// The same file in model space, in a block and on a layout; another only on the layout,
// saved with a folder that no longer exists (found by its name next to the drawing); and
// one that is nowhere.
d.add(&Space::Model, Common::default(), image("maps/a.png", "a")).unwrap();
let mut blk = Block::new("MAP");
let bh = d.new_handle();
blk.entities.push(Entity::new(bh, image("maps/a.png", "a")));
d.blocks.insert("MAP".into(), std::sync::Arc::new(blk));
let layout = Space::Paper("Layout1".into());
d.add(&layout, Common::default(), image("maps/a.png", "a")).unwrap();
d.add(&layout, Common::default(), image("C:\\old\\place\\b.png", "b")).unwrap();
d.add(&layout, Common::default(), image("gone.png", "gone")).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
open(&mut s, &host);
let doc = s.doc().unwrap();
assert_eq!(doc.rasters.len(), 2, "each file once");
assert!(doc.rasters.get("maps/a.png").is_some() && doc.rasters.get("C:\\old\\place\\b.png").is_some());
assert!(!s.state().unwrap().is_dirty());
assert_eq!(s.log.iter().filter(|l| l.starts_with("Image ")).count(), 1, "{:?}", s.log);
let l = listed(&mut s);
let row = |name: &str| l.as_array().unwrap().iter().find(|r| r["name"] == name).cloned().unwrap();
assert_eq!((row("a")["status"].clone(), row("a")["references"].clone()), (json!("Loaded"), json!(3)));
assert_eq!(row("b")["status"], "Loaded");
assert_eq!(row("gone")["status"], "Not Found");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_drawing_opened_from_bytes_gets_its_pixels_handed_over() {
// The web app's way: no file system, the drawing arrives as bytes and so do the images.
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Space};
hooks();
let mut d = Drawing::new_metric();
let i = Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: "no-such-folder/web.png".into(),
name: "web".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
let dxf = cadcraft_io::write(&d, "plan.dxf").unwrap();
let mut s = Session::new();
s.execute("open", &json!({ "data": base64_encode(&dxf), "name": "plan.dxf" })).unwrap();
assert_eq!(images(&s).len(), 1, "opened with its image reference");
assert!(s.doc().unwrap().rasters.is_empty());
assert!(s.log.iter().any(|l| l.contains("web.png")), "{:?}", s.log);
assert_eq!(listed(&mut s)[0]["status"], "Not Found");
assert_eq!(drawn_images(&s), 0);
// The same file handed over as bytes: the reference that was there draws too.
s.execute("imageattach", &json!({ "path": "no-such-folder/web.png", "data": base64_encode(&png()), "at": [10, 0] })).unwrap();
assert_eq!((images(&s).len(), drawn_images(&s)), (2, 2));
let l = listed(&mut s);
assert_eq!((l[0]["status"].clone(), l[0]["references"].clone(), l[0]["name"].clone()), (json!("Loaded"), json!(2), json!("web")));
// Saved and read back: the references are kept, the pixels are not in the file.
let bytes = cadcraft_io::write(s.doc().unwrap(), "plan.dxf").unwrap();
let back = cadcraft_io::read(&bytes, "plan.dxf").unwrap();
assert!(back.rasters.is_empty());
assert_eq!(back.model.iter().filter(|e| matches!(e.kind, EntityKind::Image(_))).count(), 2);
}
#[test]
fn many_files_are_loaded_together_and_reported_in_order() {
// More files than loaders, with missing and unreadable ones among them: each good file is
// loaded, each bad one reported once, in the order the drawing uses them.
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Space};
hooks();
let dir = folder("many");
let mut d = Drawing::new_metric();
let mut expected_notes = Vec::new();
for n in 0..60u32 {
let name = format!("tile{n:02}.png");
match n % 10 {
3 => expected_notes.push(format!("Image {name}: file not found")),
7 => {
std::fs::write(dir.join(&name), b"not an image").unwrap();
expected_notes.push(format!("Image {name}: "));
}
_ => {
// A different width per file, so a mix-up would show.
let px = vec![200u8; (n as usize + 1) * 4];
std::fs::write(dir.join(&name), cadcraft_io::raster::encode_png(&Raster::new(n + 1, 1, px).unwrap()).unwrap()).unwrap();
}
}
let i = Image {
insert: Vec3::new(f64::from(n) * 3.0, 0.0, 0.0),
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(f64::from(n + 1), 1.0),
path: name.clone(),
name: format!("tile{n:02}"),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
}
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
for _ in 0..3 {
let mut s = Session::new();
open(&mut s, &host);
let doc = s.doc().unwrap();
assert_eq!(doc.rasters.len(), 48);
for n in 0..60u32 {
let loaded = doc.rasters.get(&format!("tile{n:02}.png")).map(|r| r.width());
assert_eq!(loaded, if matches!(n % 10, 3 | 7) { None } else { Some(n + 1) }, "tile {n}");
}
let notes: Vec<&String> = s.log.iter().filter(|l| l.starts_with("Image ")).collect();
assert_eq!(notes.len(), expected_notes.len(), "{notes:?}");
assert!(notes.iter().zip(&expected_notes).all(|(got, want)| got.starts_with(want.as_str())), "{notes:?}");
assert!(!s.state().unwrap().is_dirty());
}
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,55 @@
//! A raster image decoder that panics on a damaged file fails that image only (#956): the
//! drawing it belongs to still opens, and IMAGEATTACH reports an error.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, base64_encode, set_image_decoder, set_io};
use cadcraft_engine::doc::geom::{Vec2, Vec3};
use cadcraft_engine::doc::{Common, Drawing, EntityKind, Image, Line, Space};
use serde_json::json;
fn hooks() {
set_io(IoHooks {
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
plot: None,
});
set_image_decoder(|_| panic!("decoder bug"));
}
#[test]
fn a_panicking_decoder_fails_the_image_not_the_drawing() {
hooks();
let dir = std::env::temp_dir().join(format!("cadcraft-image-panic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("map.png"), b"anything").unwrap();
let mut d = Drawing::new_metric();
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 5.0, 0.0) })).unwrap();
let i = Image {
u: Vec3::new(1.0, 0.0, 0.0),
v: Vec3::new(0.0, 1.0, 0.0),
size: Vec2::new(4.0, 2.0),
path: "map.png".into(),
name: "map".into(),
..Image::default()
};
d.add(&Space::Model, Common::default(), EntityKind::Image(i)).unwrap();
let host = dir.join("host.dxf");
std::fs::write(&host, cadcraft_io::write(&d, "host.dxf").unwrap()).unwrap();
let mut s = Session::new();
s.execute("open", &json!({ "path": host.to_string_lossy() })).unwrap();
let doc = s.doc().unwrap();
assert_eq!(doc.model.len(), 2, "the drawing opened with everything in it");
assert!(doc.rasters.is_empty());
assert!(s.log.iter().any(|l| l.contains("map.png") && l.contains("damaged")), "{:?}", s.log);
let before = s.doc().unwrap().model.len();
let e = s.execute("imageattach", &json!({ "path": "other.png", "data": base64_encode(b"bytes") })).unwrap_err().to_string();
assert!(e.contains("damaged"), "{e}");
assert_eq!(s.doc().unwrap().model.len(), before);
// The session still works.
s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), before + 1);
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -40,6 +40,7 @@ fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins)));
Ok(d)
+1
View File
@@ -40,6 +40,7 @@ fn drawing() -> (Session, Handle) {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
};
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap();
(s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap())
+1
View File
@@ -21,6 +21,7 @@ fn insert(block: &str) -> Insert {
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
clip: None,
}
}

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