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.
Keep adaptive sampling in both text shaders with compact sampling loops. Validate shader derivatives and existing text rendering, including GPU pipeline creation.
Closes#964
Delete the thin re-export file; src/app/doc_api.rs now uses ocs_doc_api::convert
directly (use ocs_doc_api::convert + convert::* call sites). Remove the
mod doc_api_convert registration from src/app/mod.rs.
The conversion logic already lives in the crate (src/convert.rs) from the prior
move; the shim was redundant. 623 host lib + 38 doc_api tests green.
Move the 503-line host-independent entity-conversion module (acadrust EntityType
<-> DTO conversions, transform_entity_geometry, entity_to_profile_curves,
bulge_arc_segment, entity_bounds, entity_kind_name, points_aabb) from
src/app/doc_api_convert.rs into crates/ocs_doc_api/src/convert.rs (host feature).
The host file is now a thin re-export (pub use ocs_doc_api::convert::*) so all
existing crate::app::doc_api_convert::* paths keep working unchanged. Zero
coupling risk (the module only touches acadrust + crate DTOs).
623 host lib + 8 crate + 3 consistency green.
- layouts.json: append the 6 missing ops (CreateDimensionRadius/Diameter/Angular,
CreateAttributeDefinition, CreateTable, CreateDimensionAngular2Ln); regenerate
binding_schema.json + api_reference.md wire vocabulary (all 28 ops).
- Helix bounds: bound the evaluated spline control polygon (was a degenerate
single-start-point AABB).
- points_aabb: empty -> Geometry::Empty('entity has no points to bound'), not
UnknownId (the entity exists; don't misclassify as stale/deleted).
- mass_properties: accuracy-first fine tessellation + graceful degradation to the
approximate meshes.metrics value when the cold-tess budget is exhausted (large
distinct-solid batches no longer hard-fail).
- IMPLEMENTATION.md: Volume/Centroid rows + accuracy paragraph + test count (618)
consistent with the final design.
- 618 host lib + 8 crate + 3 consistency green.
- mass_properties: query path now uses fine mesh_body(0.1, 1e-4) for near-analytic
volume/centroid; the render-mesh metrics cache (coarse display LOD) no longer
shadows query accuracy. Memoized per (handle, epoch); cold-tess budget kept.
- IMPLEMENTATION.md: accuracy item done.
- Test accurate_volume_centroid_sphere_and_cube (sphere within 0.5% of 4/3*pi*r^3,
centroid at centre, cube exact) -> 617 host lib green.
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]>