Commit Graph
219 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.7 ef1f9a08d6 chore: rename H7CAD to Open CAD Studio
Joining the OpenAEC Foundation (open-aec.com). Crate/struct become
OpenCADStudio, user-facing strings use the spaced "Open CAD Studio"
form, repo URLs point to HakanSeven12/open-cad-studio. Logo (window
icon + assets/logo.svg) redrawn with OCS initials. Drop examples/
binaries — unused.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:16:15 +03:00
HakanandClaude Opus 4.7 a3c982c4f3 perf(wire): pack instance color as Unorm8x4 (88 -> 76 B per instance)
Step 4 (final) of the wire pipeline cold-open optimization plan. Color
shrinks from `[f32; 4]` (16 B) to `[u8; 4]` (4 B) and the vertex attribute
format flips to `Unorm8x4`; the GPU still hands the shader a `vec4<f32>`
in [0, 1], so wire.wgsl needs no change. 8-bit per channel is below
display precision.

Net: 76 B per instance, ~14 % smaller than the Step-3 instance layout and
~7.6× smaller than the pre-Step-3 per-vertex format. Wire upload memory
and PCIe bandwidth on cold open scale down accordingly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:53:49 +03:00
HakanandClaude Opus 4.7 4b4f5c94cd perf(wire): switch wire pipeline to instanced rendering
Step 3 of the wire pipeline cold-open optimization plan — the biggest
single lever.

Each segment used to upload as 6 vertices × 96 B = 576 B with the segment
payload (pos_a/pos_b/color/half_width/distance/pattern_length/pat0/pat1)
duplicated across all six corners of the quad. Wire pipeline now uploads
one INSTANCE per segment (88 B) and the vertex shader derives `which_end`
and `side` from `@builtin(vertex_index)` against a 6-entry corner table.

Net effect on cold open: ~6.5× less wire vertex memory and PCIe upload,
plus a smaller Vec to allocate and copy on the CPU side.

Wire instance attributes also collapse distance_a / distance_b into a
single mix() inside the vertex shader, so per-segment distance still
interpolates correctly for the GPU-side linetype dash test.

Touches the wire, wire_xray, and face3d-edges draw passes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:52:23 +03:00
HakanandClaude Opus 4.7 0ad7af7742 perf(wire): parallelize per-wire vertex emission with rayon
Step 2 of the wire pipeline cold-open optimization plan. Extracts the
per-wire vertex expansion into a free function and drives it from
`from_batch` via `par_iter().map(...).reduce(...)`. Per-wire emission is
already independent (no shared state, no order coupling — `block_cache`
groups wires by style upstream), so the parallel-then-concatenate shape
is correctness-preserving.

Cold-open wire upload now scales with available cores; a drawing with
many short wires shows the biggest win. Also collapses two near-identical
emission loops (`from_batch`, `build`) into one shared helper.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:49:06 +03:00
HakanandClaude Opus 4.7 522b123d82 perf(wire): upload vertex buffers via mapped_at_creation
Step 1 of the wire pipeline cold-open optimization plan. Replaces
`create_buffer_init` (intermediate staging-buffer memcpy) with a
`mapped_at_creation` buffer the build path writes into directly.
Cuts one alloc + one memcpy per wire buffer; on cold open with hundreds
of MB of wire data this shows up immediately as lower peak memory and
faster upload.

Guarded against size-0 buffers by allocating a single-vertex placeholder
when the wire is empty (the renderer already skips draws on vertex_count
below the per-segment minimum).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:47:07 +03:00
HakanandClaude Opus 4.7 e9488f99dd perf(wire): move LWDISPLAY toggle from CPU rebake to wire shader uniform
LWDISPLAY (Show Lineweight) flipping the toggle no longer triggers a full
retessellate. `render_style_for` always bakes the entity's resolved (layer-
inherited) lineweight, and the wire shader reads `Uniforms.lwdisplay_enable`
to either honour the per-vertex half_width or collapse the quad to a 1 px line.

Side effect: PDF export now uses the real lineweight regardless of LWDISPLAY,
matching AutoCAD's plot semantics (LWDISPLAY only affects screen render).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:45:24 +03:00
Hakan SevenandClaude Opus 4.7 fb3dd58d09 fix(polyline): correct wide-polyline fill band arc center + add LWDISPLAY toggle
Wide LWPolyline/Polyline2D fill bands now sit on the centerline:
- polyline_segment_fill arc center matched explode.rs / acadrust convention
  (signed d_perp = r·(1-b²)/(1+b²), handles major arcs correctly)
- per-vertex hw0/hw1 taper applied along arc; tess density 24/circle
- synced_hatch_models subtracts hatch_offset from band boundary so it lives
  in the same offset-rel space as the wire tessellation

LWDISPLAY:
- new command `LWDISPLAY [ON|OFF]` toggles header.lineweight_display
- LWT status-bar pill (between ORTHO and POLAR) drives the same flag
- bump_geometry() on change so the viewport re-tess immediately

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 07:15:26 +03:00
HakanandClaude Opus 4.7 8e6491d4a8 fix(wipeout): use real clip vertices, correct image-pixel→WCS mapping
acadrust's DWG reader read the wipeout/raster-image clip boundary
points only to advance the bit cursor and then discarded them, so every
wipeout fell back to `Wipeout::new()`'s default placeholder rectangle
(`-0.5..0.5`). For files that store the real polygon mask in clip
vertices (anteen.dwg's `DIMBLOCK` style: u_vector ≈ 643k, size = (1, 1),
clip polygon ~0.0016×0.0033 in pixel space), the wipeout rendered at the
full image bbox — hundreds of thousands of units wide — instead of the
intended ~1000×2000 mask.

Pinned to the new `fix/wipeout-clip-vertices` acadrust branch (carries
the upstream parser fix). On the H7CAD side, the polygon mapping now
follows the DXF convention:

  x_off = (clip.x + size.x/2) × u_vec
  y_off = (size.y/2 − clip.y) × v_vec      (image-Y points down)

…in `wipeout_boundary_2d` (fill), `Wipeout::to_truck` (border line),
and `Wipeout::grips` (vertex handles) so all three stay in sync. The
fill path also explicitly closes the polygon loop — without it the GPU
`in_polygon` ray-cast skipped the v(n-1)→v(0) edge and the solid mask
bled far past the boundary.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-22 14:14:22 +03:00
Hakan SevenandClaude Opus 4.7 30f7ad0918 feat(hatch): rasterise PAT pattern lines for paper canvas + PDF
Both the on-screen paper canvas and the PDF / print pipeline only knew how
to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted
brick, etc.) — the actual hatch lines were a GPU-only feature, so any
paper-space view or PDF export showed a hollow polygon where there should
be ruled fill.

