Commit Graph
1069 Commits
Author SHA1 Message Date
Hakan Seven 3385160493 Merge PR #1028: add document API with atomic edits
Add the typed document facade and plugin IPC adapter. Prepare bulk edits
before mutation, preserve undo and dirty state, keep profile elevations,
validate numeric inputs and session bindings, and generate Python constructors
with typed results.

Reuse existing entity transforms and profile conversion. Compute mass
properties and closed-surface bounds in cadkernel; pin its main revision
6a5e6fc2822f6af6b2611cd605b44437b35158f0. Keep plugin API metadata at version 6.

Validation: 646 native application tests, 597 kernel tests, 12 document API
tests, 91 plugin API tests, 3 Python tests, default/IPC feature builds and
wasm32 check passed.
2026-09-06 21:43:02 +03:00
Sebastian 919128e087 ocs_doc_api: fix branch-review findings (tab auth, bulge center, budget, caps, dead code)
CRITICAL:
- execute_doc_api: reject tab_id != bound tab (confused deputy); test
  doc_api_tab_mismatch_is_rejected.
- bulge_arc_segment: fix negative/CW bulge center (apothem magnitude + sign picks
  side; was double-sign-mirrored); test CW-vs-CCW center offset.
- cold-tess budget: move mass_cache/cold_tess_used to per-tab Scene so the budget
  persists across DocApi dispatches (was reset per request); budget-exhausted ->
  approximate meshes.metrics fallback instead of error.

WARNING:
- Loft: add BULK_ITEM_CAP (was uncapped -> unbounded kernel call from one request).
- CreateMany: pre-validate EntitySpec::Curve (ellipse ratio) in prepare phase ->
  all-or-nothing restored; regression test.
- Partial Ellipse: offset start/end_parameter by rot_z on rotation; regression test.
- resolve_body: add with_body borrow accessor (host overrides to borrow cache in
  place, O(1)); bounds() uses it instead of deep-cloning the B-rep per query.
- add_raster_image: validate file path (non-empty, image extension, reject
  UNC/traversal); regression test.

SUGGESTION:
- deploy: bump API_VERSION 5 -> 6 so DocApi plugins are gated at handshake on old
  hosts (append-only wire + defaulted method keep old V2-V5 plugins loading).
- dead code: wire query_name into apply_queries error labels; host uses crate's
  ObjectId::from_handle/to_handle (drop local duplicates); delete is_null/backend_mut.
- mock drift: test mock now stores curve specs + computes curve bounds (aligns with
  example mock); Spline/Dimension bounds delegate to points_aabb.

