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.
- 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.
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]>
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]>
`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]>
`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]>
`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]>
`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]>
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]>
`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]>
`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]>
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]>
`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]>
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]>
The open sequence computes it twice:
[perf] draw-depth 293.5ms entries=1166328 ← loader thread
…
[perf] draw-depth 302.0ms entries=1166328 ← main thread
[perf] update 1427.3ms message=other
`DrawDepthCache` is keyed on `geometry_epoch`, the loader builds it on its
temporary scene, and `PreparedOpenGeometry` carried the wire set, the
tessellation memo and the block definitions across — but not this. So the
receiving scene walked all 1 166 328 entities again to rebuild a map that
already existed, inside the message that finishes opening the file.
It travels on exactly the terms the block definitions already do: the
document is the loader's own and has not been touched between the loader
finishing and the install, so its order labels describe the receiving
scene exactly. Only the epoch is restamped.
Tested by handing a computed map back into a scene through
`install_prepared_open_geometry` and asserting both that it is served
as-is and that it equals what that scene recomputes from scratch — the
second assertion is the one that matters, since serving a map that does
not describe the document would put every entity at the wrong draw order.
Co-Authored-By: Claude Opus 5 <[email protected]>
Drawing a point in a large drawing feels slow, and nothing currently
measures the thing the user actually waits for. `update` times the message
handler, `wires-build` and friends time the rebuild, but the span between
them — press to the frame that shows the new geometry — has no number.
Navigation already has exactly that plumbing: `record_nav_perf` stamps an
instant when the input arrives, `prepare` and `encode` pick it up, and
`nav-prepare` / `nav-render` report `input`, `build`, `prepare` and
`elapsed` from the original event.
`NavPerfOp::Edit` reuses it. `update` snapshots `geometry_epoch` before
dispatch and stamps the sample when it moved, so any message that changed
geometry — a drawn point, a grip commit, an undo — reports its full
latency rather than only the handler's share.
Diagnostic only: `record_nav_perf` sets a pending sample and nothing else,
so no LOD or settle behaviour changes.
Nothing is optimised here on purpose. Four call sites feed the snapper
along different branches, and picking one to instrument before knowing
which dominates is how the earlier rounds of this work were wasted.
Co-Authored-By: Claude Opus 5 <[email protected]>