Add a CPU rasteriser as `HatchModel::pattern_segments()`:

- builds an edge list from the boundary, splitting at NaN sentinels and
  closing each sub-path so even-odd inside tests handle islands / holes;
- for every PAT family computes the world-space step vector
  (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`)
  and the inclusive k-range bounded by the boundary AABB plus a 4096-
  line safety cap;
- intersects each line with every boundary edge, sorts the hit
  parameters, walks them in pairs (even-odd) and emits one segment per
  in/out interval. Dash patterns are walked along each clipped span with
  phase preserved via `t mod period`, matching the GPU shader's
  absolute-t dash evaluation;
- pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and
  scaled the same way as `hatch_batched.wgsl`, so PAT patterns with
  non-zero origins (brick / offset herringbone) line up with the GPU
  render.

`paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route
pattern hatches through this rasteriser; solid / gradient hatches keep
their existing fast paths. The previous outline-only behaviour was a
visible regression on every layout containing a ruled fill.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-22 06:52:20 +03:00
Hakan SevenandClaude Opus 4.7 4e5d2c8d04 feat(text): route every text-bearing entity through the run-aware MText pipeline
Lift the MText parser + renderer into a reusable `layout_mtext` helper in
`text_support` and re-point the remaining text-bearing entities at it, so
every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`,
`\p…`, decorations, stacked fractions, paragraph indents, tab stops, …)
reaches the stroke output uniformly.

Per-entity changes:
- TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds
  `layout_mtext`; `\C7;Red`-style codes no longer render as literal
  glyph noise.
- MLEADER MText content (both entity path in [entities/multileader.rs]
  and the LOD-aware path in [scene/tessellate.rs]) — drops the
  `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0
  baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame /
  bg-fill rectangle now read line widths from the layout.
- ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine
  the value goes through the MText pipeline (SingleLine stays on the
  Text-style anchor maths because the DXF spec disallows codes there).
- DIMENSION text_override — `dimension_text_entity` emits a synthetic
  MText (instead of Text) whenever the value contains MText escapes;
  the LOD ladder via `tessellate_entity_dim_text` handles both kinds.

Infrastructure:
- `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`,
  `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly.
- `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so
  callers that draw chrome (frames, fills, baselines, greek rects)
  share the same numbers as the rendered strokes.
- `mtext_line_count` re-routed through the new parser so LOD row counts
  and rendered row counts can never disagree.
- Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`,
  `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the
  `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`)
  deleted — all callers migrated.

Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented
here.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-22 05:53:01 +03:00
Hakan SevenandClaude Opus 4.7 f12e864aa6 feat(mtext): integrate every inline format code in MTEXT renderer
Replace the line-based MTEXT pipeline with a run-aware parser + renderer.
Every DXF MTEXT inline code now reaches the stroke output: \H \W \Q \T \A
\C \c \f \F \M \X, the \p block (q/i/l/r/t/s), brace-scoped state, and
the \L\l \O\o \K\k decoration toggles.

Parser ([text_support]): emits Vec<MTextLine{ runs, align, indent_*,
tab_stops }> with per-run RunState; braces scope character state, paragraph
props persist across them.

Renderer ([mtext]): atomise → wrap → render. Per-run height/width/oblique/
tracking/font/colour/decorations/valign; paragraph indents and tab stops
honoured; default 4-em tab fallback.

Plumbing: tessellate() now returns Vec<WireModel>; TextStroke gains an
optional inline-colour override so per-run \C/\c emits one wire per colour.
cxf::tessellate_text_run adds a tracking multiplier. block_cache folds
multi-wire results into a single LocalWire (colour split preserved at
top-level, flattened inside cached defns).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 23:42:22 +03:00
HakanandClaude Opus 4.7 3fb23ae31b feat(scene): integrate text LOD across every text-bearing entity
Moves the Dimension and Table baked-block paths up into
`tessellate_entity` so sub-Text/MText recurse through the same LOD
ladder (baseline / greek / full) the top-level entities already use,
and routes the synth-dim text path through `tessellate_entity` as well
so files that omit `block_name` (e.g. typical DXFs) also get LOD.

Extends the text-height LOD branch from Text/MText to
AttributeDefinition, AttributeEntity, and Tolerance — the OBB helpers
gained the same coverage so the baseline/greek rect lines up.

