Commit Graph
72 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.8 3d37e1f8d8 fix(thickness): edit persists + wide polyline extrudes as a solid tube
Entering a thickness (DXF 39 extrusion) on a wide polyline now works end
to end:

- The properties Thickness field is a General-group common prop, so it is
  routed to apply_common_prop (set_entity_thickness) instead of the
  geometry setter, which ignored it — the value was silently dropped and
  reverted to 0.
- A wide polyline extrudes its whole band into a 3-D tube (outer + inner
  walls with top and bottom caps), not just its centreline.
- The tube is real 3-D geometry, not a flat draw-order hatch: it renders
  solid in every view mode with true depth, so nothing shows through the
  walls. The flat 2-D band fill is skipped for a thickened polyline.
- A fill whose triangles span depth keeps its real depth (no draw-order
  bias), so the extrusion's own edge wires stay in front of the surface
  and remain visible instead of only the silhouette.
- The corner and cap edge lines read black, framing the coloured tube.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 13:40:12 +03:00
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 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 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 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 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 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 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 1b037a68aa feat(annotative): scale text/dims/tables/blocks at the annotation scale + editable property pickers
Render: annotative-ness is resolved centrally in scene::annotative::is_annotative
(per-object context dictionary, legacy XDATA, or annotative style) so the bake and
the properties panel agree. Text, dimensions, tables and blocks now display at the
current annotation scale in model space. An annotative block scales as one uniform
unit about its insertion point — its internal geometry and attributes are carried by
that scale instead of being scaled individually, fixing the block-attribute size
regression. Tables scale their column/row/margin geometry.

Properties: the handle- and flag-backed rows are now editable pickers — MLEADER
multileader style / text style / arrowhead / leader linetype, General Material, and
Plot style (named plot-style mode only; the color-dependent mode stays read-only,
which is correct). A picked name is resolved back to its handle in the update loop,
where the document is in scope. Annotative Yes/No and the annotation-scale row are
shown per object.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 23:16:44 +03:00
Hakan SevenandClaude Opus 4.8 8ed10ceb2a feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.

Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard

Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
  through the whole text pipeline; the resolved style font name is stamped
  so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector

Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 20:18:50 +03:00
Hakan SevenandClaude Opus 4.8 d37dabb0fb fix(mtext): show preview glyphs and make Enter break the line
Two defects in the in-app MTEXT editor, both surfaced once the preview
text became visible:

1. Invisible preview text. After the SDF text migration, tessellate emits
   glyphs as SDF quads on `text_verts` and leaves the polyline `points`
   empty, but the editor preview draws on a 2D canvas that can't run the
   SDF shader — so only the selection box showed. Add a `force_text_strokes`
   parameter to tessellate: when set, TEXT/MTEXT run-groups also emit their
   glyph outline strokes as points. The preview passes true (real stroke
   font, aligned with glyph_boxes); every other caller passes false and
   keeps SDF-only text.

2. Enter did not start a new line. mtext_type inserted a literal newline,
   but the MTEXT layout only breaks on the `\P` code (a raw 0x0A is a break
   only to the caret logic). Normalise every literal newline — from Enter or
   a multi-line paste — to `\P`, so the line breaks and the saved value
   stays standard.

Closes #308

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 10:38:41 +03:00
Hakan SevenandClaude Opus 4.8 76d2aca10c fix(solid3d): curved-wall cone faces — balloon, seam, and gaps
Three defects in ACIS curved-wall (cone-surface) tessellation, all seen
in a Villa Savoye model:

- Balloon: sample_ellipse_arc ignored the edge's own sense relative to
  its curve (distinct from the coedge/loop sense). A reversed edge winds
  the ellipse about the opposite normal, so evaluating P(t) with the
  stored normal mirrors a short boundary arc across the major axis to the
  far side of the circle. That poisoned the face's angular-span estimate
  and swept the whole 2pi revolution, drawing a curved wall as a full
  cylinder of the surface radius. Honour edge.sense() (negate the sin
  term when reversed) so the arc traces its true vertices.

