egui-winit turns Cmd+C, X and V into clipboard events whatever else is held,
so Alt variants are refused too. Preferences that give one key to two
commands keep it for the first and drop the other, so no menu shows a
shortcut that runs something else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
A recorded or JSON Ctrl+K (macOS Control) was stored as Cmd+K and Cmd+Ctrl
lost its Control: shortcuts are now written the same on every platform,
Cmd for the command key and Ctrl for a Control that isn't it. Copy with
Base Point and Paste as Block arrive as clipboard events like Copy and
Paste, so their keys can't be given to another command and they keep theirs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRnJvmr59vS3DS5p2E8FhG
Tools ▸ Customize ▸ Keyboard Shortcuts lists every command with its
shortcut, a filter, and a button to record a new one (click, press the
keys, Esc cancels), Clear, Default and Reset to Defaults. A shortcut the
user gives wins over a built-in one: the command that had it loses it and
the dialog says which; menus show the shortcut in force. Shortcuts are
kept with the preferences (`shortcuts`), hostile entries dropped. JSON
form `ui.shortcuts {set: {command: key or ""}, reset}` never opens the
dialog. Clipboard keys, Delete and Cmd+Q stay as they are.
Recording and the conflict rules come from the storey shortcuts of #1703.
Co-authored-by: Alex-BB44 <273073052+Alex-BB44@users.noreply.github.com>
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
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.
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.
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.
* 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
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.
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.
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).
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).
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.
* 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
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.
* 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.
The folders are made by a new writer only user-named targets use, so autosave
and a dangling symlink's target keep the strict write_atomic. Save, Save As,
Export, Plot, WBLOCK, layout templates, render and the CLI report the topmost
new folder at the command line and in createdFolder.
* DWG: a table cell's text height is an override only when the file flags it (#2501)
acadrust's DXF writer writes group 140 for any cell with a style record, even a
default 0.18 with no override flag, and CADCraft read it as a per-cell height,
so cells ignored the table style's height. The bridge now clears the height of a
cell style that has neither the TEXT_HEIGHT property flag nor the content's
height override bit (0 reads as no override).
* Parity checklist: regenerated after merging main
* Tests: inherited table cell heights must stay unset (review on #2588)
A cell that inherits its height must read back as None, not an explicit 0.0625 override.
* Parity checklist: regenerated after merging main
* Test: a cell style with the TEXT_HEIGHT flag, content override bit 128 and a cell with no style record keep the right height (review on #2588)
* DWG: a table cell's content height override (group 144) is kept (review on #2588)
* Parity checklist: regenerated after merging main
* DWG: table cell height takes the first content's override and the style's override flags (review on #2588)
Only the first content's bit-128 height stands for the cell (one height for its text); an
invalid content height no longer keeps the writer's default 0.18; a height marked only in
the style's override flags (newer files) is kept.
A damaged DWG opens partially since #859 and the drawing's read notes
say how many object records were left out. OPEN echoes them and MCP
`open` returns them as `warnings`, but `cadcraft-cli info` and
`convert` stayed silent: a corpus round trip wrote thousands of skipped
records away without a word.
- convert: each read note goes to stderr as `IN: <note>`, next to the
skipped-records warning (#872). The exit code stays 0.
- drawing.inspect (and so info, MCP inspect_drawing and the control
channel): new field `readNotes`, next to `skipped`. Existing fields
are unchanged.
- run already prints OPEN's notes with the session log.
- README and docs/control-protocol.md say where the notes appear.
Part of #1039.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* render: a display list past the block-expansion budget says what it left out
* Report viewport truncation only after the expansion budget is exceeded
* fmt: keep ui-egui control.rs rustfmt-clean
Found while testing crates/io/src/dxf_write.rs:1964-1970 (xrecords written) and crates/io/src/dxf_read.rs:2049-2052 (apply)
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
* DWG: scale actions keep the byte AutoCAD reads after their connections (acadrust 0.6.5)
AutoCAD's DWG scale action (BLOCKSCALEACTION) ends with one byte after its
three connections that DXF doesn't carry. acadrust 0.6.4 neither read nor
wrote it, so AutoCAD read past the object and refused every DWG CADCraft
wrote with a dynamic block that scales ("Error Decrypting Data"), even an
unchanged one. acadrust 0.6.5 keeps the byte (dwg_tail_byte).
0.6.5 also maps the dimension flags byte's bit 0 to text_user_positioned
both ways, so the bridge no longer inverts it (it would now double-invert);
it still sets bit 1, which acadrust keeps no DXF source for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* DWG: acadrust 0.6.6; multi-layout DWGs get unique paper space names
0.6.6 keeps the scale action byte and the dimension flag mapping this branch
relies on, and writes only the active layout as *Paper_Space (others get
unique *Paper_SpaceN names; hakanaktt/acadrust#143), so ACadSharp opens a
DWG CADCraft saves with more than one layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io tests: a scale action survives CADCraft's own DWG save
The cadcraft-dwg test builds its DXF with acadrust's writer; this one goes
through cadcraft_io::write (the bridge DXF, then acadrust) with a scale
action added to the synthetic Door fixture, and checks the DWG holds it
with its connections and reads back as the same action.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: block description field ids from Ui::scope_id (egui 0.37 deprecates Ui::id)
main's gate fails clippy -D warnings on the one Ui::id() call (#2113);
Ui::scope_id is its new name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* DWG: the scale action byte doc names acadrust 0.6.6, the version in use
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Docs: leave the parity docs to the maintainers (AGENTS.md: PRs don't edit them)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A DIMENSION moved by an older AutoCAD keeps its *D block contents where
they were and the move in DXF group 12, and is drawn translated by the
insertion point. Group 12 was never read into Dimension, so the block
rendered at its pre-move position and the extents reached there too.
Read group 12 into a new Dimension::insertion_point, draw the block
translated by it, move its bounds with it in the extents, test the
block's text where it draws, and write group 12 back whenever the block
written is the one read from the file. Regenerated geometry (edits drop
the block link) sits at its definition points, so no offset is kept or
written for it.
Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>