When a sub-entity inside a baked dim/table block has ByBlock colour,
the recursive call's resolved colour is replaced with the parent's so
dimension text no longer renders in the raw ByBlock fallback hue.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 15:29:57 +03:00
HakanandClaude Opus 4.7 eab72062f9 fix(scene): render baked block geometry for Dimension and Table
AutoCAD bakes a per-instance block with the final, displayed geometry
into Dimension and Table entities (typically *D### / *T###, but custom
names also occur — sample-file-H uses DIMBLOCK*-4NP). The block's text
already encodes DIMTXT * DIMSCALE (or the table's scale factor), so
synthesising the entity from the dim/table style on top of that
double-applies the scale.

When the referenced block exists with content, render it directly:
- Dimension: dim.base.block_name -> block_record
- Table: block_record_handle -> block_record

The synthesis fallback stays in place for files where the block is
missing or empty.

Fixes sample-file-H.dwg dimensions rendering at 60000 instead of 200
(DIMTXT=199.196 * DIMSCALE=304.8).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 12:54:49 +03:00
HakanandClaude Opus 4.7 62876b1081 fix(text): integrate ignored fields across text-bearing entities
- MultiLeader: stop double-scaling when ctx.text_height (WCS-baked) is set;
  use it as-is and only multiply by scale_factor + anno on the fallback path.
  ctx.text_width gets the same treatment.
- Text / Attribute: width_factor and oblique_angle are entity-authoritative.
  The previous entity*style multiplication / addition mis-rendered any text
  whose TXSTYLE had a non-default width_factor or oblique_angle (RR 0.6,
  STYLE2 15°, Romans/ARIEL/A32 0.65-0.8 in the sample file).
- MText: honour TXSTYLE is_backward (negative width_factor mirror) and
  is_upside_down (180° rotation). measure_mtext_chars uses positive
  magnitude so anchor math stays correct under backward text.
- Tolerance: use tol.dimension_gap when set instead of the hard-coded
  0.35 * height heuristic.
- Table: cell text height / font / alignment resolve through
  CellContent -> CellStyle -> TableStyle.{title|header|data}_row_style
  -> compiled-in defaults. Row classification respects the suppressed
  flags. Cell + content rotation applied.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-21 12:38:03 +03:00
Hakan SevenandClaude Opus 4.7 ff2485eba9 fix(scene): bake selection colour into greek-LOD text fill
The greek-LOD text path emits no `points` and only `fill_tris`; the
face3d fill pipeline colours each triangle with `wire.color`. We were
storing the entity's own colour there, so selecting a text and then
zooming past the LOD boundary kept the wire.selected flag but the
on-screen colour dropped back to the entity hue.

Swap `wire.color` to `WireModel::SELECTED` when `selected` is set, the
same way `lod_stub_wire` already does it.

Follow-up to #19.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 06:15:44 +03:00
Hakan SevenandClaude Opus 4.7 f93d7cf9d2 fix(scene): bake selection colour into lod_stub_wire
The stub wires emitted at low LOD carried wire.selected = true but
stored the entity's own colour, so the actual on-screen pixel kept
the entity hue. Selecting an object and then zooming past the LOD
boundary made the highlight vanish even though the entity was still
in the selection set.

Swap to WireModel::SELECTED at construction time, matching what
tessellate.rs does for full-LOD wires.

Follow-up to #19.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 06:09:56 +03:00
Hakan SevenandClaude Opus 4.7 8355e5a278 fix(scene): keep low-LOD objects visible and selectable
At deep zoom-out, entities that projected to under ~5 px were dropped
from the scene entirely (and text below 2 px-baseline followed the
same path). The objects disappeared visually AND fell out of
window / crossing selection, and any prior selection highlight stopped
rendering once the zoom transition crossed the LOD threshold. #19.

Two changes:

1. `tessellate_entity`: replace the sub-5-px `return vec![]` cull, the
   sub-2-px text-baseline `return vec![]`, and the empty-greek fall-out
   with a new `lod_stub_wire` helper. The stub is a 2-point AABB
   diagonal carrying the same `selected` flag, ACI, and AABB as the
   entity — so the entity stays visible as a 1-pixel speck, tracks its
   highlight colour across LOD changes, and remains hit-test'able.

2. `box_hit` / `poly_hit`: when a wire has no `points` but a finite
   `aabb` (greek text emits only fill_tris), fall back to the AABB
   rectangle as the hit-test shape. Defense in depth so future fill-
   only wires don't silently drop out of selection.

Closes #19

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 06:07:04 +03:00
Hakan SevenandClaude Opus 4.7 9c4043ef45 feat(scene): scale Solid3D / Region / Body tessellation by header FACETRES
ACIS-bearing entities tessellated with a hard-coded LOD ladder
(circ_segs / grid_u / grid_v) so a high-detail drawing produced the
same coarse mesh as a low-detail one. Multiply the per-LOD counts by
`header.facet_resolution` (FACETRES), clamped to AutoCAD's
documented [0.01, 10.0] range with a floor of 4 segments to keep
degenerate values harmless.

All four call sites (build_caches, commit_entity mesh seed, undo/redo
populate_meshes_from_document, and the helper signatures themselves)
take a FACETRES argument now.

ISOLINES / surface_u_density / surface_v_density and the
camera / sun / geo header fields stay metadata until matching render
work lands.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 04:51:26 +03:00
Hakan SevenandClaude Opus 4.7 65920959d4 feat(scene): fall back to header EXTMIN/EXTMAX for fit-all
When a fresh document hasn't tessellated yet (no wire AABB cache,
no per-entity key vertices found), compute_model_space_extents()
returned None, so ZOOM EXTENTS / auto_fit_viewport had to wait for
the next pass before they could fit. Use the header's saved
model_space_extents_min/max as a last-resort answer so the very first
ZOOM EXTENTS / fit-all after open lands on the drawing immediately.

LIMMIN/LIMMAX and INSBASE remain read-only metadata for now — they
need a bounded-grid mode (LIMMIN/LIMMAX) and a "save as block" anchor
(INSBASE) before they map onto a visible behaviour.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 04:45:37 +03:00
Hakan SevenandClaude Opus 4.7 2d795e64ed feat(scene): honour render-affecting HeaderVariables (PDMODE/ATTMODE/FILLMODE/LWDISPLAY/MIRRTEXT)
Header system variables that change how the file renders were being
ignored. Wire them through the render path:

- PDMODE + PDSIZE: Point entities draw the requested glyph shape
  (dot / + / × / | with optional enclosing circle and square) sized by
  PDSIZE. Default PDMODE 0 keeps the single-vertex fast path.
- ATTMODE: INSERT attribute rendering now respects 0=Off (no attribs),
  1=Normal (per-attrib invisible flag), 2=On (force all visible).
- FILLMODE: when false, hatch / wipeout / face3d-fill uploads are
  short-circuited so the renderer draws wireframe only.
- LWDISPLAY: when false every entity falls back to the 1-pixel base
  width, matching AutoCAD's "Show Lineweight" toggle.
- MIRRTEXT: when false, MIRROR keeps text / mtext / shape rotation +
  oblique angle (so text stays right-reading) while still mirroring
  position.

DISPSILH / PLINEGEN were also reviewed: render path already honours
the per-polyline plinegen bit and we don't yet render Solid3D
silhouettes, so the header values are read for round-trip but produce
no extra effect here.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-20 04:44:07 +03:00
Hakan SevenandClaude Opus 4.7 613d6cf99d feat(entities): integrate remaining fields on block-based entities
MultiLeader and Table were ignoring most of the styling and content
fields that the parser populated. Bring both up to file-driven render:

MultiLeader
- line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback
- line_type_handle: dashed leader via line_types lookup by handle
- line_weight (ml.line_weight): per-leader pixel width override
- arrowhead_handle: arrow shape resolved through the dim arrow emitter
  (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None)
- extend_leader_to_text: continue the leader past the landing to the
  text insertion point
- text_angle_type: Horizontal forces 0, Optimized clamps to a readable
  half-plane, ParallelToLastLeaderLine keeps the stored direction
- text_direction_negative: adds π to the rotation
- text_alignment: drives the horizontal anchor when more specific than
  the context's text_attachment_point
- text_attachment_direction + text_top_attachment + text_bottom_attachment:
  Vertical mode uses the top / bottom attachment values chosen from the
  first root's direction.y sign
- has_block_contents + block_content_handle + block_content_location +
  block_rotation + block_scale + block_content_color: synthesise an
  Insert and explode it through the standard tessellator so block-
  content MultiLeaders render their referenced block
- style_handle, arrowhead_handle, line_type_handle, text_style_handle,
  block_content_handle, property_override_flags, text_align_in_ipe,
  block_scale, block_rotation, extend_leader_to_text,
  text_direction_negative, text_top_attachment, text_bottom_attachment
  surfaced in the Properties panel

