Avoid full document, cache, and GPU rebuilds for entity deltas so add, delete, history, selection, and live polyline edits stay responsive on large drawings.
Hold edit leases and compare disk fingerprints before replacement so another editor cannot be silently overwritten.
Offer retry, reload, Save As, or explicit overwrite recovery.
Closes#498
Replace full-document snapshots with Arc first-touch deltas and batch cache invalidation. Reuse interaction and GPU category caches across unrelated edits.
PURGE left behind three classes of dead data that AutoCAD removes,
so an OCS-purged drawing stayed far larger than AutoCAD's (a 23 MB test
file purged to 3.5 MB in OCS vs 0.58 MB in AutoCAD):
- Anonymous blocks (`*U`/`*D`/`*T`/dynamic-block variants) were skipped
wholesale. Leftover unreferenced ones now purge like AutoCAD's, held
live only when actually referenced.
- Block liveness now comes from REACHABILITY: a block is kept only if it
is reached from a real layout container by following block references
(INSERT names, dimension *D, table *T / cell blocks, MultiLeader
content). The old flat "named by some INSERT" scan could not free a
mutually-referencing dead subgraph — e.g. the dependent blocks a
detached xref leaves behind, whose head carried a DANGLING layout
handle that `is_layout()` mistook for a real layout and shielded. That
subgraph (here 467k lines) is now purged; live xrefs, reached from the
layout that inserts them, stay. The used-set also gains the by-handle
block references (table/MultiLeader/dimstyle-arrow) so purging
anonymous blocks can't delete an in-use block no INSERT names.
- SortEntitiesTable objects (per-block draw order) were never removed, so
each purged block's table lingered still listing the handle of every
deleted entity — 341k stale handles / ~2.7 MB of dead weight the save
re-emitted. Drop every table whose owning block is gone.
Net on the test file: 3.5 MB -> 0.81 MB (AutoCAD 0.58 MB).
Co-Authored-By: Claude Fable 5 <[email protected]>
PICKADD 0 = click/box/lasso replaces the selection (Shift toggles),
empty click clears; PICKADD 1 (default) keeps today's accumulate.
PICKDRAG 1 = press-drag spans a rectangle marquee through the box
machinery; PICKDRAG 0 (default) keeps the freeform lasso. Both persist
in settings.json. Also re-points the start-page gate test at the
start_allowed fn it moved into (#388/389 refactor left it broken).
Closes#226
Co-Authored-By: Claude Fable 5 <[email protected]>
PEDIT reworked around a mode machine:
- Picking a line/arc asks "Object is not a polyline. Turn it into one?
[Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and
editing continues on the result. Pickfirst: an already-selected
polyline skips the select step, a selected line/arc goes straight to
the convert prompt.
- Join gathers with the normal selection system (single picks AND
window/crossing boxes) and merges through the JOIN machinery.
- Fit replaces every segment with a classic equal-parameter BIARC: two
arcs per segment meeting tangentially at an inserted knee, both
segments at a vertex sharing that vertex's tangent — the whole run is
tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S
shapes). Spline resamples the vertex frame as a uniform cubic
B-spline (8 samples per span); Decurve straightens every bulge.
- Earlier hardening kept: undo snapshot before the mutation, immediate
repaint, polyline-only pick validation.
- Vertex editing lives on grips, so no Edit-vertex submode; instead the
vertex grip hover menu gains Break — a closed polyline opens at the
vertex, an open one splits into two (interior vertices only),
handled driver-side as an entity replacement.
JOIN itself: segs_of now emits one segment per span of an OPEN
polyline (bulges kept), so polylines merge with their neighbours —
PEDIT Join's set always contains the target polyline and used to abort
every time; plain JOIN had the same gap. JOIN also honours pickfirst:
launched with two or more objects selected it joins them immediately.
Closes#263
Co-Authored-By: Claude Fable 5 <[email protected]>
PLAN acted like a ViewCube TOP click the moment it ran — already on Top
meant an immediate flip to Bottom, and the options never appeared. It
now prompts "[Current ucs / Ucs / World] <Current>:" right on
activation and only changes the view once an option runs: Current =
plan of the active UCS, World = world XY, Ucs = prompts for a named UCS
(case-insensitive lookup). The new plan_snap goes straight to the plan
view with no flip-to-opposite cube behaviour.
KeywordCommand gains with_default(): a bare Enter at the verb step
dispatches the default verb (PLAN's <Current>) instead of cancelling.
Closes#326
Co-Authored-By: Claude Fable 5 <[email protected]>
- RENAME gains the BLOCK type (stepped button + inline arm) via the
shared Scene::rename_block.
- Stepped command front-ends (Rename, UserReg, Keyword, TwoValue,
SelectThenTwoValue) returned None after consuming a typed token, and
the driver treats None as "not consumed" — the same text was offered
again and advanced two steps at once (RENAME dispatched old==new,
SETVAR "X" became "SETVAR X X"). Consumed-but-prompting inputs now
return NeedPoint.
- Symbol renames re-key the name-keyed Table (remove -> rename -> add)
instead of mutating .name in place, which left the map keyed by the
old name so later lookups silently failed. References (entity
layer/linetype/style fields, layer linetypes, header current-* names,
dim style refs on Dimension/Leader/Tolerance) are chased
case-insensitively; layer 0/Defpoints and the built-in linetypes are
protected; the undo snapshot is taken before the mutation so undo
actually restores the old name.
Co-Authored-By: Claude Fable 5 <[email protected]>
Tool highlights used to stick: every click set active_tool, but only
interactive commands and a hand-maintained per-modal list ever cleared
it, so one-shots (Cleanup/AUDIT, view actions) and unlisted dialogs
(Customization/ALIASEDIT, CUI, Plugin Manager…) stayed blue forever.
Now the rule is mechanical: state toggles light from state only (the
click highlight drops immediately), interactive commands stay lit
while running, dialog openers stay lit until the modal closes (generic
clear replaces the per-modal list), and one-shots never keep it — the
dispatch site turns the highlight off when nothing stayed running.
On the Start tab, tools whose command the start gate refuses now render
dimmed (grey icon + label) across all button styles, dropdowns and the
quick-access strip; start_allowed() is the single shared authority.
Co-Authored-By: Claude Fable 5 <[email protected]>
ZOOM alone used to jump straight into window zoom. It now offers
Window / Extents / All / In / Out / Scale as clickable keywords
(Scale collects the factor in a second step); ZW/ZE/… shortcuts and
the spelled sub-forms still run directly.
Co-Authored-By: Claude Fable 5 <[email protected]>
Bare STRETCH used to need a separate object-selection round before the
crossing window. Now the window itself picks the objects: with no prior
selection the command emits StretchWindow, the host gathers entities
whose world AABB touches the window (locked layers excluded) and
restarts at the base-point step. Over-selection is harmless since only
points inside the window move. The noun-verb flow (select first, then
STRETCH) is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
PURGE now repeats until a pass removes nothing, so definitions freed by
a purged block (nested blocks, layers, linetypes, styles) go in one run.
Reference scan keeps layer linetypes, ATTDEF/ATTRIB text styles, and
dimstyle DIMTXSTY + arrow blocks; the undo snapshot moves before the
first removal so one UNDO restores everything. Bare PURGE prompts with
clickable category options (aliases B/LA/LT/ST run directly), and
pinned prompts drop their "[A / B / …]" listing when the same options
render as buttons.
Co-Authored-By: Claude Fable 5 <[email protected]>
Modern DWG (R2000+) has no file slot for OSMODE — reading a file left
the header at 0, and adopt_header_sysvars wiped the user's running snap
set on every open. Persist the set (as an $OSMODE-style bitmask) in
settings.json, save it on every snap toggle, and only let a file
override it when it actually carries a nonzero mask (R13/R14 DWG, DXF).
Co-Authored-By: Claude Fable 5 <[email protected]>
XATTACH / XRELOAD merge the xref's layer and linetype tables into the
document, but the Layers panel cache only rebuilds via
sync_with_viewports - which nothing triggered until the file was saved
and reopened. Refresh the layer panel (and ribbon mirrors) right after
the merge in both paths.
Co-Authored-By: Claude Fable 5 <[email protected]>
OFFSET now follows the pick-first flow of MOVE / COPY (#422): with
offsettable objects already selected it skips the pick step and goes
straight to the distance / side step, offsetting every selected object
toward the clicked side — each through the cursor point in through
mode, all by the typed magnitude once one is locked. Preview covers
every target and a multi-object result commits as one undo step; with
nothing selected the command gathers a pick exactly as before.
Co-Authored-By: Claude Fable 5 <[email protected]>
REFCLOSE only wrote the temp copies made at REFEDIT entry back into
the block, so anything drawn (or pasted / offset) during the session
stayed behind in model space (#423). The session now captures a
handle watermark at entry; on close, every current-space entity
allocated at or above it joins the working set — Save folds it into
the block through the same inverse transform, Discard removes it.
Covers every creation path with no per-command hooks.
Co-Authored-By: Claude Fable 5 <[email protected]>
Entering the block editor reframes the camera on the block's own
geometry, but nothing remembered where the model view was — Save /
Discard returned to the layout with the camera still parked at the
editor's framing (#425). The session now captures the camera on entry
and both exits restore it exactly.
Co-Authored-By: Claude Fable 5 <[email protected]>
Add SelectThenTwoValueCommand (gather a selection, then two values) and use it
for POLYSOLID: bare POLYSOLID gathers the polyline if none is selected, prompts
for wall width then height, and dispatches POLYSOLID <width> <height>.
SCRIPT/SCR, DATALINK, LANDXMLIMPORT, CUIEXPORT, CUIIMPORT/CUILOAD now prompt
for the path via ValuePromptCommand instead of printing usage when run bare;
each dispatches `NAME <path>` to the existing inline handler. The path keeps
its case (dispatch does not upper-case arguments), so case-sensitive paths on
Linux/macOS still resolve. A native file dialog would be a nicer follow-up.
Add SelectThenValueCommand (gather selection, then one value) and convert:
- SelectThenValueCommand: HYPERLINK (url), ARCTEXT (text), TEXTFIT (width),
SCALETEXT (factor), TCOUNT (start), TEXTMASK (Enter to apply).
- SelectThenKeywordCommand: JUSTIFYTEXT (Left/Center/Right/Middle/Aligned/Fit),
TCASE (Upper/Lower/Sentence/Title).
Each bare form gathers a selection when none is set, prompts, and delegates to
the inline handler. OBJECTSCALE already opens its dialog; TABLE CELL takes three
values (row/col/text) and stays typeable — TABLE bare already builds a table
interactively.
Bare COUNT now offers [All (by type) / By layer] as buttons instead of
immediately dumping the by-type tally. "All" dispatches COUNT TYPE, whose
output is identical to the old bare COUNT; "By layer" dispatches COUNT LAYER.
Counting a specific layer (COUNT <layer>) stays typeable. Finishes bucket B.
Add SelectThenKeywordCommand: if nothing is selected it first gathers a
selection (Enter confirms, following the ALIGN contract), then shows the
sub-verbs as buttons like KeywordCommand and dispatches `<name> <verb>
[value]` to the inline handler, which reads the still-selected set.
Convert CHPROP, ADJUST, XDATA, UNDERLAY and DRAWORDER. Verbs the single-value
generic can't express keep working by typing the full line: XDATA SET
<app> <value> and DRAWORDER Above/Under <handle> (a reference pick) are left
off the buttons; CHPROP/ADJUST/UNDERLAY are fully covered.
Verified end to end: SELECTALL then `CHPROP LAYER <name>` moves the selected
entity to that layer through the stepped front-end.
Convert the remaining bucket-B commands to prompt step by step instead of
requiring arguments or printing usage:
- KeywordCommand (buttons): VIEW, LINETYPE, SCALELISTEDIT, UCSICON, VSCURRENT.
- ValuePromptCommand (one value): LAYDEL (layer), ATTSYNC (block).
- TwoValuePromptCommand (new; two values): LAYMRG (source + target),
SETVAR (variable + value, blank second reads).
Each bare form now starts the stepped front-end and delegates to the existing
inline handler; the argument form is kept and is the dispatch target. The
direct visual-style shortcuts (HIDDENLINE / XRAY / …) and UCSICON's argument
form are untouched.
COUNT is intentionally left alone: bare COUNT already reports the tally (a
useful inquiry action, not a usage line), so prompting would only add friction.
Add KeywordCommand, a generic stepped front-end for keyword sub-verb
commands: bare NAME shows the sub-verbs as clickable buttons, and picking
one either dispatches `NAME <verb>` (action verbs like ATTDISP On/Off/Normal)
or prompts for an argument and dispatches `NAME <verb> <value>` (LAYERSTATE
Save/Restore/Delete <name>). It delegates to the existing inline handler, so
the logic stays in one place. First two of bucket B converted; the rest
(VIEW, LINETYPE, SCALELISTEDIT, SETVAR, LAYDEL/LAYMRG, and the selection-first
XDATA/UNDERLAY/ADJUST/DRAWORDER/CHPROP) can follow the same pattern.
The Annotation Object Scale dialog (advertised in v0.8.2) was dead code:
bare OBJECTSCALE was caught by the immediate "mark selection annotative"
arm, so the `"OBJECTSCALE" =>` dialog arm below it never ran and nothing
else opened the dialog. Move the quick mark to an explicit `OBJECTSCALE ADD`
keyword (what the ribbon "Add Scale" button now sends, dispatched whole so
it doesn't collide), leaving bare OBJECTSCALE to open the dialog.
USERI/USERR set one of five integer/real header registers and needed both
inputs (register + value) typed on one line; bare printed usage. Add a
two-step front-end (UserRegCommand): it prompts for the register as 1–5
buttons, then the value, and dispatches USERI/USERR <n> <value> to the
existing inline handler. The argument arm now requires a space so the bare
name reaches the stepped command.
Bare CLAYER / CDIMSTY / COLOR / LWDISPLAY / ISAVEBAK / FILEASSOC /
SAVETIME / ANNOSCALE printed the current value or a usage line and
stopped; only the fully-typed `NAME <value>` form set anything. Route the
bare form through ValuePromptCommand (the pattern LTSCALE / PDMODE / PDSIZE
already use): it prompts for the value and re-dispatches `NAME <value>` to
the existing inline handler, so the set/get logic stays in one place. The
bare-Enter getter is preserved — ValuePromptCommand dispatches `NAME `,
which the argument arm reports the current value for (added that empty
case to ISAVEBAK / FILEASSOC / SAVETIME, which previously errored on it).
COLOR keeps BYLAYER as an immediate shortcut rather than a prompt.
USERR / USERI are left as-is: they take two inputs (register 1-5 + value),
so they need a two-step command rather than the single-value prompt.
Bare RENAME printed a usage line and did nothing; only the fully-typed
`RENAME <type> <old> <new>` form worked. Add a stepped front-end
(RenameCommand) that prompts for the object type — as clickable buttons
(Layer, Text style, Dim style, Linetype, UCS, View) — then the current
name, then the new name, and dispatches to the existing inline handler.
The rename logic stays in one place; the argument form is kept and is also
the front-end's dispatch target. BLOCK is left off the buttons because the
inline handler does not implement it.
Never fail silently on PDF export; add EXPORTPDF <path> (#369).
The export flow handed off to an async rfd save dialog and mapped a None
result straight to Task::none(). On Linux None also means the dialog never
opened (broken XDG portal / missing zenity), and rfd reports that only via
log, so PDF export could end in total silence. The dialog-None arms now
print a message pointing at EXPORTPDF <path>, and EXPORT/EXPORTPDF take an
optional path that exports straight to a file with no dialog — the
workaround where the native dialog can't open, and a headless entry point.
Conflict: the fix (display.rs, file.rs, mod.rs, COMMANDS.md) auto-merged;
only the additive test block in automation.rs conflicted. Resolved by
keeping main's tests and adding the two #369 tests.
# Conflicts:
# src/app/automation.rs
Donate, Send Feedback, OCS Web and Manage > About all answered "No
drawing open — use New or Open first." and did nothing.
Two doors guarded the welcome tab and the outer one shadowed the inner.
`dispatch_command` already knew which commands stand alone — About,
Donate, Report and the links were all on its list — but
`on_ribbon_tool_click` refused every ribbon event before dispatch was
ever reached.
The welcome page's own buttons emit RibbonToolClick, so they are only
ever clickable while `is_start` holds: the refusal covered exactly the
case they exist in, and they had never worked. New Drawing / Open File /
Plugins send their own messages, which is why the page still looked
alive.
So the ribbon door now defers commands to dispatch and keeps refusing
only what touches the scene. The policy stays in one place instead of
being half-remembered in two. A drawing tool is still turned away on the
welcome tab (#299) — the wording just comes from dispatch now.
The same gap was swallowing tools the issues never mentioned: CUI and
ALIASEDIT configure the application, not a drawing, and were missing
from the list, so fixing only the ribbon door would have moved their
refusal one door down rather than removing it. HELP and CUILOAD /
CUIIMPORT join them.
The new test drives the real message path and fails on the old code with
the reported wording. It reads the allowlist from source rather than
dispatching DONATE / REPORT / WEBVERSION, which shell out to a browser —
that also fails the day someone adds a welcome-page button and forgets
the list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY,
draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on
every edit — the last big per-edit cost after the incremental-render work.
They now record only the touched entities' before/after images:
- Scene self-records through the five mutation primitives when a recording is
open (UndoRecording + is_recording_undo guard, zero cost off-path).
- apply_entity_delta installs one side per handle (in-place / re-insert with
the original handle / remove) plus a per-handle cache reseed, then a single
bump_entities so the incremental caches patch instead of a full re-tess —
undo/redo of a small edit is fast too, not just the edit.
- HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves
between the undo/redo stacks without re-capturing. Commands that touch
non-entity state (new layer, group, *D block, viewport) fall back to the
full snapshot via per-command predicates, and a poison flag debug-asserts
any mis-scope.
remove_entity leaves the handle dangling in its owner block record's
entity_handles (harmless on its own, a lookup miss is skipped), so a
re-insert strips stale entries first — otherwise add_entity would list the
handle twice and the DXF/DWG writer would emit the entity twice.
Round-trip tests cover transform/erase/add/copy (do->undo->redo equality,
handle preservation, no duplication).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Export to PDF could end in complete silence: the flow hands off to an
async rfd save dialog, and when that future resolves to None the app
mapped it straight to Task::none(). None does not only mean the user
cancelled — on Linux the XDG portal SaveFile call can fail outright
(rfd then tries a zenity fallback, which may not be installed) and the
future still just resolves to None. rfd reports those failures only
through the `log` crate, and logging is opt-in (--log / RUST_LOG), so
the reporter saw exactly what #369 describes: no dialog, no error, no
console output, no file.
- EXPORT / EXPORTPDF now accept an optional path argument that exports
straight to the file with no dialog — both the workaround for systems
where the native save dialog cannot open and a headless-testable
entry point. A relative path lands next to the drawing; a missing
extension gets .pdf.
- The dialog-None arms (PlotExportPath / PlotWindowExportPath) now push
a command-line message pointing at EXPORTPDF <path> instead of doing
nothing.
- The success message prints the full output path, not just the file
name, so users can find the PDF.
Verified end to end on the real binary via --serve: LINE + CIRCLE then
EXPORTPDF <path> writes a valid PDF with the expected geometry. Both
new tests fail on the previous code.
Fixes#369
Co-Authored-By: Claude Fable 5 <[email protected]>
Selecting a block and running BEDIT (command, ribbon button, or a
double-click on the block) opens a block-editor "space" tab, named after
the block, alongside Model/Layout in the status bar. The tab draws ONLY
the block record's own entities in block-local coordinates; edits are
live on the definition, so every INSERT updates. Save Block / Discard
buttons (side toolbar) finish the session; Discard restores the on-entry
snapshot. Space switching is locked while a block is open, and BEDIT and
REFEDIT are mutually exclusive.
Implementation: a Scene.block_edit_block override at the top of
current_layout_block_handle scopes render/pick/new-entity ownership to the
edited block while current_layout stays "Model" (so the pervasive
Model-vs-paper code keeps model behaviour). The model render/pick paths
that hardcoded model_space_block_handle (wire tile set, hatch render +
pick, block-instance meshes) are made binding-aware so the block isolation
actually takes effect and model content does not leak in. Entering fits
the camera to the block (fit_all already scopes via the binding). Renaming
the block tab renames the block itself — new Scene::rename_block re-keys
the record, updates the Block marker and repoints every INSERT reference.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Make objects genuinely annotative — carrying real per-scale
representations that interoperate with other CAD apps — rather than just
a native flag. `create_annotation_context` synthesizes the extension-dict
chain (AcDbContextDataManager -> ACDB_ANNOTATIONSCALES -> per-scale leaf)
from the entity's placement; the editable Annotative toggle now covers
Text and block references as well as MText/MLeader; and a new
"Annotation Object Scale" dialog (OBJECTSCALE) adds/removes an object's
per-scale memberships. Requires the acadrust ObjectContextData encoder
(Cargo.lock bump).
Also fixes a render bug where objects carrying an *empty*
ACDB_ANNOTATIONSCALES (a single-representation marker with no per-scale
reps) were treated as annotative and (mis)scaled by the annotation factor
in scaled paper viewports, ballooning the text: an object is now
annotative-by-context only when its scale collection is non-empty, so
such objects render at their base geometry.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The drawing's annotation-scale list (ACAD_SCALELIST) is now editable, so the
set of scales offered by the status-bar scale pill can be grown or trimmed:
- Scene::add_scale / remove_scale create or drop a Scale object and its
SCALELIST dictionary entry (creating the SCALELIST dictionary, and
registering it in the root, for drawings that carry none).
- SCALELISTEDIT gains ADD <ratio> / DELETE <name> alongside the existing
listing (e.g. `SCALELISTEDIT ADD 1:50`).
- The scale popup (model space) grows a delete affordance on each non-active
scale and a "+ Add scale..." row that primes the command line. The current
annotation scale is protected from deletion.
Co-Authored-By: Claude Opus 4.8 <[email protected]>