The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.
A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Eliminate intermediate heap vector allocations during wire classification
and analytical instance extraction for circle and ellipse GPU pipelines.
1. Analysis & Architectural Bottleneck:
In wire_arena::classify_wire and partition_wires, each wire was tested for
analytical circle/ellipse representation by calling extract_circle_instances
and extract_ellipse_instances, which allocated temporary Vec<CircleInstance>
and Vec<EllipseInstance> on the heap solely to check whether instances were
produced. Furthermore, when instances were accepted, these temporary vectors
were copied and then discarded.
2. Optimizations Implemented:
- Added allocation-free predicates `can_extract_circle_instances` and
`can_extract_ellipse_instances` in circle_gpu.rs and ellipse_gpu.rs.
- Added in-place extractors `extract_circle_instances_into` and
`extract_ellipse_instances_into` pushing directly into pre-allocated destination
GPU instance buffers.
- In wire_arena.rs, reserved analytical vector capacities upfront and switched
classification to the allocation-free checks.
- In pipeline/mod.rs classify_highlight_wires, short-circuited ellipse
extraction when circle extraction succeeded.
3. Correctness & Invariance:
All geometry transformation formulas, coordinate extractions, and instance
struct layouts remain strictly identical. Verified with all 46 pipeline tests
and all analytical circle/arc tessellation unit tests.
4. Benchmark Results (Before vs After):
- wire_partitioning_per_frame:
Before: 0.18 ms (mean 0.19 ms, p95 0.38 ms)
After: 0.06 ms (mean 0.07 ms, p95 0.28 ms)
Improvement: -63.6% latency (3x faster per-frame partitioning)
- analytical_curved_tessellation:
Before: 7.79 ms (359.5k entities/s)
After: 6.87 ms (407.7k entities/s)
Improvement: -11.8% latency (+13.4% throughput)
- wide_and_tapered_arc_tessellation:
Before: 6.36 ms (235.8k entities/s)
After: 5.48 ms (273.8k entities/s)
Improvement: -13.8% latency (+16.1% throughput)
- analytical_curved_zoom_100x:
Before: 12.75M FPS (0.01 ms)
After: 13.97M FPS (0.01 ms)
Improvement: +9.6% FPS
- Repurpose CircleInstance center_high.w and params.w for start_width and end_width (preserving exact 128-byte stride)
- Route wide polylines, tapered polylines with bulges, and donuts through split_mixed_polyline into GPU analytical arc instances
- Compute physical annular bands, width tapers, and round end caps in circle.wgsl using world-space distances to prevent 3D perspective foreshortening distortion
- Expand bounding quad margins dynamically with grazing-angle compensation to eliminate clipping
- Relax pick_tris check in wire classification and partition routines so wide entities retain CPU picking while drawing analytically
- Add unit and hardware GPU integration tests, including tilted 3D perspective rendering verification
`skipped=false` on every frame of a real session — the skip added in the
previous commit never fired once.
The membership test used the same cheap structural filter that
`partition_wires` uses before attempting extraction: a tangent geom, no
fill, pick or text triangles. That filter is a pre-check, not a decision.
An ordinary drawn line carries a tangent geom for snapping and no
triangles, so it passed, and every drawn line was counted as feeding the
analytical uploads. Nothing was ever skippable.
It now decides the way the walk decides, by attempting the extraction.
`draw_depth` only rides along in the extracted instance, so the value
passed for the test cannot change the answer, and it runs only for the
handful of wires a patch changed.
Drawing a circle or an ellipse still forces the walk, correctly — those
are contributors, and the session that exposed this contained both. What
changes is that the lines drawn afterwards no longer do.
Tested on real tessellated geometry: a line must not force the walk, a
circle must.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
almost the whole of the 73 ms an arena patch spends after patching, and it
walks all 236 956 resident wires to serve a one-entity change.
Its three consumers here — the instanced, circle and ellipse uploads —
depend only on wires that are block references or analytical curves. A
patch that touches none of those cannot change any of them, so the walk is
skipped and the uploads already on the slot are kept.
Two things make that sound, and both are checked rather than assumed:
**Depths hold still.** The instances bake a draw depth.
`inserted_draw_depth_label` allocates into a reserved gap instead of
renumbering, so adding an entity leaves every other label alone — the
existing doc comment says so and there is now a test. A rebuild from
scratch *can* move them, so `draw_depth_generation` bumps only there, and
a slot reuses its uploads only while that generation is unchanged.
**Contributors are remembered.** `partition_wires` records which handles
fed those three outputs, and the skip requires that no changed handle is
among them and that no changed handle's new wires would join them. That
catches an entity entering the set and an entity leaving it. The
membership test is shared with the walk it decides to skip, so the two
cannot drift, and it is deliberately conservative: a wire that looks
analytical but fails extraction forces a needless re-partition, never a
stale upload.
`arena-post` gains `skipped=` so a run says how often this actually fires
rather than leaving it to be inferred from the timing.
Not verified on a device yet — the headless harness never reaches
`prepare`, so this needs a GUI run on the reproducer to confirm both the
saving and that nothing goes missing on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
the whole of the 73 ms an arena patch spends after patching, and it runs
on every drawn point over the entire resident set.
Inside it, `wire_draw_depth` is a hash lookup whose result feeds only the
analytical circle and ellipse extraction, and it was computed before the
test that decides whether either will run. So every patch paid 236 956
lookups to serve the ~56 000 wires that are circles or ellipses.
Moving it inside that branch is behaviour-preserving: the value was
unused on every path that skipped it.
This does not make the partition incremental — it still walks the resident
set for a one-entity change — but that walk is memory-bound and a
structural fix needs the cached partition to survive a patch, which the
borrows in `PartitionedWires` make its own piece of work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Emit transformed analytical curves (circles, arcs, ellipses) in block
expansion as standalone wires rather than merging them into batched line
strips.
- Keep render_instance unset on analytical curves in block prototypes so
they route directly to CircleGpu / EllipseGpu instead of BlockWireGpu.
- Support TangentGeom::Circle in extract_circle_instance and wire partitioning.
- Drop analytical tangents on wires clipped by xclip so clipped geometry
falls back correctly to line segments.
- Add unit test verifying block circles and arcs extract as analytical GPU
instances.
- Add fast classify_wire to bypass circle/ellipse extraction for the 95%+ of wires that are straight/polylines.
- Implement single-pass partition_wires extracting regular wires, mesh edges, block wires, circles, and ellipses in one traversal.
- Upload circle and ellipse instances directly from pre-extracted buffers in PersistentWireArena and build_wire_buffers without redundant iteration.
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 <noreply@anthropic.com>
Invalidate cached world-space block vertices when the base translation changes. Share the regular/mesh partition between full uploads and patches so instanced blocks are uploaded only once. Cover both paths with CPU and GPU regression checks.
`PersistentWireArena` keeps one instance buffer for the whole resident
set and patches it in place on an edit. Sizing that against the shared
per-chunk budget caps it at 32 MiB, which is 524 288 `WireInstance`s.
A drawing with more than that fails `WireArena::build`, falls back to the
batched path, and re-uploads everything on every edit. On the 198 k-wire
reproducer -- 821 316 instances -- that shows up as:
wire 200-370ms arena-hybrid
instead of the ~12 ms `arena-patch` the same drawing reached before the
limits moved onto the shared budget.
The 32 MiB cap is right for transient chunks, where many are allocated
per frame and any one of them failing loses the frame. The arena's buffer
is a single long-lived allocation that exists precisely to avoid those
re-uploads, and it can afford more: 821 k instances is 53 MB, ~79 MB with
the arena's headroom. 128 MiB leaves it four times smaller than the
505 MiB allocation that crashed a 2 GB card, and it is allocated through
`gpu_upload`, so exhausting VRAM still degrades the frame rather than
aborting.
`OCS_GPU_CHUNK_MIB` overrides both caps, so a test can still force small
buffers everywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Keep storage fast paths on capable adapters and use packed or texture fallbacks only when device limits require them.\n\nCompact mesh-edge varyings to avoid WebGL shader rejection.
Avoid full document, cache, and GPU rebuilds for entity deltas so add, delete, history, selection, and live polyline edits stay responsive on large drawings.
Keep resident wires, draw depths, GPU slabs, and edit history incremental across entity changes. Defer rollover work to avoid cursor jumps on dense drawings.
Xref merge now inserts entities in the source block's chain order and
carries SortEntitiesTable objects across (remapped to merged handles).
draw_depth_map values become [depth, half] so exploded block/xref fills
and wide-polyline band wires both compose child depth as
insert_depth + in-block rank x half, recursing into nested inserts with
the same divisor - bands and fills from one block interleave by the
block's internal order instead of stacking at the insert's single depth.
Band batches split per rank via WireModel.depth_override; thin wires
stay merged. The wire arena falls back to a full rebuild when a slab
carries mixed depths so refreshes don't flatten band overrides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A wide polyline with per-vertex varying widths rendered at its widest edge
(uniform-width ones were already exact). Draw the true taper instead.
`WireModel.taper_widths` carries a per-point full band width. The wire
instance (native slim + web fat) gains per-endpoint `world_hw_a/b`, and both
wire shaders interpolate `mix(hw_a, hw_b, which_end)` so each segment's band
narrows/widens linearly — depth-tested, and the linetype dash still applies.
Alignment (widths must match the tessellated points) is solved by building
both together: a new `TruckObject::TaperedLines(points, widths)`, filled by
`common::tapered_band_points` (straight = endpoints, arc = 16 samples with the
width lerped along it). to_truck routes a varying-width polyline here —
LwPolyline before the PLINEGEN split, Polyline2D at its return — while a
uniform-width one keeps the constant-band Contour path unchanged. `world_width`
stays the widest edge as the PDF-stroke + zoom-floor fallback.
Blocks fall back to the constant-widest band (`LocalWire` carries no
taper_widths).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single-batch arena required a set with no mesh/solid edges, so any drawing
with a 3D solid or mesh (this one: 1693 mesh-edge wires of 12200) disqualified the
WHOLE set — every edit fell back to build_wire_buffers re-uploading ~265 MB
(4.4M instances), stalling ~1s per edit on a shared-memory GPU.
Split the resident set into a regular-2D arena and a mesh-edge arena (the wires
that need the draw loops mesh-edge skip/black treatment), one WireArena each,
passed as &[&WireModel] subsets (no clone). Both patch incrementally; the mesh
arena carries is_3d_mesh_edge so the existing draw loop handles it. A changed
handle absent from an arenas subset is a no-op for it, so each edit touches only
the batch it belongs to. Result: the wire block drops from ~300ms + a 265 MB GPU
upload to ~2ms.
Two correctness points folded in from adversarial review:
- mesh-edge wires are stamped draw_depth 0 (matching WireGpu::from_run) so their
hidden back edges dont peek through the shaded fill; the mesh arena also always
bails on append (all its wires share depth 0, so relocation would reorder
coincident edges).
- buffers are capped at wgpus 256 MB limit — a batch larger than one buffer
returns None so the caller chunks it via the batched path (fixes a create_buffer
panic on the 355 MB regular batch).
Still behind OCS_WIRE_GPU_PATCH (default off). OCS_PERF prints the per-edit wire
timing + outcome.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-place-only arena still fell back to a full batched rebuild on add/remove —
re-uploading the entire wire instance buffer, which on a drawing whose wires
expand to millions of segments (~265 MB) stalls ~1s per add/remove on a
shared-memory GPU. Only move/edit avoided it.
Add append/tombstone so add/remove patch the instance buffer too: a new or
segment-count-changed entity bump-allocates a slab at the tail (buffers carry
1.5x headroom); an erased entity is tombstoned (blanked at const slot 0). The
huge instance buffer only grows/blanks by the changed entity.
Three correctness points make add/remove safe (each verified by an adversarial
review, findings folded in):
- draw_depth_map normalises each entity`s z-bias by the block entity count, so a
structural change re-scales every entity`s bias. Keep a CPU mirror of the const
array; on any structural change refresh every slab`s draw_depth and re-upload
the small (~1 MB) const buffer, leaving the instance buffer untouched.
- a tail-appended entity draws last, which only mis-orders wires whose overlap
resolves by submission order rather than the z-bias: transparency, and 3D-solid
fallback edges (Solid3D/Region/Body/Surface are excluded from draw_depth_map, so
their opaque edges share depth 0). `append_unsafe` bails to a full rebuild when
the set contains either.
- an in-place edit also flushes its consts to the GPU (a colour change is not
structural, so the whole-buffer refresh would otherwise skip it).
Still behind OCS_WIRE_GPU_PATCH (default off); every uncertain case returns false
and the caller does the identical full rebuild, so correctness never rides on the
fast path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a geometry edit the resident set gets a fresh content id, so the batched wire
path (build_wire_buffers) re-emits and re-uploads EVERY wire instance — the whole
buffer even when one line moved. Behind the OCS_WIRE_GPU_PATCH flag (default off,
native only), add a persistent per-entity instance arena: one long-lived
VERTEX|COPY_DST instance buffer + its WireConst storage, with a per-entity slab
table. An in-place edit (move / rotate / scale / colour — same segment count)
becomes a queue.write_buffer of just that entity`s slab.
Scope is deliberately IN-PLACE ONLY. draw_depth_map normalises each entity`s
draw-order z-bias by the block`s entity count, so an Add or Remove re-scales EVERY
entity`s z-bias — which a per-slab patch can`t honour — and a segment-count change
relocates the entity. patch() returns false for any of those (and for mesh-edge /
scissored sets), and the caller falls back to the identical full batched rebuild,
so correctness never rides on the fast path. An adversarial review of an earlier
append/tombstone design surfaced exactly these draw-order hazards; restricting to
in-place removes them while keeping the common interactive-edit win.
The Scene hands the changed handles to the arena via model_wire_gpu_patch,
recorded by try_resident_patch for the Model block and matched by content id in
prepare(); scissored paper viewports and mixed 2D/3D sets keep the batched path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>