Table
- Per-cell borders: drop the always-grid pass, walk the cells and
  emit each border honouring its `invisible` flag with dedup so shared
  edges aren't doubled
- table_style_handle, block_record_handle, data_version, value_flags,
  override_flag / override_border_* flags, break_options /
  break_flow_direction / break_spacing, normal surfaced in the
  Properties panel

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 21:17:05 +03:00
Hakan SevenandClaude Opus 4.7 e0ec7f1999 fix(scene): render INSERT attribute values
Inserting a block with attributes (ATTRIB entities attached to the
INSERT) drew only the block's primary geometry — the attribute values
the user filled in were silently dropped. block_cache::expand_insert
walks the block's BlockRecord which lists the ATTDEF templates, not the
per-INSERT ATTRIB instances, so they were never tessellated.

Add append_insert_attribute_wires: after expanding the block sub-wires,
iterate ins.attributes, skip the entries flagged invisible, and emit a
text wire for each at its stored WCS position. The wire's name reuses
the parent INSERT handle so selection / picking still treats the block
and its attribute text as one entity. Runs on both the block-cache
fast path and the legacy explode fallback.

Closes #20

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 21:04:40 +03:00
Hakan SevenandClaude Opus 4.7 e59917f798 feat(scene): integrate the last Dimension + DimStyle fields
Closes out the file-driven dim render. Remaining entity fields and the
last handful of DimStyle knobs are now honoured:

- leader_length: extend the radius/diameter dim line past the arrow tip
  toward the text by this distance
- ext_line_rotation: rotate extension lines off perpendicular (DIMEDIT
  Oblique result) for Linear / Aligned dims
- attachment_point: map the 1..9 grid to Text horizontal / vertical
  alignment so the saved anchor is used
- horizontal_direction: use as in-plane text reading direction override
- DIMTXSTY by handle: prefer dimtxsty_handle over the name; survives
  text-style renames
- DIMTFAC + DIMTOLJ: tolerance text rendered as a separate Text entity
  scaled by DIMTFAC and aligned vertically by DIMTOLJ
- DIMALTTD + DIMALTTZ: alternate-units tolerance suffix formatted with
  these decimal-place / zero-suppression knobs
- DIMARCSYM / DIMJOGANG / DIMUNIT / normal / line_spacing_factor /
  insertion_point / block_name / version: read so the file round-trips
  but no-op in the render path until the matching dim variants / 3D
  text plane are supported

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 20:28:09 +03:00
Hakan SevenandClaude Opus 4.7 4adbb20c5d feat(scene): honour the last DimStyle fields end-to-end
Tessellation and DIMBASELINE pick up the few DimStyle fields that were
still hard-coded or ignored:

- DIMTMOVE = 1 draws a connector segment from the dim-line anchor to the
  saved text_middle_point when the text has been moved
- Leader arrows are resolved via the leader's DIMSTYLE → DIMLDRBLK; the
  shape (closed-filled / open / dot / tick / …) is drawn through the same
  arrow emitter the dim path uses, including fill_tris for filled blocks
- DIMBASELINE picks up DIMDLI from the active DimStyle instead of the
  hard-coded 1.5 increment
- DIMTOFL / DIMTIX / DIMATFIT / DIMUPT / DIMTXTDIRECTION are read so the
  fields round-trip on save; their effect is at dim creation, not render,
  so they're acknowledged with a no-op block

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 13:37:37 +03:00
Hakan SevenandClaude Opus 4.7 9d0b406b31 feat(scene): expand DimStyle integration to the remaining visual fields
Drives more of the dim render off the parsed DimStyle so files round-trip
visually identical to AutoCAD instead of using hard-coded fallbacks:

- text format dispatch via DIMLUNIT (Sci / Decimal / Engineering /
  Architectural / Fractional / Windows) + DIMFRAC fraction denominator
- alternate units appended in brackets per DIMALT / DIMALTF / DIMALTD /
  DIMALTU / DIMALTRND / DIMAPOST / DIMALTZ
- tolerance / limits text per DIMTOL / DIMLIM / DIMTP / DIMTM / DIMTDEC /
  DIMTZIN; "value ± t", "+tp / -tm", or stacked "high/low"
- center mark for radius/diameter from DIMCEN (positive = "+", negative =
  "+" plus radial strokes)
- DIMDLE dim-line overshoot at tick endpoints
- DIMJUST horizontal text slide along the dim axis
- DIMTVP perpendicular text offset multiplier when DIMTAD=0
- DIMFXL/DIMFXLON fixed extension-line length override
- DIMTFILL/DIMTFILLCLR text background fill rectangle
- DIMLTEX/DIMLTEX1/DIMLTEX2 linetype handles resolved per-wire; ext1 and
  ext2 split into separate wires when their patterns differ
- DIMSOXD read; left as a no-op until autofit is added

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 09:44:59 +03:00
Hakan SevenandClaude Opus 4.7 1b2415bde5 feat(scene): render dimensions from DimStyle values read from file
Dimension tessellation was hardcoding arrow shape (open-V line pair) and
text format ("{:.4}"), ignoring almost every DimStyle field the parser
populated. Drives the wire/text geometry from the resolved DimStyle:

- text: DIMDEC, DIMZIN, DIMLFAC, DIMRND, DIMDSEP, DIMPOST, DIMADEC,
  DIMAZIN, DIMAUNIT (decimal/DMS/grad/rad), DIMTXSTY
- arrows: DIMBLK/DIMBLK1/DIMBLK2/DIMSAH resolution against block_records,
  supporting ClosedFilled (default), Closed/Closed-Blank, Small, Open
  (30/90), Dot (small/blank), Origin, Oblique/ArchTick, Box, Datum, None;
  DIMTSZ overrides to oblique ticks
- colours: DIMCLRD / DIMCLRE / DIMCLRT split into three wires (ext, dim,
  text); BYBLOCK/BYLAYER fall through to entity colour
- weights: DIMLWD / DIMLWE drive line_weight_px per wire
- suppress: DIMSE1/DIMSE2 (ext) and DIMSD1+DIMSD2 (dim) honoured
- text placement: DIMTAD vertical offset using DIMGAP + text height when
  text_middle_point is unset; DIMTIH/DIMTOH force horizontal text

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-19 09:34:27 +03:00
Hakan Seven a73dffdd76 fix(scene): rescale dash pattern when projecting wires to paper space
`viewport_content_wires` cloned model-space tessellations and overwrote
`points` with paper-space projections (× scale) but left `pattern_length`
and `pattern` at their model-coord values (pre-multiplied by PSLTSCALE's
1/vp_scale). The GPU then compared a paper-coord distance against a
model-coord pattern, so dashed linetypes in viewport content collapsed to
solid (a typical 9 mm paper line fell inside the first 180 mm dash).

