Compare commits

...
105 Commits
Author SHA1 Message Date
Dylan M. Taylor f02ee5c0b8 Merge main into release 2026-11-04 21:13:29 -05:00
Dylan M. Taylor d4a9a37a09 Merge main into release 2026-11-04 21:08:13 -05: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 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
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
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
Tanish Manku fdfe4d95a8 Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670)
* Autosave: a failed write is shown until one works (E6)

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

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

A crashed app's lock file looked alive until it went stale, so a relaunch
within 5 minutes skipped its autosaves, and a suspended app's looked dead.
A running app now holds a system lock on the file, which the OS drops however
the process ends. File age still decides where locks don't work and for lock
files of older versions, which don't record holding one.
2026-11-02 18:06:35 -07:00
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
4 changed files with 305 additions and 56 deletions
+40 -4
View File
@@ -187,10 +187,15 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
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 {
@@ -522,4 +527,35 @@ mod tests {
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:?}");
}
}
+175 -52
View File
@@ -149,6 +149,15 @@ pub enum Icon {
PickAdd,
SelectObjects,
QuickSelect,
ZoomIn,
ZoomOut,
MoveTop,
MoveUp,
MoveDown,
MoveBottom,
ZoomFit,
TextStyle,
DimStyle,
}
struct Pen<'a> {
@@ -219,6 +228,14 @@ impl Pen<'_> {
let (nx, ny) = (-uy, ux);
self.tri([(x, y), (x - ux * 3.0 + nx * 1.4, y - uy * 3.0 + ny * 1.4), (x - ux * 3.0 - nx * 1.4, y - uy * 3.0 - ny * 1.4)], c);
}
/// A larger arrowhead than [`Pen::arrow_tip`]: `len` long, `half` wide either side.
#[allow(clippy::too_many_arguments)]
fn big_arrow(&self, x: f32, y: f32, dx: f32, dy: f32, len: f32, half: f32, c: Color32) {
let l = (dx * dx + dy * dy).sqrt().max(1e-3);
let (ux, uy) = (dx / l, dy / l);
let (nx, ny) = (-uy, ux);
self.tri([(x, y), (x - ux * len + nx * half, y - uy * len + ny * half), (x - ux * len - nx * half, y - uy * len - ny * half)], c);
}
fn text(&self, x: f32, y: f32, s: &str, size: f32, c: Color32) {
self.p.text(self.at(x, y), egui::Align2::CENTER_CENTER, s, egui::FontId::proportional(size * self.sc()), c);
}
@@ -242,7 +259,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) {
paint_constraints(&pen, icon, b, a, dim);
}
fn paint_file_view(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) {
fn paint_file_view(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, dim: bool) {
use Icon::*;
match icon {
New => {
@@ -258,26 +275,48 @@ fn paint_file_view(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool
pen.rect(8.0, 4.0, 8.0, 5.0, b);
pen.rect(7.0, 13.0, 10.0, 7.0, b);
if icon == SaveAs {
pen.la(15.0, 22.0, 22.0, 15.0);
pen.fill_c(15.0, 22.0, 1.2, a);
// A large pencil across the lower-right corner: body, ferrule line and a filled tip.
pen.p.add(egui::Shape::convex_polygon(
[(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)].iter().map(|(x, y)| pen.at(*x, *y)).collect(),
Tokens::get().panel,
Stroke::NONE,
));
pen.poly(&[(12.6, 19.4), (20.6, 11.4), (23.6, 14.4), (15.6, 22.4)], true, a);
pen.la(19.0, 13.0, 22.0, 16.0);
pen.tri([(12.6, 19.4), (15.6, 22.4), (10.8, 24.0)], a);
}
}
Undo => {
pen.arc(12.0, 13.0, 7.0, 150.0, -40.0, b);
pen.arrow_tip(5.0, 9.0, -1.0, 2.5, b);
Undo | Redo => {
// CADCraft's own: a step back along the drawing's path. A short straight run with a
// half-turn above it, the arrowhead at the free end pointing back (Undo: to the
// left, Redo: mirrored to the right), in the icon colour.
let m = |x: f32| if icon == Undo { x } else { 24.0 - x };
let col = if dim { a } else { b };
// The straight run along the bottom, then a half circle up and back over it.
let (cx, cy, r) = (13.0_f32, 12.0_f32, 5.0_f32);
let mut pts: Vec<egui::Pos2> = vec![pen.at(m(6.0), cy + r)];
pts.extend((0..=20).map(|i| {
let ang = (-90.0 + 180.0 * i as f32 / 20.0).to_radians();
pen.at(m(cx + r * ang.cos()), cy - r * ang.sin())
}));
pts.push(pen.at(m(7.5), cy - r));
pen.p.add(egui::Shape::line(pts, Stroke::new(pen.w * 1.8, col)));
// The arrowhead at the free end, pointing back along the top run.
let dx = if icon == Undo { -1.0 } else { 1.0 };
pen.big_arrow(m(5.0), cy - r, dx, 0.0, 5.0, 4.6, col);
}
Redo => {
pen.arc(12.0, 13.0, 7.0, 30.0, 220.0, b);
pen.arrow_tip(19.0, 9.0, 1.0, 2.5, b);
}
Plot | Preview => {
Plot => {
pen.rect(4.0, 9.0, 16.0, 8.0, b);
pen.rect(7.0, 4.0, 10.0, 5.0, b);
pen.rect(7.0, 14.0, 10.0, 7.0, b);
if icon == Preview {
pen.c(17.0, 17.0, 3.0, a);
pen.la(19.0, 19.0, 22.0, 22.0);
}
}
Preview => {
// A smaller printer on the left and the magnifier clear of it on the right.
pen.rect(1.5, 8.0, 12.0, 6.5, b);
pen.rect(3.8, 4.0, 7.4, 4.0, b);
pen.rect(3.8, 12.0, 7.4, 6.0, b);
pen.c(17.5, 13.0, 4.0, a);
pen.p.line_segment([pen.at(20.4, 15.9), pen.at(23.0, 18.5)], Stroke::new(pen.w * 1.8, a));
}
Publish => {
pen.rect(3.0, 10.0, 13.0, 7.0, b);
@@ -291,12 +330,14 @@ fn paint_file_view(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool
}
Import | Export => {
pen.poly(&[(5.0, 3.0), (14.0, 3.0), (18.0, 7.0), (18.0, 21.0), (5.0, 21.0)], true, b);
// A large, thick arrow: into the page for Import, out of it for Export.
let thick = Stroke::new(pen.w * 2.0, a);
if icon == Import {
pen.la(1.0, 13.0, 11.0, 13.0);
pen.arrow_tip(12.0, 13.0, 1.0, 0.0, a);
pen.p.line_segment([pen.at(0.5, 13.0), pen.at(8.0, 13.0)], thick);
pen.big_arrow(14.5, 13.0, 1.0, 0.0, 7.0, 5.0, a);
} else {
pen.la(11.0, 13.0, 21.0, 13.0);
pen.arrow_tip(23.0, 13.0, 1.0, 0.0, a);
pen.p.line_segment([pen.at(9.0, 13.0), pen.at(17.0, 13.0)], thick);
pen.big_arrow(24.0, 13.0, 1.0, 0.0, 7.0, 5.0, a);
}
}
Attach => {
@@ -520,20 +561,24 @@ fn paint_edit_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: boo
use Icon::*;
match icon {
Move => {
pen.la(12.0, 3.0, 12.0, 21.0);
pen.la(3.0, 12.0, 21.0, 12.0);
pen.arrow_tip(12.0, 2.0, 0.0, -1.0, a);
pen.arrow_tip(12.0, 22.0, 0.0, 1.0, a);
pen.arrow_tip(2.0, 12.0, -1.0, 0.0, a);
pen.arrow_tip(22.0, 12.0, 1.0, 0.0, a);
pen.la(12.0, 6.0, 12.0, 18.0);
pen.la(6.0, 12.0, 18.0, 12.0);
pen.big_arrow(12.0, 1.5, 0.0, -1.0, 5.5, 3.3, a);
pen.big_arrow(12.0, 22.5, 0.0, 1.0, 5.5, 3.3, a);
pen.big_arrow(1.5, 12.0, -1.0, 0.0, 5.5, 3.3, a);
pen.big_arrow(22.5, 12.0, 1.0, 0.0, 5.5, 3.3, a);
}
Copy => {
pen.rect(3.0, 3.0, 11.0, 13.0, b);
pen.rect(10.0, 8.0, 11.0, 13.0, a);
}
Rotate => {
pen.arc(12.0, 12.0, 8.0, 100.0, 380.0, a);
pen.arrow_tip(10.6, 4.1, -1.0, 0.1, a);
// Most of a circle, counter-clockwise, ending in an arrowhead along it.
pen.arc(12.0, 12.0, 8.0, 50.0, 300.0, a);
let end = 300f32.to_radians();
let (px, py) = (12.0 + 8.0 * end.cos(), 12.0 - 8.0 * end.sin());
let (ux, uy) = (-end.sin(), -end.cos());
pen.big_arrow(px + ux * 4.5, py + uy * 4.5, ux, uy, 5.5, 3.3, a);
pen.dot(12.0, 12.0);
}
Scale => {
@@ -558,20 +603,22 @@ fn paint_edit_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: boo
pen.la(4.0, 18.0, 4.0, 22.0);
}
Trim => {
pen.l(8.0, 2.0, 8.0, 22.0);
pen.l(16.0, 2.0, 16.0, 22.0);
pen.l(2.0, 12.0, 8.0, 12.0);
pen.l(16.0, 12.0, 22.0, 12.0);
for x in [9.5, 12.0, 14.5] {
pen.la(x, 12.0, x + 1.0, 12.0);
}
pen.poly(&[(10.0, 17.0), (12.0, 15.0), (14.0, 17.0)], false, a);
// Scissors cutting a line in two: the line's halves apart under the blades.
pen.l(1.5, 20.0, 9.0, 20.0);
pen.l(15.0, 20.0, 22.5, 20.0);
pen.c(7.5, 4.5, 3.0, a);
pen.c(16.5, 4.5, 3.0, a);
pen.poly(&[(9.3, 6.9), (14.6, 18.5)], false, a);
pen.poly(&[(14.7, 6.9), (9.4, 18.5)], false, a);
pen.tri([(14.6, 18.5), (13.0, 13.5), (11.8, 14.6)], a);
pen.tri([(9.4, 18.5), (11.0, 13.5), (12.2, 14.6)], a);
pen.fill_c(12.0, 12.6, 1.3, pen.pt);
}
Extend => {
pen.l(20.0, 2.0, 20.0, 22.0);
pen.l(3.0, 12.0, 11.0, 12.0);
pen.la(11.0, 12.0, 19.0, 12.0);
pen.arrow_tip(19.5, 12.0, 1.0, 0.0, a);
pen.l(21.0, 2.0, 21.0, 22.0);
pen.l(2.0, 12.0, 9.0, 12.0);
pen.la(9.0, 12.0, 14.0, 12.0);
pen.big_arrow(20.5, 12.0, 1.0, 0.0, 7.0, 4.0, a);
}
Fillet => {
pen.l(4.0, 20.0, 4.0, 12.0);
@@ -645,7 +692,21 @@ fn paint_edit_tools(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: boo
fn paint_dimensions(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: bool) {
use Icon::*;
match icon {
DimLinear | DimAligned | DimBaseline | DimContinue | QDim => {
DimAligned => {
// The linear dimension's figure turned 13° counter-clockwise, as an aligned
// dimension follows a slanted object.
let (sin, cos) = 13f32.to_radians().sin_cos();
let r = |x: f32, y: f32| (12.0 + (x - 12.0) * cos + (y - 12.0) * sin, 12.0 - (x - 12.0) * sin + (y - 12.0) * cos);
let line = |x1: f32, y1: f32, x2: f32, y2: f32, c: Color32| pen.poly(&[r(x1, y1), r(x2, y2)], false, c);
let (y0, y1) = (9.0, 19.0);
line(4.0, y1, 4.0, 6.0, b);
line(20.0, y1, 20.0, 6.0, b);
line(4.0, y0, 20.0, y0, a);
let ((ax, ay), (bx, by)) = (r(4.0, y0), r(20.0, y0));
pen.arrow_tip(ax, ay, -cos, sin, a);
pen.arrow_tip(bx, by, cos, -sin, a);
}
DimLinear | DimBaseline | DimContinue | QDim => {
let (y0, y1) = (8.0, 18.0);
pen.l(4.0, y1, 4.0, 5.0);
pen.l(20.0, y1, 20.0, 5.0);
@@ -658,7 +719,6 @@ fn paint_dimensions(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, _dim: boo
pen.l(12.0, y1, 12.0, 5.0);
}
QDim => pen.text(12.0, 15.0, "Q", 7.0, b),
DimAligned => {}
_ => pen.text(12.0, 14.5, "1.5", 6.0, b),
}
}
@@ -845,15 +905,20 @@ fn paint_display_toggles(pen: &Pen<'_>, icon: Icon, b: Color32, _a: Color32, dim
pen.l(17.0, 13.0, 17.0, 21.0);
}
Gear => {
pen.c(12.0, 12.0, 4.0, b);
// A gear wheel: eight square teeth on a rim, and the hole in the middle.
let mut outline = Vec::new();
for i in 0..8 {
let ang = (i as f32 * 45.0).to_radians();
pen.p.line_segment(
[pen.at(12.0 + 6.0 * ang.cos(), 12.0 + 6.0 * ang.sin()), pen.at(12.0 + 9.0 * ang.cos(), 12.0 + 9.0 * ang.sin())],
Stroke::new(2.2 * pen.sc(), b),
);
let c = i as f32 * 45.0;
for (da, rr) in [(-17.0, 7.0), (-11.0, 9.6), (11.0, 9.6), (17.0, 7.0)] {
let a = (c + da).to_radians();
outline.push(pen.at(12.0 + rr * a.cos(), 12.0 + rr * a.sin()));
}
}
pen.c(12.0, 12.0, 6.5, b);
if let Some(f) = outline.first().copied() {
outline.push(f);
}
pen.p.add(egui::Shape::line(outline, Stroke::new(pen.w * 1.2, b)));
pen.c(12.0, 12.0, 3.2, b);
}
Annotation => {
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], true, b);
@@ -885,6 +950,59 @@ fn paint_navigation_controls(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32,
pen.fill_rect(x, y, 8.0, 8.0, if x == 3.0 && y == 3.0 { a } else { b });
}
}
ZoomIn | ZoomOut => {
pen.c(10.0, 10.0, 7.0, b);
pen.p.line_segment([pen.at(15.0, 15.0), pen.at(21.5, 21.5)], Stroke::new(pen.w * 2.2, b));
pen.la(6.5, 10.0, 13.5, 10.0);
if icon == ZoomIn {
pen.la(10.0, 6.5, 10.0, 13.5);
}
}
MoveTop | MoveBottom => {
// A bar at the end the arrow points to.
let (bar, tip, tail, dir) = if icon == MoveTop { (4.0, 7.0, 21.0, -1.0) } else { (20.0, 17.0, 3.0, 1.0) };
pen.p.line_segment([pen.at(5.0, bar), pen.at(19.0, bar)], Stroke::new(pen.w * 1.6, a));
pen.l(12.0, tail, 12.0, tip - 5.0 * dir);
pen.big_arrow(12.0, tip, 0.0, dir, 6.0, 4.5, b);
}
MoveUp | MoveDown => {
let dir = if icon == MoveUp { -1.0 } else { 1.0 };
pen.l(12.0, 12.0 - 8.0 * dir, 12.0, 12.0 + 2.0 * dir);
pen.big_arrow(12.0, 12.0 + 8.0 * dir, 0.0, dir, 7.0, 5.0, b);
}
ZoomFit => {
// Four right-angle corners framing a rectangle.
let s = Stroke::new(pen.w * 1.6, b);
for (x, y, dx, dy) in [(3.0, 5.0, 1.0, 1.0), (21.0, 5.0, -1.0, 1.0), (3.0, 19.0, 1.0, -1.0), (21.0, 19.0, -1.0, -1.0)] {
pen.p.line(vec![pen.at(x + dx * 5.0, y), pen.at(x, y), pen.at(x, y + dy * 4.5)], s);
}
pen.rect(8.0, 9.0, 8.0, 6.0, a);
}
TextStyle | DimStyle => {
if icon == TextStyle {
pen.text(9.5, 10.5, "A", 15.0, b);
} else {
pen.l(2.0, 4.0, 2.0, 12.0);
pen.l(15.0, 4.0, 15.0, 12.0);
pen.l(2.0, 8.0, 15.0, 8.0);
pen.arrow_tip(2.0, 8.0, -1.0, 0.0, b);
pen.arrow_tip(15.0, 8.0, 1.0, 0.0, b);
}
// A small gear: the style's settings.
let mut outline = Vec::new();
for i in 0..6 {
let c = i as f32 * 60.0;
for (da, rr) in [(-20.0, 3.6), (-12.0, 5.0), (12.0, 5.0), (20.0, 3.6)] {
let ang = (c + da).to_radians();
outline.push(pen.at(17.5 + rr * ang.cos(), 17.5 + rr * ang.sin()));
}
}
if let Some(f) = outline.first().copied() {
outline.push(f);
}
pen.p.add(egui::Shape::line(outline, Stroke::new(pen.w, a)));
pen.c(17.5, 17.5, 1.5, a);
}
Close => {
pen.l(6.0, 6.0, 18.0, 18.0);
pen.l(18.0, 6.0, 6.0, 18.0);
@@ -1025,6 +1143,11 @@ fn paint_constraints(pen: &Pen<'_>, icon: Icon, b: Color32, a: Color32, dim: boo
/// A shared button interaction with CADCraft's original multicolor icon painting.
pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {
button_bare(ui, icon, size, tooltip, selected).on_hover_text(crate::i18n::t(tooltip))
}
/// [`button`] without its hover text, for callers that show a richer tooltip of their own.
pub fn button_bare(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response {
let t = Tokens::get();
let visuals = |fill| ButtonVisuals { fill, stroke: Stroke::NONE, icon: t.icon };
let style = IconButtonStyle {
@@ -1041,7 +1164,7 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected:
paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled());
});
crate::control::record_widget(ui, &resp, tooltip, "button", Some(selected));
resp.on_hover_text(crate::i18n::t(tooltip))
resp
}
/// Labels and focus outlines for controls whose existing geometry is painted by the app.
@@ -1060,9 +1183,9 @@ pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, s
node.set_selected(selected);
}
});
if response.clicked() {
response.request_focus();
}
// A click leaves the keyboard with the drawing (shortcuts such as Ctrl+V and typed
// commands keep working after switching drawings), as the drafting buttons already do.
// Keyboard focus still shows its outline below when the tab gets it another way.
}
if response.has_focus() && response.rect.width() > 4.0 && response.rect.height() > 4.0 {
ui.painter().rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.0, Tokens::get().accent), egui::StrokeKind::Inside);
@@ -0,0 +1,89 @@
//! Ctrl+C in one open drawing, Ctrl+V in another: the objects are pasted there.
use cadcraft_engine::doc::EntityKind;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Input, Session};
use cadcraft_ui_egui::{CadApp, Services};
use egui::{Event, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(app: CadApp) -> Harness<'static, CadApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.run_steps(5);
h
}
#[test]
fn copy_in_one_drawing_and_paste_in_another() {
// A DXF writer for the clipboard text (the app installs the real one).
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
read: |_, _| Ok(cadcraft_engine::doc::Drawing::new_metric()),
write: |_, _| Ok(b"0\nSECTION\n0\nENDSEC\n0\nEOF\n".to_vec()),
plot: None,
});
let mut app = CadApp::new(Session::new(), Services::default());
app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();
app.run("circle", json!({ "center": [5, 5], "radius": 2 })).unwrap();
app.run("block", json!({ "name": "DOT", "base": [5, 5], "handles": [app.session.doc().unwrap().model.iter().nth(1).unwrap().handle.hex()], "keep": "convert" }))
.unwrap();
let first = app.session.active;
let all: Vec<_> = app.session.doc().unwrap().model.iter().map(|e| e.handle).collect();
app.session.set_selection(all);
let mut h = harness(app);
h.event(Event::Copy);
h.run_steps(2);
// The copy is mirrored to the system clipboard as text (what another drawing or another
// CADCraft window pastes): checked on a frame of its own, where egui's output is kept.
let text = {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::clipboard::mirror(h.state(), ui.ctx()));
out.textures_delta.clear();
out.platform_output.commands.iter().find_map(|c| match c {
egui::OutputCommand::CopyText(t) => Some(t.clone()),
_ => None,
})
};
let text = text.expect("the copy is on the system clipboard as text");
assert_eq!(
h.state().session.clipboard.len(),
2,
"{:?}",
h.state().session.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::<Vec<_>>()
);
// Another drawing, made active by clicking its tab (which must not keep the keyboard).
h.state_mut().run("new", json!({})).unwrap();
h.run_steps(3);
let second = h.state().session.active;
let title = h.state().session.docs[second].title.clone();
h.state_mut().run("document.switch", json!({ "index": first })).unwrap();
h.run_steps(3);
h.get_by_label(&title).click();
h.run_steps(3);
assert_eq!(h.state().session.active, second);
assert_ne!(second, first);
assert!(h.state().session.doc().unwrap().model.is_empty());
assert!(!h.ctx.egui_wants_keyboard_input(), "the clicked tab doesn't hold the keyboard");
// Ctrl+V: egui reports it as a Paste event with the system clipboard's text.
h.event(Event::Paste(text));
h.run_steps(3);
assert_eq!(
h.state().session.running.as_ref().map(|r| r.id.as_str()),
Some("pasteclip"),
"{:?}",
h.state().session.log.iter().rev().take(5).collect::<Vec<_>>()
);
h.state_mut().session.input(Input::Point(Vec2::new(100.0, 100.0))).unwrap();
h.run_steps(2);
let d = h.state().session.doc().unwrap();
assert_eq!(d.model.len(), 2);
assert!(d.model.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "DOT")));
assert!(d.block("DOT").is_some());
}
+1
View File
@@ -1,6 +1,7 @@
mod animation_effects;
mod block_description_palette;
mod chrome_geometry;
mod clipboard_between_drawings;
mod context_menu;
mod context_menus;
mod control_cmdline;