Commit Graph
2956 Commits
Author SHA1 Message Date
PanosandClaude Opus 5 cabb20db45 Put the rest of the selection settings on the Options card
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]>
2026-09-09 13:19:06 +03:00
Panos f9cacaec2b Say where a selection's hit-testing time goes, and stop hashing to skip wires
`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.
2026-09-09 10:39:37 +03:00
Panos 273821658a Stop the properties panel walking the whole selection
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.
2026-09-09 10:38:12 +03:00
Panos f128ed3eaf Build the selection highlight in one pass instead of four
Highlighting a whole-drawing selection took 299 ms, and it runs when the
selection changes — directly between the click that completes a selection and
seeing it.

Most of that was asking the same question twice. Each wire went through the
circle and ellipse extractors at its draw depth to build the analytic
instances, then through both again at depth 0.0 purely to ask *whether* it is
a circle or an ellipse, allocating a vector each time and dropping it.
Neither extractor's `Some`/`None` answer depends on the depth: every
`return None` in them turns on the geometry — which `TangentGeom` variant,
the radius, the axis lengths, the parameters — and the depth is only written
into the instance, never read to decide. So the extraction that builds the
instances already answers the classification. One pass now does both, and
fans out across cores, since it reads a wire and writes nothing shared. That
took 173 ms of shape extraction and splitting down to 19 ms, and the whole
highlight from 299 ms to 150 ms.

Gathering the highlighted wires also cloned each handle's slot vector and
sorted the copy — one allocation and one sort per selected entity. The slots
are already ascending: both builders walk the wires in order and push as they
go, and `patch_handle_index` shifts a handle's slots by a constant, which
preserves it. A `debug_assert` on the slots actually read holds that in
place. The compat path of `from_highlight_refs` emitted its instances
sequentially while the native path beside it fans out; it now matches.

Nothing renders in a test, so a wrong bucket in the classification would
surface only as circles drawn wrong when selected. Two tests cover it: each
wire lands where the old predicate put it, and the colour override — always
for hover, and the configured tint or `SELECTED` for a selection — reaches
the instances.

`wire-highlight-detail` reports the phases: gathering, classifying,
uploading the analytic instances, the regular wires and the block ones.
2026-09-09 10:36:45 +03:00
Panos d5bf919414 Cut the cost of finding what a selection box contains
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.
2026-09-09 10:35:17 +03:00
Panos d02ce08349 Make a large selection scale with the drawing
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.
2026-09-09 10:29:24 +03:00
Hakan Seven 56e09cd3f5 fix(dwg): preserve hatch context loops 2026-09-09 08:23:41 +03:00
Hakan Seven 3fb49c4d3e chore: update DWG database repair stack 2026-09-09 07:23:33 +03:00
Hakan Seven b0c0cbdf4a Merge PR #1086: add collapsible property sections 2026-09-09 06:38:47 +03:00
Hakan Seven 4ddd19f6e8 feat(mcp): describe complete CAD record schemas 2026-09-09 01:01:44 +03:00
Hakan Seven 8f574c8388 feat(mcp): expose complete drawing records 2026-09-08 23:59:13 +03:00
Hakan Seven 89572e5ca9 fix: keep valid small drawing curves on open 2026-09-08 22:42:49 +03:00
Hakan Seven 2d55b90b0e chore: update DWG save dependencies 2026-09-08 22:20:07 +03:00
Marcel d22f4fdbe7 Remove accidental file 2026-09-08 20:53:20 +02:00
MarcelP102 c9bb898b92 Merge branch 'HakanSeven12:main' into codex/collapsible-properties-sections 2026-09-08 20:47:42 +02:00
Marcel 7a87e08a02 Add collapsible Properties-panel sections 2026-09-08 20:33:24 +02:00
Hakan Seven c5503e4758 Merge PR #1083: implement solid shell workflow 2026-09-08 21:02:01 +03:00
ramox81 7913f8e2da Merge remote-tracking branch 'origin/main' into codex/shell-parity
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ocs_doc_api/Cargo.toml
#	src/modules/model/mod.rs
2026-09-08 19:49:35 +03:00
Hakan Seven cadd506f76 Merge pull request #1079 from mechpanos/pr/first-edit-patches
Carry the loader-built resident wire layout into the installed scene so the first edit can use the incremental patch path. Add a regression test that exercises the full prepare/install/edit handoff.
2026-09-08 19:45:18 +03:00
ramox81 be06a756da Implement solid shell workflow and properties 2026-09-08 19:37:35 +03:00
Hakan Seven f611ceb638 Update CAD dependency revisions 2026-09-08 19:34:39 +03:00
Hakan Seven fd3ddc1608 Finish Ramox edge and slice integration 2026-09-08 19:02:08 +03:00
Hakan Seven d3f0d0ec42 Merge pull request #1081 from ramox81/codex/chamferedge-split 2026-09-08 18:33:50 +03:00
Hakan Seven 39492860d3 Merge pull request #1077 from ramox81/codex/filletedge-split
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/app/model_ops.rs
#	src/scene/model/solid_history.rs
2026-09-08 18:33:20 +03:00
Hakan Seven 7ec07f3222 Merge pull request #1070 from ramox81/codex/filletedge-parity
# Conflicts:
#	Cargo.lock
2026-09-08 18:30:34 +03:00
Hakan Seven 001605baa1 Merge pull request #1065 from ramox81/codex/slice-command-parity 2026-09-08 18:28:01 +03:00
Karim Jerbi 08891816be fix(render): highlight untinted analytical curves and guard uniform bind group
- Fall back to WireModel::SELECTED when selected_tint is None (classic mode) so selected circles, arcs, and ellipses are not omitted from the highlight pass.

- Explicitly rebind uniform_bind_group before drawing analytical curves in selection_xray pass.

- Skip invisible wires (display_visible == false) during selection and hover wire extraction.
2026-09-08 16:21:28 +01:00
ramox81 4f22d998e7 Complete independent CHAMFEREDGE workflow 2026-09-08 18:08:52 +03:00
Hakan Seven 9868158e27 Merge pull request #1080 from mf4633/registry-stormsewer-pro
registry: note Storm Sewer free/Pro split in description
2026-09-08 18:00:37 +03:00
Hydro Engineer e6e60c136a registry: note Storm Sewer free/Pro split 2026-09-08 10:56:19 -04:00
Karim Jerbi 058cd02042 fix(pick): require all sub-geometry inside window selection for multi-wire and mesh entities 2026-09-08 15:49:54 +01:00
PanosandClaude Opus 5 18517a20a3 perf: let the first edit after opening a file patch instead of rebuild
The first drawn point costs 946 ms against 17-39 ms for every one after it,
and `build=587.67ms` is most of that — a full wire assembly where the
following points patch in 1.2 ms.

`try_resident_patch` refuses any resident set whose `layout` is `None`, and
`install_prepared_open_geometry` inserted the loader's set with exactly
that. So the set the file open hands over could never be patched, and the
first edit rebuilt all 236 954 wires to add one.

The layout is a pure function of the wire order, and the loader already
built it for those very wires — `resident_wires_for` stores it in the
temporary scene's resident set. It now travels in `PreparedOpenGeometry`
like the tessellation memo and the draw-order map, taken by `Arc::ptr_eq`
identity so it cannot be paired with a different assembly.

`try_resident_patch` also reports why it declines. Each refusal costs a
full assembly, and until now the only one that said anything was the shared
`Arc`; `no-layout`, `no-entry` and `journal-too-old` were silent.

**Not verified.** The headless harness opens through the synchronous path,
so `install_prepared_open_geometry` never runs there, and `ZOOM E` does not
reach `try_resident_patch` at all — no skip line is emitted for it. This
needs a GUI run on the reproducer: the first drawn point should drop well
below 946 ms, and if it does not, `resident-patch-skip reason=` now says
what is still refusing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 17:44:38 +03:00
ramox81 f16423ef8e Keep primitive properties editable after fillet 2026-09-08 17:35:51 +03:00
Hakan Seven 4972bbed70 Fix issues found while integrating performance PRs 2026-09-08 17:33:02 +03:00
Hakan Seven 2b510c1a1e Merge pull request #1076 from mechpanos/pr/skip-partition-on-patch 2026-09-08 17:17:59 +03:00
Hakan Seven 1d9244177e Merge pull request #1075 from mechpanos/pr/open-and-selection-cost 2026-09-08 17:17:53 +03:00
Hakan Seven d710868d89 Merge pull request #1074 from mechpanos/pr/selection-filter-perframe 2026-09-08 17:17:45 +03:00
Hakan Seven 2ba5415479 Merge pull request #1073 from mechpanos/pr/edit-latency-metric 2026-09-08 17:17:37 +03:00
ramox81 261ab66afd Restore primitive properties after edge operations 2026-09-08 17:11:23 +03:00
PanosandClaude Opus 5 eef007d080 perf: report a skipped partition without printing zero counts
The skip branch printed `circles=0 ellipses=0`, which reads as "there are
no circles" when it means "not recomputed this frame" — and those counts
were the evidence that confirmed the skip works, so the wording nearly
cost a misreading of it.

The skip now prints its own line with what it retained.

Measured effect of the skip itself, on the reproducer: a drawn point goes
from 98 ms to 17-39 ms, `prepare` from 89 ms to 11-23 ms. Frames that draw
a circle still re-partition, and the instance counts show it — 40868,
40869 … 40874, one per circle, with the line frames skipped in between.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 5eb762e88d fix: forget the partition record with the keys that describe it
`forget_cached_keys` is the one place that means "this slot holds nothing
you can trust", and it left the partition record behind. A reused slot
rebuilds regardless — its content id changed, so the arena takes the build
path and the record is overwritten — but that made the safety an accident
of another code path rather than something this function guarantees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 d46c00281f fix: decide partition membership by extraction, not by shape
`skipped=false` on every frame of a real session — the skip added in the
previous commit never fired once.

The membership test used the same cheap structural filter that
`partition_wires` uses before attempting extraction: a tangent geom, no
fill, pick or text triangles. That filter is a pre-check, not a decision.
An ordinary drawn line carries a tangent geom for snapping and no
triangles, so it passed, and every drawn line was counted as feeding the
analytical uploads. Nothing was ever skippable.

It now decides the way the walk decides, by attempting the extraction.
`draw_depth` only rides along in the extracted instance, so the value
passed for the test cannot change the answer, and it runs only for the
handful of wires a patch changed.

Drawing a circle or an ellipse still forces the walk, correctly — those
are contributors, and the session that exposed this contained both. What
changes is that the lines drawn afterwards no longer do.

Tested on real tessellated geometry: a line must not force the walk, a
circle must.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 03c64e3d38 perf: skip the wire partition when a patch cannot change its output
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
almost the whole of the 73 ms an arena patch spends after patching, and it
walks all 236 956 resident wires to serve a one-entity change.

Its three consumers here — the instanced, circle and ellipse uploads —
depend only on wires that are block references or analytical curves. A
patch that touches none of those cannot change any of them, so the walk is
skipped and the uploads already on the slot are kept.

Two things make that sound, and both are checked rather than assumed:

**Depths hold still.** The instances bake a draw depth.
`inserted_draw_depth_label` allocates into a reserved gap instead of
renumbering, so adding an entity leaves every other label alone — the
existing doc comment says so and there is now a test. A rebuild from
scratch *can* move them, so `draw_depth_generation` bumps only there, and
a slot reuses its uploads only while that generation is unchanged.

**Contributors are remembered.** `partition_wires` records which handles
fed those three outputs, and the skip requires that no changed handle is
among them and that no changed handle's new wires would join them. That
catches an entity entering the set and an entity leaving it. The
membership test is shared with the walk it decides to skip, so the two
cannot drift, and it is deliberately conservative: a wire that looks
analytical but fails extraction forces a needless re-partition, never a
stale upload.

`arena-post` gains `skipped=` so a run says how often this actually fires
rather than leaving it to be inferred from the timing.

Not verified on a device yet — the headless harness never reaches
`prepare`, so this needs a GUI run on the reproducer to confirm both the
saving and that nothing goes missing on screen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 b68989768d perf: look the draw depth up only where it is used
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
the whole of the 73 ms an arena patch spends after patching, and it runs
on every drawn point over the entire resident set.

Inside it, `wire_draw_depth` is a hash lookup whose result feeds only the
analytical circle and ellipse extraction, and it was computed before the
test that decides whether either will run. So every patch paid 236 956
lookups to serve the ~56 000 wires that are circles or ellipses.

Moving it inside that branch is behaviour-preserving: the value was
unused on every path that skipped it.

This does not make the partition incremental — it still walks the resident
set for a one-entity change — but that walk is memory-bound and a
structural fix needs the cached partition to survive a patch, which the
borrows in `PartitionedWires` make its own piece of work.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 feb3ca4d28 perf: split the arena patch's post-work into its three steps
With the selection-filter rebuild gone, a drawn point costs 98 ms and
`prepare` is 93 % of it:

    wire  73.0ms  arena-patch  wires=236961 gpu_instances=405551
    wire-cull      5.1ms
    face3d-upload  3.9ms
    text-upload    3.0ms
    resident-patch 1.0ms  changes=1
    draw-depth-patch 0.0ms

`arena-base ok=true patch=Some(16) changes=1` — the arena patches
incrementally, one entity, and still takes 73 ms. The patch itself is
careful; the three steps that follow it are not, and all three are
O(resident set) rather than O(changes):

    partition_wires(&vp_wires, …)          236 961 wires, every frame
    upload_block_wires(…instanced…)         10 719 placements rebuilt
    upload_circles/ellipses_from_instances

`arena-post partition= blocks= curves=` says which dominates. Guessing
between three plausible candidates is what the earlier rounds of this work
paid for repeatedly.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 6f01d4215b perf: fold a lone addition into the block-member index
`vis_index=19.4` of a 55 ms selection, on every build, because the index
is keyed on `geometry_epoch` and the epoch moves on every edit. The walk
visits every entity in the drawing to add one handle to one list.

A lone addition is folded in instead. **Only** a lone one: entities are
appended to the document, so a single new handle belongs at the end of its
block's list, whereas `replay_since` returns several changes in no
particular order and each list must stay in document order because that is
draw order. A removal, a modification that could move an entity between
blocks, or more than one change all take the full walk.

Measured on a 586 k-entity drawing, headless, opening then drawing two
lines with a rebuild after each:

    before   visible=50.7  vis_index=19.7      visible=48.2  vis_index=17.6
    after    visible=34.1  vis_index=0.0       visible=34.4  vis_index=0.0

A third of the selection, and it holds for each subsequent edit.

Tested against the full walk, including that the appended handle lands
last — putting it anywhere else would reorder the drawing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 5e5a810ee5 perf: stop rebuilding the selection filter list on every drawn point
`view-detail` on an edit frame:

    total=105.6ms  chrome=105.4   [every other section 0.0]

and the edit latency measurement puts ~102 ms of a 192 ms drawn point in
the gap between the handler returning and the scene primitive being built
— which is that same `chrome`.

The status bar asks for `entity_type_names_in_layout` on every frame to
populate the selection-filter menu, and the cache behind it was keyed on
`geometry_epoch`. Every drawn point bumps that epoch, so every drawn point
walked all 444 937 handles a model space owns to answer a question the
line did not change: adding a line to a drawing that already has lines
leaves the set of type names exactly as it was.

The cache now carries the set alongside the list, and folds edits in from
the delta journal — the same incremental shape `entity_index` already
uses. Additions can only grow the set, so they are applied directly and
the existing `Arc` is handed back untouched when nothing new appeared. A
removal can retire a type and cannot be resolved from a handle that is
already gone, so those still fall back to the full walk.

`entity_type_name` returns a literal for every arm but one — `Extended`
borrows its class name from the entity — so the set holds owned strings,
and the hot path tests `contains` before allocating.

Tested: a second line of an existing type must hand back the same `Arc`,
a new type must still appear, and the folded answer must equal what a
rebuild from scratch produces.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 d267083a29 perf: say which message caused an edit's latency
The first measurement showed four consecutive edits at ~192 ms each with
a handler cost of 0.55 ms. That is below the `update` line's 5 ms
threshold, so the log reports the latency but not what asked for it —
and whether a live command preview is bumping the geometry epoch on every
mouse move is exactly the question the numbers raise.

`nav-prepare` now carries the message label alongside the op.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 41b794e9a7 perf: resolve each layer once per selection, not once per entity
`Table::get` normalises the name with `to_uppercase`, so every layer
lookup allocates a String and runs a Unicode pass.
`resident_entity_visible` did one per entity, and the selection calls it
once per candidate to look up a few hundred distinct layers.

The predicate splits so the layer can be passed in, and the selection
resolves each distinct name once into a small map keyed on the entity's
own layer string. Keys borrow from the document, so naming the lifetime on
`wires_for_block_culled` is what lets them outlive the closure call.

**Measured, and smaller than it looks.** On a 585 786-entity drawing with
37 826 candidates, A/B through `OCS_NO_LAYER_MEMO`:

    with memo   visible=55.1ms
    without     visible=57.1ms

3.5 %, not the order of magnitude the allocation count suggested. The
per-entity cost of that selection is dominated by traversing entities
scattered across the document arena, not by the work done at each one — a
resolve-only pass over the same handles measures 1.4 ms, and only because
it runs second, on a warm cache.

Kept because it is measured, cheap and correct, not because it is the
answer to the selection's cost. `OCS_NO_LAYER_MEMO` stays so the claim can
be rechecked on other drawings.

The keying is on the **raw** name while the table is case-insensitive, so
two spellings of one layer take two slots and both resolve to it — one
extra normalisation, never a different answer. Tested, along with the
predicate that reads it.

The early cheap checks moved into the layer-taking variant rather than
staying in the wrapper, so calling it directly cannot skip them.

`vis_index=` reports what `block_members` costs inside `visible`: 19.2 of
the 55 ms here, rebuilt by walking the document once per `geometry_epoch`
— which moves on every edit.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 d5859a01a5 perf: separate the two costs inside the visible-entity selection
The first drawn point costs 1068 ms and `wires-build` owns 710 of it:

    visible=402.5  build=148.4 [classify=18.4 hits=127.5]  sort=41.9
    memo_hit=186455 memo_miss=1  candidates=444938

The memo is doing its job. `visible` is not, and two things in that loop
could own the 402 ms: resolving each handle through the document's hash
index, or the predicate — which reaches `document.layers.get(&c.layer)`,
a **string-keyed** lookup, so a string hash per entity, 444 938 times.

They call for different fixes, so `visible_probe=` times a resolve-only
pass and the remainder is the predicate. The pass repeats the resolve
rather than reading the clock inside the loop, where the measurement would
cost more than the thing measured, and it runs under PERF only.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-08 16:42:21 +03:00