Multiply the projected wire's pattern by the same vp scale used for the
points. Works for both PSLTSCALE on (paper-uniform dashes) and off
(dashes shrink with viewport scale).

Adds two diagnostic examples used to track this down:
  - inspect_lt: which layers/entities carry dashed linetypes
  - inspect_block: enumerate entities + linetypes inside a named block
2026-05-19 08:27:05 +03:00
Hakan Seven 66243e53ca feat: correct viewport scale calculation with correct dimension and text scaling 2026-05-19 00:26:08 +03:00
Hakan SevenandClaude Opus 4.7 798a391207 refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5)
The batched hatch pipeline has soaked through visibility + LOD; it's
correct on every file tested (Floor plan.dxf, UTM site plans, the
non-UTM regression case). Time to remove the parallel per-hatch
plumbing that's been sitting empty since step 3:

- `gpu_hatches: Vec<HatchGpu>` — never populated since
  `upload_hatches` switched to the batched builder; dropped.
- `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped.
- `compute_hatch_scissors` — no longer has a consumer; dropped, with
  the render-time call removed from `Primitive::prepare`.
- Hatch render pass — the dead `else if` branch over `gpu_hatches`
  is gone; only the single batched `pass.draw(0..vertex_count, 0..1)`
  remains.
- `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is
  `None` (empty hatch list) it just returns early.

`HatchGpu` itself stays — the wipeout pipeline still uses it. So do
`hatch_pipeline` / `hatch_bgl1`, kept around for that path.

`#[allow(dead_code)]` on the four storage-buffer fields in
`HatchBatchedGpu` — they exist only to keep the bind group's
referenced resources alive; nothing reads them directly.

Known gap left as a follow-up: per-hatch viewport scissor for paper-
space MSPACE viewports isn't ported to the batched path yet. Hatches
inside a paper-space viewport can render past the viewport border
until that's added. Hasn't surfaced as a visible regression in the
test files so far.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 20:18:06 +03:00
Hakan SevenandClaude Opus 4.7 398eee551d feat(pipeline): per-instance visibility for batched hatches (Phase 4-B, step 4)
Adds a dedicated `visibility: array<u32>` storage buffer (binding 4)
alongside the existing batched hatch buffers. Vertex shader reads
`visibility[v.instance_index]` — 0 → emit an out-of-NDC clip
position so the GPU rasterizer clips the primitive before the
fragment stage runs.

`compute_hatch_lod` now drives this buffer instead of (or alongside,
for the per-hatch fallback) `hatch_skip_flags`. Walks a per-instance
CPU AABB mirror that the builder fills at upload time (`instance_aabbs`),
applies the same `aabb_below_pixel` (sub-pixel LOD) OR `aabb_offscreen`
(frustum cull) rule the legacy path used, and pushes the resulting
0/1 flags via `queue.write_buffer` on a separate 4 B-per-hatch buffer
— ~40 KB per pan tick for 10 k hatches, far cheaper than re-writing
the 112 B-per-instance data.

Step 5 will remove the legacy `gpu_hatches` Vec, the per-hatch draw
loop, and `hatch_skip_flags` once the batched path has soaked.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 20:11:28 +03:00
Hakan SevenandClaude Opus 4.7 cfe53bd789 fix(pipeline): batched-hatch pattern math + invisible-instance clip
Two issues in the step-3 batched path surfaced during testing:

1. WGSL rejects compile-time NaN literals (`0.0 / 0.0`), so the
   visibility-skip path that emitted a NaN clip position failed
   `Device::create_shader_module` validation. Replaced with an
   out-of-frustum clip position `(2, 2, 2, 1)` — the GPU rasterizer
   clips the primitive and the fragment shader never runs.

2. Pattern hatches rendered with the wrong spacing / direction. The
   batched builder was rotating `perp_step` / `along_step` into the
   global frame, but the per-hatch shader (`hatch.wgsl`) expects them
   in QCAD PAT local-frame convention: `perp_step = family.dy`,
   `along_step = family.dx`, with the cos_off/sin_off rotation applied
   inside the shader. Now matches `build_family_batch` from
   `hatch_gpu.rs` line-for-line. `line_width` also reset to 0 (the
   shader picks 1 px from a screen-space derivative; the stored field
   is unused).

3. Padding's `max_spacing` likewise dropped its bogus rotation and
   now uses `|dy|` per family, matching the new perp_step convention.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 20:04:16 +03:00
Hakan SevenandClaude Opus 4.7 3593e9dabc feat(pipeline): wire batched-hatch pipeline + single draw call (Phase 4-B, step 3)
Pipeline carries a new `hatch_batched_pipeline` + matching `bgl1`
alongside the existing per-hatch pipeline. `upload_hatches` now packs
the renderable hatch list into the single `HatchBatchedGpu` and
clears `gpu_hatches`; the draw loop prefers the batched path when
that resource is set, falling back to the per-hatch loop only when
build returned `None` (empty list).

One draw call replaces N — for a 100-hatch frame that's a 99-call
saving plus 99 fewer `set_bind_group(1, ...)` / `set_vertex_buffer`
pairs.

Caveats acknowledged for step 4 cleanup:
- `compute_hatch_lod` still writes into `hatch_skip_flags`, which the
  batched path ignores. Step 4 will push the same flags into the
  per-instance `visible` field on the GPU instance buffer so the
  sub-pixel + frustum cull keeps working.
- Per-hatch viewport scissors (paper-space MSPACE) aren't ported yet;
  the legacy per-hatch path still serves that case when the batched
  build is skipped, but a model-space → paper-space transition keeps
  the batched buffer intact and may render hatches outside the
  intended viewport. Will be addressed alongside visibility.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 19:52:28 +03:00
Hakan SevenandClaude Opus 4.7 fe202f5f8e feat(pipeline): batched-hatch GPU resources + builder (Phase 4-B, step 1)
Stand-alone module — no consumers yet. Packs an entire `&[HatchModel]`
into four storage buffers + a per-vertex (corner, instance_index)
stream:

- `InstanceBuffer`  — `HatchInstance[]` (112 B each): color, mode,
  gradient params, world_origin, local-space aabb, boundary range,
  family range, visibility flag.
- `BoundaryBuffer`  — flat `vec4<f32>[]` (NaN markers preserved as
  per-path separators, just like the per-hatch shader expects).