- Seam: the fallback sampler's angular_range used raw min/max of atan2
  angles, so an arc straddling the +/-pi seam read as a 2pi span (false
  full circle). Derive the arc from the largest angular gap instead.

- Gaps: truck's rsweep/triangulation degenerates to zero triangles on
  some short-arc cylinders (profile through the local origin), leaving a
  see-through hole while the rest of the solid renders. Fall back to the
  bespoke parametric sampler for just that face, into the same body-local
  buffers. Also scale tess_cone_face's angular divisions by the arc span
  so a short arc isn't tessellated as densely as a full rim.

Verified against the model: ballooned walls become wall-sized, and a
sweep of all 528 solids shows no empty faces and no triangle explosion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-10 00:13:58 +03:00
Hakan SevenandClaude Opus 4.8 e1c19d8af3 perf(snap): keep snap/hover responsive on block-heavy drawings
Snap and hover hit-testing scanned the whole model wire set every cursor move; a heavily block-instanced drawing explodes to millions of wires, so this stalled for over a second per move. Add a cached spatial grid (wire_grid) keyed on the geometry epoch and query only the cursor neighbourhood via Scene::hit_test_wires_near. Gate the O(in-range segments squared) intersection passes on the in-range POINT count, not the wire count — a handful of curved wires, each hundreds of tessellated points, blew the quadratic up while the wire count looked modest. Give zero-size entities away from the origin (POINTs) a real cullable AABB, and fall back to a block sub-wire's own world points when its entity bounding box is degenerate.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 01:00:51 +03:00
Hakan SevenandClaude Opus 4.8 5c15091e74 refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.

- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
  SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
  text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
  the SDF path unconditionally everywhere: tessellate Text arm (per-group by
  GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
  mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
  block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
  plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
  text_obb_corners_native / mtext_line_count and the LocalWire
  text_height_local / text_obb_local fields. All text is crisp at every zoom;
  text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
  aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
  aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
  MTextRunKind::Tab).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 21:07:00 +03:00
Hakan SevenandClaude Opus 4.8 67ce45d40b feat(text): SDF for synthesized-table cell text + guard table pick box
An in-app-created table (no baked *T### block) synthesised its cell text via
layout_mtext as stroke polylines — the last text path still rendering strokes
while SDF is the default (imported/baked tables already render SDF via the
recursive Text arm).

- tessellate_table: when SDF is on, build each cell's glyph quads from the
  run-carrying layout and collect them into one text-carrying wire (per-vertex
  cell colour, glyph-bounds AABB); the stroke/fill accumulation is the SDF-off
  fallback. Mirrors the MLEADER SDF branch.
- tess.rs synthesized-table path: guard the whole-table AABB clobber on
  (!points.is_empty() || !fill_tris.is_empty()) — matching the dim / mleader /
  baked-block paths — so the empty SDF-text wire keeps its tight glyph-box AABB
  and the pick box hugs the text instead of the whole table. Found by the
  adversarial review pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 18:37:17 +03:00
Hakan SevenandClaude Opus 4.8 d9a2c8245c feat(text): SDF for tolerance + complex-linetype text; per-group model; pick cycle
Extend SDF text to the remaining text kinds and make the Text arm decide SDF
per group instead of per entity type.

- Text arm: a stroke group renders as SDF glyph quads when it carries a
  GlyphRun (strokes suppressed); a run-less group keeps its strokes. TEXT /
  MTEXT / dim / block / mleader are unchanged (all run-groups). A composite
  Text object can now mix geometry + text: its run-less groups stay strokes and
  its run-groups become SDF. The glyph-bounds AABB is hoisted so both the
  bins-empty and bins-non-empty paths stamp it on the text wire.
- Tolerance: refactored to emit the feature-control frame (border, separators,
  dividers) as run-less stroke groups and each cell's text as a run-group, so
  the frame stays crisp geometry while the values/symbols render as SDF text.
- Complex linetypes: embedded LtSeg::Text renders as SDF glyph quads on an
  extra text wire (glyph-bounds AABB) instead of stroke polylines.
- click_hits_all: include SDF text boxes so selection cycling steps through
  text too, ranked after real geometry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 18:22:08 +03:00
Hakan SevenandClaude Opus 4.8 cd7306f530 feat(text): MTEXT background fill / mask + text frame, editable in Properties
Render the MTEXT background (never drawn before — the flags were only shown
read-only in the panel) and make it editable.

- Text arm emits an opaque fill_tris rect behind the glyphs: flag 0x01 uses
  the entity background colour, 0x02 uses the canvas colour (a wipeout-style
  mask). Box = glyph bounds padded by (background_scale - 1) × text height.
- Text frame (flag 0x10) draws a closed rectangle in the text colour around
  the same box.
- Threaded `bg_color` into `tessellate()` (+ all callers and the
  MultiLeaderTess trait) so the mask can use the drawing-window colour.
- Properties: "Background mask" is a Off / Fill / Mask dropdown, "Background
  color" is its own colour picker (independent of the entity colour — new
  PropBgColor* messages; picking a colour switches to Fill mode), and "Text
  frame" is a checkbox toggle. Edits re-tessellate so the change shows live.

