Commit Graph
610 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.8 9b7fe2c84d feat(solid3d): draw spline (intcurve) edges of ACIS solids
A curved solid's fillet/blend boundaries are `intcurve-curve` spline edges.
The edge collector handled only straight and elliptic curves, so a spline
edge fell back to a straight chord between its two vertices — or, for a
closed-loop edge whose endpoints coincide (a sphere/torus face rim), to
nothing at all. Those boundaries simply weren't drawn.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 09:51:03 +03:00
Hakan SevenandClaude Opus 4.8 95ef3b3890 perf(render): split the wire arena into regular + mesh-edge batches
The single-batch arena required a set with no mesh/solid edges, so any drawing
with a 3D solid or mesh (this one: 1693 mesh-edge wires of 12200) disqualified the
WHOLE set — every edit fell back to build_wire_buffers re-uploading ~265 MB
(4.4M instances), stalling ~1s per edit on a shared-memory GPU.

Split the resident set into a regular-2D arena and a mesh-edge arena (the wires
that need the draw loops mesh-edge skip/black treatment), one WireArena each,
passed as &[&WireModel] subsets (no clone). Both patch incrementally; the mesh
arena carries is_3d_mesh_edge so the existing draw loop handles it. A changed
handle absent from an arenas subset is a no-op for it, so each edit touches only
the batch it belongs to. Result: the wire block drops from ~300ms + a 265 MB GPU
upload to ~2ms.

Two correctness points folded in from adversarial review:
- mesh-edge wires are stamped draw_depth 0 (matching WireGpu::from_run) so their
  hidden back edges dont peek through the shaded fill; the mesh arena also always
  bails on append (all its wires share depth 0, so relocation would reorder
  coincident edges).
- buffers are capped at wgpus 256 MB limit — a batch larger than one buffer
  returns None so the caller chunks it via the batched path (fixes a create_buffer
  panic on the 355 MB regular batch).

Still behind OCS_WIRE_GPU_PATCH (default off). OCS_PERF prints the per-edit wire
timing + outcome.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 09:48:20 +03:00
Hakan SevenandClaude Opus 4.8 9761423b84 perf(render): arena handles add/remove too, not just in-place edits
The in-place-only arena still fell back to a full batched rebuild on add/remove —
re-uploading the entire wire instance buffer, which on a drawing whose wires
expand to millions of segments (~265 MB) stalls ~1s per add/remove on a
shared-memory GPU. Only move/edit avoided it.

Add append/tombstone so add/remove patch the instance buffer too: a new or
segment-count-changed entity bump-allocates a slab at the tail (buffers carry
1.5x headroom); an erased entity is tombstoned (blanked at const slot 0). The
huge instance buffer only grows/blanks by the changed entity.

Three correctness points make add/remove safe (each verified by an adversarial
review, findings folded in):
- draw_depth_map normalises each entity`s z-bias by the block entity count, so a
  structural change re-scales every entity`s bias. Keep a CPU mirror of the const
  array; on any structural change refresh every slab`s draw_depth and re-upload
  the small (~1 MB) const buffer, leaving the instance buffer untouched.
- a tail-appended entity draws last, which only mis-orders wires whose overlap
  resolves by submission order rather than the z-bias: transparency, and 3D-solid
  fallback edges (Solid3D/Region/Body/Surface are excluded from draw_depth_map, so
  their opaque edges share depth 0). `append_unsafe` bails to a full rebuild when
  the set contains either.
- an in-place edit also flushes its consts to the GPU (a colour change is not
  structural, so the whole-buffer refresh would otherwise skip it).

Still behind OCS_WIRE_GPU_PATCH (default off); every uncertain case returns false
and the caller does the identical full rebuild, so correctness never rides on the
fast path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 08:37:27 +03:00
Hakan SevenandClaude Opus 4.8 e914be9852 perf(scene): route hide / isolate-end / solid-register through bump_entities (phase 5)
hide_selected, end_isolation and register_solid_model called bump_geometry() —
a full-drawing invalidation (clears the tessellation memos, bumps block_epoch) —
even though each touches a known, bounded set of handles. On a large drawing
hiding a few objects then re-tessellated everything still visible.

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

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

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 08:11:21 +03:00
Hakan SevenandClaude Opus 4.8 38c8583d27 perf(render): in-place GPU wire arena for move/edit (phase 4, OCS_WIRE_GPU_PATCH)
On a geometry edit the resident set gets a fresh content id, so the batched wire
path (build_wire_buffers) re-emits and re-uploads EVERY wire instance — the whole
buffer even when one line moved. Behind the OCS_WIRE_GPU_PATCH flag (default off,
native only), add a persistent per-entity instance arena: one long-lived
VERTEX|COPY_DST instance buffer + its WireConst storage, with a per-entity slab
table. An in-place edit (move / rotate / scale / colour — same segment count)
becomes a queue.write_buffer of just that entity`s slab.

