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
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
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
* 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
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.
- 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.
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.
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
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
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>
* 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>
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.
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.
* 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>
* 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>
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.
* 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>
* 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>
* 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>
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>
* 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
* 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
* 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