Renders via the face3d fill path, so the fill shows with FILLMODE on (same as
MLEADER background fill); the frame is a stroke and always shows.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 17:41:16 +03:00
Hakan SevenandClaude Opus 4.8 110683f419 refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).

- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
  cached by the tess memo, cloned on hit, and transformed by the block
  expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
  the strokes; render gathers text_verts from the viewport wire set
  (`gather_text_verts`, cached on wire content id) — the old collector is
  removed. Covers model, dimension, table, block-internal and paper-sheet
  text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
  SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
  entity_aabb which mis-places the box for MTEXT; composite paths no longer
  clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
  highlight, keyed on selection_generation) mirroring the selected-wire
  xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 17:01:36 +03:00
Hakan SevenandClaude Opus 4.8 75ddab6da7 feat(text): bypass the greek/baseline LOD ladder for SDF text
With OCS_TEXT_SDF on, top-level text took the full path only at ≥5 px and
still drew greeked OBB rects / baseline lines when zoomed out — doubling
with the SDF glyphs. Force the text LOD height to None while SDF is on so
text always falls through to the full path, which is itself suppressed to
an empty snap-only wire; SDF then renders it crisply at every zoom.
(Block-internal text is unaffected — it is not collected for SDF, so its
wire/greek path stays.)

Verified in-app: zoomed-out SDF text stays crisp with no greek/baseline
double draw. Flag off: unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 08:24:11 +03:00
Hakan SevenandClaude Opus 4.8 9f60470067 feat(text): suppress glyph strokes when SDF text is on
With OCS_TEXT_SDF set, text now renders only as SDF quads: the wire path
skips building glyph-stroke geometry (bins stays empty, so the existing
empty-wire branch still emits the insertion snap point). This removes the
double draw (strokes + SDF) and, as a side effect, fixes the long-standing
snap pollution (#276) — snapping near text no longer catches individual
glyph-stroke endpoints, only the insertion point.

Shared sdf_text_enabled() (cached env read) gates both the collector and
the stroke suppression so they stay in lock-step. Flag off: unchanged.

Verified in-app: TEXT/MTEXT/decorations render clean as single SDF, and
snap no longer hits glyph strokes. Click-pick on text is limited while the
flag is on (no stroke geometry) pending a text OBB pick box.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 08:18:08 +03:00
Hakan SevenandClaude Opus 4.8 d528a3e2a5 feat(text): parse TEXT via acadrust + extend SDF text to MTEXT
Two related steps for the text pipeline:

- TEXT parsing now goes through acadrust's parse_plain_text (like MTEXT
  uses parse_mtext). text::acad_text_encode parses the value's %% codes —
  including %%u/%%o underline/overline and %%nnn — and re-encodes to the
  stroke tessellator's \L/\O + resolved-Unicode grammar, so OCS no longer
  tokenizes TEXT %% codes itself. (ATTRIB/TOLERANCE/DIMENSION still use the
  local tokenizer for now.) Bumps the acadrust pin to the commit that
  completes the TEXT/MTEXT control-code set.

