Commit Graph
630 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.8 626e8091f7 refactor(viewport): unify content-viewport clipping on the stencil path
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 <[email protected]>
2026-07-20 11:06:28 +03:00
Hakan SevenandClaude Opus 4.8 c97c2a8250 fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.

Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.

Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 10:02:02 +03:00
Hakan SevenandClaude Opus 4.8 b30f9b9741 feat(scene): draw nonprint light, camera and geo-location glyphs
Lights, camera views and the geographic-location marker had no on-screen
representation. Add display glyphs for all three:

- Lights render through a new EntityType::Light: a cone/flashlight for spot
  lights, a wire sphere for point lights, a sun for distant lights, sized in
  screen space (fixed-world fallback).
- Camera views (perspective VIEW records) and the GEODATA "daisy" marker are
  synthesized into the model-space resident wire set from the document, since
  they are not entities.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-19 22:15:40 +03:00
Hakan SevenandClaude Opus 4.8 007d03422e fix(solid): cone silhouette uses the base radius, not the root radius
The DISPSILH silhouette for a cone anchored its `CurvedGen::Cone.base` at
the face's h_min (the base rim) but stored `radius` as the surface's h=0
root radius. The silhouette builder treats `radius` as the radius AT
`base`, so its base line landed at the wrong radius and its top line
(`radius + span·tan_a`) overshot the apex — a visibly wrong outline in
wireframe, and wrong lines drawn over the (correct) shaded fill. Store the
radius at the base instead (`radius + h_min·tan_a`). Cylinders (tan_a=0)
and root-based cones (h_min=0) are unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-19 19:14:11 +03:00
Hakan SevenandClaude Opus 4.8 e0f5b29941 fix(mesh): hide subdivision Mesh face fill in wireframe mode
The `fill_is_3d` classifier tagged only PolyfaceMesh / PolygonMesh as real
3-D surfaces (fill routed to the 3-D buffer, drawn shaded-only). The
modern subdivision Mesh was missing, so its face fill fell into the 2-D
overlay buffer that draws in every view mode — showing the shaded faces in
wireframe. Add `EntityType::Mesh` to the classifier.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-19 18:38:54 +03:00
Hakan SevenandClaude Opus 4.8 fc29139056 fix(hatch): radial gradients and correct gradient stop colours
A gradient hatch was drawn linear from the entity colour to its second
stop, so a red->yellow spherical fill came out as a pale linear wash. Two
fixes:
- Colour: start from the gradient's first stop, not the entity colour.
  hatch_model_from_dxf already baked stop-0 in; stop synced_hatch_models
  from clobbering it with the entity's resolved colour for gradient fills.
- Shape: spherical / hemispherical / curved gradient names now render as a
  radial fill (new shader mode 3, centred on the boundary with radius to the
  farthest vertex) instead of linear. Native and web shaders both updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 22:01:22 +03:00
Hakan SevenandClaude Opus 4.8 69354eca07 feat(proxy): decode shell, transformed geometry and text previews
Extends the proxy-graphics decoder past the door/wall's absolute polylines
so an ACAD_TABLE preview renders in full:
- type 29 transform: a 4x4 local->world matrix now applies to every
  following primitive (identity for the door/wall, which are absolute).