- `FamilyBuffer`    — flat `LineFamilyGpu[]` (matches the per-hatch
  family layout one-for-one apart from `dash_offset` indexing into a
  separate dash buffer).
- `DashBuffer`      — flat `f32[]`.

The per-vertex buffer holds `6 × instance_count` records of
`(corner: u32, instance_index: u32)`. Step 2 will add the matching
WGSL shader that uses these to project a per-instance AABB quad and
run the in-polygon test against `BoundaryBuffer[offset..offset+count]`.

iced reports 8 storage buffers per stage, 128 MB binding cap, 256 MB
buffer cap — plenty for ~10 k hatches × 112 B + ~32 MB boundaries.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 19:45:26 +03:00
Hakan SevenandClaude Opus 4.7 0ec56015e0 chore(quadtree): silence dead-code warnings on planned API
`len` / `is_empty` / `remove` / `update` are exercised by unit tests
and are part of the quadtree's public surface, but no production
caller exists yet — Scene::add/erase/transform still trigger an
epoch-based full rebuild rather than mutating the index in place.
Annotating them with `#[allow(dead_code)]` keeps the API intact for
the incremental-update follow-up without spamming the release build.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 19:34:56 +03:00
Hakan SevenandClaude Opus 4.7 416b0d4dc3 fix(quadtree): split() on internal node was orphaning the entire subtree
Symptom: open a non-UTM drawing, most lines/polylines/hatches/etc.
vanish — only ~5% of indexed entities reachable from a huge query
(walking the nodes flat showed all 2975 items present, but recursing
through `node.children` from root reached only 161).

Root cause: when straddler items piled up at an INTERNAL node and the
count exceeded LEAF_CAPACITY, `insert()` re-invoked `split()` on that
node. `split()` allocates four fresh children and assigns them to
`node.children = Some([...])` — overwriting the existing children
pointer and orphaning the entire previous subtree (the descendant
nodes still hold their items; nothing reaches them).

Splitting an internal node is futile anyway: straddlers don't fit
smaller children either. Fix: gate the split on
`children.is_none()` so only leaves split. Internal nodes accumulate
straddlers as a linear-scan list — slower per-query for big strad-
dler counts, but correct.

Adds a regression test (`many_straddlers_dont_orphan_subtree`) that
floods an internal node with straddlers and verifies a huge query
still returns every inserted handle.

Bonus: `examples/dump_index_stats.rs` — standalone tool that loads a
DWG/DXF and dumps per-type counts (indexed / unbounded / top-level /
block-internal) plus a sample of bboxes. Useful for future bug
triage; built and ran cleanly against the file that exposed this.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 19:28:57 +03:00
Hakan SevenandClaude Opus 4.7 b4745efb24 fix(scene): emit entities with degenerate bbox from the quadtree path
Phase 2.1's wire path was silently dropping any entity whose
`bounding_box()` returns the degenerate default (or non-finite
coords). The legacy `entity_aabb` mapped those to the
`UNBOUNDED_AABB` sentinel, which `tessellate_entity` treated as
"render unconditionally". The new path filtered them at index time
(no bbox → no quadtree entry) AND `is_unindexable_entity` only
matched type variants (Insert/Viewport/Block/BlockEnd), so those
entities fell through neither path.

Symptom: opening a non-UTM file showed the floor plan with most
walls / dimensions / hatching dropped while isolated entities (text,
circles) still rendered.

Fix: `Scene::entity_index()` now returns an `EntityIndex` carrying
both the quadtree and a parallel `unbounded_handles: Vec<Handle>` of
entities whose bbox came back degenerate. The wire path always emits
that list in addition to the view-rect candidates, mirroring the
legacy "no bbox → never cull" behaviour.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 16:02:09 +03:00
Hakan SevenandClaude Opus 4.7 6ae3a9d91d fix(pipeline): hatch frustum cull tests AABB in wrong space
`HatchGpu.world_aabb` was built by iterating `model.boundary` — but
those vertices are stored as f32 offsets from `model.world_origin`
(precision anchor for UTM-scale WCS). So the AABB was in
*origin-relative* space, not the local space `view_proj` projects
from. `aabb_offscreen` (Phase 2.3) then asked "is this rect near (0,0)
in NDC?" instead of "is this rect at the hatch's actual position?" —
which culled in-view hatches whenever the camera moved off the
origin and rendered out-of-view ones near it.

Sub-pixel LOD (`aabb_below_pixel`, Phase 3.3) survived because it
measures a projected diagonal, which is translation-invariant.

Fixes:
- Add `world_origin` back when building `HatchGpu.world_aabb`, so the
  stored rect is in absolute local space.
- 25 % margin on the viewport rect in `aabb_offscreen`, matching the
  wire path's `view_world_aabb` margin — keeps hatches at the edge
  rendered while panning.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 15:36:15 +03:00