- SDF text (OCS_TEXT_SDF) now covers MTEXT and every text-producing
  entity, not just top-level TEXT. TextStroke carries a GlyphRun (layout
  params); the collector walks convert()'s TruckObject::Text runs and
  applies annotation scale exactly as tessellate does for strokes.
  layout_glyph_quads shares lff::tokenize_run so %% specials resolve and
  \L/\O decorations are skipped (decoration LINES in SDF are still TODO).

Verified: stroke rendering unchanged with the flag off; SDF glyphs render
correctly for TEXT and MTEXT with OCS_TEXT_SDF=1. 154 lib tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-07 00:53:19 +03:00
Hakan SevenandClaude Opus 4.8 cbf454979d chore(solid): drop needless mut on full_circle
`full_circle` from `angular_range` is only read, never reassigned — clears
the unused-mut warning.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 23:47:00 +03:00
Hakan SevenandClaude Opus 4.8 f8ceddd303 fix(solid): read rational NURBS spline-surface control points with weights
An ACIS spline-surface stored as `nurbs` (rational) carries a weight on each
control point, so the control net is a stream of x, y, z, w tuples. The
parser read plain x, y, z triples, shifting every control point by one float
and scrambling the whole net — the surface, and the solid it bounds,
rendered badly deformed. Non-rational `nubs` surfaces were unaffected, which
is why only some solids (rational lofts/sweeps with varying weights) broke.

Detect `nurbs` vs `nubs`; for rational surfaces read the fourth weight
component and build a truck `NurbsSurface` from homogeneous (xw, yw, zw, w)
control points so the weights actually shape the surface. Both the truck
B-rep path and the bespoke fallback share this parser.

Verified on VillaSavoye_MartinHedin2.dwg solid 0x24A1EE (handle 2400750): the
tessellated surface now stays within the solid's true extent instead of
flying outside it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 20:15:17 +03:00
Hakan SevenandClaude Opus 4.8 1065bc12b2 feat(render): draw ACIS solids as B-rep feature edges, meshes as tri edges
Solid wireframes rendered every triangle edge of the tessellation, so a
curved solid read as a dense mesh instead of its real outline. Now each ACIS
solid's B-rep face-boundary edges are collected from the SAT `edge` records
(straight edges as segments, ellipse/circle edges sampled along their bounded
arc), body-transformed and split into the double-single pair, and stored on
the MeshLodSet. The block-instance and top-level offset paths transform these
edges alongside the mesh vertices.

The batch builder routes a solid with feature edges into a dedicated LineList
vertex buffer and skips its triangle wireframe; a mesh with no B-rep edges
keeps its triangulation edges. Edge colour follows the display mode: the flat
`fs_edge` shader draws the entity colour in the lines-only modes (wireframe,
hidden-line) and a `fs_edge_black` twin forces black for the edge overlay in
filled render modes.

Verified: the Clinic storefront arch bars now yield ~14-16 clean edge
segments (vs the full triangulation) bounded to the solid's true extent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 17:57:00 +03:00
Hakan SevenandClaude Opus 4.8 9f7fdc5f8f fix(solid): tessellate cone-surface faces to their arc, not a full circle
Curved 3D-solid faces backed by an ACIS cone-surface (e.g. the arched
mullion bars of a storefront curtain wall) were swept a full revolution
regardless of the face's actual angular extent, so a bar bounded to an
8-16 degree arc rendered as a full circle of the surface radius — a large
jagged loop instead of a small arch.

cone_faces (the truck B-rep path) now derives the angular span from the
face's boundary loop and rsweeps only that arc; the bespoke fallback
tess_cone_face no longer forces a full revolution when a thin face
collapses to a single height. Arc extraction uses a seam-robust
largest-gap method so faces straddling +/-pi (the crown of an arch) are
not mistaken for full circles.