- type 9 shell: vertices + a face list -> each face as a closed boundary
  (the table's cell rectangles).
- type 32: a normal-tagged polyline (grid lines).
- type 38 text: position + direction (its length is the glyph height) +
  UTF-16 content and font, drawn as glyph strokes (simplex.shx and kin are
  single-stroke fonts, so the outline is the character).

True-colour (22) and lineweight (23) traits now reach real geometry too.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 21:34:15 +03:00
Hakan SevenandClaude Opus 4.8 1d117f514c feat(proxy): honour proxy-graphics colour traits
The preview stream sets a current colour (record type 14, an ACI) before
its geometry; track it and tag each decoded primitive. The Unknown-entity
path groups primitives by colour and draws one wire per colour, resolving a
real index (1..=255) to its RGB and letting ByLayer/ByBlock inherit the
entity colour. So a custom entity's preview now renders in its own colours
(e.g. an ACA door's blue swing) instead of a single flat colour.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 20:44:47 +03:00
Hakan SevenandClaude Opus 4.8 18d116bb33 feat(proxy): decode proxy-graphics geometry for custom entities
A custom entity without its object enabler (an AutoCAD Architecture door,
wall, ...) arrives as Unknown but ships a cached proxy-graphics preview —
the vector fallback AutoCAD itself draws. The old decoder read only a lone
type-6 polyline; generalise it to the real record stream
([total_size, record_count, {record_size, record_type, data}...]) and decode
the geometry records: polyline (6), polygon (7, closed) and circular arc
(4/5, flattened to a strip). Trait records (colour/lineweight/...) are
skipped for now, so the preview draws in the entity's own colour.

The Unknown-entity path now draws every decoded primitive in one wire, so
these objects appear (and become selectable) instead of vanishing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 20:33:03 +03:00
Hakan SevenandClaude Opus 4.8 b3e5743808 feat(image): fetch remote rasters and clip them to their boundary
A raster image reference now resolves through one memoised path shared by
the loader and the unresolved-reference placeholder:
- http/https URLs are fetched natively (rustls, 8s timeout, 32MB cap); a
  local file is read as before. The web build never fetches. The cache is
  cleared when a new document opens.
- Online, the fetched image displays; offline or on a broken/missing
  reference, the frame + saved path text still shows. One resolver keeps the
  two in agreement without fetching twice.

The raster is also clipped to its boundary: the image draws as the
triangulated clip polygon (ear-clipped) with per-vertex UVs instead of a
full quad, so pixels appear only inside the clip. Clip-boundary Y is raster
space (top-down), so it is flipped to the image's Y-up space in both the
outline and the clip triangulation so they align.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 20:11:29 +03:00
Hakan SevenandClaude Opus 4.8 0658601c41 fix(block): render constant attributes; bump acadrust for multiline attrs
A CONSTANT ATTDEF has no per-insert ATTRIB — its value belongs to the block
itself — so block expansion must draw it instead of skipping every ATTDEF.
Non-constant templates are still skipped (the insert supplies their ATTRIB).

Also bumps acadrust to decode the embedded MTEXT of R2018 multiline
attributes, so a value like "A\PB" renders both lines.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 17:57:23 +03:00
Hakan SevenandClaude Opus 4.8 bf85896294 fix(mtext): treat the 0x02 background-fill flag as a mask over 0x01
MTEXT background fill flag 0x02 means "use the drawing-window colour" — a mask
that erases geometry behind the text — while 0x01 means "paint background_color".
The fill-colour pick tested 0x01 first, so text flagged 0x03 (both bits, e.g. a
dimension whose DIMTFILL is the drawing background) painted the stored
background_color (a stray grey box) instead of masking. AutoCAD shows no colour
there.

Test 0x02 first: when set, fill with the drawing-window colour (invisible on a
matching canvas) regardless of 0x01; a plain 0x01 fill still paints its colour.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 15:23:03 +03:00
Hakan SevenandClaude Opus 4.8 958c06ffe0 fix(dimension): don't draw a dimension's Defpoints markers
A dimension's definition points are baked into its *D block as POINT entities on
the Defpoints layer. AutoCAD treats them as grip markers and never draws them,
but OCS rendered them through the baked-block path as PDMODE glyphs (a vertical
tick with the drawing's PDMODE 4 / PDSIZE). Two of them sit on the measured
geometry at each extension line's foot, so the tick read as the extension line
running past the geometry — the lines looked too long.

Skip POINT sub-entities when rendering a dimension's baked block; only its lines,
arrowhead blocks and text are geometry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 15:16:11 +03:00
Hakan SevenandClaude Opus 4.8 50f6196594 fix(render): flag 2D overlay fills so they show in every view mode
A SOLID's filled quad and a dimension/MultiLeader text background are flat 2-D
overlays that should draw in wireframe and shaded alike, but face3d_gpu
classified fills by `!fill_tris_low.is_empty()` — and a 2-D fill at UTM scale
carries a double-single low residual for precision just like a real 3-D mesh
face. So the arrowhead fill and the "5;8" cyan text background were routed to
the 3-D buffer and only appeared in shaded mode, vanishing back to nothing on
return to wireframe.

WireModel now carries an explicit `fill_is_3d`: true only for a real surface
(PolyfaceMesh / PolygonMesh face, set in the tessellator), false for every flat
overlay fill. face3d_gpu classifies on the flag, so 2-D fills stay in the
always-visible buffer while keeping their low residual (and UTM precision).

Verified on budweiser: the `_BoxFilled` arrow SOLID and the dim '5;8' MTEXT
background both tessellate with fill_is_3d = false.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 15:15:24 +03:00
Hakan SevenandClaude Opus 4.8 b655bd232b feat(ole): render the OLE2FRAME embedded presentation bitmap
An OLE2FRAME drew only a rectangle-with-X placeholder. AutoCAD shows the OLE
object's cached presentation image, which the DWG stores as a BMP embedded in
the OLE data blob (a BITMAPFILEHEADER "BM" + DIB).

`ImageModel::from_ole2frame` scans the blob for a self-consistent embedded BMP
(validating the DIB-header size, so a metafile/other-format OLE cleanly falls
back), decodes it with the `image` crate, and builds the textured quad at the
frame rectangle with a per-corner double-single split so it stays precise at UTM
scale. The scene's image collection now feeds OLE2FRAME entities through it
alongside RasterImage. The placeholder's diagonal X is dropped — only the frame
border is drawn now, so it no longer crosses over the bitmap.

Verified on budweiser handle 3088: the 32×32 embedded bitmap decodes to RGBA
with the expected colours (yellow upper-left, blue centre, red lower-right),
upright and positioned at the frame corners.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 13:33:46 +03:00
Hakan SevenandClaude Opus 4.8 5e127ffa75 fix(table): place a baked *T table block at the table's insertion point
A Table whose cells are baked into a *T block (block_record_handle) was rendered
by tessellating the block's sub-entities directly. Those are laid out in
block-local space (origin at the table's top-left corner) — the Table entity
carries the world placement in insertion_point + horizontal_direction, exactly
like an INSERT — so the whole table drew at the world origin instead of its
location.

Each baked sub-entity is now cloned and placed (rotated about the local origin
by the horizontal_direction angle, then translated by insertion_point) before
tessellation, so tessellate_entity produces correctly located, relative-to-eye
geometry. Verified on budweiser handle 9378: its baked lines move from (0,0) to
the insertion point (-1200018.7, -800071.4).

Also bumps acadrust to pull the OLE2FRAME corner-decode fix, which places
OLE2FRAME entities (e.g. handle 3088) at their real corners instead of the
(1,1)/(0,0) defaults.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 13:12:08 +03:00
Hakan SevenandClaude Opus 4.8 340a358c26 fix(solid3d): keep loaded-solid mesh verts f64 until the RTE split
Loaded 3DSOLID faces rendered on a ~0.06 m grid and crawled against their own
wireframe at UTM scale. Both ACIS tessellation paths cast each vertex f64→f32 in
the samplers, and only split the double-single low half (verts_low) when the
body carried a `transform` record. But many files — budweiser among them — store
solid geometry in absolute coordinates with no body transform, so verts_low
stayed empty: the GPU got position_low = 0 and reconstructed an already
f32-quantized absolute position, while the feature edges (split unconditionally
from f64) stayed exact.

Both paths now accumulate vertices in f64 and hand them to one finalize step:

  finalize_mesh(name, verts_f64, normals, indices, color, xform)

the only place a solid vertex becomes f32 — it computes the world coordinate in
f64 (body placement applied, or identity), then splits into (verts, verts_low)
unconditionally, exactly as attach_feature_edges already does for the wireframe.
push_quad / tess_plane_face / tess_cone_face / tess_sphere_face /
tess_torus_face / tess_spline_face and the truck append_group / bespoke_face /
append_spline_faces all carry f64; apply_body_transform is gone.

Verified on budweiser (all solids, body_transform = NONE): verts_low is now
populated with the captured residual, max |low| ≈ 0.0624 m. The Model-tab
(truck_tess::polygon_to_result) and edge paths were already correct and are
unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 12:30:00 +03:00
Hakan SevenandClaude Opus 4.8 608cf8c74b refactor(solid3d): drive curved-face sampling from one chord tolerance
Curved 3-D solid faces used fixed per-LOD grid counts, so a partial arc (a
sphere cap, a torus C-arc, a short cone wall) was sampled as finely as a whole
rim, and the density knobs were scattered across four files.

Every curved face (sphere / cone / torus / spline), feature edge and isoline
now derives its segment count from a chord-height tolerance plus the arc's own
radius and span — the same model the 2-D circle/arc/ellipse wires use
(tess_util::arc_segments): a big rim samples finer than a small one, a short arc
proportionally less than a full turn, and facet error is size-independent.

All tolerances live in one block at the top of solid3d_tess: LodConfig is now a
single chord fraction per LOD (arc_segs / circle_segs derive counts), plus
EDGE_CHORD_FRAC, TRUCK_CHORD_FRAC and BOUNDARY_CHORD_FRAC. FACETRES scales the
fraction. The cone height is a straight generator (no curvature) so it meshes in
one division. Removes the scattered constants (circ_segs/grid_u/grid_v,
CURVE_REL_TOL, BOUNDARY_SEGS, per-site M / EDGE_SEGS / ring_segs).

Adaptive counts come out lower, not higher: the € solid drops 3056→1780 tris, a
full sphere stays at 1024, five-solid total is 2989.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 01:31:46 +03:00
Hakan SevenandClaude Opus 4.8 570165e77f fix(solid3d): trim torus faces to the revolution arc between end caps
An open "C" tube (the € currency symbol's ring) rendered as a full closed
donut: truck builds a torus as a full revolution and can't trim it, and the
wireframe/silhouette arc estimate was wrong.

The arc estimate failed because `collect_face_polygon` reads only the first
loop, and the "widest empty gap = tube body" heuristic breaks once the tube
carries interior holes (where the two horizontal bars punch through it): a hole
splits the body into wide hole-free stretches that masquerade as the opening.

`torus_phi_range` now walks every boundary loop and classifies each: a minor
circle sitting in a constant-φ plane (spread < ~8.6°) is an end cap; a wider
loop is an interference hole. The opening is the gap flanked on both sides by
end caps — a hole never bounds it — so the body is the rest of the turn.

Torus faces now route to the bespoke sampler (like spheres); `tess_torus_face`
clips its φ grid to that arc, so the shaded tube is an open C. `torus_isolines`
and `collect_curved_gens` use the same range for the wireframe rim and the
silhouette. Removes the now-dead truck torus path (`torus_faces`,
`Outward::Torus`, `vadd`).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 01:10:29 +03:00
Hakan SevenandClaude Opus 4.8 c0ec866fac feat(solid3d): draw the ring outline of torus faces in wireframe
A torus's ISOLINES were only its minor cross-section circles, so a ring read
as a row of disconnected circles rather than a ring — the outer and inner
profile arcs that make its outline were never drawn. They are the major
(ring-direction) isolines: a circle around the ring at each tube angle, the
outer (θ=0) and inner (θ=π) ones being the defining outline.

Both directions now draw. The revolution is swept in full rather than clipped
to the face: a tube's boundary is only its two end caps, clustered at the ends,
so it never covers the revolution arc and can't say which way round the tube
runs — the same limitation the arc range already had. Full is correct for a
closed or nearly-closed ring (this Euro symbol is a ~345° torus) and only
over-draws a short torus fillet by the small missing arc, which a later
winding-aware extent would tighten.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 00:31:27 +03:00
Hakan SevenandClaude Opus 4.8 9b7fe2c84d feat(solid3d): draw spline (intcurve) edges of ACIS solids
A curved solid's fillet/blend boundaries are `intcurve-curve` spline edges.
The edge collector handled only straight and elliptic curves, so a spline
edge fell back to a straight chord between its two vertices — or, for a
closed-loop edge whose endpoints coincide (a sphere/torus face rim), to
nothing at all. Those boundaries simply weren't drawn.

Both the feature-edge list and the face-boundary sampler now sample the
spline over the edge's own parameter span via the new `SatIntCurve`, so the
curved edges draw as real curves. On budweiser2018.dwg handle 2700 the
solid's edge-segment count rises from 110 to 392 — the previously missing
sphere/torus rim curves.

The face-boundary sampler feeding a real loop (instead of a degenerate
chord) is also the prerequisite for trimming those curved faces to their
extent, a follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 00:18:10 +03:00
Hakan SevenandClaude Opus 4.8 8c72a06790 feat(solid3d): ISOLINES and silhouettes on sphere and torus faces
Extends the curved-face wireframe treatment from cones to spheres and tori, so
a solid built from those faces reads the way a cylinder already did.

ISOLINES: sphere faces get meridian lines across the face's colatitude span at
evenly spaced longitudes; torus faces get minor cross-section circles at evenly
spaced revolution angles. Both clip to the face's own parametric window (from
the boundary polygon via a shared `angular_span` gap-finder), so a partial
sphere or torus arc doesn't draw lines out over the missing part.

Silhouettes (DISPSILH): `CurvedGen` becomes an enum. A sphere silhouettes as
the great circle perpendicular to the view, clipped to the face window; a torus
as the two curves where the tube turns edge-on, traced around the revolution.

The silhouette now uses the camera's view *direction* alone, not the eye
position — a single parallel direction for the whole scene — so the outline
follows the view angle, holds still under pan, and doesn't foreshorten, which
is the orthographic silhouette a CAD wireframe expects.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 23:30:05 +03:00
Hakan SevenandClaude Opus 4.8 ae5231427b feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.

Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.

Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 20:02:56 +03:00
Hakan SevenandClaude Opus 4.8 0ce150411a feat(solid3d): draw ISOLINES on curved faces in wireframe
A cylinder's wireframe showed only its two rim circles, so from the side it
read as two disconnected ellipses rather than a cylinder. AutoCAD draws
ISOLINES — a fixed set of longitudinal lines on each curved face — so the
surface reads from any angle.

`collect_isolines` walks the cone-surface faces (cylinder and cone), recovers
each face's height and angular extent with the same logic `tess_cone_face`
uses, and emits `header.isolines` (default 4) longitudinal segments from the
bottom rim to the top rim. They are appended to the feature-edge line list, so
they ride the existing body transform, double-single split, and top-level /
per-INSTANCE re-split for free.

Being part of `edge_verts`, they inherit the edge pass's gating: drawn in the
wireframe modes and in shaded-with-edges, hidden in plain shaded — matching
where AutoCAD shows isolines. `isolines` is threaded from the header through
the tessellation entry points alongside `facet_res`.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 19:45:42 +03:00
Hakan SevenandClaude Opus 4.8 26e83a90bf fix(solid3d): double-single split for shaded ACIS faces at UTM
A 3D solid's shaded faces used absolute f32 world coordinates while its
feature edges carried the double-single (high, low) pair the mesh shader
reconstructs relative to the eye. At UTM scale the faces snapped to a
~0.06 m grid, so the shaded surface crawled against its own wireframe as
the camera moved.

`apply_body_transform` is where a solid gets its world placement — the
large body translation is exactly where the coordinate stops fitting an
f32. It now computes each world vertex in f64 and splits it into the pair,
filling `verts_low` the same way the feature edges already are. Both
tessellation paths (truck B-rep and the bespoke sampler) route through it,
and the downstream re-split points (top-level offset, per-INSTANCE block
transform) already consume the pair, so they inherit the corrected
residual. Picking, which reads `verts_low` too, sharpens at UTM as well.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 19:15:02 +03:00
Hakan SevenandClaude Opus 4.8 4f1e710b35 fix(pick): catch a wide polyline on its band, not just its centreline
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.

The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.

Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.

This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.

That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.

Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 15:22:56 +03:00
Hakan SevenandClaude Opus 4.8 3f060a570a fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.

A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.

Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.

Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.

The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 14:58:55 +03:00
Hakan SevenandClaude Opus 4.8 f720e40ba1 feat(render): draw a proxy entity's cached preview instead of nothing
An entity from an application we have no reader for — e.g. an Autodesk Raster
Design embedded raster image — now arrives as `Unknown` (acadrust 769741d) and
rendered as nothing at all, so the drawing silently lost it with no hint that
anything was there.

Such entities almost always ship proxy graphics: the vector preview their author
cached for precisely this case, and what AutoCAD itself falls back to when the
object enabler is absent. Decode it and draw it, so the entity holds its real
place and size.

The record grammar has no reference implementation to copy — LibreDWG keeps the
blob raw ("see par 29") and ACadSharp stores the bytes without parsing them — so
rather than infer a general decoder from one sample, `proxy_graphics` accepts a
single polyline record and only when the blob matches it byte-for-byte: sizes
must agree, the points must consume every remaining byte, and coordinates must
be finite. Anything else returns None and draws nothing, exactly as before, so
a file this does not model cannot regress or sprout invented geometry.

Verified on a Raster Design drawing: its 140-byte preview decodes with 0 bytes
left over to the image frame (1192.149 x 877.824, closed), matching what ODA
reports for the same entity. Unit-tested against that real blob.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 12:53:42 +03:00
Hakan SevenandClaude Opus 4.8 f9a26978dd fix(render): install the GPU error handler once per device, throttled
The uncaptured-error handler from #404 was armed in `Pipeline::new`, which
`MultiPipeline::ensure_len` runs again for every pane added — so the slot was
re-registered on every new pane. Move it to `MultiPipeline::new`, which runs
once per device; if iced ever rebuilds the device it builds a new
`MultiPipeline` too, so the fresh device stays covered — where a `Once` guard
would have left it on wgpu's panicking default.

Throttle the message. The handler slot is per-device and single, so it also
catches iced's own draw errors and is the only thing that can report them — it
has to stay loud. But a validation error normally repeats on every frame, and
an unthrottled print floods stderr at refresh rate and buries the first, most
useful message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error is
never hidden, the running count shows it is ongoing, and the output stays
finite.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 10:38:53 +03:00
Kevin GriffinandClaude Fable 5 a204237268 fix(render): chunk Face3D fill buffers to the device limit (#358)
Face3DGpu batched every visible 3DFACE quad and PolyfaceMesh /
PolygonMesh fill triangle into a single vertex buffer. A mesh-heavy
DWG (e.g. a Navisworks import) blows past wgpu's default 256 MB
max_buffer_size once enough layers are thawed; the resulting
uncaptured validation error panics inside iced's main-thread redraw
and aborts the whole process. Split the 3D and 2D fill streams into
per-limit chunks on whole-triangle boundaries — the same scheme
build_mesh_batch already uses (#203) — and register an
on_uncaptured_error handler so any remaining GPU validation error
degrades the frame instead of killing the session.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-16 21:32:20 -07:00
Hakan SevenandClaude Opus 4.8 5bd683cf4e feat(mline): GPU-dash multiline elements with shared A-type alignment
MLINE dashed elements were fully CPU-expanded (apply_along) — one wire per
dash — to keep the parallel lines in phase. Move pure-dash elements onto the
GPU dash shader and end-align every element to the multiline's centre-line so
they share one interior phase.

- wire_distances now accumulates arc-length from the double-single (high+low)
  points, so the dash phase stays precise at UTM coordinates. f32-high-only
  deltas quantised ~0.1 there and drifted the parallel lines apart — the
  original reason MLINE dashes were CPU-side.
- New WireModel.dash_align_end carries a shared begin/end dash length. The
  shader uses it as align_end for every parallel element (the interior phase
  depends on align_end, not the wire's own length) while align_total stays the
  element's own length, so each still ends on a dash at its own endpoint.
- MLINE tessellation derives that length once from the centre-line, routes
  pure dash/space/dot elements to the GPU pattern and text-bearing elements to
  apply_along.
- apply_along gained an "A"-type mode (shared reference length): a solid
  begin/end dash and a phased interior, so its dashes — and embedded text —
  line up with the GPU-dashed sibling elements instead of tiling from start.

Pure-dash MLINEs now cost one WireModel + pattern per element instead of N
segments; single (non-MLINE) entities keep the from-start tiling unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 02:59:11 +03:00
Hakan SevenandClaude Opus 4.8 3e34da4ac6 chore(glam): use camera-module projection API for deprecated Mat4 rh ctors
glam 0.33.2 deprecated Mat4::{orthographic,perspective,look_at}_rh in
favour of the glam::camera::rh::* functions. Switch the 7 call sites in
camera.rs and viewcube.rs to the equivalent camera-module functions
(directx orthographic/perspective = same [0,1] depth range; look_at_mat4
= same view matrix), silencing the deprecation warnings. No behaviour
change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 01:49:26 +03:00
Hakan SevenandClaude Opus 4.8 48e6d0d553 feat(dgn-ls): dash walls from start + fix wall connector and cap identity
DET pipe walls now carry the stroke pattern (dash_from_start), which
tiles from each wall's own start vertex and skips the A-type end
alignment that was force-lighting a solid dash under the end symbol.

Two rendering fixes on top:
- Dedup the host polyline's vertices before offsetting the walls. Some
  DGN pipe polylines carry trailing duplicate end vertices; a zero-length
  segment gave offset_xy a degenerate normal, leaving that vertex
  un-offset and folding the wall down to the centre line. The two walls'
  fold segments met at the centre and drew a spurious line linking them.
- Re-key the symbol (end circle / half-ellipse) wires to the host handle
  so the whole pipe picks and selects as one entity instead of the
  symbols acting as separate phantom entities.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 01:49:18 +03:00
Hakan SevenandClaude Opus 4.8 765e6606e2 feat(dgn-ls): render DET pipe as dashed double walls
DGN line-style linetypes (empty standard LTYPE) drew only end symbols on
a single centre line. Render the host polyline as two parallel walls
offset by ±(symbol radius = block extent / scale) so each wall sits
tangent to the end circles, and dash them with the line style's stroke
pattern.

The stroke dash lengths live in the component leaf (byte-aligned
big-endian f64 after the shared class GUID). `wall_dashes` reads the
native dash of the root compound's dash stroke ([5.25, 7.125] for DET);
`stroke_dashes` decodes the leaf. The native lengths scale to drawing
units by f = radius / symbol-scale (0.1875) — the same factor that maps
the compound's native offset (2.0) onto the measured wall offset
(0.375), which also lands the dash at ~1.0 drawing unit.

Walls dash through the normal WireModel pattern (shader rebuilds each
segment relative-to-eye, so it stays precise at UTM coordinates).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 22:58:29 +03:00
Hakan SevenandClaude Opus 4.8 1d6a04183e feat(render): draw DGN line-style symbols on linetypes
Linetypes converted from MicroStation DGN keep their pattern in DGN line-style
objects (empty standard LTYPE), so entities using them drew as a plain line and
lost the symbols (e.g. a pipe's end circles). Walk the line-style tree
(resolved by name to its LSDefinition), collect the symbol components' anonymous
blocks, and draw each at the host polyline's endpoints, scaled by the symbol's
decoded scale (geometry / scale) so it matches the drawing (verified: a 0.75
ellipse renders at the file's intended 0.375 radius).

Bumps acadrust to 8e18c6b (DGN line-style symbol-scale decode). First pass: the
stroke dash pattern and exact per-vertex placement need more of the line-style
leaf decoded; the base line still draws solid.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 21:48:06 +03:00
Hakan SevenandClaude Opus 4.8 0e18b5bb33 feat(mline): render multilines from their MLINESTYLE + complex linetypes
Multilines were drawn with a fixed ±scale/2 two-line guess in the entity
colour, ignoring the MLINESTYLE entirely, so a drawing's custom style (per-
element offsets, colours and linetypes) was lost — a red/yellow multiline
rendered as a white box.

- mline.rs: resolve the MLINESTYLE (by style_handle, then name) and build one
  parallel line per element from its offset x scale + justification shift.
- tessellate.rs: special-case MLINE (like Leader) to emit one WireModel per
  element in the element's own colour, CPU-dashing every element through
  apply_along so the parallel lines stay in phase.
- linetypes.rs: render a drawing's OWN complex linetypes (embedded text/shape),
  document definition first and the bundled .lin catalog only as a fallback, so
  a file-defined text linetype like PLYN shows its text.
- Run the dash/glyph layout in a local frame (f64 origin subtracted, re-split to
  double-single after) so spacing stays precise at UTM coordinates, and pass
  tracking = 1.0 so embedded text keeps its letter spacing.

Cargo.lock pins acadrust to the MLINE->MLINESTYLE handle-link reader fix.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 18:44:15 +03:00
Hakan SevenandClaude Opus 4.8 f452ae9211 fix(export): keep plot coordinates in f64 until they cancel
A UTM drawing plotted deformed — both geometry and text — while the same
drawing near the origin came out clean. Every pass of the exporter narrowed
an absolute world coordinate to f32 before the offset had a chance to cancel
it, and f32 resolution at UTM is ~0.03 m on easting and ~0.5 m on northing,
so the whole plot snapped onto that grid.

Four places did it, all now folding in f64 and narrowing only once the
result is a single-digit sheet-mm value:

- The wire pass never read `points_low` at all — the word did not appear in
  the file — so it plotted the double-single "high" half alone and dropped
  every residual.
- emit_hatch cast the f64 `world_origin` to f32, re-introducing at the last
  step exactly the quantisation the boundary-relative encoding exists to
  avoid.
- pattern_segments rasterised entirely in absolute f32. The pattern is
  anchored at world (0,0), so a hatch at UTM runs a line index k of ~1e7:
  `origin + k · step` and the `edge − line` intersections both fall apart
  there. k also becomes i64 — `as i32` saturates silently, which would
  collapse the range to a bogus run from i32::MIN rather than drop the fill.
- emit_text folded `pos + pos_low` in f32, which rounds straight back to
  `pos`: the residual the pair exists to carry was discarded at precisely
  the coordinates it was carried for.

The offsets themselves were also narrowed by the callers, so the value meant
to cancel a UTM coordinate was quantised before it could. Widen ox/oy to f64
through export_pdf, build_pdf, print_wires and the two plot-param builders;
printing shared the same path and the same bug.

Pre-existing, not from #390 — that change only routed text through the same
faulty pipe the wires were already in.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 10:25:28 +03:00
Hakan SevenandClaude Opus 4.8 5018963d3d fix(export): project viewport text into paper space
Review follow-ups to #390, which fixed PDF/print text for model space but
left the layout-plot path — the one issue #385 was filed from — broken.

viewport_content_wires rewrote points/snap_pts/key_vertices/aabb and then
cloned text_verts through untouched, so paper-space viewport text stayed at
model (UTM) coordinates: a dimension at model (25000, 12000) plotted its
lines onto the sheet and its glyphs kilometres off the page. A text-only
wire fared worse — TEXT/MTEXT carry no stroke points since text went
SDF-only, so it was dropped outright by the empty-clip and AABB rejections.
Project the glyphs through the same proj_abs the snap points use (via the
existing map_text_verts helper), cull per glyph on the quad centroid, count
them towards the paper AABB, and dim them like the wire colour.

Also in emit_text, which diverged from the wire pass it mirrors:
- divide the pen by `scale`, or a Fit/windowed plot prints text as
  near-invisible hairlines while its own lines stay correct;
- apply the CTB plot style, or a monochrome.ctb plot renders the lines
  black and leaves the text on its screen colour;
- reset the dash pattern, which is persistent PDF state the wire pass
  leaves set — a HIDDEN-linetype last wire printed glyph outlines dashed.

export_table's doc claimed it honoured TEXTFILL and widened the bold pen
itself; it did neither. Gate fill_tris on textfill() so hollow-on-screen
text exports hollow, carry the bold flag and widen the pen by the 1.7x the
bake uses, and resolve Face once per family instead of once per glyph (the
atlas mutex is held for that whole walk).

uv_key identifies a glyph by its tile's uv_min, but grow_height rescales
every entry's V and reset rewinds the packer, leaving already-built quads
addressing the wrong tile — garbage on screen and a silent miss in the
export table, i.e. #385 again under #347's grow-the-atlas conditions. Bump
an atlas generation on both and fold it into the tessellation memo guard so
the text re-lays-out instead. Quads built earlier in the same pass that
grows are still stale for that pass; the guard heals them on the next one.

Tests: the integration test's "some wire has text_verts" assert passed on
pre-fix code too (the exporter ignored them; the scene always carried
them), and its dimension half was satisfied by the TEXT alone — assert per
entity handle instead, and stop colliding on fixed names in the shared temp
root. The unit test's bbox came from the same quads the mapping reads, so
it held by construction; pin the run's own world box instead. That still
cannot catch a mirrored corner assignment — verified by mirroring it — so
say so rather than claim coverage the assert doesn't have.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 10:06:15 +03:00
Hakan Seven 5bdfe483da Merge pull request #390 from KevinGriffin-new/fix/pdf-export-dimensions
Render SDF text as vectors in PDF/print export (#385).

Text became SDF-only, so all text — standalone TEXT/MTEXT and dimension
text — rode WireModel::text_verts as GPU glyph quads that the CPU PDF
exporter never sampled. Reconstruct the vector geometry at export time via
a new GlyphAtlas::export_table() keyed by the quads' atlas uv_min.
2026-07-16 09:46:20 +03:00
Kevin GriffinandClaude Opus 4.8 6b15884444 fix(export): render SDF text as vectors in PDF/print export (#385)
Since text became SDF-only (the legacy stroke-text path was removed), all
text — standalone TEXT/MTEXT and dimension text — rendered on-screen only as
GPU glyph quads carried on `WireModel::text_verts`. The CPU PDF/print exporter
draws only wire stroke `points` and hatch fills, so it silently dropped every
glyph: dimensions and text were missing from exported PDFs and prints
(present in v0.7.6).

Reconstruct vector text in the exporter instead of resurrecting the stroke
path, so the SDF render path and its caching are untouched:

- sdf_atlas: `GlyphAtlas::export_table()` snapshots each baked glyph's vector
  geometry (outline strokes + fill triangles, glyph space) keyed by its tile
  `uv_min`, re-resolved through `Face` so it honours the current TEXTFILL.
- pdf_export: a new `emit_text` pass walks each wire's `text_verts` (one
  6-vertex quad per glyph), recovers the glyph geometry by `uv_min`, and
  affine-maps it into the quad — `DrawLine` polylines for stroke (LFF/SHX)
  fonts, filled triangles for TrueType, plus solid decoration bars. Runs under
  the same rotation/scale/clip CTM as the wires, so it covers model space,
  paper space and windowed plots, and every SDF-text kind (text, dimension,
  multileader, table, tolerance).

Text embedded in complex linetypes is SDF-only with no stroke fallback and
stays out of scope.

Tests: a unit test asserts a stroke glyph emits vector ops within the glyph's
world bbox; an integration test drives a real TEXT + linear DIMENSION through
scene.entity_wires() -> export_pdf and checks text reaches the file. Verified
bug-first: with emit_text disabled the end-to-end test fails with the reported
symptom (text adds no content).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 13:49:21 -07:00
Hakan SevenandClaude Opus 4.8 ca253c2777 fix(solid): keep SOLID fills placed on UTM-scale drawings
`solid_hatch_model` cast each corner from absolute WCS straight to f32 and
left `world_origin` at [0, 0]. At UTM magnitudes (~1e6) f32's 24-bit mantissa
resolves to ~0.06 units, so the quad snapped to a grid and the fill drifted
off its own outline.

Hold the corners in f64 until the AABB centre is known, then store each as a
small f32 offset from it — the same precision-preserving anchor
`hatch_model_from_dxf` already uses for real HATCH entities. All nine callers
feed through this one function, and every reader of `boundary` already adds
`world_origin` back, so they go from adding zero to adding the real anchor.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 22:55:51 +03:00
Hakan SevenandClaude Opus 4.8 c10e8dae44 fix(tolerance): draw the feature control frame properly
A tolerance drew one row of ASCII soup — "RUN | ANGtol(M) | ..." — about
a tenth of its proper size. Six defects, each independent.

Rows were split on the two-character string "^J". A DWG stores a raw
newline, and the text-DXF reader rewrites the on-disk ^J to one before we
see it, so that split never fired on either mainstream path and every row
collapsed into the first. It now normalises all four spellings the break
arrives in — including the \P our own DXF writer emits, which made
open-DWG → save-DXF → reopen asymmetric.

Symbols were replaced with ASCII labels from a table that was itself
wrong: it read r as circular runout, n as angularity, p as position. The
font's own encoding says concentricity, diameter, projected tolerance
zone, and the benchmark's pre-exploded reference agrees. That table was
an inversion of acadrust's, which is self-contradictory — it binds n to
two different meanings. Do not re-derive from it.

The frame is a proportion of the character height: a compartment spans
-h..+h about its row's centreline, so it is 2h tall and the margin falls
out of that. It was built as height + 2*DIMGAP instead, which is a
different entity's geometry — DIMGAP sizes the dimension line's break and
the rectangle around a basic dimension, where it genuinely applies twice
per axis. Even fed a self-consistent style that can only ever produce
1.5h, so it was wrong in kind rather than mis-fed.

Height came from the entity's own field, which a DWG never sets — the
reader carries only the style's handle, leaving a small non-zero default
that a "> 0" test happily used. It now resolves the style by handle first
and name second: a DWG records the handle and defaults the name, a DXF
the reverse, so matching on the name alone would have picked the wrong
style for every DWG-read tolerance. An entity may also override single
style variables on itself, and this one halves its character height that
way; ignoring that drew the frame several times too large.

Rows stacked upward, every row inherited the widest row's columns, and
one full-width border boxed them all — none of which showed while the
parse only ever produced a single row. The insertion point sits on the
first row's centreline; anchoring the frame's bottom-left there offset it
by its own height, and by an amount that changed with the row count.

Cell text ran together because the glyph run asked for no letter spacing
while the width measurement assumed the font's own, so the characters
crowded inside correctly-sized boxes. Compartments are also measured by
what they draw now: the pen advance stops one letter-space past the last
glyph, and counting that made every one of them too wide.

Symbols come from a bundled stroke font, restored from this repository's
own history: it shipped QCAD's gdt.cxf until the text engine moved from
CXF to LFF, which removed every .cxf and left nothing in their place —
the ASCII labels date from exactly there. Converted to LFF, keyed by the
letter its \Fgdt;X escape carries, and registered under that name like
any other font, so a switch to it resolves through the ordinary lookup:
an MTEXT carrying the escape, or a text style named gdt.shx, reaches it
too, with no code of their own. The geometry is public domain by explicit
waiver; provenance is in the file's header.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 22:22:07 +03:00
Hakan SevenandClaude Opus 4.8 1b60924bbe fix(view): show geometry far off the drawing plane
A benchmark drawing carrying a circle and an ellipse 800 km below its own
plane showed neither, and ZOOM Extents zoomed out to 800 km to find them.
Both come from sizing the view off the wrong axis.

Zoom and clipping answer to different extents and are now measured that
way: the zoom frames what the view actually shows, the extent across the
screen plane, while near/far span what lies along the eye direction.
Sizing both from the 3-D diagonal let one far-off Z drag the horizontal
zoom out with it. The two agree on a flat drawing, which is why it went
unnoticed.

A camera restored from a file's saved view keeps its pose, but nothing
had sized its near/far, so the projection fell back to a guess scaled
from the zoom distance — far too narrow when geometry reaches off the
plane, and the geometry was silently clipped away. It now takes the
drawing's own recorded extent.

$EXTMIN/$EXTMAX is untrustworthy for positioning — stale after an edit,
or the sentinel when the writer never computed it — which is why fit_all
scans entities instead. It is the right input here: too wide only costs a
little depth precision, too narrow deletes geometry, so the drawing's own
claim errs in the safe direction. It also costs nothing on open.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 22:21:38 +03:00
Hakan SevenandClaude Opus 4.8 1490fe4ea9 perf(undo): record entity deltas instead of cloning the whole document
Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY,
draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on
every edit — the last big per-edit cost after the incremental-render work.

They now record only the touched entities' before/after images:

- Scene self-records through the five mutation primitives when a recording is
  open (UndoRecording + is_recording_undo guard, zero cost off-path).
- apply_entity_delta installs one side per handle (in-place / re-insert with
  the original handle / remove) plus a per-handle cache reseed, then a single
  bump_entities so the incremental caches patch instead of a full re-tess —
  undo/redo of a small edit is fast too, not just the edit.
- HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves
  between the undo/redo stacks without re-capturing. Commands that touch
  non-entity state (new layer, group, *D block, viewport) fall back to the
  full snapshot via per-command predicates, and a poison flag debug-asserts
  any mis-scope.

remove_entity leaves the handle dangling in its owner block record's
entity_handles (harmless on its own, a lookup miss is skipped), so a
re-insert strips stale entries first — otherwise add_entity would list the
handle twice and the DXF/DWG writer would emit the entity twice.

Round-trip tests cover transform/erase/add/copy (do->undo->redo equality,
handle preservation, no duplication).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 11:11:17 +03:00
Hakan SevenandClaude Opus 4.8 aea1fd2caa perf(render): arena on by default; trim per-edit resident + text work
Three cuts to the per-edit cost, on top of the incremental wire arena:

- Enable OCS_WIRE_GPU_PATCH by default (kill-switch OCS_WIRE_GPU_PATCH=0), so the
  in-place / append-tombstone arena — validated on the reference drawing (wire
  block ~300ms + 265MB upload -> ~2ms) — is on for everyone; a bail always falls
  back to the identical batched path.

- Resident patch: an all-Modified edit never reorders, so reassemble by walking
  the recorded assembled order (~10k handles) instead of scanning the whole
  document (~800k) to re-derive draw order. Add/Remove still scan (positions
  change). ~14ms -> <1ms on a move.

- SDF text: a new content id misses the glyph cache every edit, but if no
  text-bearing entity (Text/MText/Dimension/MLeader/Leader/Table/attribute/INSERT)
  changed since the last build the glyphs are identical, so reuse them. ~23ms -> 0
  on a plain-geometry edit.

A move/edit on this 800k-entity drawing now spends ~50ms (dominated by the undo
document clone) instead of ~85ms, and the wire GPU upload is gone.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 10:14:44 +03:00
Hakan SevenandClaude Opus 4.8 f12100d783 perf(scene): keep draw_depth_map warm across move/edit (phase 5)
draw_depth_map rebuilt on every geometry_epoch bump — an O(document) walk (all
objects for SortEntitiesTable, all entities grouped-by-block and ranked), ~45ms
per edit on a 788k-entity drawing, and it runs on every viewport snapshot.

But draw order depends only on each block`s entity COUNT (the rank denominator),
the entities` handles, and the SortEntitiesTable — none of which a Modify (move /
rotate / scale / colour / hide) changes (owner and type are preserved, and the map
ignores visibility). Gate the cache on the journal: an all-Modified edit keeps the
map; only Add / Remove (count change) or a table change (a `full` delta) rebuilds
it. So the common move/edit now skips the walk entirely.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 09:51:03 +03:00
Hakan SevenandClaude Opus 4.8 95ef3b3890 perf(render): split the wire arena into regular + mesh-edge batches
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 <[email protected]>
2026-07-15 09:48:20 +03:00
Hakan SevenandClaude Opus 4.8 9761423b84 perf(render): arena handles add/remove too, not just in-place edits
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 <[email protected]>
2026-07-15 08:37:27 +03:00
Hakan SevenandClaude Opus 4.8 e914be9852 perf(scene): route hide / isolate-end / solid-register through bump_entities (phase 5)
hide_selected, end_isolation and register_solid_model called bump_geometry() —
a full-drawing invalidation (clears the tessellation memos, bumps block_epoch) —
even though each touches a known, bounded set of handles. On a large drawing
hiding a few objects then re-tessellated everything still visible.

Report the exact changed handles via bump_entities instead: hide/reveal mark
just the affected entities Modified so the resident set drops or re-adds only
those, and register_solid_model reports its one solid (also making a bulk
register loop O(n) rather than O(n^2)). No block-definition changes are
involved, so skipping block_epoch is correct.

Deliberately left on the full path: layer freeze/off (block sub-entity layer
visibility legitimately needs block_epoch), viewport-scale (annotation-key
sensitive), isolate (already cheap — keeps few), and undo/redo/style/file-open.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 08:11:21 +03:00
Hakan SevenandClaude Opus 4.8 38c8583d27 perf(render): in-place GPU wire arena for move/edit (phase 4, OCS_WIRE_GPU_PATCH)
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 <[email protected]>
2026-07-15 06:02:25 +03:00