Hakan SevenandClaude Opus 4.7 b70c9f9262 perf(pipeline): per-frame hatch+wipeout frustum cull at draw time (Phase 2.3)
The Phase 2.1 step-4 attempt at culling hatches inside
`synced_hatch_models` was wrong: GPU hatch/wipeout buffers are
uploaded on geometry_epoch only (see render.rs — they're "static
buffers"), so any CPU-side cull at build time would freeze the
visible set at the geometry epoch boundary and never re-evaluate on
pan. Reverted that approach; the build-time function now returns the
full visible set again and the cache key is back to geometry_epoch.

The actual per-frame frustum cull moves into the existing draw-time
skip-flag machinery:

- `compute_hatch_lod` now also folds in `aabb_offscreen` (ORed with
  the existing Phase 3.3 sub-pixel skip), so out-of-view hatches are
  skipped at the draw call.
- New `compute_wipeout_lod` + `wipeout_skip_flags` mirror the hatch
  path. The wipeout draw loop honors the flag.
- `Primitive::prepare` calls the new compute step alongside the rest.

Per-frame projection + skip is a few hundred microseconds on dense
docs; far cheaper than re-uploading GPU buffers per pan tick.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 15:02:07 +03:00
Hakan SevenandClaude Opus 4.7 49fddea1ef fix(scene): include camera_generation in hatch/wipeout cache keys
The Phase 2.1 step-4 change made `synced_hatch_models` and
`wipeout_models` view-cull through `entity_index().query_rect`, but
left both caches keyed on `geometry_epoch` alone. Cache hit would
return a list culled to a previous camera's view, dropping hatches as
the user panned. Now keyed by `(geometry_epoch, camera_generation)`
— same pattern as `wire_cache`.

`image_cache` stays geometry-only: images self-cull per frame via
`vp_scissor` in the GPU pipeline; the cached list is camera-agnostic.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:56:20 +03:00
Hakan SevenandClaude Opus 4.7 fcf4d4fe28 perf(pipeline): per-frame mesh frustum cull (Phase 2.2)
Roadmap calls for an Aabb3 octree, but H7CAD's mesh entities only
carry XY world_aabb (Phase 3.4 stored XY for the LOD selector and
never extended to Z) and the camera is orthographic top-down for
every realistic workflow. So Phase 2.2 here becomes a draw-side cull:
`compute_mesh_lod` now also populates a `mesh_visible: Vec<bool>`
flag using the same 4-corner projection it already does for LOD
picking, and the mesh render pass skips draws whose projected AABB
sits entirely outside the viewport rect.

Mesh quadtree-indexing is still in place from Phase 2.1 (Solid3D /
Region / Body are not in `is_unindexable_entity`, so they're inserted
into `entity_index`). When mesh entity counts grow large enough that
the upload-time scan dominates over per-frame draws, Phase 2.3 can
plug `query_rect` into `upload_meshes` too.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:54:17 +03:00
Hakan SevenandClaude Opus 4.7 5b8cdd940e perf(scene): quadtree culling in hatch + wipeout passes (Phase 2.1, step 4)
`synced_hatch_models` and `wipeout_models` now pre-filter their
per-entity scans through `entity_index().query_rect(view)` when a
Model-layout view AABB is available. Affects:

- Top-level hatch HashMap iteration (the dominant cost — clone +
  render_style + selection tint per hatch).
- Wide LWPolyline / Polyline2D solid-fill loop.
- Wipeout entity scan.

The Insert→exploded-hatch loop is left alone; Insert is not
quadtree-indexed (its WCS bbox depends on block defn × insert
transform, handled inside block_cache).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:50:00 +03:00
Hakan SevenandClaude Opus 4.7 e43498d6c4 perf(scene): quadtree culling in wire tessellation (Phase 2.1, step 3)
`wires_for_block` now drives candidate selection from the quadtree
when a Model-layout view AABB is available — `query_rect` replaces
the O(N) `doc.entities().filter(...)` scan. Visibility predicate is
hoisted into a closure and reused for the quadtree-candidate path
and the unindexable-entity append (Insert/Viewport).

Paper space and the pre-fit first-frame settle path keep the
original full-scan behaviour (no view AABB → no cull). Per-entity
LOD + frustum checks inside `tessellate_entity` stay in place; the
quadtree is purely a candidate reducer so culling correctness still
funnels through one place.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:46:51 +03:00
Hakan SevenandClaude Opus 4.7 3e874602e2 feat(scene): Scene::entity_index — lazy quadtree build (Phase 2.1, step 2)
Adds `entity_index_cache: RefCell<Option<(u64, QuadTree)>>` and an
accessor that lazily (re)builds on geometry_epoch change. Skips
`Insert`/`Viewport`/`Block`/`BlockEnd` and entities whose
`bounding_box()` is degenerate or non-finite — those callers must
iterate separately. Root bounds derived from the union of indexed
entity AABBs with a 1% margin, so the tree is always tight.

Still unused; consumers wired in upcoming steps. Cargo emits dead-code
warnings for the helpers; they evaporate when the wire/hatch paths
switch to `query_rect`.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:43:09 +03:00
Hakan SevenandClaude Opus 4.7 727992bc84 feat(scene): quadtree primitive for 2D spatial index (Phase 2.1, step 1)
Standalone data structure — no consumers yet. Flat-vector node
storage, leaf capacity 32 / max depth 16, O(1) remove/update via
handle locator map, out-of-root items kept in an overflow list that
surfaces on every query.

Foundation for replacing the O(N) entity scan in the wire tessellation
path (scene/mod.rs:1232) with a query_rect call.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-17 14:40:11 +03:00
Hakan SevenandClaude Opus 4.7 de37ee04bf fix(scene): viewport auto-fit fallback + f64-precision projection
Three changes to make paper-space viewports render correctly on
UTM-scale drawings whose viewports were saved with default
view_target=(0, 0, 0) and a view_center pointing at empty WCS:

1. **Auto-fit fallback.** When the saved view's WCS rect doesn't
   overlap the IQR-bounded content cluster (`world_offset ±
   local_extent_max`), override `view_center` with `world_offset` and
   recompute `view_height` to fit the cluster into the paper viewport.
   Matches AutoCAD's silent auto-fit-on-open for stale / uninitialized
   viewports; without it those viewports rendered blank.

2. **f64 projection inner loop.** The previous f32 path computed
   `(wire_offset_rel - target_offset_rel).dot(view_right) -
   view_center` by subtracting values at ~5e6 magnitude (f32 ULP
   ~0.5 m) to land on a small paper offset. The cancellation produced
   centimetre-scale jitter on paper output even when the model was
   clean. Reconstruct the wire WCS coord, subtract the display center,
   and dot-project in f64; cast to f32 only at the final paper
   position where magnitudes are bounded by the viewport rect.

3. **examples/inspect_viewports.rs** — diagnostic that dumps every
   viewport's saved view fields (view_target, view_center, view_height
   vs paper height, custom_scale, status flags) plus drawing-level
   insertion_units and model-space extents. Used to verify that
   acadrust's DWG reader reads view_height correctly; the
   `view_height == vp.height` patterns observed on real files turn out
   to be genuine "default 1:1" file content rather than a reader bug
   (a round-trip test through DwgWriter / DwgReader survives all
   per-viewport values).

Also: switch the `acadrust` patch from `HakanSeven12/acadrust@feat/
expose-blockrecord-is-loaded` (a stale branch with the abandoned
is_loaded experiment) to `hakanaktt/acadrust@main`. Upstream main
now carries the layout paper-dimensions and mirrored-arc fixes that
this branch used to add — plus PR #20's DXF reading fixes that the
old branch was missing.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 19:32:36 +03:00
Hakan SevenandClaude Opus 4.7 c815be9c6f fix(hit_test): correct hatch selection — NaN-aware ray-cast + block-insert resolve
Two related click bugs:

1. `point_in_polygon` treated NaN points (the path separators used by
   multi-path hatches with islands / holes) as regular vertices,
   producing spurious closing edges between sub-paths. The cursor
   inside a real hatch failed the test → the click iterator moved on
   to the NEXT hatch in HashMap order and silently returned the wrong
   one. Rewrite the ray-cast to reset the previous-vertex / path-start
   state at every NaN, so each sub-path closes against its own first
   vertex and contributes its parity flip correctly.