Scope is deliberately IN-PLACE ONLY. draw_depth_map normalises each entity`s
draw-order z-bias by the block`s entity count, so an Add or Remove re-scales EVERY
entity`s z-bias — which a per-slab patch can`t honour — and a segment-count change
relocates the entity. patch() returns false for any of those (and for mesh-edge /
scissored sets), and the caller falls back to the identical full batched rebuild,
so correctness never rides on the fast path. An adversarial review of an earlier
append/tombstone design surfaced exactly these draw-order hazards; restricting to
in-place removes them while keeping the common interactive-edit win.

The Scene hands the changed handles to the arena via model_wire_gpu_patch,
recorded by try_resident_patch for the Model block and matched by content id in
prepare(); scissored paper viewports and mixed 2D/3D sets keep the batched path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 06:02:25 +03:00
Hakan SevenandClaude Opus 4.8 0907d2912d perf(scene): splice the resident wire set per edit, no clone-all (phase 3)
On a geometry-only edit the resident wire set was rebuilt from scratch: every
memoized entity deep-cloned into a fresh Vec (mod.rs clone-all) and the whole set
re-sorted by draw order — O(total wires) on each single-entity change even though
the per-entity tessellation memo already reused everything.

Add try_resident_patch: when the cached set is only a journal-replayable gap
behind and uniquely held, move its wires out (Arc::try_unwrap), group them into
per-entity runs, re-tessellate only the changed entities, and reassemble in
document order with the same stable draw-order sort. Unchanged entities are MOVED,
not deep-cloned; only the changed handful are re-tessellated (into the memo, so a
later full build still reuses them). It bails to the identical full build whenever
an assumption does not hold — no cache entry, journal not replayable (full delta /
ring overflow), the Arc still shared, a wire not named with its handle, or an
entity`s wires not contiguous — so correctness never rides on the fast path.

A changed entity is classified through the shared resident_entity_visible (also
now used by the full build) so it is included/excluded exactly as a rebuild would.

Adds a differential-oracle test: the patched resident set equals a from-scratch
rebuild (same wires, same draw order) after add / move / erase, and asserts the
patch path actually runs for every edit (a resident_patch_hits counter) rather
than silently always falling back.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 00:19:14 +03:00
Hakan SevenandClaude Opus 4.8 88dbbb76af perf(scene): incremental spatial index + annotative scan (phase 2)
The pick/snap spatial index (entity_index) rebuilt its whole quadtree on every
geometry_epoch bump, and annotation_affects_wires re-scanned every entity to
decide whether any is annotative — both O(document) on each single-entity edit.

Drive both off the delta journal. entity_index now replays the changes since its
cached epoch and applies them to the existing quadtree (which already supports
insert/remove/update, out-of-root items falling into overflow): Removed drops the
handle, Added/Modified update its AABB or move it to the unbounded list, and only
a full/overflow delta triggers the old full rebuild. annotation_affects_wires
stays warm unless an annotative entity changed (PSLTSCALE toggles arrive as a
full delta and still recompute).

Adds a differential-oracle test asserting the incrementally-patched index equals
a from-scratch rebuild after add / move / erase.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 00:01:26 +03:00
Hakan SevenandClaude Opus 4.8 57401f6f32 perf(scene): delta journal so edits touch only changed entities (phase 0-1)
Every geometry mutation previously bumped a monotonic geometry_epoch and each
derived cache compared its stamp and, on a miss, re-walked the whole document —
so drawing or moving one line rebuilt every hatch / image / mesh / wipeout model
even though none of them changed.

Add a per-mutation delta journal: bump_entities(&[(Handle, ChangeKind)]) records
exactly which handles changed (Added / Removed / Modified), folding the memo drop
and the epoch bump into one call a site cannot forget. A bounded ring keeps the
recent deltas; replay_since(last_epoch) coalesces the changes past a consumer or
returns None (full rebuild) when a spanned delta is `full` or the consumer fell
off the ring. bump_geometry / bump_geometry_no_blocks push a `full` delta so the
journal is never missing a step.

Route the common single-entity ops through it: add (Added), update (Modified),
erase (Removed), move/rotate/scale/mirror (Modified), copy (Added). Gate the four
per-category model caches on category relevance — an edit whose handles are not in
a category leaves that category cache warm; a removal is treated conservatively
(the erased handle can no longer be classified), and the mesh cache also
invalidates on any INSERT edit since it carries the instanced block meshes.

Net: a plain-entity edit no longer rebuilds the hatch/image/mesh/wipeout model
sets. The resident wire clone-all and the GPU re-upload are still whole-set — the
journal is the groundwork for making those per-entity next. Adds 6 journal unit
tests (replay, coalescing, full-fallback, ring overflow, category gate).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 23:50:04 +03:00
Hakan SevenandClaude Opus 4.8 0f61a2e582 perf(render): share one GPU wire buffer across slots by content id
Each pipeline slot owned its own gpu_wires: every paper viewport and every
Model tile rebuilt an identical resident wire set into its own GPU vertex
buffers and its own handle index. With N content viewports drawing one shared
tessellation that is N copies of the (potentially huge) wire buffers in VRAM
plus N batch+index builds on every content change — the issue-131 memory
exposure multiplied by the viewport count.

A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares
its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and
wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self)
that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by
wire_content_id. prepare() builds an entry once on a cache miss and hands Arc
clones to every slot with that id — so N viewports (and, since one MultiPipeline
backs all panes, N panes) upload the wire vertices exactly once between them.
Camera uniforms and scissor stay per-slot, so the draw still issues one call per
viewport with the right view; only the geometry is deduplicated. The shared Vec
is never mutated in place — only reassigned on a content change — so the sharing
is sound.

The cache drops entries no slot still references (Arc::strong_count == 1) once it
passes a small bound, so a live buffer (held by any pane) is never evicted.
Model multi-tile gets the same dedup for free, and two panes showing the same
model now share buffers.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 21:19:18 +03:00
Hakan SevenandClaude Opus 4.8 f39a2d3126 perf(render): unify model/paper/block spaces on one resident wire path
Model space held a full, camera-independent, un-culled wire set with a
stable content id, so pan/zoom re-drew without re-tessellating or
re-uploading and idle frames collapsed to a single blit. Paper did none of
that: the sheet was camera-keyed (full re-tess every pan/zoom), content
viewports re-baked per zoom step with their own frustum cull + LOD, and a
per-frame wire_force_nonce forced a GPU re-upload every frame AND poisoned
render_signature so paper never hit the single-blit scene cache.

Route every space through one Scene::resident_wires_for(block, anno, frozen):
the full, un-culled, LOD-free set per (block, ambient bg, anno, frozen) key,
held resident and stamped with a stable WIRE_CONTENT_GEN id. Model tiles,
BEDIT, the paper sheet (a thin border-drop + printable-area dressing over the
resident base) and paper content viewports all share it. When annotation is
inert (PSLTSCALE off and no annotative entity, cached per epoch) the per-
viewport scale is normalized away so every viewport reuses one tessellation.

Result: paper pan/zoom does zero re-tessellation and zero wire re-upload, and
paper now hits the single-blit scene cache exactly like model. Deletes
wire_force_nonce, the per-viewport height/view wire cache, and view_cull_aabb.
Tradeoff (deliberate, matches model): no cull/LOD anywhere.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 20:50:37 +03:00
Hakan SevenandClaude Opus 4.8 3548de332c feat(block): BEDIT block editor as a dedicated space tab (#261)
Selecting a block and running BEDIT (command, ribbon button, or a
double-click on the block) opens a block-editor "space" tab, named after
the block, alongside Model/Layout in the status bar. The tab draws ONLY
the block record's own entities in block-local coordinates; edits are
live on the definition, so every INSERT updates. Save Block / Discard
buttons (side toolbar) finish the session; Discard restores the on-entry
snapshot. Space switching is locked while a block is open, and BEDIT and
REFEDIT are mutually exclusive.

Implementation: a Scene.block_edit_block override at the top of
current_layout_block_handle scopes render/pick/new-entity ownership to the
edited block while current_layout stays "Model" (so the pervasive
Model-vs-paper code keeps model behaviour). The model render/pick paths
that hardcoded model_space_block_handle (wire tile set, hatch render +
pick, block-instance meshes) are made binding-aware so the block isolation
actually takes effect and model content does not leak in. Entering fits
the camera to the block (fit_all already scopes via the binding). Renaming
the block tab renames the block itself — new Scene::rename_block re-keys
the record, updates the Block marker and repoints every INSERT reference.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 13:03:04 +03:00
Hakan SevenandClaude Opus 4.8 a4fc34c6a2 feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).

Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.

Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".

Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.

Closes #204.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 00:53:51 +03:00
Hakan SevenandClaude Opus 4.8 9b1a495549 fix(linetype): A-type endpoint alignment so dashed lines end on a dash
Dashed/dash-dot lines could end in a gap ("empty space at the end"),
especially inside paperspace viewports. The dash pattern was only centred
on each wire, which makes the two ends symmetric but does not force them
onto a solid dash.

Implement AutoCAD "A" (aligned) linetypes: the line begins AND ends on a
solid dash, interior dashes stay nominal, and the two end dashes stretch
symmetrically to absorb the remainder (so parallel lines share an
identical interior). wire_distances computes the end-dash length D and
total length L for a dash-first pattern (D = (L - k*P + a) / 2); the GPU
in_dash walk force-lights the two end regions and phases the interior to
resume right after the start dash. Lines shorter than one pattern period
draw solid.

Only alternating dash-first patterns are aligned; gap/dot-first,
consecutive-dash, NaN-break (plinegen=false) and solid lines keep the
prior behaviour. Native and web (wasm) shaders kept in sync.

Refs #13.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 23:50:25 +03:00
Hakan SevenandClaude Opus 4.8 c0844ad55c feat(annotative): synthesize per-object contexts + editable UI
Make objects genuinely annotative — carrying real per-scale
representations that interoperate with other CAD apps — rather than just
a native flag. `create_annotation_context` synthesizes the extension-dict
chain (AcDbContextDataManager -> ACDB_ANNOTATIONSCALES -> per-scale leaf)
from the entity's placement; the editable Annotative toggle now covers
Text and block references as well as MText/MLeader; and a new
"Annotation Object Scale" dialog (OBJECTSCALE) adds/removes an object's
per-scale memberships. Requires the acadrust ObjectContextData encoder
(Cargo.lock bump).

Also fixes a render bug where objects carrying an *empty*
ACDB_ANNOTATIONSCALES (a single-representation marker with no per-scale
reps) were treated as annotative and (mis)scaled by the annotation factor
in scaled paper viewports, ballooning the text: an object is now
annotative-by-context only when its scale collection is non-empty, so
such objects render at their base geometry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 22:38:29 +03:00
Hakan SevenandClaude Opus 4.8 31d3023167 fix(paper): reset a reused viewport GPU slot so the survivor keeps its content
Pipeline slots are addressed by list index, but the renderer drops
off-canvas viewports from the list, so a slot can be reused by a
different viewport across frames — e.g. when the first paper viewport
scrolls off the canvas the second slides into its slot and inherits its
stale, differently frustum-culled GPU buffers. The text upload is gated
on a geometry-epoch change (unaffected by the index shift), so the
survivor rendered the gone viewport's text and its own vanished.

Give each viewport a stable identity, remember which one last used a
slot, and reset all cache keys when the occupant changes so wires, text,
hatches, meshes and Face3D re-upload for the new viewport. No cost in the
steady state — the reset fires only when a viewport enters/leaves view.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 22:38:13 +03:00
Hakan SevenandKarimJerbi b4a25701de Merge PR #374: pick innermost hatch boundary and detect nested holes
HATCH/GRADIENT/BOUNDARY pick-inside now resolves the click against every
enclosing outline, fills the smallest-area one, and emits inner outlines as
NaN-separated holes. DXF export writes one boundary path per ring.

Co-authored-by: KarimJerbi <[email protected]>
2026-07-13 17:46:55 +03:00
Hakan SevenandClaude Opus 4.8 04bd49c715 fix(io): resolve root named-objects dict robustly on foreign DWGs
Some DWGs written by other programs leave the root named-objects
dictionary unrecoverable: the header pointer names a non-dictionary and
the real NOD child dicts are owned by a table-control handle, so nothing
has owner==NULL. Navigating the root then silently no-ops, and
registering a new named-object entry (page setup, annotation scale, CTAB)
against the missing root orphans it — it never round-trips.

Add shared annotative::root_named_dict_handle(): resolve the header
pointer, else adopt the richest owner==NULL dict, else synthesise a fresh
root and write it back. Route ensure_plotsettings_dict, add_scale's
scalelist-create branch, and set_saved_active_layout through it. Resolve
the plot-settings dict via the PlotSettings owner when the header pointer
is stale, mirroring scalelist_dict_handle.

Verified page setups survive a DWG save/reload on the repro file with
layouts intact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 16:28:21 +03:00
Hakan SevenandClaude Opus 4.8 70225de11f fix(annotative): make scale-manager rename and edits actually persist
Double-click rename did nothing and ratio edits were lost when switching
scales — and on drawings saved by other programs nothing could be edited
at all.

- Fallback scales were virtual (no Scale objects), so edit/rename no-oped;
  materialize the standard set into real staged objects when the manager
  opens, reverted on close unless applied.
- On third-party DWGs the mutators walked the root named-objects dict ->
  ACAD_SCALELIST, which can be unreachable, and matched dictionary keys
  that are internal ids (*A1, A0) rather than scale names. Locate the
  scale-list dict robustly and match scales by object name + handle.
- Rename rows inline via double-click; repair a dangling current-scale
  header pointer on staged rollback.
- Stage text/mleader style-manager edits on switch so they persist like
  the scale manager.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 14:37:33 +03:00
Hakan SevenandClaude Opus 4.8 1071570f40 feat(annotative): annotation-scale manager window
Replace the scale popup's inline delete/add affordances with a single
"Manage scales..." row that opens a proper manager, matching the style
managers: it reuses the shared style-manager palette and chrome (toolbar +
list + editor frame), so it looks and behaves like the Text / Dim / MLeader
managers.

The manager lists the drawing's annotation scales (name + ratio, the current
one checked) and edits them through a Name / Paper units / Drawing units
editor: New adds a scale from the fields, Apply renames / re-ratios the
selected one, Delete removes it (never the current scale), and Set Current
makes the selected scale the model-space annotation scale. Backed by
Scene::add_scale / remove_scale / edit_scale on ACAD_SCALELIST.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 10:39:14 +03:00
Hakan SevenandClaude Opus 4.8 b1eba180eb feat(annotative): add/remove drawing annotation scales
The drawing's annotation-scale list (ACAD_SCALELIST) is now editable, so the
set of scales offered by the status-bar scale pill can be grown or trimmed:

- Scene::add_scale / remove_scale create or drop a Scale object and its
  SCALELIST dictionary entry (creating the SCALELIST dictionary, and
  registering it in the root, for drawings that carry none).
- SCALELISTEDIT gains ADD <ratio> / DELETE <name> alongside the existing
  listing (e.g. `SCALELISTEDIT ADD 1:50`).
- The scale popup (model space) grows a delete affordance on each non-active
  scale and a "+ Add scale..." row that primes the command line. The current
  annotation scale is protected from deletion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 10:18:05 +03:00
Hakan SevenandClaude Opus 4.8 fc5e93dcc1 feat(properties): editable Annotative toggle for MText and MLeader
The Annotative row becomes an editable toggle for the two entity types that
carry a native per-object annotative flag — MTEXT (is_annotative) and
MULTILEADER (enable_annotation_scale). Turning it on flags the object
annotative (it then scales with the current annotation scale in model space);
turning it off clears the flag AND removes the per-object annotation context
(the AcDbContextDataManager -> ACDB_ANNOTATIONSCALES leaf subtree) plus the
legacy annotative XDATA markers, via scene::annotative::set_entity_annotative /
clear_annotation_context, so the object stops resolving annotative instead of
staying on through a stale context. The shared SCALE objects are left intact.

Style-derived types (text/dimension/leader/table/insert) keep a read-only
Annotative row — their state comes from the assigned style, and making a single
object annotative there needs a per-object context object (a dedicated
acadrust ObjectContextData writer) which is out of scope here. Verified an
existing annotative DWG round-trips its 134 per-object contexts through a
save/reload unchanged, so the far more common open-and-save path is unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 09:32:22 +03:00
Karim Jerbi 5299a483da fix(hatch): flag only the outer ring as external when serializing holes
When writing a hatched region with holes to DXF/DWG, every NaN-separated ring was created with BoundaryPath::external(), so consumers treated inner loops as outer islands instead of holes. Only the first (outer) ring should carry the external / outermost flags; hole rings are left unflagged. A picked big-minus-small hatch now persists with the small rectangle as a real hole.

Adds a regression test asserting only the first boundary path is flagged external.
2026-07-13 05:55:54 +01:00
Karim Jerbi 934570862c fix(hatch): pick innermost boundary and detect nested holes
The HATCH/GRADIENT/BOUNDARY "pick inside" mode returned the first outline containing the click point, so the result depended on draw order and any nested boundary was ignored. For a small rectangle inside a big one, clicking inside the small shape would hatch the whole big rectangle (or the small one, depending on which was drawn first), and clicking in the gap between them always hatched the full big rectangle with no hole.

Resolve the click against every enclosing outline and choose the smallest-area one as the fill. Any other outline fully contained by that ring but not containing the click point becomes a hole. The boundary is emitted as NaN-separated rings, which the GPU and CPU rasteriser already fill via even-odd, so pattern, solid, and gradient hatches all render the correct region (the small shape, or the big-minus-small ring) regardless of draw order. DXF export now writes one boundary path per ring so nested hatches persist with real holes.
2026-07-13 01:32:05 +01:00
Hakan SevenandClaude Opus 4.8 7cf1bf014b feat(properties): editable per-object leader dimension overrides
A legacy leader's Lines & Arrows / Text / Fit rows are now editable
per-object overrides of its dimension style, stored in the standard
ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow
block, arrow size, dim-line lineweight, text offset, vertical text
position and overall scale each prefer an override over the style; the
renderer honours arrow size / block / overall scale and the dim-line
lineweight, so an edited leader redraws at its new arrow and weight.

XDATA edits go through a new dispatch::set_entity_xdata, which registers
the application in the APPID table (the DWG writer drops records for an
unregistered app) and drops that app's stale verbatim EED block (which
otherwise wins over the structured record on a DWG save), so edits and
clears round-trip. The hyperlink editor moves onto this path too.

Dim-line colour stays read-only: it would live in the leader's
override_color, which the file layer doesn't serialise, so making it
editable would silently lose the pick on save. Picking "Closed filled"
writes an explicit null-handle arrow override rather than clearing, so
it sticks even when the style's arrow differs; an unparseable numeric
entry is ignored instead of wiping the stored override.

Note: the DXF entity writer only emits XDATA for hatches, so these
overrides (and hyperlinks) currently persist on DWG save but not DXF —
an acadrust-side gap to close separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 00:28:42 +03:00
Hakan SevenandClaude Opus 4.8 984fa79189 feat(properties): make the Hyperlink field editable
The General-group Hyperlink row becomes an editable text field; committing a
value writes the standard PE_URL XDATA record (an empty value clears it). Adds
scene::view::dispatch::set_common_xdata, which rebuilds an entity's XDATA to
replace or remove a single application record — ExtendedData only appends, so a
plain add_record would leave a stale duplicate the reader would pick first.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 23:24:34 +03:00