Commit Graph
237 Commits
Author SHA1 Message Date
Hakan SevenandClaude Fable 5 1de4344280 feat(shape): render SHAPE entities from their compiled SHX shape file
SHAPE entities only drew a diamond placeholder — .SHX files were never
parsed. A new reader (scene/text/shx.rs) decodes the classic
`AutoCAD-86 shapes 1.0/1.1` container and interprets the full shape
bytecode — pen up/down, scale divide/multiply, position push/pop,
subshapes, signed XY runs, octant/fractional/bulge arcs, the 16
direction vectors and the vertical-only skip — into unit-space
polylines, cached per file and per shape number.

The entity resolves its STYLE by handle (name fallback), the style's
shape file with the shared path fallbacks (as stored → relative to the
drawing → basename next to it; traded files carry foreign absolute
paths), then places the glyph with size × relative X scale, oblique
shear and rotation through the OCS mapping. load_file now records
doc.source_path so render-time resolution knows the drawing's folder.
A missing or unreadable shape file keeps the diamond placeholder.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 03:53:41 +03:00
Hakan SevenandClaude Fable 5 f4f8c0687c fix(image): stop darkening rasters; X only for broken references
Image textures were created Rgba8UnormSrgb: sampling decoded sRGB to
linear, but the surface isn't sRGB, so every raster (images, OLE, PDF
underlays) rendered visibly dark. Rgba8Unorm passes the texels through
in the same space as every other colour in the app.

The frame wire also always carried the X diagonals — that's the
broken-reference placeholder, and it scribbled over resolvable images.
The diagonals now draw only when the reference doesn't resolve.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 03:41:52 +03:00
Hakan SevenandClaude Fable 5 1e913e9e78 feat(underlay): render PDF underlays end-to-end
PDF underlays only drew their clip outline / insertion cross; the page
itself never appeared. The chain now runs: the definition's file path
resolves with the raster-image fallbacks (as stored, relative to the
drawing, basename next to the drawing — attach-time relative paths go
stale), the page rasterises through the system pdftoppm (poppler) at
150 dpi — no new crate; missing tool degrades to the outline — cached
per (path, page) including negative results, and the pixels feed the
existing image pipeline as an ImageModel: 1 underlay unit = 1 PDF inch
(px/dpi × entity scale), rotated about the insertion, fade as opacity,
the clip polygon (non-inverted) as the textured region. Draw-order,
layer visibility and per-viewport freezes ride the generic image path;
in-place edits rebuild through the shared image seed.

When the page renders, the wire path draws its frame with an interior
pick surface instead of the lone cross, so selection covers the visible
page. wasm keeps the outline placeholder (no external process).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 03:41:41 +03:00
Hakan SevenandClaude Fable 5 ac0a6ac150 fix(mleader): honour the baked context — anchors, rotation, dogleg, frames
The annotation context is AutoCAD's baked display state; the renderer
now trusts it instead of re-deriving:

- text_attachment_point (L/C/R) is the stored horizontal anchor — the
  leader-side guess shifted centre/right-attached texts by their width.
- ctx.text_direction/text_rotation apply as stored; forcing the
  Horizontal angle type to 0 un-rotated angled leaders. The conn.y
  baseline snap now applies only to unrotated text.
- The dogleg runs along the leader root's stored direction (angled
  leaders drew it world-horizontal), and vertically-attached leaders
  draw no dogleg at all.
- Underline attachments (BottomLine / BottomOfTopLineUnderline…) draw
  the leader-coloured rule under the attached line(s), full text width.
- Vertical text flow (ctx.text_flow_direction) feeds the shared
  vertical layout; frame/background boxes wrap the laid-out glyph
  bounds padded by ctx.landing_gap, and run-less decoration strokes +
  inline \C colours reach the output instead of being dropped.
- The wrap-width grip rides the shared flow_grip_axis() (rotation, flow
  and attachment side), and its drag projects on the same axis.

acadrust bump: every MULTILEADER decodes (has_ds peek), MTEXT
top-to-bottom direction reads correctly.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 02:58:17 +03:00
Hakan SevenandClaude Fable 5 6e225e068c feat(text): real top-to-bottom layout; shared flow-aware width grips
Vertical (top-to-bottom) text used to reuse the horizontal line stack —
every line landed on the same baseline and overlapped. The layout now
stacks each character in its own 1.4h cell down the column, one column
per paragraph (1.2h apart), with word-boundary column wrap against the
defined width (which measures COLUMN LENGTH for vertical flow — the
horizontal pre-wrap is disabled there). Justification anchors place the
column block from wrap-accurate metrics: the pre-pass simulates the
render's word wrap exactly, so right/centre/bottom-attached blocks stay
put as columns grow. Layouts also report pre-rotation local_bounds so
frames and fills can box what was actually drawn.

