Commit Graph
1594 Commits
Author SHA1 Message Date
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-19 18:38:54 +03:00
Hakan SevenandClaude Opus 4.8 ef19f898b0 fix(mtext): size the empty-line caret slot to the text, not the entity
The paragraph-start caret slot used the raw entity height, so a blank
line's caret stood entity-tall — many times the visible text when `\H…x;`
runs shrink it (5× on budweiser2018.dwg handle 50019). Size it to the
line's own text height instead (an empty line inherits the previous line's
height, the same rule `per_line_h` already uses for spacing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:24:37 +03:00
Hakan SevenandClaude Opus 4.8 4479ba8c29 fix(mtext): correct imported-text size in the editor (#394)
The editor preview mis-sized imported MTEXT badly (tiny, huge, or
collapsed). Two root causes:

- The entity's base height often isn't the visible text height: relative
  `\H…x;` codes compound, so an entity of height 1 whose body is `\H0.2x`
  renders at 0.2. Normalize the preview scale by the DOMINANT rendered run
  height (the mode of glyph-box heights, skipping zero-width caret slots),
  not `height_value()`, so the body lands at a fixed readable size and
  headings keep their proportions. EM_PX = 15.
- The bug that made it "completely wrong" was in the serializer, fixed in
  acadrust 389b31a (\H heights decayed on the parse→serialize→parse the
  editor does via folded_value); bumped here.

Also render multi-column MTEXT with its columns side by side (as the
drawing does) and let the preview scroll horizontally to reach them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:16:53 +03:00
Hakan SevenandClaude Opus 4.8 57ff14d3f3 fix(mtext): render font italic flag and non-breaking space
Complete the editor/layout support for two MTEXT inline codes that were
parsed but not rendered:
- `\f…|i1;` set the font italic flag but layout only fed oblique from
  `\Q`, so imported italic showed upright. Map font.italic to the same
  15° slant the editor's Italic button uses (explicit `\Q` still wins),
  and make that button recognise + clear the flag so imported italic can
  be toggled off.
- `\~` (non-breaking space) was a normal Space atom, so lines could wrap
  at it. Fold it into the word — it still renders as a space but no longer
  breaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:16:41 +03:00
Hakan SevenandClaude Opus 4.8 977701b47e feat(ui): MTEXT editor in shared modal + resizable dialogs
Route the MTEXT editor through the shared in-canvas modal frame with an
Apply-at-top toolbar (style-manager style): Apply commits and keeps the
editor open (creates on first apply, updates in place after), the ✕
cancels. Give every modal a bottom-right corner resize grip driven by a
shared grow-only `modal_resize` delta that resets with `modal_offset` on
open/close, so each dialog opens at its natural size and can be dragged
larger — style managers included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:47:09 +03:00
Hakan SevenandClaude Opus 4.8 f797fd800b chore(release): v0.8.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v0.8.4
2026-07-19 02:58:25 +03:00
Hakan SevenandClaude Opus 4.8 6cf2d68824 fix(mtext): a blank line takes the surrounding text height
An empty `\P` paragraph carries no glyph to size it, so the per-line
advance fell back to the full entity height. In a small-text block (a
`\H0.2x` column) that blew a single blank line up to several times the
height of the text around it, shoving everything below it far down the
page. Size a glyph-less line by the last non-empty line's height instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 02:39:42 +03:00
Hakan SevenandClaude Opus 4.8 b2720987a7 build(deps): bump acadrust to 9d2e67f (reject out-of-range ACI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:36:40 +03:00
Hakan SevenandClaude Opus 4.8 4e9ff101b1 build(deps): bump acadrust to 8e86b4d (MTEXT parser gap fixes)
Picks up the ezdxf-audited MTEXT parser fixes: raw LF as a paragraph
break, control chars rendered as spaces, and the corrected `\c`
true-color byte order (so `\c` colors no longer come out red/blue
swapped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:31:57 +03:00
Hakan SevenandClaude Opus 4.8 f808030eb6 fix(mtext): tab stops are column-relative and align-aware when wrapping
Two defects in tabbed fields inside a multi-column MText:

- Stop positions are relative to the paragraph's own left edge, but the
  tab logic compared them against the column-absolute `cursor_x`. In a
  second column `cursor_x` already carries the column offset, so no stop
  ever matched and the field shot off to the default 4-em grid, way past
  the page. Measure against the column's `box_left` instead.
- A field after a center/right/decimal tab aligns on the stop, reaching
  only part of its width to the right, but word-wrap flushed it left and
  wrapped it off the line by its full width — dropping a wide decimal
  number onto a fresh, un-tabbed line. Don't wrap the field that follows
  an aligning tab.

Picks up acadrust b16137f (decimal stop position parse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:10:56 +03:00
Hakan SevenandClaude Opus 4.8 3da678b2ca feat(mtext): align tabbed fields on center/right/decimal tab stops
A `\t` reaching a center, right, or decimal (`D`) tab stop now aligns the
field that follows it — the run of words up to the next space/tab — instead
of just advancing the pen:

- decimal: the field's first `.` lands on the stop (a column of numbers
  aligns on the decimal point; a field with no `.` aligns its end),
- right: the field's end lands on the stop,
- center: its middle.

The stop wins even when the field is wider than the room before it, so the
dot/edge stays put and the field overruns to the left, keeping the column
aligned. Left stops and the default 4-em grid are unchanged. Picks up
acadrust 162f194, which carries the tab-stop kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:45:26 +03:00
Hakan SevenandClaude Opus 4.8 0de2f76921 feat(mtext): render justify and distribute paragraph alignment
`qj`/`qd` were stubbed to left. Now the layout fills the line to the
content width:

- Distribute (`qd`, and headings that reset with `q*` → justify) shares
  the slack across every glyph gap — word gaps widen and letters spread
  via tracking (LFF stroke fonts; TTF with zero letter-spacing spread
  their word gaps only). Every line, including the paragraph's last.
- Justify spreads only the word gaps and leaves the last line ragged.

Bumps acadrust to 504281a, which parses `\pqd` as Distributed and
`\pq*` as Justified so these alignments reach the layout at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:00:24 +03:00
Hakan SevenandClaude Opus 4.8 d936e538b7 fix(mtext): paragraph spacing and heading placement
Several MTEXT layout fixes, all in the run-aware layout engine:

- Honour paragraph spacing (`\p…b#/a#`) and per-paragraph line spacing
  (`\psm#/pse#`), which the parser already produced but layout ignored.
  They are sticky per-field, so a `\pxb0.25;` set once applies to every
  later paragraph — stacked paragraphs now read apart instead of running
  together.
- Underline / overline / strike now continue across the blanks between
  words: a decorated Space atom emits its own rule, matching the exact
  advance lff gives the space so it meets its neighbours' rules.
- Anchor the first line by its own cap height rather than the entity
  height, so a small heading (`\H0.2x;First column`) no longer opens a
  full blank line above it; the column's first paragraph still opens its
  own `\p…b#;` space-before as the top margin, so the heading sits where
  the entity data places it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:19:01 +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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-18 20:11:29 +03:00
Hakan SevenandClaude Opus 4.8 1b974df505 feat(image): show the file path for an unresolved raster reference
An IMAGE whose file can't be opened (a URL, or a missing/renamed file) now
draws its clip/frame outline plus the saved path as centred text in the
image colour — AutoCAD's broken-reference placeholder — instead of an empty
frame. OCS never fetches remote paths. Bumps acadrust so the entity's path
is populated from its ImageDefinition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:32:14 +03:00
Hakan SevenandClaude Opus 4.8 cb83575fdc chore(deps): bump acadrust — ATTDEF flag decode
Picks up the ATTDEF flags/geometry fix so a CONSTANT block attribute
renders instead of being skipped as a template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:02:47 +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 <noreply@anthropic.com>
2026-07-18 17:57:23 +03:00
Hakan SevenandClaude Opus 4.8 e26b1ad488 chore(deps): bump acadrust — ATTRIB invisible flag decode
Picks up the ATTRIB flags/generation/field-length/lock fix so an
attribute tagged invisible is no longer drawn (ATTMODE 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:29:32 +03:00
Hakan SevenandClaude Opus 4.8 3be9d8925e fix(point): match AutoCAD PDMODE glyph geometry
The mode-4 tick was drawn as a full vertical line centred on the point;
AutoCAD draws it rising from the point upward by PDSIZE/2. The '+' and
'×' arms were half size — AutoCAD extends them to the full PDSIZE so the
cross pokes out past an enclosing circle/square (which sit at PDSIZE/2).
Ground-truthed every PDMODE combination against ezdxf's point renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:07:27 +03:00
Hakan SevenandClaude Opus 4.8 f2adfad2b3 chore(deps): bump acadrust — MULTILEADER has_ds decode fix
Pulls the acadrust fix that decides the R2013 has_ds bit for MULTILEADER by
peeking the class_version, so a MULTILEADER whose writer emits the bit
(budweiser handle 9821) decodes its leader roots / arrowhead / content instead
of an empty context. The leader now renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:03:16 +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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-18 15:15:24 +03:00
Hakan SevenandClaude Opus 4.8 015973babb fix(solid): fill 2D SOLIDs so block-nested ones aren't hollow
A 2D SOLID's `to_truck` emitted only its 4 perimeter edges; its fill came from
a solid-fill HatchModel the scene injects for top-level SOLIDs. That injection
never runs for a SOLID nested in a block, so a filled arrowhead like the
`_BoxFilled` dimension arrow rendered as a hollow box outline.

`to_truck` now also emits the two fill triangles (visual quad p0→p1→p3→p2, the
second degenerate for a triangle SOLID). Block expansion carries fill_tris, so
the arrowhead — and any SOLID inside a block — renders filled; the dimension /
MultiLeader / mesh 2-D fill pass already routes these to the flat fill batch.
Top-level SOLIDs also keep their HatchModel fill; the duplicate is invisible for
the opaque solids these are.

Verified on budweiser dim 3728: the `_BoxFilled` arrow SOLID now tessellates to
fill_tris (2 triangles) instead of an empty fill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:15:13 +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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-18 13:12:08 +03:00
Hakan SevenandClaude Opus 4.8 db896ec632 fix(spline): interpolate fit-point splines with end tangents, grip via fit points
A fit-point SPLINE (no control vertices) was drawn with Catmull-Rom through its
fit points, which ignores the stored begin/end tangents and uses local slopes.
An open curve then peeled away from the true shape at its ends — budweiser's
handle 2735 lost the tangent-driven overhang at its top end.

A fit spline is the C² cubic that passes through every fit point clamped to the
stored end tangents. Replace Catmull-Rom (kept only for closed splines, whose
wrap the clamped solve doesn't model) with `fit_spline_polyline`: chord-length
parameterisation (honouring knot_parameterization), a tridiagonal Thomas solve
for the knot slopes with clamped-tangent or natural end rows, and Hermite
evaluation. Verified on 2735: the curve passes through all five fit points and
its ends follow begin_tangent/end_tangent (dir·tan = 0.997).

Also fixes grip-selection: grips()/apply_grip() only ever looked at
control_points, so a fit spline (0 CV) showed no grips and couldn't be
grip-selected or edited. Both now fall back to fit_points when there are no
control points — dragging a grip moves the fit point and the curve re-fits on
the next tessellation, keeping the end tangents. The mirror path already
transformed fit_points; move/rotate/scale run through acadrust.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:46:09 +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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-18 00:18:10 +03:00
Hakan SevenandClaude Opus 4.8 d80d037e2f chore(deps): bump acadrust — SatIntCurve spline decoder
Adds SatIntCurve (nubs B-spline parse + De Boor sampler, plus sample_range
for an edge's own arc), so intcurve-curve spline edges decode to real
geometry instead of nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-07-17 19:15:02 +03:00
Hakan SevenandClaude Opus 4.8 5e1be74651 fix(mtext-editor): keep UTM text sharp, preserve columns through an edit
Three faults in the in-place editor, all on the same columnar UTM MTEXT.

Preview at UTM was garbled: the panel drew each stroke point as an absolute
f32, dropping the double-single low half, so at ~1.2M coordinates every glyph
collapsed under f32 cancellation (317 distinct x positions fell to 17). The
preview fits its own box and never lands on the drawing, so it now lays out at
the origin — insertion is a pure translation, so shape, wrapping and alignment
are unchanged, and no large value reaches an f32.

Columns vanished from the preview: `build_mtext` rebuilt the entity from
`MText::default()`, keeping only the seven fields the toolbar edits and zeroing
the rest — column_data above all. It now starts from the entity as opened, so
columns (plus rotation, normal, defined height, background fill, annotative)
round-trip untouched. (Commit-of-edit was already safe — it updates the live
entity in place — so this was a preview-only miss.)

Columns collapsed on the first keystroke: every edit rewrites `content`
through doc_to_cells -> cells_to_doc, and `Cell::Break` carried only the
paragraph properties, not whether the break was a `\N` column break or a plain
`\P`. So one round-trip flattened every `\N` to `\P` and the committed value
lost its column breaks even though column_data survived. `Cell::Break` now
carries a `starts_column` flag; a typed or pasted break is always a plain `\P`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:57:16 +03:00
Hakan SevenandClaude Opus 4.8 292e23f44f fix(mtext): size \S fractions to fill and persist paragraph alignment
Two layout fixes on the same MTEXT (budweiser2018.dwg handle 2609 / 8782).

Fraction size: `\H0.7x;\S1/2;\H1.429x;` is how a 70 %-scaled fraction is
spelled, so the 0.7 already *is* the fraction's size — the earlier constants
shrank each half again on top of it and buried the stack inside one line.
A half is now near the run height and the stack stands about twice that: the
numerator rises past the cap and the denominator sits on the baseline, the way
a fraction is meant to grow past its line rather than be squeezed into it.

Alignment persistence: a `\p...;` block stays in force until the next `\p`, so
a paragraph opened by a bare `\P` / `\N` inherits it. The parser records only
the literal codes, so the second line of a centred or right column
(`\pxqc;col2\P≡`) fell back to the left edge while its heading stayed aligned.
Layout now carries a paragraph's whole property block forward to any paragraph
that sets none of its own — measured, the column symbols land on the same
centre / right edge as their headings.

Kept in the layout adapter rather than the parser: acadrust faithfully records
which paragraphs carry an explicit `\p`, so persistence is a rendering
interpretation, and the literal record stays available to an editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:26:29 +03:00
Hakan SevenandClaude Opus 4.8 7e1cd6842b feat(mtext): lay out \N columns and stack \S fractions
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).

Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.

Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.

Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.

The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
Hakan SevenandClaude Opus 4.8 6d7eb94701 chore(deps): bump acadrust — keep \N column breaks distinct
The MTEXT parser turned `\N` into a plain paragraph, identical to `\P`, so
a multi-column MTEXT arrived with nothing left to lay columns out from —
and the writer sent every break back as `\P`, collapsing the columns for
good on round-trip. `MTextParagraph::starts_column` now carries it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:47:47 +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 <noreply@anthropic.com>
2026-07-17 15:22:56 +03:00
Hakan SevenandClaude Opus 4.8 07d7888b94 fix(render): halve polyline width — wide bands drew twice as wide
DXF codes 43 / 40 / 41 store a polyline band's full width, and
`polyline_segment_fill` offsets ±hw about the centreline, so the stored
value has to be halved. Both `wide_fills` passed it in whole and drew
every wide polyline at double width.

The codebase was writing the spec value and reading it as something else:
`make_donut` stores `outer_r - inner_r`, a full width, which `wide_fills`
then treated as a half-width. So OCS mis-drew even its own donuts.

Seen on budweiser2018.dwg handle 2579, a donut of width 2.2 on a 1.1
centreline radius: it should fill out to radius 2.2 and instead reached
3.3 — a disc 1.5× its real size, overflowing the square it sits in.

Affects every wide polyline, so TRACE and PEDIT-widened runs come back to
true size too. The writers already stored full width and are left alone,
as is the property panel, which shows the file's own value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:00:06 +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 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
Hakan SevenandClaude Opus 4.8 72d471c2e7 fix(overlay): clip dashed snap/track guides to the viewport
Same hazard as the DYN guide in 48a43aa, in the three remaining dashed strokes:
a dash pattern is measured in pixels, so a path costs the tessellator a quad
every few pixels and its price is set by screen length. All three run from a
projected world point — an entity endpoint for the extension guides, the
acquired point for the OTRACK alignment line — which lands millions of pixels
away for a far or very large entity. That is millions of quads, and the process
fills RAM (#406).

The alignment line even said "the canvas clips it to the viewport" as the reason
extending it by 5000 px was safe. It isn't: the canvas clips after tessellating,
so every quad is built first. That assumption is what made these look bounded.

Clip in `clip_seg` (Liang–Barsky) before stroking, padded a dash period so a
guide still visibly leaves the frame. Unit-tested: a guide from 5,000,000 px
away comes back under 2000 px with its direction and its on-screen end intact,
a fully visible segment is untouched, and one that misses is dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:52:29 +03:00