Verified against Clinic_Architectural.dwg: the seven arch-bar solids drop
from a ~12.9 (2R) bbox to their true ~1-2 unit extent, and a file-wide
check of 155 cone-bearing solids shows none ballooned and none collapsed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-04 16:11:24 +03:00
Hakan SevenandClaude Opus 4.8 f9b6834ad9 feat(export): exact ACIS geometry for planar solids on save
OCS models 3-D solids with truck, but extrude/boolean results carried no ACIS
body, so other CAD apps dropped them on open. Add an exact planar exporter and
run it at save time.

- scene::convert::acis_export::planar_solid_to_sat walks a truck Solid whose
  faces are all planes bounded by straight edges into a vertex/face-ring mesh
  and builds an exact ACIS body via acadrust's build_planar_body. Curved
  faces/edges return None (left for the NURBS path). A signed-volume check
  flips the winding if the shell comes out inside-out.
- OpenCADStudio::sync_truck_solids_to_acis fills in ACIS geometry for every
  cached truck solid that still has none, just before a Save / Save-As, so the
  written DWG/DXF carries real modeler geometry.
- Bump acadrust to 9142a50 (build_planar_body + classic-ACIS AcDs roundtrip fix).

Booleans (already cached as truck solids) now export exact geometry. Extruded
polygons will too once the EXTRUDE command caches its truck solid (a separate
change kept out of this commit because command_driver.rs holds unrelated
in-progress edits).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 15:43:37 +03:00
Hakan SevenandClaude Opus 4.8 56e1c03ac2 feat(layers): enforce layer lock (select/modify/fade + cursor badge)
Locked-layer objects were fully editable. Now they stay visible and
snappable but are otherwise protected:

- Not selectable: the five interactive pick paths (box, lasso, single
  click, cycling, box-release) plus SELECTALL and QSELECT skip locked
  entities.
- Not modifiable (defense-in-depth even if a handle slips through):
  transform_entities, erase_entities, apply_grip and the Properties
  commit all skip locked entities.
- Faded: entities on a locked layer render dimmed toward the background
  (render_style + wire tessellation); toggling lock re-tessellates.
- Snap still works: locked entities are intentionally kept in the wire
  set (visibility_ok untouched), so object snap keeps finding them.
- Feedback: a small padlock is drawn by the crosshair when hovering a
  locked object, and clicking one prints "Object is on locked layer …".

New Scene::is_layer_locked / locked_layer_name helpers centralise the
check (mirroring layer_hidden).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 13:10:39 +03:00
Hakan SevenandClaude Opus 4.8 a994a148fe perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models
Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids)
was effectively frozen. Several independent costs, all addressed here:

- Face3D edge/fill buffers were re-uploaded on every camera move (keyed on
  camera_generation). They are world-space and selection-independent, so gate
  them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every
  wire.
- Solid meshes were drawn one buffer + one draw call per solid (~10k draws/
  frame), which strangled the GPU front end. Concatenate every solid's LOD0
  into a few large buffers (split only to stay under the 256 MB buffer cap) and
  draw the whole set in a handful of calls. Built once per geometry epoch, so
  selection / hover never re-pack it.
- Selection / hover highlight is now a small tinted overlay of just the picked
  solids, drawn last with an Always-depth pipeline so it shows on top even when
  the solid is occluded — instead of re-tinting and re-uploading all meshes.
- Hover / pick hit-testing scanned every triangle of every solid each move
  (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase
  (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh
  set, so picking is O(solids) cheap projections plus the few solids actually
  under the cursor.
- Block-internal hatch hit-testing exploded every INSERT on every hover.
  Cache the result per geometry epoch and skip blocks that contain no hatch.
- Curved-surface (cone/sphere/torus) tessellation tolerance is now radius-
  relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and
  matches the circle/arc wire tessellation, instead of exploding on large radii.

Also adds docs/tessellation.md mapping every EntityType to its tessellation
path (truck B-rep vs truck curve topology vs direct).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 22:46:29 +03:00
Hakan SevenandClaude Opus 4.8 970ee5a09f fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.

Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.

Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
  explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities

New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 17:24:04 +03:00