Commit Graph
1077 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.7 39150d617f feat(commandline): always-on keyboard capture + space submits like enter
Two behaviour changes that bring the command line in line with
established CAD workflow:

1. **Always-on keyboard capture.** The input was blurred whenever no
   command was running, so typed letters went nowhere until the user
   clicked into the field. Replace the blur with a re-focus on every
   command lifecycle transition (cmd_result.rs) and on viewport / tab
   / ribbon clicks (update.rs), focus the input at startup, and add
   a global key subscription that catches any unhandled character key
   or backspace and routes it into the buffer via two new messages
   (CommandAppendChar / CommandBackspace) — so the command line keeps
   accumulating keystrokes even if focus parks somewhere not
   focusable. Removes the unused blur_cmd_input helper.

2. **Space submits.** Typing a space at the end of the input now
   submits the buffer the same way Enter does. Only exception: an
   active command that's currently collecting free-form prose
   (TEXT / MTEXT / DDEDIT body, ATTDEF prompt / default value,
   INSERT attribute value, ATTEDIT cell). Those advertise the new
   `CadCommand::wants_text_with_spaces()` flag so the Space character
   reaches the buffer as a literal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:59:51 +03:00
Hakan SevenandClaude Opus 4.7 e55b280dda fix(paper): handle NaN sentinels when building hatch fill path
`HatchModel.boundary` stores multi-region hatches as a single Vec
with NaN-NaN sentinels separating each disconnected sub-path
(documented on the field). The paper-canvas hatch fill ignored the
sentinels and forwarded every vertex straight to the iced canvas
path builder. When a layout contained a multi-region hatch, lyon
received a polygon containing NaN coordinates and panicked with
`Tessellate path.: Internal(IncorrectActiveEdgeOrder(4))` from
inside `iced_wgpu::geometry::Frame::fill`, taking the whole app down
the first time Layout 1 painted.

Walk the boundary instead, opening a fresh sub-path on each sentinel
and closing whichever was active. Each region is now filled as its
own closed polygon. Reproduces with #22.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:22:52 +03:00
Hakan SevenandClaude Opus 4.7 e66905e9b1 refactor(entities): Solid3D/Region/Body accessor trio in entities/solid3d.rs
The same three-arm `match entity { Solid3D | Region | Body }` block
appeared at five callsites — twice in tessellate.rs (point_of_reference,
fallback wires) and three times in scene/mod.rs (mesh tess dispatch,
apply_grip pre/post position).

Add three accessors in entities/solid3d.rs:

  point_of_reference(e) → Option<&Vector3>
  fallback_wires(e)     → Option<&[Wire]>
  tessellate_volume(e, color, facet_res) → Option<MeshLodSet>

Callsites shrink from 5–8 lines to one call. The apply_grip path
collapses two parallel pre/post matches plus the entity-list `matches!`
guard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:22:02 +03:00
Hakan SevenandClaude Opus 4.7 5da32ff202 refactor(entities): collocate ui_name + dxf_name into entities/names.rs
Two near-identical match-on-EntityType tables lived far apart:
scene/properties.rs::entity_type_name returned mixed-case UI labels;
app/commands.rs::entity_type_name returned uppercase DXF record names.
Both enumerated ~30 variants; adding a new entity meant remembering to
touch two files in two crates' worth of source layout.

Move both into a single entities/names.rs as `ui_name()` and
`dxf_name()`. Adjacent so a new variant gets both labels added at once;
function names make the intended audience explicit at the callsite.

scene/properties.rs and app/commands.rs each lose their local match
plus one re-export, gaining a `crate::entities::names::*` call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:18:47 +03:00
Hakan SevenandClaude Opus 4.7 5e36e85e07 refactor(common): unify bulge → arc math into BulgeArc
The same DXF/DWG bulge → (center, radius, sweep) formula was inlined
in six places (hatch outline, polyline tess + truck path, lwpolyline
×4, scene hatch fill, explode bulge_to_arc) — each with subtly
different sweep-direction handling for the bulge sign.

Add `entities::common::BulgeArc { center, radius, start_angle,
end_angle, sweep }` with one `from_bulge(p0, p1, bulge)` constructor
and a `sample(t)` helper. Callsites collapse to a few lines.

Net: -121 lines, one canonical sweep-direction convention shared by
all consumers.

array.rs (the path-along-curve f32 helper) keeps its inline math —
its sign convention is inverted relative to the others and changing
it without exercising the array-path command risks regressing the
output.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:42:26 +03:00
Hakan SevenandClaude Opus 4.7 40180e9089 refactor(scene/mod.rs): pull entity-specific helpers into entities/
Five chunks moved out of the 5965-line scene/mod.rs to live with the
entities they belong to:

- wide_lwpolyline_fills → entities/lwpolyline.rs::wide_fills
- wide_polyline2d_fills → entities/polyline.rs::wide_fills
- polyline_segment_fill → entities/common.rs (shared by both above)
- text_obb_corners_native + text_baseline_points + text_greek_obb_tris
  → entities/text_support.rs (handles Text/MText/Attribute alike)
- append_insert_attribute_wires → entities/insert.rs

Net: scene/mod.rs 5965 → 5404 lines (~560 lines extracted). Three
helpers in scene/render.rs / scene/block_cache.rs widened from
pub(super) to pub(crate) so the relocated functions can still reach
them. scene::entity_aabb also widened for the same reason.

scene::tessellate_entity_dim_text stays put — it's a thin wrapper
around the private tessellate_entity dispatcher and moving it would
require exposing tessellate_entity's full signature across the crate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:26:14 +03:00
Hakan SevenandClaude Opus 4.7 b413fe0efe refactor(tess): rename LegacyTess → FallbackTess
The trait isn't deprecating anything — it carries the path entities
take when they're not covered by the truck topology pipeline
(Viewport, Insert, Hatch outline, Ole2Frame). "Fallback" names the
purpose; "Legacy" implied retirement that isn't actually on the
roadmap.

Same rename for LegacyGeometry → FallbackGeometry,
legacy_geometry() → fallback_geometry().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:16:57 +03:00
Hakan SevenandClaude Opus 4.7 aadb64860d refactor(tess): move per-entity tessellation into entities/
Each entity's tessellation now lives alongside its definition in
entities/*.rs, attached to the entity type via a local trait:

  LegacyTess     — Viewport / Insert / Hatch / Ole2Frame
                   (fallback path for entities not on the truck pipeline)
  LeaderTess     — Leader
  MultiLeaderTess — MultiLeader
  DimensionTess  — Dimension (carries its dim-only helpers too:
                   ~1700 lines of dim_geometry, dim_text_*, format_*,
                   tolerance, suppression, etc.)

scene/tessellate.rs keeps only the cross-entity dispatcher plus the
shared dim machinery reused by leader/multileader/dimension
(ArrowKind, DimGeom, append_arrow, arrow_from_block, add_segment,
add_polyline, normalized_or, push_tri, color_or_inherit, entity_z,
offset_snap_pts). Cross-entity arc helpers + aci_to_rgba moved to
scene/tess_util.rs.

scene/tessellate.rs shrinks from ~3300 to 809 lines. Compile clean,
all 9 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:12:27 +03:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
2026-05-17 14:46:51 +03:00