2. Clicking on a hatch *inside* a block was either missing entirely
   (after the visible_hatches_for_click filter) or returning the
   block-defn source hatch handle (misleading — that handle isn't the
   thing the user sees). AutoCAD selects the parent Insert when a
   sub-entity of a block is clicked. Add `Scene::insert_hatches_for_click`
   which walks the same explode path `synced_hatch_models` uses and
   tags every exploded sub-hatch with its parent Insert handle, plus
   `hit_test::click_hit_insert_hatch` which iterates that list. The
   click resolver now tries wire → MSPACE hatch → insert-internal hatch
   in order, so clicking on a block-internal hatch selects the Insert.

Box / lasso select paths are unchanged — wire hit-test already picks
up Inserts, and dragging a box over block-internal hatches is rare
enough not to need its own resolution path yet.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 13:02:57 +03:00
Hakan SevenandClaude Opus 4.7 0f252e8b53 fix(scene): honor rational weights + adaptive sampling for hatch spline boundaries
Block-internal hatches with spline boundaries (common from
AutoCAD-style fillet / curve cuts) rendered with hollow / partially-
filled interiors. Two bugs combined to wreck the in_polygon test:

1. `SplineEdge::control_points` is `Vec<Vector3>` where `.z` is the
   RATIONAL WEIGHT, not a Z coordinate. The legacy code stuffed
   `Point3::new(p.x, p.y, 0.0)` into a plain BSplineCurve, throwing
   the weight away. For the typical NURBS boundary AutoCAD emits this
   completely distorts the curve — the fill polygon ends up far from
   the visible spline edge and many interior pixels fail the ray-cast.

2. Sampling was a fixed 16 segments regardless of curve length /
   curvature. A long meandering boundary was approximated by a few
   chords that cut through the interior; pixels inside the true curve
   but outside the chord polygon failed in_polygon and stayed blank.

Replace both: build a NurbsCurve<Vector4> when `spline.rational`
(packing `(x*w, y*w, 0, w)`), otherwise a plain BSplineCurve, then
sample via truck's adaptive `parameter_division` at the same
`fill_chord_tol` the arc / bulge paths use. Fallback when the knot
vector is bad now prefers `fit_points` (which lie on the curve) over
`control_points` (whose polygon is a convex-hull silhouette of the
curve — visibly wrong).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 12:46:24 +03:00
Hakan SevenandClaude Opus 4.7 172c538159 fix(scene): hatch boundary + pattern precision for large-WCS drawings
Two precision problems were producing visibly wrong hatches on UTM-scale
(and similarly-offset) drawings even after world_offset was correct:

1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge
   arc tessellation ran the whole arc-center computation in f32. With
   vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated
   into the sampled arc points and showed up as visibly wavy / jagged
   hatch boundaries on edges that crossed the long axis of the drawing.
   Re-run the full bulge → arc math in f64; only cast at the to_xy
   call that feeds the (also f64) boundary buffer.

2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew
   precision when `xz` was at offset-rel WCS magnitude and perp_step
   was sub-cm. Move the precision-critical bits out of f32 by keeping
   each hatch's anchor in f64 on the model side: `HatchModel` now
   carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]`
   vertices are stored as small offsets from it. The GPU vertex shader
   adds `origin` back inside the view_proj multiply for clip position
   (sub-pixel-lossy at large hatches but invisible); the `xz` varying
   that the fragment shader uses for in_polygon + pattern math stays
   in hatch-local space at full f32 precision.

   For pattern alignment across adjacent hatches with the same family,
   the GPU pipeline snaps the anchor to the family's perp/along grid
   in f64 before sending it down. Snap uses the same QCAD-PAT
   convention (`fam.dy = perp_step`, `fam.dx = along_step`) the
   shader's check_family uses — an earlier attempt used the world-frame
   `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and
   no alignment.

   Trade-off: pattern phase is "global modulo per-family-grid" rather
   than tied to WCS (0,0). Same hatches still align with each other;
   the absolute phase relative to the WCS origin may differ from the
   pre-fix behaviour on small drawings.

Consumers that read `model.boundary` directly (hit_test, paper_canvas,
add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`.
The hatch wgsl bind-group entry for binding 0 was bumped to
`VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`.

The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be
and the scissor plumbing from fbbac42 still work — those touched
different fields of the same struct.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 08:41:23 +03:00
Hakan SevenandClaude Opus 4.7 dc80968023 perf(scene): zoom-adaptive arc tessellation for hatch boundary outlines
The shared `arc_segments` helper now takes (radius, span, chord_tol) so
each call site can pick its own tolerance:

* Hatch fill polygon (built at load / on edit, never re-tessellated)
  uses `fill_chord_tol(r) = max(0.001*r, 1µm)` — fixed high quality so
  zoom-in stays sharp without rebuilding the boundary.
* Hatch wire outline (rebuilt every frame inside the render scope) uses
  `wire_chord_tol(r) = min(scene_override, fill_chord_tol(r))` —
  reads the per-frame `truck_tess::set_curve_tol_override` (the Scene
  already sets it to `world_per_pixel × 0.5` for Phase 3.2 wires) so
  far-out boundary arcs collapse to a handful of segments at low zoom
  while staying at least as sharp as the underlying fill at high zoom.

Same arc_segments switch applied to the legacy 16-segments-per-circle
formula in `hatch_model_from_dxf`'s polyline-bulge tessellation, so
fill boundaries with bulged polylines also gain the proper chord-error
target.

Also expose `truck_tess::current_curve_tol` (pub(crate)) and add
`active_curve_tol()` returning Option so the new wire path can
distinguish "scene override active" from "load-time, no zoom info".

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 07:55:46 +03:00
Hakan SevenandClaude Opus 4.7 9b4ded79be fix(scene): tessellate hatch-boundary polyline bulges in the wire outline
The hatch fill polygon (`hatch_model_from_dxf`) handled `BoundaryEdge::
Polyline` vertex bulges correctly — sampling arcs between v0 → v1 when
v0.z (bulge) was non-zero. The wire outline path
(`legacy_geometry::Hatch`) just pushed (x, y) for every vertex and
ignored v.z, so any bulged boundary segment drew as a straight chord
even though the fill underneath was correctly curved.

Mirror the bulge-arc tessellation into the wire path, reusing the
shared `arc_segments` helper from this module. Polyline edges now emit
the arc start vertex + sampled arc points + the trailing endpoint, with
a NaN separator added between distinct edges so connected boundaries
don't draw spurious chords from one edge's last point to the next
edge's first.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-16 07:48:21 +03:00