623 host lib + 8 crate + 3 consistency + 34 plugin_api green.
2026-09-06 16:03:34 +02:00
gianlucafiore 2ed09459c1 fix(images): refresh raster cache during grip edits 2026-09-05 16:09:40 -03:00
Hakan Seven ac8a545669 Fix layout cache correctness and GPU resource recovery 2026-09-05 19:56:49 +03:00
Hakan Seven 1a4b389b0e Merge pull request #1014: perf: let one tessellation serve every background 2026-09-05 19:28:34 +03:00
Hakan Seven a03973bc6e Merge pull request #1017: Pr/viewport slot memory - Give back the device memory of viewports nobody is drawing 2026-09-05 19:28:33 +03:00
Hakan Seven ed5b3d9276 Merge pull request #1016: Pr/gpu error recovery - Never cache a buffer the device rejected 2026-09-05 19:28:32 +03:00
Hakan Seven 99bb9889e1 Merge pull request #1013: perf: store block-wire segments once instead of six times 2026-09-05 19:28:31 +03:00
PanosandClaude Opus 5 391a74a615 perf: let one tessellation serve every background
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]>
2026-09-05 18:53:16 +03:00
PanosandClaude Opus 5 49318a1c55 perf: allocate a shadow map only for viewports that cast shadows
`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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 d9c998fa17 fix: never cache a buffer the device rejected
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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 6599b82776 perf: store block-wire segments once instead of six times
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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 deebc14012 fix: a rejected upload must not latch the slot as up to date
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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 0118b28d10 fix: give back the memory of viewports nobody is drawing
`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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 d81a858040 perf: account for the device memory the renderer holds
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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 a9a2c82478 fix: share block definition geometry across viewport slots
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]>
2026-09-05 18:53:09 +03:00
PanosandClaude Opus 5 d9a1d55f2e perf: don't resolve viewport cameras for a grid nobody draws
`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]>
2026-09-05 18:53:02 +03:00
PanosandClaude Opus 5 0b4551b6ec perf: stop tessellating the whole model to measure it
`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]>
2026-09-05 18:53:01 +03:00
PanosandClaude Opus 5 4e6a9c5177 fix: make the visible-path diagnostic actually report a path
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]>
2026-09-05 18:53:01 +03:00
PanosandClaude Opus 5 1f14ba35d1 perf: index the entities the quadtree cannot hold
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]>
2026-09-05 18:53:01 +03:00
PanosandClaude Opus 5 67ea5fa2dc perf: report which path the visible-entity selection took
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]>
2026-09-05 18:53:01 +03:00
PanosandClaude Opus 5 efcd73727f perf: select a block's contents from an index, not by scanning the drawing
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]>
2026-09-05 18:53:01 +03:00
Hakan Seven de3fe2fdf1 Remove native and web compiler warnings
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.
2026-09-05 13:21:02 +03:00
Hakan Seven 3253965194 Run wire arena regressions in CI and shorten cache documentation 2026-09-05 12:33:20 +03:00
Hakan Seven ff3262e39d Merge commit '7bf095e02be49764fdda1fa32ef13a1675592246' 2026-09-05 12:30:43 +03:00
Hakan Seven c39fd8c4c0 Merge commit '69bd3c40a9ed3c0a2e412cc5716ef52b5e300033' 2026-09-05 12:30:43 +03:00
Hakan Seven d91e54e501 Merge commit 'b9c252474853d47c91bd6c03cd6bf8752378061f' 2026-09-05 12:30:43 +03:00
Hakan Seven af0cc68c5a Merge pull request #1006 with block rendering fixes
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.
2026-09-05 12:30:43 +03:00
PanosandClaude Opus 5 19490a548b fix: give the wire arena its own buffer budget
`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]>
2026-09-05 09:50:17 +03:00
PanosandClaude Opus 5 fb64e76f04 fix: let the wire arena serve drawings that contain blocks
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]>
2026-09-05 09:18:50 +03:00
PanosandClaude Opus 5 071b4b6a87 perf: keep block geometry on the GPU across edits
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]>
2026-09-05 09:16:12 +03:00
PanosandClaude Opus 5 7bf095e02b perf: parse wire names across cores when building the handle index
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]>
2026-09-05 09:10:49 +03:00
PanosandClaude Opus 5 69bd3c40a9 perf: stop scanning every entity for associative centers on each edit
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]>
2026-09-05 09:10:49 +03:00
PanosandClaude Opus 5 b9c2524748 perf: warm the entity quadtree alongside the handle index
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]>
2026-09-05 09:10:49 +03:00
PanosandClaude Opus 5 909b7f3379 perf: hand the loader thread's block definitions to the scene
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]>
2026-09-05 09:10:49 +03:00
PanosandClaude Opus 5 38f576c296 perf: warm the interaction handle index during open
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]>
2026-09-05 09:10:48 +03:00
PanosandClaude Opus 5 d5d9ceec45 perf: hand the loader thread's tessellation memo to the scene
`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]>
2026-09-05 09:10:48 +03:00
PanosandClaude Opus 5 2d2fdacdfd perf: parse each wire name once when applying draw order
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]>
2026-09-05 09:10:48 +03:00
Hakan Seven 13afc1479f Automate weekly releases and synchronize application versions 2026-09-05 02:13:20 +03:00
Hakan Seven 9d991d7feb Bound GPU uploads and complete Greek and performance integration 2026-09-04 20:26:29 +03:00
Hakan Seven 89624f64fc Merge PR #1002: opt-in performance instrumentation 2026-09-04 20:11:02 +03:00
Hakan Seven 75f9205da4 Merge PR #1001: GPU allocation stability and Greek locale 2026-09-04 20:06:56 +03:00
Hakan Seven 92e1dfd685 Merge PR #1000: cache selection filter entity types 2026-09-04 20:06:56 +03:00
Hakan Seven 90761904a9 Merge reviewed modeling PRs with Unicode and PRESSPULL fixes 2026-09-04 19:49:55 +03:00
Hakan Seven d8790ef433 Use exact kernel curves for PRESSPULL area containment 2026-09-04 19:34:38 +03:00
PanosandClaude Opus 5 6747d7125e fix: no GPU allocation path maps, however small the buffer
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]>
2026-09-04 16:21:08 +03:00
PanosandClaude Opus 5 0d11421dea fix: free superseded wire buffers before allocating their replacement
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]>
2026-09-04 16:21:08 +03:00
PanosandClaude Opus 5 d0ef752532 fix: an out-of-memory GPU no longer aborts the process
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]>
2026-09-04 16:21:08 +03:00
PanosandClaude Opus 5 771a5b2c5c fix: one VRAM-aware buffer budget for every GPU upload path
max_buffer_size is an API validation limit, not physical VRAM. NVIDIA
reports several GB of it on a 2 GB card, so sizing a chunk from it
produces buffers larger than the whole device. That is the same bug as
#203 (mesh_gpu), #358 (face3d_gpu) and the block-wire vertex buffer:
rediscovered three times because each upload path spelled the budget out
for itself.

