Capture the rendered canvas during the clean frame instead of requesting synchronous GPU buffer readback. Preserve embedded previews and allow saving when canvas readback is denied. Add a browser save and reopen regression check.
Fixes#1021
Switching to a Layout re-tessellated the whole model — `memo_miss=186455`,
2034 ms — to arrive at geometry identical to what was already in the memo.
The only thing that differed was colour.
`local_wires_for` already knows this: it stores the raw colour and drops
its background argument with `let _ = bg_color;`, noting that
`Batches::finalize` applies `adapt_to_bg` last, "so the cache no longer
has to rebuild on BACKGROUND / layout-switch". The tessellation memo's
guard had not been told, and still mixed the background in, so every
entry was discarded on a switch.
`finalize` now records what its colour resolution consumed — the raw
colour, the raw glyph colours, and the three flags it branches on — as
`WireModel::bg_adapt`, and `resolve_colors_for_bg` replays it for another
background. The guard drops the background, so a memo entry serves any
layout.
The resolution recomputes from the recorded raw colour rather than from
the resolved one, which makes it idempotent and therefore safe over a set
that mixes memo hits with freshly tessellated misses. Re-adapting the
resolved colour would not be safe: `adapt_to_bg` snaps a near-white colour
to pure black on a light background, and pure black comes back pure
*white* on a dark one, losing the tint. A test pins that asymmetry so the
raw colour is not optimised away later.
The contract is tested against the real `finalize` rather than a copy of
its logic: for every background pair, building under one and resolving for
the other lands exactly where building under the other would have.
`bg_adapt` is `None` for wires built anywhere else, and the resolution
skips them, so nothing outside the block cache changes colour. It is also
`None` when the resolution would be a no-op — a preserved colour with no
canvas fill looks the same under every background — which keeps the common
case at no cost.
Ordering is unchanged: the pass runs inside the builder, before
`append_scene_markers` and `apply_refedit_fade`, both of which have their
own separate background dependency.
`wires-build` gains `colors=` so the pass has a number of its own.
Co-Authored-By: Claude Opus 5 <[email protected]>
`gpu-live` made this the obvious next one. Once idle slots started giving
their memory back, the shadow maps were the largest thing that never did:
128 MiB across eight slots, 37 % of the 341 MiB floor, and none of it
sampled — `shadow_enabled` defaults to false and most visual styles never
turn it on.
Every slot allocated `SHADOW_MAP_SIZE` squared at `Depth32Float` in
`Pipeline::new` and held it for the life of the slot, because the frame
bind group needs *something* at its shadow binding and the field was not
optional.
It is now `Option<(Texture, TextureView)>` alongside a 1x1 placeholder
that satisfies the binding while shadows are off — nothing samples it
then, so one texel is the whole requirement. `sync_shadow_target`
allocates on the transition into shadows and drops on the way out, and
only on the transition, so a viewport that keeps its setting does not
rebuild a bind group every frame.
The frame bind group had two inline builders — one in `Pipeline::new`, one
in `upload_background_images` — and this adds a third caller, so they are
now one `rebuild_frame_bind_group`. A shadow transition cannot forget the
background image the slot already had.
The shadow pass now tests for the target rather than the flag: wanting
shadows and having somewhere to put them are different questions, and
without the second one a failed 16 MiB allocation would have rendered the
pass into the 1x1 placeholder. Releasing a cold slot hands the target back
too, and `gpu-live` reports what is actually held rather than a constant.
Covered by a GPU test that toggles the setting off/on/off/on against a
real adapter with a validation scope armed, then releases the slot — the
transition is the risky path and the other GPU tests never take it.
Co-Authored-By: Claude Opus 5 <[email protected]>
After an out-of-memory the viewport stopped updating for the rest of the
session — both Model and Layout frozen, the UI still responsive. The log
shows why: `block-wire-geometry` prints *after* the errors it provoked.
The failed uploads were cached.
A rejected allocation still yields a `Buffer`. It is invalid, every later
use reports again, and it is indistinguishable from a good one at the
cache key. So the entry keeps hitting, nothing ever rebuilds, and the
frame stays empty even once memory frees up. Sharing `block_geometry`
across slots in the previous commit made that worse, not better: one
poisoned entry now serves every viewport.
The device's uncaptured-error counter was already there, private to the
handler that throttles the log. It is now readable, and the three caches
that can retain a bad upload — block definition geometry, the shared wire
buffer cache, and the arena's content id — snapshot it across their build
and decline to record what it condemns. The degraded frame still draws;
the next frame tries again.
Best effort by construction: wgpu surfaces some errors asynchronously, so
a moved counter means something failed, while an unmoved one is not proof
that nothing did. That asymmetry is the right way round here — believing
a bad buffer costs a frozen viewport, rebuilding a good one costs a frame.
`block-wire-geometry` gains `poisoned=` so a run says whether this
happened rather than leaving it to be inferred.
Co-Authored-By: Claude Opus 5 <[email protected]>
The layout-switch OOM names its buffer: `Buffer with 'block_wire.vertices'
label is invalid`, right after `uncaptured wgpu error #1 … Out of Memory`.
That buffer is the largest thing this renderer allocates, and five sixths
of it is duplication.
A segment reaches the shader once per corner of its quad, and the packed
path satisfies that by writing the whole 60-byte record six times —
360 bytes a segment — with `vs_main` recovering the corner from
`vertex_index % 6`. Six identical copies, on a 2 GB card.
`block_wire_storage.wgsl` puts the segments in a read-only storage buffer
indexed by `vertex_index / 6` instead, so each record is stored once. The
geometry vertex buffer disappears; the placements move to slot 0 and the
segments arrive through the definition's bind group, which becomes
per-chunk because each chunk binds its own slice.
This is the pattern `wire_indexed.wgsl` already uses for per-wire
constants, down to the capability gate: `supports_wire_storage` asks for
exactly one vertex-stage storage binding, which is exactly what this
needs. Where it is false — WebGL, the forced compatibility renderer — the
packed shader and its vertex buffer are built as before.
`Segment` is declared with scalar members rather than `vec3<f32>`, which
would align to 16 in storage and reintroduce the padding this removes. A
test validates the shader through naga and asserts the struct's stride
equals `size_of::<BlockWireVertex>()`, because a layout mismatch would
otherwise surface only as garbage geometry on a real device.
`block-wire-geometry` reports the bytes uploaded and what the packed path
would have taken, so the saving is measured rather than assumed.
The cache value becomes `Arc<Vec<BlockGeometryChunk>>` while keeping the
translation-aware key from #1006 — both modes still have to invalidate
when the base instance moves, because the records hold its world
coordinates either way. `const_bgl` and the mode are decided together
when the pipeline is built, so they travel as one `BlockWireTarget`
rather than pushing `from_wires` past the argument limit.
Co-Authored-By: Claude Opus 5 <[email protected]>
The drawing vanished from Model and only came back with REGENALL. That is
this bug's exact signature, and the guard added two commits ago was
incomplete.
`cached_wire_id` gates the entire wire upload block — line 620 skips it
whenever the slot's id already matches the viewport's content id — and it
was stamped unconditionally at the end of that block, whether or not the
device had accepted what was uploaded. So a rejected allocation left the
slot holding invalid buffers *and* claiming to be current. Nothing
rebuilt it. REGENALL worked because it mints a new content id, which is
the one thing that makes the ids differ again.
`wire_buffer_cache` and `wire_arena_id` were guarded; this third latch,
the one that gates them both, was missed. Guarding it individually would
leave the same question for `cached_text_source`, `cached_hatch_source`
and the rest, each stamped after its own upload.
So the check moves to where it covers all of them: if the device reported
anything during this viewport's turn, the slot forgets every cache key it
holds and the next frame rebuilds it. The counter is device-wide, so an
error raised by one viewport also invalidates its neighbours in that
frame. That is deliberate — the cost is one slow frame, and the
alternative is the failure this fixes.
The narrower guards stay: `wire_buffer_cache` is shared across slots, so
keeping a bad entry out of it protects viewports that were healthy.
Co-Authored-By: Claude Opus 5 <[email protected]>
`MultiPipeline::inners` only ever grew. A slot that no viewport is using
still owned its wire arena, its render targets, its text atlas and every
category buffer — tens of megabytes each — and nothing ever asked for them
back. Zoom a paper layout out until five viewports are on screen, and the
memory the hidden ones are holding is exactly what the visible one cannot
get.
`Pipeline::release_heavy_resources` hands it back: arenas, wire and block
batches, the handle index, selection and preview buffers, wipeouts,
images, mesh batches, silhouettes, the text atlas, and the render targets,
which go through `ensure_depth_texture` at its smallest size rather than
being reached for directly so the blit bind group stays consistent with
the views it samples — about 0.6 MiB against the ~70 MiB a full-canvas
slot holds.
Everything released is derived state whose rebuild is guarded by a cache
key, and every one of those keys is reset. That reset was already written
once, inline, for slot reuse; both now call `forget_cached_keys` so they
cannot disagree about what "this slot holds nothing you can trust" means.
It gained two entries in the move — `cached_highlight_key` and
`silhouette_source_key` — which slot reuse had not been clearing, so a
recycled slot could keep a stale highlight or silhouette.
The policy is gradual while the device is healthy: a slot is released once
it has been idle for 64 prepare calls, so an ordinary session pays nothing
and a layout the user keeps returning to stays warm. It stops being
patient the moment the device refuses an allocation — at that point
holding an invisible viewport is what turns a degraded frame into a
session that cannot recover, and the operator needs the drawing to stay
usable long enough to close a viewport or two.
The cost of releasing a slot that is wanted again is one slow frame.
`slots-released n= urgent= slots=` reports when it happens, since the
alternative is inferring it from a memory number.
Co-Authored-By: Claude Opus 5 <[email protected]>
Stage 0 of the roadmap asks for a memory baseline before any more
optimisation, and explicitly not from RSS: the allocator and the driver
both retain high-water memory that RSS cannot tell apart from live
resources.
`gpu-live` counts from the sizes this renderer allocated, in the
categories large enough to decide where the next fix goes:
slots total shadow targets text_atlas wire_arena block_geometry
It is partial by construction and says so, rather than presenting itself
as a total. Hatches, images, meshes and Face3D are not counted yet.
Three of the numbers needed a size that was not reachable before: the
block-geometry chunk records its bytes at build because in storage mode
the buffer belongs to the bind group, the arena exposes its allocation
rather than its occupied prefix — headroom is what a budget has to answer
for — and the text atlas reports its `R8` texel count.
Emitted only when a number moves. `prepare` runs thousands of times in a
session and these change a handful of times, so a per-frame line would
bury the log it is meant to explain.
This answers the question the previous fix left open: whether the arena's
52 MiB per slot, the 16 MiB shadow map every slot allocates whether or not
shadows are on, or the render targets dominate once a paper layout has
four or five viewports on screen.
Co-Authored-By: Claude Opus 5 <[email protected]>
Zooming a paper layout out until several viewports fit on screen ran the
card out of memory. The log correlates exactly: every error cluster
follows a `nav-prepare … space=Paper` with `viewports=3`, `4` or `5`,
where earlier sessions had reported `viewports=2` and stayed inside the
budget.
Each viewport gets its own `Pipeline` slot, and `block_geometry` was a
slot field — so every slot uploaded its own copy of the same definition
segments. That is the single largest upload this renderer makes: 1 101
definitions, 1 040 267 segments, 59.5 MiB. Three of them land back to
back in one burst in the log, each bracketed by its own full arena
rebuild.
The cache key is `(source_id, mesh_edge, colour, base translation)` —
nothing a viewport contributes. Two viewports drawing the same definition
draw the same segments, so the cache belongs beside `wire_buffer_cache`
on the `MultiPipeline`, which already shares the finished `BlockWireGpu`
batches across slots for the same reason.
That existing sharing also settles the one question this raises: bind
groups built against one slot's layout are already handed to other slots
today, so nothing new is being asked of wgpu here. Both GPU regression
tests still pass on a real adapter with validation scopes armed.
This does not address the wire arena, which stays per slot and rebuilds
52 MiB of instances each time a slot changes hands — `arena-base
ok=false` on every single build in the session, never `ok=true`.
Co-Authored-By: Claude Opus 5 <[email protected]>
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]>
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]>
`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]>
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]>
`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 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]>
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]>
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.