Switching to a Layout stops the main thread long enough that the
compositor decides the application has stopped responding and offers to
kill it. The switch itself is not the problem — `layout-switch` reports
about 10 ms — it is the frame after it, which rebuilds the whole wire set.
That frame now has a cheap one in front of it. `layout_settling` is set
when the switch takes, and while it holds, the viewport is replaced by a
centered "Preparing layout…" on the canvas background. No shader widget
means no scene primitive, so `prepare` has nothing to do and the frame
costs nothing. A `window::frames` subscription clears the flag, which is
the guarantee this needs: that callback runs once a frame has actually
been presented, so the notice is on screen and the compositor has been
answered before the expensive frame starts.
The deferral is of the *frame*, not the switch. Two callers depend on the
switch having completed when it returns — one calls `fit_all` immediately
after, another compares `current_layout` — so deferring the switch itself
would have broken both.
This restarts the compositor's timer immediately before the slow frame and
replaces a stale window with something that says what is happening. It
does not make the slow frame fast: if the rebuild still exceeds the
compositor's timeout the dialog can return, and the durable fix is the
2 s `memo_miss` behind it, which is a separate change.
Only `en-US` is validated against at build time; Greek is filled in too
since it is in use here, and the rest fall back until translated.
Co-Authored-By: Claude Opus 5 <[email protected]>
`grid-detail` came back `views=1095.2ms params=0.0ms bg=0.0ms n=0`. Zero
grid views — every millisecond of it thrown away.
`grid_views` asked `active_viewports` for every viewport, which resolves a
camera for each, and `camera_for_viewport` reaches `model_space_extents`,
which outside the Model layout tessellates the whole drawing. Only then
did it filter on `grid_on`, which removed all of them. The parallel walk
from the previous commit had already cut this from 7826.9 ms; it was
7.8 seconds of work for an empty vector.
`grid_views` now asks first. `any_viewport_grid_on` reads the same fields
`active_viewports` copies into `ViewportInstance::grid_on`, and skips the
visibility filters that function also applies: answering "maybe" costs
only the work that would have happened anyway, while answering "no"
wrongly would drop a grid the user asked for.
Also reports the tessellation memo's guard and the inputs that can move
it. Two consecutive identical resident builds each missed all 186 455
entries — `memo_hit=0` twice in a row — which a stable guard cannot
explain, so something is clearing the memo between them and reading the
code has not said what. `guard=`, `guard_stale=`, `avp=`, `anno=`,
`anno_h=`, `all_vis=` and `sdf_gen=` will.
Co-Authored-By: Claude Opus 5 <[email protected]>
`view-detail` put 7826.9 of the first paper frame's 7832.8 ms in the grid
overlay. The path is `grid_views` → `active_viewports` →
`camera_for_viewport` → `model_space_extents`, whose fast path reads the
already-built wire AABBs but is gated on `current_layout == "Model"` —
correctly, because `wire_cache` holds the *current layout's* wires, which
in a Layout are the paper sheet's. So a paper frame takes the fallback,
which tessellates every model-space entity to read its key vertices.
That fallback called `tessellate_one` per entity, and `tessellate_one`
resolves the background, annotation scale and block cache from `&self`
every time — including a `RefCell` borrow and a cache lookup for a block
cache that is identical on all 186 455 calls. The walk was also serial,
while the wire builder's own tessellation pass has been parallel for a
while.
Those inputs are now resolved once, by a `tessellation_context` that
`tessellate_one` also uses so the two cannot drift, and the walk runs
through rayon. The reduction is min/max/or, so parallel order does not
change the result; the arguments to `tessellate_entity` are unchanged.
This makes the fallback cheaper, not absent. Tessellating a million
entities to obtain a bounding box is the wrong shape, and the structural
fix — a model-space extent that does not depend on which layout is being
viewed — needs the next measurement to size. Recorded in DEBUG_NOTES.
Co-Authored-By: Claude Opus 5 <[email protected]>
`view-detail` located the first paper frame's cost precisely: 7826.9 of
7832.8 ms in `grid`. Once, on the first frame after a layout switch —
every other frame in a 2769-frame session reports it at 0.0.
That is the largest single cost in a layout switch, larger than the model
re-tessellation and the arena rebuild combined, and it is not geometry
work at all. `grid_overlay` itself only wraps a canvas; `grid_segments`
runs later, in `draw`. So the time is in `grid_views` or in the per-view
parameter build, and reading the two does not settle which: both reach
helpers that can fall back to a full document walk, and neither obviously
fires for this drawing.
`grid-detail views= params= bg= n=` splits it, and reports only when the
block costs at least a millisecond so the quiet frames stay quiet.
Co-Authored-By: Claude Opus 5 <[email protected]>
The first Model→Layout switch costs ~9.2 s, and the largest single piece
of it is `view` at 5810 ms — iced widget-tree construction, not the scene.
The scene work that follows it in `prepare` (two `wires-build` calls plus
`frame-prepare`) accounts for 3377 ms, and the log's ordering shows the
two are sequential, not nested: `view` prints before any of them.
`view` being one number could not say which part of a 1950-line
`view_main` that is. It now reports eight sections — viewport_3d,
model_input, grid, selection_overlay, viewport_mouse, viewport_stack,
active_vp_rect, chrome — plus the preamble as `head`.
The marks go through a closure over a `RefCell` rather than one binding
per section: two of the sections sit inside a nested block, where
per-section bindings are out of scope by the time the report runs.
Co-Authored-By: Claude Opus 5 <[email protected]>
The previous version declared `visible_path` with a default of "quadtree"
and never assigned it in any branch, so every run printed
`visible_path=quadtree candidates=0` no matter which of the three
branches ran. Clippy's "does not need to be mutable" on the declaration
was the tell.
All three branches now name themselves — `quadtree`, `full-scan`,
`block-index` — and report how many candidates they considered. The
binding is left uninitialised so the compiler rejects any branch that
forgets, which is the property the first version lacked.
The full-scan branch reports no count: obtaining one means walking the
document a second time, on the one branch already doing that walk.
Co-Authored-By: Claude Opus 5 <[email protected]>
After querying the quadtree and appending the unbounded entities, the
quadtree path has to add the ones the tree cannot index — Insert,
Viewport, Block, BlockEnd — and did so by walking every entity in the
document. On a 1.17 M-entity drawing that is a full walk per resident
rebuild to find a few thousand, which defeats the point of having a
spatial index on that path at all.
Membership depends only on the entity's type, so it is resolved once per
`geometry_epoch` and reused. The list is built by walking
`document.entities()`, so it stays in document order and the wires come
out in the order the loop produced before — which matters, because that
is draw order for a pass that alpha-blends.
This is the same treatment the previous commit gave the block-membership
path. Which of the two a layout switch actually takes is still unmeasured:
the diagnostic added to report it was defaulted rather than assigned, so
its `visible_path=quadtree` output was a constant. The next commit fixes
that. Both branches carried an avoidable full walk either way.
Co-Authored-By: Claude Opus 5 <[email protected]>
The block member index cut the paper sheet's selection from 313 ms to
112 ms but left model space at 296 ms, which is not what a shared
per-epoch index should do — the second call in a frame ought to be free.
Either the drawing falls into the permissive legacy-DXF case, where the
predicate still needs the full walk, or the index is being rebuilt
between the two calls. The wires-build line now says which path ran and
how many candidates it considered, so one run settles it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Switching to a paper layout took 6.9 s, of which the switch itself was
8.66 ms; the rest landed in the next frame. Part of it was this:
wires-build total=334.0ms visible=313.3 entities=43 wires=43
313 ms of a 334 ms resident build, to gather the 43 entities of a paper
sheet — because the selection walked all 1 166 357 entities in the
document and asked `belongs_to_visible_block` about each one.
The same cost is paid on every resident rebuild of any block, so it also
shows up as ~315 ms in every model-space rebuild.
Those membership rules depend only on owner handles and the block
records, so one pass answers them for every block at once. `block_members`
resolves them per `geometry_epoch`, mirroring the predicate exactly: a
non-null owner is the block; a null owner falls back to the reverse map
built from the block records; an entity neither naming nor named by any
block is unowned.
Two things kept deliberately:
* The index is filled by walking `document.entities()`, so each block's
list is already in document order. That is draw order for the wire
pass, where submission order decides alpha blending, so the wires come
out in exactly the order the full scan produced.
* The permissive legacy-DXF case — no block record enumerates anything,
and unowned entities count towards every block — still takes the full
walk. Splicing unowned entities onto a block's list would have changed
their relative order.
Adds a test asserting the index and the old scan agree, in order, across
adds and modifies.
Co-Authored-By: Claude Opus 5 <[email protected]>
Reuse the application library from the binary entry point instead of compiling the module tree twice. Keep desktop-only transports and caches out of web builds, remove unused test code, and check executable targets in CI.
Invalidate cached world-space block vertices when the base translation changes. Share the regular/mesh partition between full uploads and patches so instanced blocks are uploaded only once. Cover both paths with CPU and GPU regression checks.
`PersistentWireArena` keeps one instance buffer for the whole resident
set and patches it in place on an edit. Sizing that against the shared
per-chunk budget caps it at 32 MiB, which is 524 288 `WireInstance`s.
A drawing with more than that fails `WireArena::build`, falls back to the
batched path, and re-uploads everything on every edit. On the 198 k-wire
reproducer -- 821 316 instances -- that shows up as:
wire 200-370ms arena-hybrid
instead of the ~12 ms `arena-patch` the same drawing reached before the
limits moved onto the shared budget.
The 32 MiB cap is right for transient chunks, where many are allocated
per frame and any one of them failing loses the frame. The arena's buffer
is a single long-lived allocation that exists precisely to avoid those
re-uploads, and it can afford more: 821 k instances is 53 MB, ~79 MB with
the arena's headroom. 128 MiB leaves it four times smaller than the
505 MiB allocation that crashed a 2 GB card, and it is allocated through
`gpu_upload`, so exhausting VRAM still degrades the frame rather than
aborting.
`OCS_GPU_CHUNK_MIB` overrides both caps, so a test can still force small
buffers everywhere.
Co-Authored-By: Claude Opus 5 <[email protected]>
Clicking to end a segment froze the UI until the whole drawing had been
re-uploaded. With block geometry cached the remaining cost is the regular
half, and on two drawings it dominates:
113 k wires regular=96ms block=13ms index=12ms total 122ms
198 k wires regular=85ms block= 7ms index=25ms total 118ms
It is rebuilt from scratch because `PersistentWireArena` -- the only
thing that patches wire buffers in place -- was disabled outright by:
&& !vp_wires.iter().any(|wire| wire.render_instance.is_some())
One block reference was enough; the reproducer has 11 363. The scene
already produces an incremental patch in 0.9 ms and the GPU side could
not use it.
Instanced wires are not something the arena has to handle: they are a
different batch type with their own pipeline. Partition them out, let the
arena own and patch the rest, and keep building `BlockWireGpu` for them.
The handle index is unaffected -- it maps into `vp_wires`, not into arena
slabs, and the patch path already maintains it.
Measured on the 198 k-wire drawing, per drawn segment:
before wire 350ms (shared-fullupload)
after wire 12ms (arena-patch)
with the whole click, including `bump_entities`, going from ~411 ms to
~20 ms.
Verified visually on drawings with and without blocks, through repeated
edits and undo/redo.
Co-Authored-By: Claude Opus 5 <[email protected]>
Adding one entity to a drawing rebuilt every block group's geometry from
scratch. Measured on a 1.17 M-entity drawing with 11 380 block wires, the
per-edit upload split as:
upload-split regular=80.4ms block=230.3ms
The block half is three quarters of it, and every byte of it was
identical to the previous frame: a new line is not a block reference.
`BlockWireGpu::from_wires` re-emitted each group's segments, re-expanded
them six-fold and re-uploaded the vertex buffers anyway.
That work depends only on the block definition being expanded, which
already has a stable identity: `render_instance.source_id`, minted once
when the block cache expands a definition and unchanged while that
expansion stays cached. Cache the vertex chunks and constants under
`(source_id, mesh_edge, colour)` -- colour is in the key because a
highlight tint changes the constants while leaving the vertices alone.
Only the per-instance placements are rebuilt, and they must be: each
carries its own draw depth and the depth map shifts on every add or
remove. They are also small, a few thousand placements against millions
of vertices.
Entries not seen in a build are dropped, so a block-cache rebuild that
mints fresh source ids does not strand old buffers on the GPU.
Measured: the block half falls from 230 ms to 7.4 ms, and the whole
per-edit upload from ~350 ms to ~118 ms.
Co-Authored-By: Claude Opus 5 <[email protected]>
With the block geometry cached, the per-edit upload is 135 ms of which
only 92 ms was accounted for by the regular and block halves. The rest is
the handle index: a `String::parse` per wire over the whole 198 k
resident set, rebuilt from scratch on every edit.
The arena has an incremental path for this (`patch_handle_index`), but
the shared path never reaches it -- the same gate that keeps the arena
away from this drawing. Short of that, the parses are independent, so run
them across cores and keep only the map insertion serial.
`par_iter().enumerate()` collects in index order, so the slot lists keep
the emission order they depend on.
Adds a `wire-handle-index` PERF line, since this was the unaccounted
third of the upload.
Co-Authored-By: Claude Opus 5 <[email protected]>
The spans put it exactly: 63.5 ms of a 63.8 ms pointer-up is
`bump_entities`, and inside it two drawing-wide scans.
`refresh_associative_centerlines` and `refresh_associative_center_marks`
each walk all 1 166 357 entities, filter to Lines and parse every one's
extended data looking for an association that references a changed
handle. About 30 ms each, run on every edit -- to find, on this drawing,
none at all.
Track whether the document holds any such association. The question is
settled once, with a single pass, and after that each edit only examines
the handles it actually changed: an entity that carries an association
flips the answer to yes, and nothing else can. Deleting the last one
leaves the answer at yes, which costs a scan that finds nothing rather
than skipping one it should have run. A coarse `bump_geometry` re-opens
the question, since it can carry a new document.
Where an association really exists the scans run exactly as before.
Co-Authored-By: Claude Opus 5 <[email protected]>
Warming only the interaction handle index left the first hover at
385 ms, down from 992 but not gone:
hover-detail query=0.1 handles=384.6 ... candidates=186
The handles stage builds two indexes, not one. `interaction_candidate_handles`
also reaches for `entity_index()` — the entity quadtree, keyed on
geometry_epoch and just as cold at that point. Warm both.
Co-Authored-By: Claude Opus 5 <[email protected]>
The first edit on a large drawing spent 1147 ms rebuilding the block
definition cache:
wires-build 2723.3ms ... blk_cache=1147.2 ... memo_miss=186455
Not because the edit invalidated it -- `add_entity_internal` already
routes a non-block entity through `bump_entities`, so drawing a line
leaves `block_epoch` alone. The cache was empty because
`prepare_open_geometry` built it on a throwaway `Scene`, exactly as it
did the tessellation memo, and it went the same way.
`PreparedOpenGeometry` now carries the definitions across, moved rather
than cloned, and `install_prepared_open_geometry` stamps them with the
receiving scene's `block_epoch`. That stamp is sound: the document is not
touched between the loader finishing and this call, so the definitions
describe it exactly. A key that does not match what the scene later asks
for simply misses, which is the old behaviour.
Co-Authored-By: Claude Opus 5 <[email protected]>
The first hover over the canvas after opening a large drawing froze the
UI for a second:
hover-detail query=0.2 handles=986.1 ... candidates=253
hover-dwell 986.6ms
`interaction_handle_index` and the insert-hatch map it reads are built
lazily, and first use is that hover. Neither can move to the loader
thread: they read `hatches`, `meshes` and `block_meshes`, which are
installed on the real `Scene` only after the loader has finished.
Build them at the end of the open instead, where the user is already
waiting. This does not make the work cheaper -- open grows by roughly the
second the hover used to cost -- but a second inside an operation that is
already seconds long reads very differently from a second-long freeze on
a mouse move.
Co-Authored-By: Claude Opus 5 <[email protected]>
`prepare_open_geometry` builds the first Model wire set on a throwaway
`Scene`, filling its per-entity tessellation memo with an entry for every
one of the drawing's 186 455 entities. `install_prepared_open_geometry`
then transferred the wire set and the interaction index but not the memo,
so the temporary scene took it to the grave.
The first geometry edit therefore rebuilt from cold:
wires-build 2723.3ms memo_hit=0 memo_miss=186455
against 760 ms once the memo is warm -- 1.3 s of work the loader thread
had already done, repeated on the user's first action.
`PreparedOpenGeometry` now carries the memo, moved rather than cloned
since the scene is dropped immediately afterwards. The guard hash travels
with it: the memo is only usable under the state it was built for, and a
mismatch on the receiving side clears it, which is what happened
unconditionally before.
Co-Authored-By: Claude Opus 5 <[email protected]>
The resident wire sort keys on the entity handle parsed out of each
wire's name `String`. `sort_by_key` evaluates that key on every
comparison, so a 198 k-wire resident set parsed every name roughly
eighteen times over.
Measured at 295 ms on every rebuild of the resident set -- including
rebuilds that re-tessellated nothing, where the memo hit on all 186 455
entities and the sort was most of what was left.
`sort_by_cached_key` evaluates the key once per element. It is stable in
the same way `sort_by_key` is, so draw order is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
- Add 'i ' prefix to EntryKind::Info and coordinate set_step_prompt deduplication to satisfy WCAG 1.4.1 across all 22 themes
- Drop unused _desk parameter from crosshair_background and clean up its call sites
- Document AutoCAD-standard context-based crosshair drafting surface architecture in crosshair_background
- Add doc comments documenting deliberate alpha omission in canvas_is_light and backlog scope in is_wide