Width grips route through one shared helper, flow_grip_axis(): the flow
axis (rotated baseline, or down the column) plus an attachment-side
factor — a right-attached text forms LEFT of its insertion, so its grip
sits there too; centre attachments use ±half. MTEXT's grip and drag
projection both use it, covering all 9 attachments × both flows × any
rotation from a single table.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 02:58:02 +03:00
Hakan SevenandClaude Fable 5 f1c494b81e fix(solid): skip display-cache wires when ACIS parses; real centres
The embedded solid display-cache wires are body-local (unplaced), and
R2013+ AcDs-backed solids used to misparse them into garbage points near
the origin — a clipped region rendered a stray fragment at (0,0,0). When
the ACIS payload parses, the mesh pipeline (fill + feature edges +
isolines, all body-placed) is the authoritative render, so the wires now
only serve as a last resort for unreadable ACIS data.

acadrust bump brings the decoded wireframe section and real reference
points: solids/regions report the file's stored bounding-box centre (or
a geometry-derived one) instead of (0,0,0), which feeds OCS grips,
Position properties and insertion snaps.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-23 01:13:43 +03:00
Hakan SevenandClaude Fable 5 bf06a8e330 fix(leader): derive hookline for DWG leaders; size it from the dim style
DWG stores no "hookline exists" flag (only the direction bit), so every
DWG-loaded leader kept hookline_enabled=false and drew no hook. Derive
it: a text-annotated leader hooks when its final segment leaves the
horizontal by more than 15°, shared by the render and snap/grip paths.