mesh_gpu.rs was the only site that clamped the result. Move that clamp
into src/scene/pipeline/gpu_budget.rs and route every allocating path
through it, so a new upload path cannot reintroduce the mistake by
copying one of the unclamped expressions.

Five sites had no clamp at all: text_gpu (block-text instances),
wire_gpu (block-wire instances), and face3d_gpu (vertices, instances,
and upload_chunks).

Three behaviour changes worth noting:

  * Silhouette chunks were capped at a private 64 MiB constant and now
    use the shared 32 MiB budget.
  * The storage-hatch chunk limit was the minimum of two device limits,
    both of them validation figures; it is now also capped by the
    budget. The `*_fits` predicates next to it are genuine "would wgpu
    reject this" checks and are left alone.
  * mesh_gpu's instanced path used the unclamped ceiling to decide
    whether a mesh fits in a single buffer, which would still admit a
    multi-GB allocation. It now uses the clamped budget, so a few more
    meshes take the chunked path.

OCS_GPU_CHUNK_MIB overrides the cap. Raising it above the real VRAM is
the cheapest way to force an allocation failure and check that the
renderer degrades instead of aborting.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 16:20:38 +03:00
PanosandClaude Opus 5 8552450aa8 fix: chunk block-wire vertex buffers
BlockWireGpu::from_wires built one unbounded vertex buffer per
(source_id, mesh_edge) group. On a block-heavy drawing that reached
~505 MiB in a single create_buffer_init, which fails on a 2 GB card;
wgpu then returns an invalid buffer and the unchecked
get_mapped_range_mut() inside create_buffer_init panics.

The instance buffer in the same function was already chunked. Convert
the vertex buffer the same way, matching the solid-mesh chunking in
mesh_gpu.rs (#203) and the fill chunking in face3d_gpu.rs (#358),
including mesh_gpu.rs's clamp: max_buffer_size is an API validation
limit, not physical VRAM, and NVIDIA reports several GB on a 2 GB card.

Chunk sizes are rounded down to a multiple of 6 so a segment's quad
never straddles two buffers -- the shader derives its corner from
vertex_index % 6 and every draw restarts that counter at zero.

Instance buffers are hoisted out so they are built once and reused
across vertex chunks instead of re-uploaded per chunk.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 16:20:38 +03:00