Each band segment was a rectangle ending square at its vertex, so the
outer corner of every joint showed a notch. Every segment end now carries
the tangent of half its turn, and the shader slides that end's corners
onto the joint's bisector so neighbours meet in a miter. Turns sharper
than the limit keep butt ends. Constant-width runs inside a polyline split
around its arcs no longer count as tapered, which had kept them unmitred.
Fixes#1565
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Lighting follows the view: the default source is a key light from the
upper left and a fill light from the lower right over an ambient term,
the other source one light from above; POINTCLOUDLIGHTING 1 lights the
front faces, 2 both.
- A selected or hovered point cloud shows its extents box.
- A move-gizmo arrow can be grabbed along its shaft; the grab offset is
taken on the press, so click-and-place and typed distances start from
the insertion point.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Points carry their world normal: POINTCLOUDLIGHTING lights them in the
shaded visual styles (1 lit from both sides, 2 with back faces dark),
from the view (POINTCLOUDLIGHTSOURCE 0) or the default lights (1).
The 2D wireframe style draws no cloud points: a found cloud shows its
extents and "Point clouds are not displayed in 2D visual style.",
or nothing with POINTCLOUD2DVSDISPLAY 1. While the view moves only
POINTCLOUDRTDENSITY per cent of the points are drawn; the points are
stored in an order where every leading part samples the whole cloud.
The colour map dialog lists its schemes by name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Scans (.rcs) and projects (.rcp) are read and their points drawn as
screen-sized dots, coloured by the cloud's stylization. The cloud's
placement carries its scale and rotation; its extents and crops stay in
the scan's coordinates. A missing scan shows the extents with the saved
path. -POINTCLOUDATTACH / POINTCLOUDATTACH, POINTCLOUDCROP and the
POINTCLOUD* settings follow the reference's prompts and messages, the
Properties list is the reference's, and a locked cloud is left alone by
edits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
DWFATTACH / -DWFATTACH and DGNATTACH / -DGNATTACH attach a DWF / DWFx sheet
or a DGN (V7 or V8) model with the reference's prompts; ATTACH routes the
file types to them. The sheet or model is read into vector paths, drawn as a
transparent underlay raster (contrast, fade, monochrome and background
adjustment apply) and offers its geometry to object snaps. Definitions are
kept in ACAD_DWFDEFINITIONS / ACAD_DGNDEFINITIONS, the External References
palette lists and reloads them, and image textures get line-keeping mip
levels so thin underlay lines stay visible when zoomed out.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
An image's transparency flag decides whether transparent pixels show what
is behind the image or draw in their stored colour: the image pass and PDF
plots now follow it (it was ignored, so every PNG alpha showed through).
TRANSPARENCY selects images and asks for the mode, defaulting to the first
image's current one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Raster image textures use nearest magnification; PDF pages and OLE
pictures keep bilinear filtering.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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
Top Front Right/Left kept Y (north) as up when starting from Top,
tilting the base because orbit is turntable (never banks). Make
snap_to_direction deterministic: world +Z horizon except near +-Z
where north +Y is used, sign-preserved only for intentionally
inverted views. Expand opposite() from faces-only to all 26 regions
so already-there flip works for edges/corners.
Unselected block text (the BS33-11 Mark/SectionMark labels) was erased by
its own block's wipeout pass and only came back through the selection
xray. Two compounding causes:
- Glyph quads rendered at the bare insert level, an exact depth tie with
every sibling fill, so the later wipeout pass won LessEqual. Wire
batches now carry the composed child rank as depth_override (the same
units the scene graph composes: depths[insert] + label * half), which
the block-text upload resolves per instance. Glyph vertices stay
neutral on purpose — baking the raw per-block label into them
double-counts and, for a negative rank, sinks the text BELOW its own
masks.
- The wipeout pipeline's depth bias (-8 quanta) was larger than the
whole within-block band: once child ranks compress into the insert's
half-band, sibling steps are ~3 quanta and a wipeout-to-text gap is
only 9-16 quanta of the 24-bit buffer. The mask only needs to break
exact ties, so the bias is now WIPEOUT_DEPTH_BIAS_QUANTA = 2.
block_text_draw_depth regression pins the contract: neutral vertices,
override carrying the rank, and a margin over the wipe that exceeds the
bias on a 600-entity scene.
Keep fractional raster placement and matching integer texture coverage
through compositing. Apply parent clipping without changing the scale.
Fixes#1267
A day of pan/zoom that had suddenly become unusable on a 1.2M-entity
drawing turned out to be no regression at all: an unattended NVIDIA
userspace update had been applied without a reboot, the Vulkan ICD could
no longer talk to the still-loaded kernel module, and wgpu silently fell
through to Mesa's llvmpipe. Nothing in the app said so. iced picks the
adapter and names it only under RUST_LOG, so the only evidence was 32
`llvmpipe-N` threads in /proc. The same silence covers WARP on Windows,
SwiftShader in browsers, and the case where wgpu finds no adapter at all
and iced quietly draws the chrome with tiny-skia while the shader widget
is dropped, leaving a blank viewport. This commit makes all of that
visible, on every platform, without new dependencies.
Detection (src/scene/pipeline/mod.rs)
-------------------------------------
`GpuStatus` is derived from two signals:
* `record_gpu_adapter`, run from `MultiPipeline::new` (once per device,
next to the existing uncaptured-error handler), reads
`Device::adapter_info()`, prints one `[gpu] adapter: <name> (<backend>,
<device type>, driver …)` line — stderr natively, `console.warn` on the
web, which has no stderr — and keeps a copy.
* `note_scene_draw`, run from `ViewportPane::draw`, counts how often the
viewport asked for a primitive. Every renderer makes that call; only
wgpu goes on to `prepare` and create a device. Three draws with no
device ever recorded therefore mean the primitive is being dropped.
The verdict is one of `Unknown`, `Hardware`, `Software(adapter)` or
`NoRenderer`. `Software` is `DeviceType::Cpu` — what Vulkan reports for
llvmpipe, DX12 for WARP and GL for a renderer string that gives it away —
plus a name match on the fallbacks browsers expose (SwiftShader, llvmpipe,
lavapipe, "Microsoft Basic Render"), because the WebGPU backend reports
every adapter as `Other` (gfx-rs/wgpu#8819). Metal has no software device,
so on macOS only `NoRenderer` can occur (a VM without a GPU); it is caught
by the draw count like everywhere else.
`gpu_status_if_changed` costs one atomic load per app message: a
generation counter moves only when a device is recorded or the draw count
crosses its threshold, so the app re-derives the verdict a handful of
times per session.
Notification (src/app/startup.rs, view/modal.rs, ui/statusbar)
-------------------------------------------------------------
`refresh_gpu_status` runs after every message. On a degraded verdict it
speaks through three channels, because many users never read the command
line:
* A popup, `ModalKind::GpuWarning`, queued once per verdict through the
startup-modal queue so it waits its turn behind the file open. It names
the adapter, states the consequence (slow navigation, unstable or
missing rendering) and gives the remedy that usually applies on the
running platform: a driver update not yet followed by a reboot on
Linux, a missing driver or a Remote Desktop / VM session on Windows, a
VM without a Metal device on macOS, hardware acceleration switched off
in the browser. "Don't show again for this device" stores
`GpuStatus::identity()` in `settings.gpu_warning_silenced`; keying it by
verdict means silencing "software rendering on llvmpipe" does not
silence a later, different failure.
* A status-bar pill in the theme's warning colours, `⚠ Software rendering`
or `⚠ No GPU renderer`. It is not a `StatusPill`, so it cannot be hidden,
and it stays for the session; clicking it reopens the popup, silenced or
not.
* A command-line entry through the new `CommandLine::push_warning`, which
is styled like an error but deliberately does not touch `last_error` /
`error_revision`: automation reads those to decide whether the command it
just ran failed, and a graphics warning is not a failed command.
`subscription()` requests frames only while the verdict is `Unknown` on a
document tab (the Start page draws no viewport), so the probe costs one
frame on a working GPU, three on none, then stops.
Verification
------------
* Live on the machine that had the incident, with the Vulkan loader
restricted to Mesa (`VK_DRIVER_FILES=…/lvp_icd.json WGPU_BACKEND=vulkan`):
popup → OK → pill → click reopens; "Don't show again" → restart → pill
only, no popup.
* `gpu_tests::the_adapter_is_reported_once_per_device` (ignored, needs an
adapter) asserts the classification on a real device; it was run on both
llvmpipe (`Cpu`) and an NVIDIA GT 1030 (`DiscreteGpu`).
* Unit tests cover the name heuristic, the verdict function, the identity
rule, the warning text with an adapter name that itself contains
parentheses and commas, the settings round trip and the command-line
semantics. New strings are in all 21 locales as the `gpu` Fluent group.
Co-Authored-By: Claude Opus 5 <[email protected]>
Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).
Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:
- src/app/automation.rs, src/app/command_driver.rs: combined both
sides — kept this branch's sketch-constraint/named-parameter loading
on "new"/"open", adopted main's Scene::clear()/material_base_dir and
its newer rebuild_derived_caches() over the older bump_geometry().
Scene::clear() (src/scene/entity.rs) now also resets
sketch_constraints/named_parameters, so every caller gets this for
free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
outright (commit 973af87d, authored by Hakan directly) — a large
MINSERT is no longer purged as "corrupt" at load, only its rendering
is bounded, preserving the source data. This branch's own earlier
design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
copy was an explicit verbatim snapshot from an earlier upstream PR
(commit 4c264b8c) with zero constraint-system-specific content, and
main has since evolved it considerably further.
Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").
Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
- 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
Resolve curve intersections through cadkernel, retain empty exact results, gather analytic candidates independently of tessellation, and bound material uploads. Preserve large MINSERT data while limiting rendering work; remove obsolete review narratives.
Ports the fixes from fix/osnap-circle-arc-ellipse-intersection (and
this session's audit follow-up) onto this branch. All are independent
correctness/reliability fixes, unrelated to the parametric-constraint-
system work also in progress here.
1. OSNAP Intersection snap used a tessellated approximation instead of
exact geometry (GitHub discussion #1052): a circle/arc always
tessellates at a fixed 48 segments in the resident wire set
regardless of radius or zoom, so every circle/arc intersection snap
was off by construction. Added exact_circle_intersections, a
closed-form, frame-independent circle/arc-vs-circle/arc solver,
wired into both Intersection-snap code paths ahead of the
tessellated sweep. This branch's TangentGeom predates the
PlanarEllipse variant added upstream, so the ellipse/line
generalization from the upstream fix isn't ported here — this
covers the literal reported bug (a circle-circle case) using only
variants this branch already has.
2. Unbounded MINSERT allocation: array_offsets() reserved one Vec
element per array instance with row/column counts parsed as
unchecked u16s — a corrupt 65535x65535 pair requests ~96 GiB.
Fixed with a documented instance budget (20,000), enforced at both
the load-time corrupt-entity filter and a render-graph failsafe.
3. MINSERT spacing was scaled by the block's own scale:
insert_instance_transform composed the offset translation before
the instance transform, so x/y block scale multiplied the row/
column spacing too. Fixed by orienting the offset with rotation +
OCS only, then translating the already-scaled instance transform.
4. Automation "open" skipped the finalization (block-origin
normalization, raster-path resolution, source_path) and corrupt-
entity purge a UI open always runs. Added
io::load_bytes_finalized(path, bytes), used by automation's "open"
handler, which now also reports the purge count.
5. Material maps were uploaded without checking
device.limits().max_texture_dimension_2d, unlike raster-image
rendering (which already tiles to that limit). A mesh material is
sampled by UV, so it can't use the same tiling trick — instead,
upload_rgba_texture now downscales an oversized image before
upload, falling back to the 1x1 material color if malformed.
6. Sketch-constraint persistence/undo, audit-found while reviewing
this branch's own constraint system:
- Clearing every constraint in a scope after a prior non-empty save
left the stale XRecord in place, resurrecting the deleted
constraints on the next load. materialize_sketch_constraints_for_
save now actively removes the XRecord when a scope goes back to
empty.
- ERASE removing an entity took its constraints with it (design
doc's deletion policy), but sketch_constraints lives on Scene,
outside document/document.objects, so nothing captured that
mutation for undo — the erased geometry came back on undo, but
its constraints didn't. refresh_sketch_constraints's deletion
pass now records the touched scope's before-image into the same
UndoRecording the entity removal rides in, threaded through a new
sketch_constraints channel on DeltaSnapshot, so one undo restores
both together. HistorySnapshot::Delta is now boxed (clippy
flagged the resulting size skew across undo/redo stack variants).
31 new/adapted tests across all six fixes. Full workspace suite passes
except the one pre-existing, unrelated font-test failure also present
on main.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Material map images (diffuse/specular/reflection/opacity/bump/
refraction/normal) were decoded at their original file dimensions and
uploaded to wgpu without checking device.limits().max_texture_dimension_2d,
unlike raster-image rendering, which already tiles to that same limit.
An oversized material map produced a wgpu validation error and left
the material unusable; the global error handler prevented a process
abort, but never produced a valid texture either.
A mesh material is sampled by UV across a surface, so it can't use the
same per-quad tiling trick raster images use. Instead,
upload_rgba_texture now checks the decoded image against the device's
texture-size limit and downscales it (aspect-preserving, via
image::imageops::resize) before upload when it's oversized, falling
back to the existing 1x1 material color if the decoded buffer is
malformed (its length doesn't match its reported dimensions).
3 new tests cover the resize math (aspect ratio preserved, both
dimensions respect the limit) and the malformed-input fallback.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Highlighting a whole-drawing selection took 299 ms, and it runs when the
selection changes — directly between the click that completes a selection and
seeing it.
Most of that was asking the same question twice. Each wire went through the
circle and ellipse extractors at its draw depth to build the analytic
instances, then through both again at depth 0.0 purely to ask *whether* it is
a circle or an ellipse, allocating a vector each time and dropping it.
Neither extractor's `Some`/`None` answer depends on the depth: every
`return None` in them turns on the geometry — which `TangentGeom` variant,
the radius, the axis lengths, the parameters — and the depth is only written
into the instance, never read to decide. So the extraction that builds the
instances already answers the classification. One pass now does both, and
fans out across cores, since it reads a wire and writes nothing shared. That
took 173 ms of shape extraction and splitting down to 19 ms, and the whole
highlight from 299 ms to 150 ms.
Gathering the highlighted wires also cloned each handle's slot vector and
sorted the copy — one allocation and one sort per selected entity. The slots
are already ascending: both builders walk the wires in order and push as they
go, and `patch_handle_index` shifts a handle's slots by a constant, which
preserves it. A `debug_assert` on the slots actually read holds that in
place. The compat path of `from_highlight_refs` emitted its instances
sequentially while the native path beside it fans out; it now matches.
Nothing renders in a test, so a wrong bucket in the classification would
surface only as circles drawn wrong when selected. Two tests cover it: each
wire lands where the old predicate put it, and the colour override — always
for hover, and the configured tint or `SELECTED` for a selection — reaches
the instances.
`wire-highlight-detail` reports the phases: gathering, classifying,
uploading the analytic instances, the regular wires and the block ones.
- Fall back to WireModel::SELECTED when selected_tint is None (classic mode) so selected circles, arcs, and ellipses are not omitted from the highlight pass.
- Explicitly rebind uniform_bind_group before drawing analytical curves in selection_xray pass.
- Skip invisible wires (display_visible == false) during selection and hover wire extraction.
`forget_cached_keys` is the one place that means "this slot holds nothing
you can trust", and it left the partition record behind. A reused slot
rebuilds regardless — its content id changed, so the arena takes the build
path and the record is overwritten — but that made the safety an accident
of another code path rather than something this function guarantees.
Co-Authored-By: Claude Opus 5 <[email protected]>
`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 <[email protected]>
`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 <[email protected]>
`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 <[email protected]>
- 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.