Hook length comes from the dim style like the arrowhead (DIMASZ ×
DIMSCALE), and with DIMTAD "text above" it extends under the annotation
to the far edge of the MTEXT's laid-out extents — newly exposed by
acadrust (lock bumped); the old text-height heuristic stays as the
no-style fallback.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 23:36:25 +03:00
Hakan SevenandClaude Fable 5 48e8988528 fix(spline): honour end tangents on a two-fit-point spline
fit_spline_polyline bailed to a straight chord below three fit points,
ignoring the stored begin/end tangents — a short tangent-clamped spline
rendered as a line. The C² solver handles n=2 as-is (both end rows are
clamp or natural conditions), so only the guard drops to n<2; tangentless
two-point splines still solve to the straight chord.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 23:36:15 +03:00
Hakan SevenandClaude Fable 5 e7eb84b514 feat(pedit): convert prompt, join, fit/spline/decurve; join fixes (#263)
PEDIT reworked around a mode machine:

- Picking a line/arc asks "Object is not a polyline. Turn it into one?
  [Yes/No] <Y>" — Yes converts in place (arc keeps its bulge) and
  editing continues on the result. Pickfirst: an already-selected
  polyline skips the select step, a selected line/arc goes straight to
  the convert prompt.
- Join gathers with the normal selection system (single picks AND
  window/crossing boxes) and merges through the JOIN machinery.
- Fit replaces every segment with a classic equal-parameter BIARC: two
  arcs per segment meeting tangentially at an inserted knee, both
  segments at a vertex sharing that vertex's tangent — the whole run is
  tangent-continuous (verified: zero tangent jump on L/closed/zigzag/S
  shapes). Spline resamples the vertex frame as a uniform cubic
  B-spline (8 samples per span); Decurve straightens every bulge.
- Earlier hardening kept: undo snapshot before the mutation, immediate
  repaint, polyline-only pick validation.
- Vertex editing lives on grips, so no Edit-vertex submode; instead the
  vertex grip hover menu gains Break — a closed polyline opens at the
  vertex, an open one splits into two (interior vertices only),
  handled driver-side as an entity replacement.

JOIN itself: segs_of now emits one segment per span of an OPEN
polyline (bulges kept), so polylines merge with their neighbours —
PEDIT Join's set always contains the target polyline and used to abort
every time; plain JOIN had the same gap. JOIN also honours pickfirst:
launched with two or more objects selected it joins them immediately.

Closes #263

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 20:06:04 +03:00
Hakan SevenandClaude Fable 5 b998082c82 fix(props): angle rows format via AUNITS, not LUNITS (#297)
Every angle row went through the linear formatter, so switching LUNITS
to Architectural showed a block's Rotation as feet-and-inches — and the
string wouldn't parse back, wedging the field until it was cleared and
retyped. Only the decimal/fractional LUNITS survived by accident.

New edit_angle row helper formats angles with format_angle
(AUNITS/AUPREC: decimal degrees, DMS, grads or radians), applied to all
17 angle rows (rotations, obliquing, arc start/end, hatch pattern
angle, multileader text rotation). parse_angle_deg converts every
AUNITS display form back to degrees ("30°"/"30d", DMS 30°15'20",
33.33g, 0.52r) and parse_f64 falls back to it, so the displayed value
round-trips through an edit.

Closes #297

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 16:42:14 +03:00
Hakan SevenandClaude Fable 5 d2be0c9bea fix(pline): Convert to Arc follows the cursor and seats on click (#339)
Three fixes to the polyline arc-segment grip:

- bulge_from_midpoint keyed the sign off which chord side the midpoint
  lies on — the opposite convention of BulgeArc::from_bulge, so every
  grip-drag frame flipped the arc to the other side and the radius
  jumped wildly ("snaps to a restricted range of radii"). It now
  derives sign and sweep from the p0 -> mid -> p1 traversal
  orientation, verified as the exact inverse of BulgeArc's midpoint.

- The arc segment's mid grip dragged in Translate mode, feeding
  per-frame deltas through the recomputed arc midpoint — the drag
  deadlocked the moment the cursor crossed the chord. Arc grips now
  drag in Absolute mode: the arc re-fits through the cursor itself.
  Straight-segment grips keep the Translate stretch.

- Picking Convert to Arc in the grip menu now engages that segment
  grip (the Add Leader placement mechanic), so the new arc follows the
  mouse immediately and the next click seats it.

Closes #339

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 12:15:36 +03:00
Hakan SevenandClaude Fable 5 87049967a2 fix(render): close remaining OCS/WCS gaps for mirrored entities
Follow-up to the LINE/POINT WCS fix - the same bug class elsewhere:

- 2D SOLID rendered its OCS corners raw in both the wire path and the
  shared fill builder (solid_hatch_model); SHAPE placed its marker at
  the raw OCS insertion. Both now map through the arbitrary axis.
- Grips: central wrap in view/dispatch.rs shows planar entities' grips
  at their world position (OCS→WCS) and converts drags back (WCS→OCS
  via the new wcs_point_to_ocs) - per-entity grip code stays in raw
  field space, INSERT keeps handling its own OCS.
- Pulls acadrust 81ebfee: OCS-aware bounding boxes, translate,
  transform and mirror for the ten planar entity types, so MOVE /
  ROTATE / MIRROR and culling work on mirrored entities too.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 17:58:09 +03:00
Hakan SevenandClaude Fable 5 eb0073781f fix(render): LINE/POINT endpoints are WCS, not OCS
DXF/DWG store LINE endpoints and POINT locations in world coordinates -
the extrusion normal only orients the thickness sweep / glyph. Running
them through the arbitrary-axis OCS mirrored every line and point
carried over from a MIRROR (normal 0,0,-1) onto the wrong side of the
drawing: bolt shafts of a mirrored detail block landed in the adjacent
panel while its nut arcs (correctly OCS-mapped) stayed put.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 17:27:51 +03:00
Hakan SevenandClaude Fable 5 e177f92ff5 fix(xref): blocks respect their internal draw order
Xref merge now inserts entities in the source block's chain order and
carries SortEntitiesTable objects across (remapped to merged handles).
draw_depth_map values become [depth, half] so exploded block/xref fills
and wide-polyline band wires both compose child depth as
insert_depth + in-block rank x half, recursing into nested inserts with
the same divisor - bands and fills from one block interleave by the
block's internal order instead of stacking at the insert's single depth.
Band batches split per rank via WireModel.depth_override; thin wires
stay merged. The wire arena falls back to a full rebuild when a slab
carries mixed depths so refreshes don't flatten band overrides.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 15:53:28 +03:00
Hakan SevenandClaude Fable 5 1546c1dc30 fix(polyline): spline-fit 2D polylines draw the curve, not the frame
A curve-/spline-fit heavy polyline stores both the fitted curve points
(vertex flag 8 / 1) and the spline-frame control points (flag 16),
which only SPLFRAME is meant to show. Chaining every stored vertex
drew spokes from the curve to the control frame — stray points on the
curve and, on wide polylines, a blobbed band (#408). drawn_vertices2d
now drops the control points whenever fit data is present (falling
back to the stored vertices when it is not) and feeds the wire, the
wide-band fills and the tapered band alike, so snapping no longer
lands on frame points either.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 15:12:52 +03:00
Hakan SevenandClaude Fable 5 930d8c51ea feat(gradient): selectable gradient types + working creation
GRADIENT silently degraded to a plain hatch: add_hatch rebuilt the
render model from the DXF entity but never encoded the gradient on it,
so the committed fill lost its gradient entirely. The entity now
carries the full gradient record (name, angle, colour stops,
solid-fill flag), and the standard shape set became selectable:
Linear / Cylinder / Spherical / Hemispherical / Curved plus an Invert
toggle, offered as prompt buttons on the GRADIENT command and as a
Gradient type dropdown + Invert box in the properties panel. All nine
standard DXF names round-trip (inverted linear persists by swapping
the colour stops), and the shaders shape each kind distinctly —
mirrored cylinder, eased curve, dome-profiled hemisphere — on both the
native and web pipelines without changing the GPU struct sizes.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 12:51:08 +03:00
Hakan SevenandClaude Fable 5 9bfcad18b9 fix(hatch): pattern-scale edits take effect + background on/off
Property edits on a hatch were invisible: fills render from the
prebuilt model cache, which move/copy refreshed but the properties
path never did — so a pattern-scale change (which must also rescale
the stored final pattern lines by the ratio) changed nothing on
screen (#415). invalidate_property_targets now rebuilds the edited
fill's cached model via Scene::refresh_fill_model.

The background row gains an on/off box: off removes the
HATCHBACKGROUNDCOLOR record entirely (the colour could never be
reverted before), on seeds ByLayer. The codec covers the full
colour-method range (ByLayer / ByBlock / RGB / ACI) and the backdrop
resolves ByLayer / ByBlock through the style chain. "More Colors…" on
the background picker now opens the palette window targeting the
background colour — it used to just close the picker.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 12:22:15 +03:00
Hakan SevenandClaude Fable 5 411f5396bc feat(pline): snap onto the start point closes the polyline
A PLINE point landing back on the first vertex (endpoint snap) now
closes the polyline instead of stacking a duplicate vertex there —
including an arc closing segment, whose bulge is already computed for
the last-to-first span (#421). Line mode requires two real segments
first so a doubled-back line isn't treated as closed. Setting
Closed: Yes in the properties panel on a polyline whose last vertex
already sits on the first drops the duplicate control point for the
same reason.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 11:25:33 +03:00
Hakan SevenandClaude Fable 5 8821bb7131 feat(props): uniform-scale checkbox for block references
A selected INSERT's Geometry section gains a "Uniform scale" box
(#427): while the three factors are equal it is checked and the panel
shows a single Scale row that writes all three axes; unchecking
expands the familiar Scale X/Y/Z rows without touching the values
(remembered per entity), and re-checking collapses Y/Z onto X as an
undoable CHPROP step.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 10:27:50 +03:00
Hakan SevenandClaude Fable 5 e1a4ddef5e feat(pick): select frame-like entities by clicking inside them
Images, OLE frames, wipeouts, underlays, paper-space viewports and
drawing-view borders were selectable only on their outline. Each now
carries an interior pick surface (pick_tris over its rectangle, or the
triangulated clip polygon for a clipped underlay), ranked below edge
and fill hits so content drawn inside still wins the click. The
layout's own sheet ("overall") viewport is excluded — identified by
the Layout object's viewport link, since DWG files carry viewport
id = 0 and some center the sheet viewport off-origin — or it would
swallow every click over the real viewports beneath it. Drawing-view
borders draw nothing normally; they emit an invisible pick-only wire
whose rect outline appears while selected.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 09:28:07 +03:00
Hakan SevenandClaude Opus 4.8 1cfb856224 refactor(dwg): use the first-class SectionSymbol and ViewBorder entities
Section marks and drawing-view borders are now real acadrust entity
types instead of Unknown-with-extras, so the renderer dispatches on the
variant directly, the arrow-direction resolver reads the active viewport
off the ViewBorder entity, and selection / properties show "Section
Symbol" and "View Border" instead of "Unknown". View borders are
non-plotting and tessellate to nothing; both count toward the lossy
Save-As warning since they round-trip via their raw records.

Bumps acadrust for the entity promotion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 00:19:51 +03:00
Hakan SevenandClaude Opus 4.8 fd33810057 fix(attribute): blank INSERT attribute renders nothing, not "[]"
An INSERT attribute left with an empty value drew a "[]" placeholder: the
SingleLine tessellation fell back to `[value]` whenever every line was
empty, but that branch only fires once the value is already empty, so it
always produced bare brackets (and never carried the tag it was meant to
preview). On a heavily scaled block instance the unscaled brackets ballooned
across the sheet.

Render nothing for an empty value instead, matching AutoCAD, where a blank
attribute shows no text. An attribute definition previews its tag as a
non-empty value, so it never reached this branch and is unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 18:04:41 +03:00
Hakan SevenandClaude Opus 4.8 b3016ca892 feat(polyline): smooth per-segment taper for varying-width wide polylines
A wide polyline with per-vertex varying widths rendered at its widest edge
(uniform-width ones were already exact). Draw the true taper instead.

`WireModel.taper_widths` carries a per-point full band width. The wire
instance (native slim + web fat) gains per-endpoint `world_hw_a/b`, and both
wire shaders interpolate `mix(hw_a, hw_b, which_end)` so each segment's band
narrows/widens linearly — depth-tested, and the linetype dash still applies.

Alignment (widths must match the tessellated points) is solved by building
both together: a new `TruckObject::TaperedLines(points, widths)`, filled by
`common::tapered_band_points` (straight = endpoints, arc = 16 samples with the
width lerped along it). to_truck routes a varying-width polyline here —
LwPolyline before the PLINEGEN split, Polyline2D at its return — while a
uniform-width one keeps the constant-band Contour path unchanged. `world_width`
stays the widest edge as the PDF-stroke + zoom-floor fallback.

Blocks fall back to the constant-widest band (`LocalWire` carries no
taper_widths).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 16:36:06 +03:00
Hakan SevenandClaude Opus 4.8 a8aece1909 feat(polyline): Polyline2D width band + tube, and block-instance shader bands
Extends the wide-polyline shader band to the two cases the first pass left on
the hatch pipeline.

Polyline2D:
- A flat wide Polyline2D now draws its band from the centre-line wire
  (`world_width`), same as LwPolyline (`polyline_band_width` handles both).
- A thickened wide Polyline2D extrudes a solid 3-D tube. `thick_wide_band`'s
  body moves to a shared `common::thick_band_tube(origin, fills, thickness,
  normal, to_wcs)`; LwPolyline delegates to it and Polyline2D calls it from
  its thickness path. Tube edges read black for both kinds.
- The model-space wide-band hatch loop is deleted (both kinds now wire/tube).

Blocks:
- Block-internal wide polylines draw the shader band too. `LocalWire` carries
  `world_width`; `emit_wire` scales it by the insert transform (average of the
  X/Y axis image lengths) so the band tracks a scaled insert, and the scaled
  width joins the batch `style_key`. `push_block_wide_fills` (the block hatch
  band) is removed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 16:04:03 +03:00
Hakan SevenandClaude Opus 4.8 bd1a5affd7 feat(lwpolyline): render the wide-polyline band via the wire shader
A wide polyline's flat band was drawn by the hatch pipeline: a solid fill
with only draw-order depth. It ignored the entity linetype (the width
"crushed" the dash pattern) and showed through 3-D geometry in a tilted view.

Draw the band from the polyline's own centre-line wire instead. A new
`WireModel.world_width` (drawing units) makes the wire vertex shader expand
the quad by `world_half_width / world_per_pixel` (clamped to a hairline), so
the band is sized in world units and tracks zoom. Because it is now a wire:

- the GPU dash shader dashes the band by arc-length — the linetype applies;
- it is depth-tested at the polyline elevation — no draw-order see-through;
- no CPU band triangles or per-dash geometry are generated.

`world_half_width` rides in `WireConst` (native, replacing a pad) and the web
`WireInstance` (loc 14); both wire shaders branch on it. The tessellator sets
`world_width` from the LwPolyline width on the Contour / SegmentedLines arms,
and the model-space fill loop no longer emits a hatch band for any LwPolyline.
Pick still uses the band polygon (`pick_tris`).

Blocks keep the hatch band: `LocalWire` carries no `world_width` (finalize
zeroes it), so a scaled insert still scales its band correctly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 14:50:27 +03:00
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 1b6a3a6f5b feat(mtext): evaluate dynamic text fields at render time
Re-evaluate AcDbField-driven MTEXT — date/login/document-property fields, table
formulas and object properties — against the current system instead of showing
the frozen last-save cache. The evaluation engine lives in `acadrust::fields`;
OCS supplies only the environment (system clock, OS login, env vars) through a
FieldContext implementation, and hooks the resolved text into MTEXT rendering,
falling back to the cached value for anything it can't evaluate.

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 20:11:29 +03:00
Hakan SevenandClaude Opus 4.8 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 <[email protected]>
2026-07-18 19:32:14 +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 <[email protected]>
2026-07-18 17:07:27 +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 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 <[email protected]>
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 <[email protected]>
2026-07-18 13:33:46 +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 <[email protected]>
2026-07-18 12:46:09 +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 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 <[email protected]>
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 <[email protected]>
2026-07-17 16:48:10 +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 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 <[email protected]>
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 <[email protected]>
2026-07-17 14:58:55 +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