Arcs are now full constraint participants (center/radius via the existing
Circle-shaped solver path, plus real endpoints kept consistent by arc-rules
CurveValue constraints); adds Diameter/DistanceX/DistanceY/ArcLength/Normal
constraint kinds and initial Ellipse entity support (center-based
constraints only). DWG-native class mappings and gaps (ArcLength, Ellipse)
are grounded against real ObjectARX headers rather than guessed, so files
saved with DWG-native constraints stay honest about what AutoCAD can
actually represent.
Also fixes a case-sensitivity bug where a lowercase named-parameter
reference typed at a dimensional constraint's value prompt was rejected
(the command line uppercases SingleToken input before the parameter-name
lookup), and adds a "Used by" column to the Named Parameters panel.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
`new_for_test` was `Self::new()`, and `new` loads the real settings file. So
every test ran against whatever the person running it had last set in the
application. It surfaced as `a_selection_past_the_limit_gets_no_grips`
failing on a machine where GRIPOBJLIMIT had been changed by hand: the test
expected the default limit and got 32767. On a clean checkout, and in CI, it
passes — which is the worst shape for this kind of bug.
Tests now start from `AppConfig::default()`.
That alone made things worse rather than better. The suite builds whole
applications and changes preferences on them, and `AppConfig::save` writes to
the real path, so a test run would have written *defaults* over the user's
file where before it wrote back roughly what it had read. `save` is now inert
under `cfg(test)`.
Both halves matter: the first makes the suite deterministic, the second makes
it harmless. This series adds around fifteen persisted settings, so every one
of them was another way for a developer's own configuration to turn the suite
red — or to be quietly overwritten by it.
Co-Authored-By: Claude Opus 5 <[email protected]>
**TEXTFILL marked the drawing modified.** It is an application preference —
it lives in a process global and is snapshotted into settings.json — but it
was missing from the list of names that persist, so setting it dirtied the
open drawing instead and never reached the settings file through that path.
**LTSCALE and CELTSCALE validated differently depending on the door.** The
standalone commands reject a value of zero or less, because a non-positive
linetype scale degenerates every dash pattern in the drawing. `SETVAR LTSCALE
-5` accepted it. A test drives both entry points for both variables and fails
on the SETVAR path without the fix.
**ISAVEBAK, SAVETIME and FILEASSOC were missing from the autocomplete
registry** although all three dispatch, so the command line never offered
them.
**`bg_color` and `paper_bg_color` were dead fields**, always written as `None`
and ignored on load since the model-space configuration superseded them.
Removing them is safe: the config is `#[serde(default)]` and ignores unknown
keys, so existing settings files simply stop carrying them.
One thing the survey flagged that turned out not to be a bug: on the Display
page the model background row hides itself outside Custom mode while the
paper and desk rows do not. That asymmetry is correct — the canvas mode
governs only the model background, and the other two apply in every mode.
Co-Authored-By: Claude Opus 5 <[email protected]>
The application has no support-file search path — the four folders it uses
are functions returning fixed paths, not settings. Rather than build a Files
page that pretends otherwise, this one answers the question people actually
open it to ask: where does this thing keep my plot styles. Each row shows the
location and opens it in the file manager, and a row whose path the platform
could not give says so with its button disabled.
General gains buttons through to the plugin manager, the keyboard shortcut
editor and the alias editor. Those three have good dialogs of their own and
are not absorbed; Options simply stops being a place you cannot reach them
from.
Co-Authored-By: Claude Opus 5 <[email protected]>
The ViewCube, the UCS icon, its at-origin placement and selection cycling all
have commands and status-bar pills, and all four kept their value only on the
app struct. Turn the ViewCube off with NAVVCUBE, restart, and it is back.
Nothing failed — the setting evaporated — so a checkbox for any of them would
have been a checkbox that forgets. They are persisted first, with a
round-trip test, and only then given controls.
The new 3D Modeling page carries the three view tools plus the drawing
variables that decide how solids are tessellated: ISOLINES, DISPSILH, SURFU,
SURFV, SURFTYPE, SOLIDHIST and SHOWHIST. Those are saved in the DWG rather
than in preferences, so the page says so beside each group and hides them
entirely when no drawing is open.
Changing them also rebuilds the geometry. The SETVAR path never did, which is
why the comment beside LTSCALE's invalidation exists — the same reasoning
applies here, and without it a new isoline count does not appear until some
unrelated edit rebuilds the solid.
Selection cycling joins the Selection page next to the other two selection
modes, since that is what it is.
Co-Authored-By: Claude Opus 5 <[email protected]>
Two drafting preferences had no proper home. The Drafting Settings dialog
displays the drafting rotation and offers only a Reset button — there was no
way to type an angle short of `SETVAR SNAPANG`. The polar tracking increment
existed in exactly one place: a pop-up hanging off the status-bar pill.
The rotation is a text field over an edit buffer rather than the value
itself, so clearing it to retype does not snap the crosshair to zero on the
way through; the buffer is reseeded whenever the value is restored from
behind it. Angles render without a trailing `.0`, matching how the polar
pop-up has always shown them — same kind of number, same reader.
Snap modes, grid spacing and object snap stay in Drafting Settings, where
they belong; the page links to it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Zoom direction and factor, whether TEXTEDIT ends after one object, whether
continued dimensions inherit the base dimension's style, which points QDIM
measures from, and which annotative objects pick up a newly set scale. All
six work today; none had a control.
Two of them needed their meaning worked out rather than copied.
ANNOAUTOSCALE stores the mode in the magnitude and on/off in the sign, and
the four modes are not documented anywhere in the app — the command prompt
just says `[-4..4]`. Reading `add_annotation_scale_to_objects` gives them:
1 skips layers that are off, frozen, locked or frozen in the viewport, 2
keeps the locked ones, 3 skips only locked, 4 takes everything. The dropdown
names them instead of showing bare numbers, and choosing Off negates rather
than zeroing, so the mode survives being switched off and back on — which is
the convention the status-bar pill already follows.
The QDIM origin priority had no system variable at all; it could only be
reached through a keyword inside the running command.
Drawing units stay in their own dialog; this page links to it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Three settings that work, persist, and could only be reached by typing their
name: TEXTFILL, CLIPROMPTLINES and COMMANDLINEFADETIME. The last two govern
how much of the command window is prompt and how long its history lingers —
the kind of thing people fiddle with once and then leave, which is exactly
what a settings dialog is for.
One fix rides along. TEXTFILL is a process-global, so changing it invalidates
the text in every open drawing; the SETVAR path rebuilt only the active tab,
leaving the others rendering the previous setting until an unrelated edit
happened to rebuild them. Both paths now go through
`invalidate_text_everywhere`, so the checkbox and the command cannot disagree.
Co-Authored-By: Claude Opus 5 <[email protected]>
General had become three unrelated controls: a language picker, a save format
and a file association. The last two move to an Open and Save page, where
they join the two saving preferences that have never had a widget.
Automatic save has worked since the SAVETIME command was added and is
persisted; the only way to change the interval was to type it. Same for the
`.bak` copy from #205 and its ISAVEBAK command. Both are settings people
expect to find in a dialog, and neither was in one.
The page also carries a button through to the Plot dialog. Options holds the
preferences; the plot device, paper and plot styles stay where they already
work well. Note that the modal stack shows one dialog at a time, so the
button replaces Options rather than stacking on it.
Co-Authored-By: Claude Opus 5 <[email protected]>
The dialog laid its pages out as a row of buttons across the top, which is
what AutoCAD does and what fits while there are four of them. The pages this
series adds take it to nine, and the names are translated into 21 languages —
a row of nine German or Russian page names does not fit at any dialog width
worth having.
The rail is now a vertical list down the left, the dialog is 880×620 rather
than 540×560, and the content pane scrolls on its own.
Which page was open is now remembered. With four pages, reopening on General
was a shrug; with nine it means hunting for the one you were last in.
The ribbon's Options tool has existed all along — `ToolDef`, icon and all —
but was marked `#[allow(dead_code)]` and never added to a group, so the
dialog could only be reached by typing OPTIONS or from the start page. It
now sits in Manage ▸ Application, beside About.
No settings change in this commit; it is only the shell the rest needs.
Co-Authored-By: Claude Opus 5 <[email protected]>
Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).
Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:
- src/app/automation.rs, src/app/command_driver.rs: combined both
sides — kept this branch's sketch-constraint/named-parameter loading
on "new"/"open", adopted main's Scene::clear()/material_base_dir and
its newer rebuild_derived_caches() over the older bump_geometry().
Scene::clear() (src/scene/entity.rs) now also resets
sketch_constraints/named_parameters, so every caller gets this for
free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
outright (commit 973af87d, authored by Hakan directly) — a large
MINSERT is no longer purged as "corrupt" at load, only its rendering
is bounded, preserving the source data. This branch's own earlier
design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
copy was an explicit verbatim snapshot from an earlier upstream PR
(commit 4c264b8c) with zero constraint-system-specific content, and
main has since evolved it considerably further.
Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").
Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
New ConstraintKind variants — Concentric, CenterPoint, Colinear, Midpoint,
Fixed, PointOnCurve, EqualDistance, Symmetric — each built entirely from
existing ocs_gcs (FreeCAD planegcs) primitives, no new solver math needed.
build_constraint now returns Vec<Rc<dyn Constraint>> instead of
Option<Rc<...>>, generalizing the old Coincident-only "needs a second
system-level constraint" special case to every kind that needs one.
Wired into the ribbon (7 new buttons), a new point+pre-selected-entity
command for CenterPoint/Midpoint/PointOnCurve, a new 4-point command for
EqualDistance, and the existing DWG-native class-name mapping (all 8 reuse
the same generic "plain Geometrical node" path already built for
Coincident/Horizontal/etc.). Deliberately not modeled: Normal and
EqualCurvature, which for this app's Line/Circle-only entity model would
just be Perpendicular and Equal wearing different DWG class names.
Also makes DWG-native constraint persistence opt-in (Options > General,
off by default) rather than unconditional: a scope that doesn't yet have
the native graph is only given one when the setting is on, but a scope
that already has one — from an earlier save, or a file that came from
real AutoCAD — keeps getting it resynced regardless of the setting, so
toggling it off never freezes a stale graph in place.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Implements docs/dwg_constraint_compatibility_design.md's staged plan in
full: materializes the sketch-constraint set as acadrust's real
AssocNetwork/Assoc2dConstraintGroup object graph on save, additive to
the existing OCS_SKETCH_CONSTRAINTS XRecord, so files also carry
constraints AutoCAD/BricsCAD recognize as their own native objects
(per Hakan's request on the upstream project). Covers every
ConstraintKind, including AssocVariable wiring for named-parameter
driving values, and clears+rebuilds on each save.
DWG round-trips the full dependency chain; DXF only keeps the
constraint-group shell since acadrust's own DXF writer deliberately
omits AssocDependency-family objects. Both behaviors are covered by
tests and documented in the design doc's progress section, along with
two other implementation-time findings (skipping the NOD-anchored
top-level network, and the DWG writer's root-node/connections wire
format).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Named parameters (docs/named_parameters_design.md, stages 3-4):
- ParameterTable (src/scene/named_parameters.rs): a document-wide table of
named formulas, each resolved through a shared expression evaluator with
cross-parameter references, cycle detection, and division-by-zero/parse
error reporting. SketchConstraint::driving_param becomes DrivingValue
(Literal | Named) instead of a bare f64, resolved at solve time by
sketch_solve::build_constraint via Scene::named_parameters.
- Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord
model sketch_persist.rs already established, synced around open/save.
Bumps the sketch-constraint XRecord's own format version (1 -> 2) since
DrivingValue's shape doesn't line up with a version-1 blob's bytes.
- PARAMETERS command and its editor (src/ui/window/named_parameters.rs):
add/remove/edit rows, live validation, duplicate-name detection: Apply
defines the table and re-solves every constraint scope with a
named-parameter-driven constraint (command_driver.rs). Distance/Angle
constraint prompts (modules/draw/constrain/value.rs) now accept either a
literal or an existing parameter's name, mirroring how the constraint's
own value prompt already worked.
- Undo scope-down carried over from this project's own established pattern
(constraint-system stage 9): the geometry an Apply moves is one undo
step, but the parameter table edit itself has no undo entry yet — a
documented follow-up, not a silent gap.
UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs):
a whole-interface scale percentage (50-200%), wired directly into iced's
own per-window scale_factor hook rather than per-panel font-size
plumbing, so it applies immediately to every panel, dialog, and the
ribbon at once. Persisted alongside the existing lineweight-display-scale
setting.
OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs):
OPTIONS was registered for autocomplete but had no dispatch arm — the
Start-tab welcome screen's own button was the only way to reach it.
Found live-testing the UI-scale setting, unrelated to that feature
itself. "OP" is AutoCAD's own short alias.
help/: a new self-contained static user-help manual (manual.md as the
authored source, tools/build_help.py splits it into the committed static
HTML pages plus search index) — not yet wired into the application.
64 new/updated tests across the named-parameters and constraint-prompt
changes.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
The ten strings the Options card gained were English in nineteen of the
twenty-one locales. They are now translated in all of them, using the words
each locale already uses for the surrounding labels — de-DE "Griffe",
fr-FR "Poignées", zh-CN "夹点", zh-TW "掣點" and so on for grips, and the
same for selection, preview and object. System-variable names stay in Latin
uppercase, as they already do in every locale's SETVAR listing.
Composing them from existing keys, the way el-GR composes the neighbouring
labels, only worked for one of the ten: there is no existing key for "limit",
"unlimited", "shift", "rectangle" or "lasso", and "Grips: Objects" as the
label of a numeric limit would read worse than English.
The rollover preview was reported as possibly not working. It works, and now
says so: `the_idle_rollover_follows_bit_one` drives a settled hover over a
line and checks what the scene highlighted, for all four values of the
bitmask. Bit 1 alone highlights, bit 2 alone does not, both do, neither does
not. That is the assertion the inverted config comment would have made
someone doubt — the pick simply needs the cursor still for the 500 ms of
`HOVER_DWELL_MS` before it runs.
Co-Authored-By: Claude Opus 5 <[email protected]>
Use the maintained cadkernel main revision and retain source surfaces. Verify signed and two-point input, partial failure, DWG round-trips and one-step undo/redo of solids and history objects.
Options → Selection already carried the pick box, the selection-area effect
and the grip visuals. Four settings behind it were finished, persisted and
reachable only by typing a system variable, and one more was a constant.
**Four that only needed a widget.** `SELECTIONEFFECT`, `SELECTIONPREVIEW`,
`PICKADD` and `PICKDRAG` are all wired end to end already. `SELECTIONEFFECT`
was the odd one: the card's own "Restore Defaults" button resets it, so it
could be turned off by a button and only turned back on from the command
line.
`SELECTIONPREVIEW` is a bitmask and gets one checkbox per bit, which is why
`set_preview_bit` exists rather than two inline expressions — the bits are
the thing this change is most able to get backwards.
The `PICKADD` checkbox is worded as AutoCAD words it, "use Shift to add to
selection", which is the *inverse* of the stored flag. That inversion is
commented at both ends; it reads as a bug otherwise.
**`GRIPOBJLIMIT`.** The limit past which a selection gets no grips arrived
with the selection work as a hardcoded 100, matching AutoCAD's default. It is
now a setting, a slider and a system variable, following `PICKBOX`'s path
exactly. AutoCAD's 0 means "no limit", which the old `>` comparison could not
express, so the read now guards on it — with a test, because zero is the one
value that would otherwise suppress every grip instead of allowing all of
them.
The slider covers 0..=1000, where every useful value lives; the sysvar takes
AutoCAD's full 0..=32767, and a larger value set from the command line parks
the handle at the end while the read-out keeps showing the real number.
**`SelectionPrefs`** replaces the `pick_box` parameter rather than adding
three more. `view_window` already took seventeen positional arguments, and
`pick_add` / `pick_drag_rect` are two adjacent `bool`s that would swap
silently at the call site.
**Two fixes found on the way.** `OPTIONS` and `OP` were registered for
autocomplete but never dispatched, so typing either answered "Unknown
command" and the dialog could only be opened from the start page. And the
`SELECTIONPREVIEW` doc comment had its bits the wrong way round — the code
reads `& 1` for the idle rollover and `& 2` for the in-command one, as
AutoCAD does — which would have put the two new checkboxes' labels backwards.
**Not done, deliberately.** `PICKFIRST` and `PICKAUTO` exist but are always
on, with no variable. `PICKSTYLE` is stored in the header and read by nobody
— group expansion decides from the group's own `selectable` flag — so an
"object grouping" checkbox would control nothing. Associative-hatch selection
does not exist as a selection behaviour. Each needs a behaviour change rather
than an exposure.
Of the ten new strings, en-US and el-GR are translated; the other nineteen
locales carry the English text. Composing them from existing keys, the way
el-GR composes the neighbouring labels, only works for one of the ten —
there is no existing key for "limit", "unlimited", "shift", "rectangle" or
"lasso", and "Grips: Objects" as the label of a numeric limit would be worse
than an untranslated string.
Co-Authored-By: Claude Opus 5 <[email protected]>
Resolve curve intersections through cadkernel, retain empty exact results, gather analytic candidates independently of tessellation, and bound material uploads. Preserve large MINSERT data while limiting rendering work; remove obsolete review narratives.
Ports the fixes from fix/osnap-circle-arc-ellipse-intersection (and
this session's audit follow-up) onto this branch. All are independent
correctness/reliability fixes, unrelated to the parametric-constraint-
system work also in progress here.
1. OSNAP Intersection snap used a tessellated approximation instead of
exact geometry (GitHub discussion #1052): a circle/arc always
tessellates at a fixed 48 segments in the resident wire set
regardless of radius or zoom, so every circle/arc intersection snap
was off by construction. Added exact_circle_intersections, a
closed-form, frame-independent circle/arc-vs-circle/arc solver,
wired into both Intersection-snap code paths ahead of the
tessellated sweep. This branch's TangentGeom predates the
PlanarEllipse variant added upstream, so the ellipse/line
generalization from the upstream fix isn't ported here — this
covers the literal reported bug (a circle-circle case) using only
variants this branch already has.
2. Unbounded MINSERT allocation: array_offsets() reserved one Vec
element per array instance with row/column counts parsed as
unchecked u16s — a corrupt 65535x65535 pair requests ~96 GiB.
Fixed with a documented instance budget (20,000), enforced at both
the load-time corrupt-entity filter and a render-graph failsafe.
3. MINSERT spacing was scaled by the block's own scale:
insert_instance_transform composed the offset translation before
the instance transform, so x/y block scale multiplied the row/
column spacing too. Fixed by orienting the offset with rotation +
OCS only, then translating the already-scaled instance transform.
4. Automation "open" skipped the finalization (block-origin
normalization, raster-path resolution, source_path) and corrupt-
entity purge a UI open always runs. Added
io::load_bytes_finalized(path, bytes), used by automation's "open"
handler, which now also reports the purge count.
5. Material maps were uploaded without checking
device.limits().max_texture_dimension_2d, unlike raster-image
rendering (which already tiles to that limit). A mesh material is
sampled by UV, so it can't use the same tiling trick — instead,
upload_rgba_texture now downscales an oversized image before
upload, falling back to the 1x1 material color if malformed.
6. Sketch-constraint persistence/undo, audit-found while reviewing
this branch's own constraint system:
- Clearing every constraint in a scope after a prior non-empty save
left the stale XRecord in place, resurrecting the deleted
constraints on the next load. materialize_sketch_constraints_for_
save now actively removes the XRecord when a scope goes back to
empty.
- ERASE removing an entity took its constraints with it (design
doc's deletion policy), but sketch_constraints lives on Scene,
outside document/document.objects, so nothing captured that
mutation for undo — the erased geometry came back on undo, but
its constraints didn't. refresh_sketch_constraints's deletion
pass now records the touched scope's before-image into the same
UndoRecording the entity removal rides in, threaded through a new
sketch_constraints channel on DeltaSnapshot, so one undo restores
both together. HistorySnapshot::Delta is now boxed (clippy
flagged the resulting size skew across undo/redo stack variants).
31 new/adapted tests across all six fixes. Full workspace suite passes
except the one pre-existing, unrelated font-test failure also present
on main.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Automation's "open" op called io::load_bytes directly and installed
the result as-is, skipping everything the canonical path-based loader
(load_file_for_open / finalize_loaded_outcome) runs after parsing:
block-origin normalization for legacy non-zero-origin blocks,
relative raster/material path resolution, source_path assignment,
and the corrupt-entity purge the UI open path always applies. The
same file opened two ways behaved differently, and headless/automation
use was more exposed to malformed geometry with no filter at all.
Added io::load_bytes_finalized(path, bytes), which runs load_bytes
plus that same normalization and purge using the real path (not just
its basename, which the old code passed to load_bytes as the "name"
parameter — losing the directory needed for raster-path resolution
and source_path). automation.rs's "open" handler now uses it and
reports the purge count as a "purged" field in the response.
load_bytes itself is unchanged, since the byte round-trip tests
(sketch_persist.rs, named_parameters_persist.rs) deliberately want
the simpler byte-only path with no real filesystem path behind it.
New test constructs a document with one normal and one corrupt
(zero-radius) entity, saves it, opens it through automation, and
checks the corrupt entity was purged and source_path was set —
proving the automation path now matches the UI path.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
`hit` was one number covering five things: resolving candidate handles, the
wire box test, two hatch tests and two mesh tests. `select-commit` now
reports them apart, which immediately reframed the problem — at 186 468
entities the wire box test is 55 ms of 206, the hatch tests are 103, and
handle resolution is 47.
Two changes alongside it, both in the crossing scan.
Skipping a wire already in the selection was keyed by hashing its name, once
per triangle and once per glyph inside the box. A bitmap over wire indices
answers the same question with an array index. The name set stays behind it,
because several wires can share one entity's name and the output dedups by
name; a wire whose name is already in that set now gets tested once more
before being dropped, which costs one projection and changes no output.
The degenerate-wire loop that ends both the box and the lasso scans tested
`seen.contains(wire.name)` before the structural tests beside it. `||`
short-circuits, so with the name lookup last a wire with two or more points —
very nearly all of them — is dropped without hashing anything. That loop
walks every candidate, so it was one string hash per wire in the box.
Rebuilding the properties panel for a 186 468-entity selection took ~112 ms,
and four things in it scaled with the selection for no reason.
The panel filtered the selection down to the active group with
`group.handles.contains(handle)`, a linear scan of a `Vec`, once per selected
entity. The default group is "All", which holds every selected handle, so
both factors are the whole selection: 186 468 squared. Hashing the group once
makes it linear. The measured cost never showed this — 55 ms at 1 993
entities against 112 ms at 186 468 is not a quadratic curve — but the scan is
still wrong, and it is the shape of thing that turns a large selection into
an unresponsive window.
`entity_in_working_plane` clones the entity before it checks whether the
plane is the identity, so with no UCS in play the panel deep-cloned every
selected entity to get back what it was handed. It now borrows, and clones
only where a transform is applied.
`aggregate_sections` built every entity's property list, collected them all,
then folded them together and read each exactly once. A property list is
sections of labels and formatted values, every one an allocation, so a
whole-drawing selection held 186 468 of them in memory at the same time. It
now builds one, folds it, and drops it, and stops once nothing is left in
common. The fold order is unchanged, so the result is too.
The ribbon sync cloned each entity's linetype into a `String` only to compare
it, and kept walking after every field had been found mixed. Once all four
are mixed nothing further can change, so it stops there.
`aggregate_sections` and `merge_sections` had no tests, so the fold that
drives the whole multi-selection panel was resting on the compiler. Two now
cover it: a field the entities agree on keeps its value, one they differ on
reads as varying, and a single entity folds to its own rows.
`properties-detail` and `properties-panel` report the phases, which is how
the panel arm was ruled out as the remaining cost — it does not reach its
5 ms floor at 186 468 entities.
A window over a whole drawing spent 407 ms gathering candidates and 259 ms
testing them, of 825 ms total, at 186 468 entities.
**Gathering** ran eight spatial queries because that is what hover and
snapping need between them. An area selection reads five: the wires and their
segments, plus the fill triangles, pick triangles and glyphs that
`indexed_box_crossing_hits` uses to catch hatches and text inside the box.
The three that exist only for snapping are skipped.
That was worth 148 ms. The rest came from the queries themselves.
`SpatialGrid::query` sorts its result twice — once over the entry indices, to
remove the repeats an entry collects for spanning several cells, and once
over the values. Over a box covering the drawing that is two O(n log n)
passes across 4.4 M elements to remove a handful of duplicates.
`query_unordered` returns the same set in cell order with the duplicates left
in, and reserves the space it needs instead of growing a 17 MB vector in
steps. Gathering went from 515 ms to 69 ms on a synthetic set at that size,
and from 407 ms to 27-40 ms in the application.
Everything an area selection does with the result already dedups and does not
read the order. Click picking keeps the sorted `query`, where scan order
decides which of two overlapping entities a click lands on.
**Testing** projected both endpoints of every segment in the box, then threw
the result away for any wire already selected. A circle or a polyline
contributes one segment per tessellated span, so at 24 spans per wire that is
23 projection pairs out of 24 doing nothing. Skipping a wire once it is in
took a 4.4 M-segment scan from roughly a second to 42 ms.
The overlay path — active after any edit, so most of a working session —
needed the same treatment, including its own merge: `extend_indexed` sorts
and dedups every category, which would have put back the sorts this removes.
`extend_indexed_area` keeps the dedup on `wire_indices`, and must: the window
branch of `box_hit` walks it directly, so a wire left in twice would be
selected twice.
Tests cover the equivalence in both directions: the area query matches the
full one as a set in every category an area selection reads, and the merge
dedups `wire_indices` and nothing else.
Selecting a whole drawing was unusable on a 186 468-entity file. Four costs
scaled with the selection where they did not have to.
Every selected handle triggered a walk of the entire document to find LEADERs
pointing at it. On the file this came from, selecting 471 583 entities in a
585 786-entity drawing did not finish inside eight minutes. A reverse index
built once per geometry epoch answers the same question; a test asserts it
returns exactly what the walk returned, including the case of two leaders on
one annotation.
Group expansion asked the selection about every group: for each selected
handle it scanned each group's own member list. Asking each group once
whether it holds anything selected turns that around.
Selecting and deselecting went one entity at a time, and `deselect_entity`
rebuilt the ordered list with a `retain` per handle, so removing N of M
walked the order list N times. Both now take a slice.
Grips were built for every selected entity. AutoCAD stops at GRIPOBJLIMIT,
and the DWG header does not carry the variable, so its default of 100 stands
in for it.
The remaining two commits are measurement: `select-commit` reports the phases
of a selection separately, which is what showed the bulk paths had been added
to the press-drag branch while a click-move-click window goes through a
different one entirely.