`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]>
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]>
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]>
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.
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]>
The previous commit converted the allocations that scale with the
drawing and deliberately left the small fixed-size ones -- uniforms, the
viewcube's static geometry, per-image and per-wipeout quads -- reasoning
that an allocation failure was not a realistic outcome for a few dozen
bytes.
That reasoning was wrong, and the log says so precisely. On the
reproducer the vertex buffers degraded exactly as intended:
[gpu] uncaptured ... Out of Memory
[gpu] uncaptured ... In Queue::write_buffer
Buffer with 'block_wire.vertices' label is invalid
... errors #4#8#16#32#64#128, session kept alive
and then the process died here:
thread 'main' panicked at wgpu_core.rs:2253:
Error in Buffer::get_mapped_range: Validation Error
Caused by: Buffer with 'block_wire.const' label is invalid
'block_wire.const' is a single uniform struct. Once the device is out of
memory every allocation fails, including a 32-byte one, and
create_buffer_init maps it and panics. Size was never the relevant
property -- whether the path maps is.
So every remaining allocation goes through gpu_upload too: block-wire
const, the viewcube's seven buffers, the blit and clip-boundary buffers,
image instances/params/vertices, mesh material params and surface and the
empty index stub, the texture-hatch uniform, and all five wipeout
buffers. `grep -r create_buffer_init src` now finds only a comment, and
no mapped_at_creation:true remains outside gpu_upload's own docs.
`queue` is threaded through the last holdouts: upload_wipeouts,
upload_clip_boundary, MeshBatchStubs::new and
create_material_bind_group_from_resources.
Co-Authored-By: Claude Opus 5 <[email protected]>
The renderer shares one set of wire GPU buffers per wire_content_id
across every pane and viewport slot. Every geometry edit mints a new
content id, so every edit builds a new set -- and the old one stayed in
wire_buffer_cache until the map exceeded sixteen entries.
Sixteen entries is a sane bound for small drawings and fatal for large
ones. The policy counted entries, not bytes. On the reproducer a single
set is most of the card: the VRAM trace shows 572 MiB baseline rising to
1871 MiB on a 2048 MiB GT 1030. The second set has nowhere to go, so the
first edit after opening exhausts VRAM. The perf log shows exactly that
-- two `shared-fullupload` lines, an edit, then the process dies.
Evict on every miss instead of at a count threshold, and do it before
building the replacement rather than after, so the old allocation is
released while the new one is still being sized. Dropping this slot's own
handles first is what lets the retain see the superseded entry as
garbage; a pane still drawing that content keeps its own clone and its
strong count stays above one, so shared geometry is never pulled out
from under a live viewport.
The trade is that returning to a previous content id -- undo, mostly --
now rebuilds instead of hitting a warm entry. On a card where the
alternative is dying, that is the right way round.
Adds a PERF line reporting evicted and retained entry counts, so the
policy can be watched rather than assumed.
Co-Authored-By: Claude Opus 5 <[email protected]>
The crash in #358 was not the out-of-memory itself. wgpu reports OOM as
recoverable and the app already installs a handler that logs and keeps
the session alive. What killed the process was what happened next:
create_buffer_init maps the new buffer unconditionally, and mapping the
invalid buffer an OOM device returns panics. That panic unwinds through
iced's main-thread redraw, trips the wgpu-hal swapchain assertion in a
destructor, and becomes a non-unwinding abort -- no chance to autosave.
Every allocation whose size scales with the drawing now goes through
src/scene/pipeline/gpu_upload.rs, which creates the buffer unmapped and
fills it with Queue::write_buffer. A failed allocation then surfaces as a
validation error on the write, which reaches install_gpu_error_handler
and degrades the frame -- exactly what that handler's comment promised:
"A bad frame must degrade, not end the session."
Converted: block-wire vertices and instances, the wire const storage
buffer, all three wire-arena allocators, face3d vertices and instances,
block-text instances and text vertices, both hatch backends, and the
silhouette buffers. mesh_gpu already allocated this way, which is why the
mesh path never produced this panic; the rest now match it.
No mapped_at_creation:true remains in the renderer. The create_buffer_init
calls left are fixed-size or bounded-small -- uniforms, the viewcube's
static geometry, per-image and per-wipeout quads -- where an allocation
failure is not a realistic outcome.
Two trade-offs worth stating:
* write_buffer stages through a copy, which mapping at creation avoided.
Chunking bounds that transient cost, and the measured block-wire
upload got faster with chunking, not slower.
* `queue` had to be threaded through the wire, face3d, text and hatch
builders and their callers. Mechanical, but it widens the diff.
This touches every upload path, so it needs a visual check against a real
drawing before it goes upstream.
Co-Authored-By: Claude Opus 5 <[email protected]>
The 52 s silent stretch in a large-DWG open could not be attributed:
OpenTimings covered parse / purge / xref / caches, but the FINALIZING
phase -- prepare_open_geometry, which does the first full Model
tessellation and builds the interaction index -- was unmeasured and
reports only two progress ticks, so it was indistinguishable from parse
time.
Add finalize_ms to OpenTimings and a per-stage PERF breakdown:
open-phases parse / purge / xref / caches / finalize
open-finalize tessellation vs interaction index
open-index index build vs prepare_screen
wires-build sort cache, visible filter, block cache, classify,
memo hits, parallel tessellate, materialize, sort
resident-post scene markers, refedit fade, resident layout
wire-upload build_wire_buffers, which runs on the render thread
The wires-build line separates the parallel tessellate from the serial
materialize loop that deep-clones every WireModel while the memo retains
a second copy -- the leading hypothesis for both the stall and the
6.45 GB RSS.
No behaviour change. Every timer is gated on crate::perf::enabled(), so
nothing runs without PERF=1 or the in-app PERF panel. The localized
open-summary format string is left alone; changing it would rekey the
string across every locale, so the full breakdown goes to PERF instead.
Co-Authored-By: Claude Opus 5 <[email protected]>
Refresh stale depth bounds after geometry edits, allow orbiting through camera poles, and preserve hidden-line occlusion for selected solids.\n\nFixes #957
Model space appearance now follows the active UI theme instead of a
hardcoded dark background. Canvas mode is user-selectable (Match Theme /
Classic CAD Dark / Custom RGB), with matching controls for the paper
sheet and desk surround backgrounds and grid opacity.
Implements industry-standard CAD system variables, all settable via
command name, SETVAR, and the Options dialog:
- COLORTHEME (0/1 dark/light UI scheme, preserves custom palettes)
- COLORSCHEME (interactive named-theme selection)
- BACKGROUND (THEME/CLASSIC/DEFAULT/DESK presets, RGB triplets)
- SELECTIONAREA, SELECTIONAREAOPACITY (translucent marquee fill)
- WINDOWSAREACOLOR/WINDOWAREACOLOR, CROSSINGAREACOLOR (ACI or theme)
- SELECTIONEFFECT, SELECTIONEFFECTCOLOR (GPU highlight pass, driven by
selection_generation invalidation — no retessellation)
- SELECTIONPREVIEW (bit 0 = idle rollover, bit 1 = in-command hover)
- GRIPSIZE, GRIPCOLOR, GRIPHOT, GRIPHOVER (grip overlay styling)
Resolve ByBlock alpha independently from color and keep explicit opaque values from falling back to layer transparency.
Pin the matching cadcodec API across desktop, plugin, and web targets.
Refs HakanSeven12/cadcodec#2
Keep hidden pre-drag geometry out of snap candidates and avoid empty
scene invalidations. Require stable OTRACK dwell observations and cache
planar-solid visibility during live previews.
Refs #930
Improve the selection-cycling list box so overlapping objects can be
identified at a glance:
- Add a color swatch per row showing the object's effective drawn color
- Show the layer name right-aligned next to the element type
- Size the popup to fit its content with consistent padding
Widen render_style_for_viewport visibility from scene-internal to
crate-wide so the view layer resolves the same color the renderer uses.
Enable in-place editing of inherited properties in the Properties panel:
- Transparency: Switch to PropValue::EditChoice with ByLayer and ByBlock presets to allow direct typing of any numeric percentage value (0..90%) without combo_box autocomplete interference.
- Select-all-on-click: Integrate EditChoice text inputs into build_field_key_map with dedicated widget IDs, preserving click-to-select-all across editable fields.
- Material: Provide bounded preset choices (ByLayer, ByBlock, Global, and active custom material) to avoid scanning large drawing dictionaries.
- Plot style: Implement mode-aware behavior with interactive Choice in STB mode and ReadOnlyWithTooltip in CTB mode.
- Read-only tooltips: Introduce PropValue::ReadOnlyWithTooltip to provide contextual explanations for locked fields while preserving mouse text selection and clipboard copying.
- Dropdown alignment & unified borders: Fix EditChoice internal input/caret background clipping so the container's 1px neutral/active border surrounds the entire control identically to Lineweight and text inputs.
- Document defaults & aggregation: Synchronize document header defaults when editing with No selection, and aggregate heterogeneous multi-selections (*VARIES*).
Persist camera, render state, and solid fallback geometry across saves. Generate previews from a clean live viewport and refresh recent-file thumbnails immediately.
Keep all solid geometry GPU-resident during navigation. Use vertex-rate instancing, compact untextured vertices, vertex-cache ordering, and cached silhouette sources without culling geometry.
Reuse full VPort records during camera and tile synchronization so
navigation cannot replace file-defined display state with defaults.
Honor image, ground, IBL, and sun-backed sky parameters in
background rendering.
Share source buffers across block placements and render nested content with GPU instance transforms.\n\nChunk silhouettes and compact mesh material data so large drawings stay under device buffer limits and remain responsive.
A drawing placed at UTM shimmered as the camera moved.
The relative-to-eye path exists so the large eye translation never enters an
f32 matrix, and it zeroed the translation column to make sure. But the basis
was built first, by `look_at(eye, target)` with both positions cast to f32 —
and at (639792, 4517057) that grid is half a metre across in y. Rounding both
ends and then subtracting them left a direction that changed as the camera
moved, so the whole view basis jittered. Zeroing the translation afterwards
came too late to help: the damage was in the rotation.
It never needed the positions. The eye is `target + rotation·Z·distance`, so
the direction between them is the rotation's own Z — a unit vector carrying no
coordinate at all.
Two tests. Where the camera stands must not reach the matrix, which is the
property being restored; and at the origin, where the old construction had no
large coordinates to lose, the two must agree exactly — which is what says
this kept the same basis rather than turning the view round.
Co-Authored-By: Claude Opus 5 <[email protected]>
A paper layout painted its whole canvas in the page colour, so the sheet
had no visible edge — zooming out never reached one, because the surround
was the same colour as the page. Floating viewports then painted
themselves black over it.
Both come from the same idea, that a viewport's canvas is a colour it can
be handed. What lies behind a viewport depends on what the viewport is:
- The full-canvas sheet shows the desk. `paper_bg_color` is the page, and
the page is a bounded rectangle `paper_sheet_fill` already draws; using
it for the canvas too erased the boundary between them. The desk is now
its own colour, defined once in the scene and read by both the widget
and the renderer, so the two cannot drift apart.
- A floating viewport sits on that sheet and must paint no canvas at all.
Handing it a transparent one does not work: the background shader
returns an opaque colour whatever alpha it is given, so alpha zero came
out black. Skipping the pass is the only way to ask for see-through, so
`skip_background` now suppresses both the clear and the pass. It is part
of the frame signature too — without that, a cached frame would keep
showing the canvas the flag was just turned off for.
Shading and fog still blend toward what is behind a floating viewport,
which is the page, so the uniform keeps that colour even though nothing
paints it.
Co-Authored-By: Claude Opus 5 <[email protected]>
Preload one language-aware font resource across the interface, drawing text, and navigation labels.\n\nRender the navigation cube, ring, outlines, and labels through a 4x MSAA target.