Tracks planned work for cutting file open time and per-frame render
cost: single-pass entity walk, incremental wire cache, batched wire
pipeline, hardware-instanced block inserts, and profiling spans.
Unignore ROADMAP.md so it's tracked.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Phase 4-B (batched hatch pipeline) is in; Phase 4.1 (multi-draw
indirect / GPU compute cull) stays as a deliberate non-goal because
iced 0.14 doesn't expose the required wgpu features to widget
pipelines, and the single-draw batched path already collapses N
bind-group swaps + N draw calls into one. Phase 4.2 (Hi-Z occlusion)
only matters for perspective 3D, which H7CAD doesn't ship.
The doc is now a short stub — the per-phase rationale lives at the
call sites (search for "Phase N.X" comments).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
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]>
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]>
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]>
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]>
Companion to the storage-buffer layout from step 1. Vertex shader
reads `(corner, instance_index)` per vertex, fetches the matching
`HatchInstance`, projects an AABB-quad corner; a zero `visible` flag
emits a NaN clip position so the GPU skips the fragment stage for
culled hatches without any per-frame buffer re-upload.
Fragment shader runs the same three-stage in-polygon → mode dispatch
→ pattern evaluation pipeline as `hatch.wgsl`, but with all ranges
indexed: `BoundaryBuffer[boundary_offset..offset+count]` for the
ray-cast, `FamilyBuffer[family_offset..offset+count]` for pattern
lines, `DashBuffer[dash_offset..]` for dash sequences. Phase 3.3
sub-pixel LOD substitution is preserved.
Step 3 will wire a pipeline + bind-group-layout around this and call
`upload_hatches_batched` from render.rs (the per-hatch
`upload_hatches` path stays in place for the bisect window).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
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]>
`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]>
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]>
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]>
`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]>
Phase 2.1/2.2/2.3 landed across the previous commits:
- 727992b quadtree primitive
- 3e87460 Scene::entity_index lazy build
- e43498d wire tessellation uses query_rect
- fcf4d4f mesh draw-time frustum cull
- b70c9f9 hatch + wipeout draw-time frustum cull
What was skipped on purpose:
- Octree-with-Aabb3: meshes only carry XY world_aabb today; the
pragmatic adaptation is a 4-corner projection test in
`compute_mesh_lod`. Revisit if a true 3D viewport mode lands.
- Per-mutation incremental quadtree updates: `entity_index_cache` is
geometry_epoch-keyed and rebuilds lazily on next query. Full
rebuild is ~50 ms on a 100 k-entity doc — acceptable for occasional
edits; promote to incremental updates if profiling shows the
rebuild cost.
Phase 4 (GPU-side culling) remains the next big lever.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
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]>
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]>
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]>
`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]>
`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]>
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]>
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]>
Adds a one-shot update check that runs on app boot and pops up a small
"Update Available" window when the GitHub releases API reports a newer
tag than this build's `CARGO_PKG_VERSION`.
- `src/update_check.rs` — async wrapper around a blocking `ureq` GET to
`api.github.com/repos/HakanSeven12/H7CAD/releases/latest`, returns
`Some(version)` when a newer release exists, `None` otherwise (silent
on network / parse failure so a missing connection never blocks
launch).
- `H7CAD::boot` chains a `Task::perform` next to the main-window open
so the check runs in parallel with first-paint.
- New window plumbed like the other floating panels (page setup, about,
etc.): `update_notice_window`/`update_notice_version` state, title
entry, `OsWindowClosed` cleanup, and a `ui::update_notice::view_window`
modal that shows installed vs. latest version plus "Later" /
"Open Release Page" buttons. Open routes through `open::that` to the
releases URL and dismisses the modal.
Dependency: adds `ureq = { version = "2", default-features = false,
features = ["tls"] }` — the smallest blocking HTTP client + TLS combo
that doesn't drag in an async runtime.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
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]>
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]>
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]>
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]>
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]>
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]>
af1e655's wrap-aware `arc_signed_span` was correct per a strict reading
of the DXF "is_counterclockwise" spec but caused CW boundary arcs on
real files to sweep the long way around the circle, flipping arc
direction and filling huge unintended regions.
Restore the legacy `(TAU - sa, TAU - ea)` flip semantics. The adaptive
`arc_segments` count fix from the same commit is kept — the visible
faceting issue on large-radius arcs was orthogonal to direction and the
revert affects only the span computation.
The wrap-through-2π edge case the previous commit tried to address is
back to being a known limitation. A proper fix needs to know how each
upstream writer (AutoCAD, BricsCAD, ODA, etc.) actually encodes wrap
arcs and isn't possible without sample files exercising the case.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Hatch boundary CircularArc / EllipticArc tessellation had two bugs that
showed up as polygonal-looking arc edges and, on certain arcs, edges
that drew the long way around the seam:
1. Fixed 32 segments per full circle gave ~5 mm chord-height error at
r = 1 m, visibly polygonal at typical hatch radii. Replace with an
adaptive count targeting ~0.1% chord error (≈ 5° max step), floor 8,
cap 256.
2. The `(TAU - sa, TAU - ea)` flip for CW arcs was direction-correct
for non-wrap inputs but silently went the long way around whenever
the short path crossed the 0 / 2π seam (e.g. a CCW arc from 3π/2 to
π/2 should sweep east through 0; the old code swept west through π).
Replace with `arc_signed_span` which derives a signed span that wraps
through 2π in the correct direction.
Shared helpers (`arc_signed_span`, `arc_segments`) live in
`scene/tessellate.rs` and feed both the hatch fill boundary builder
(`scene/mod.rs::hatch_model_from_dxf`) and the legacy wire fallback
(`scene/tessellate.rs::legacy_geometry`). Same fix applied to the
EllipticArc paths in both. Dropped the unused module-level `TAU`
constant the old code carried.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
* io/xref.rs — replaced the 7-line "BlockRecord::is_loaded turned out to
be unreliable" rationale with a one-liner. The reverted experiment
lives in git history (e88a946, 0996b75).
* refedit::RefEditSession.insert_handle — set at construction but never
read; the "kept for future REFCLOSE DISCARD" TODO can come back via
git history when that feature lands.
* scene::mod — dropped the placeholder MeshModel re-export; nothing
outside `mesh_model` consumes the bare name and MeshLodSet is the
pipeline-facing type now.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
* helpers::wcs_to_ucs — UCS inverse transform with zero call sites.
* scene::Scene::compute_and_set_world_offset — never called; the load
path uses the free function `compute_world_offset` directly.
* tessellate::tessellate_mesh — never called; mesh tessellation goes
through `solid3d_tess`, not the truck Shell/Solid path.
* truck_tess::tess_to_mesh_model — orphaned with tessellate_mesh's
removal.
* MeshModel::WHITE / MeshModel::SELECTED — only used by the removed
tessellate_mesh.
* xattach::Step::FilePath variant — never constructed; XATTACH always
enters through `with_path` after the file picker. Replaced the Step
enum + prefilled_path field with flat `path` / `block_name` fields
and dropped the unreachable text-input flow.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Each item below was gated by #[allow(dead_code)] and has zero callers
across src/ — none of them are part of any public API consumed by
external crates (this is a binary, not a library):
* modules/insert/underlay_frames.rs + edit_aliases.rs — toolbar stubs
whose `tool()` registrars were never wired into the ribbon; the
parent mod.rs inlines its own include_bytes! for the SVGs, so the
files were orphan dead weight.
* ui/snap_popup.rs:snap_marker_color — never called.
* ui/ribbon/mod.rs:last_dropdown_cmd — never called.
* ui/scale_popup.rs:anno_scale_for_label — never called.
* ui/layers.rs:color_name_to_aci — inverse of aci_color_display
with zero call sites.
* linetypes.rs:name_list — never called.
* scene/pipeline/wire_gpu.rs:new_ghost — never called.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The 5,000 sub-entity guard was added when an xref block with ~74k subs
froze the UI on first render. The block_cache primary path (def72e4)
made that path unreachable for typical Inserts — the guard now only
gated the rare legacy-explode fallback, where it hid otherwise-renderable
geometry as an insertion marker.
Remove the constant and its early-return; the fallback path now matches
the primary path's "render everything" behaviour. Also drop the
RENDERING_OPTIMIZATION.md "TEMPORARY" section that tracked this guard.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
ACIS SAT entities (Solid3D / Region / Body) were tessellated at one
fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless
of how far they were from the camera. Distant solids paid the full
triangle bill for fragments that mostly resolved to sub-pixel size.
Generate three pre-built LODs per entity at load time:
* LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag.
* LOD 1 (MID) — 24/16/8. Use 50–200 px.
* LOD 2 (LOW) — 12/8/4. Use below 50 px.
Tessellation cost is paid 3× on load (cheap — typical files have few
solids). Render picks per-frame via `compute_mesh_lod` from each mesh's
`world_aabb` projected diagonal. When a level is missing (e.g. a solid
entity that only emits LOD 0), the selector walks down to the nearest
available slot.
API ripples:
* New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`,
`tess_sphere_face`, `tess_torus_face`.
* `tessellate_solid3d / _region / _body` now return `MeshLodSet`.
* `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single`
constructor so interactive truck-based commands (BOX/CYLINDER/etc.)
that only generate one tessellation stay one-line.
* GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and
a `mesh_lod_levels` per-frame index vector.
* STL/STEP export pulls slot 0 explicitly so the on-disk geometry
isn't downgraded by the view-dependent ladder.
Background-thread lazy generation (per the roadmap) is deferred — the
synchronous eager build is fine for typical CAD files. Switch to a
queue + background tessellator when files with hundreds of solids
become a load-time bottleneck.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Hatch fills drew the full per-family pattern loop every frame regardless
of zoom. Two LOD tiers, neither of which existed:
* **Shader-side solid substitute.** When the densest line family's
spacing × scale projects to less than 2 px, individual lines blur
into a solid color and the per-fragment family loop is wasted ALU
(and produces moiré). Pass `world_per_pixel` through Uniforms; the
hatch fragment shader now returns `h.color` directly in that case.
* **CPU-side sub-pixel skip.** When the entire hatch AABB projects
to less than 2 px, drop the draw call. Adds `world_aabb` to
HatchGpu plus a per-frame `compute_hatch_lod` pass (mirrors the
existing scissor pass) and `hatch_skip_flags`. The render pass
skips flagged hatches.
Factored a shared `aabb_below_pixel` helper for future LOD callers.
The Uniforms layout is unchanged in size (96 B) — the trailing
`_pad: vec2<f32>` becomes `world_per_pixel: f32, _pad: f32`, so wgsl
modules that don't read the new field keep working with their old
`_pad: vec2<f32>` declarations.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Wire pass already uses `WireModel.vp_scissor` to clip viewport-projected
geometry to its paper-space viewport rect; hatch / image / wipeout
passes drew across the whole canvas. When a future paper-space path
projects fills/images into a viewport, they would bleed past the
viewport frame.
Add `vp_scissor: Option<[f32; 4]>` to HatchModel and ImageModel
(mirroring WireModel), forward it through HatchGpu / ImageGpu, and add
per-pass `compute_*_scissors` / `set_scissor_rect` machinery. Factored
the wire-side scissor-projection math into a shared `project_scissor`
helper. Field defaults to None at every constructor site — no producer
sets it yet, so this is plumbing for the paper-viewport fill projection
that Phase 1.4 unblocks without changing current visual output.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Min/max midpoint is wrecked by a single bogus entity at WCS distance
from the real drawing — a Ray with a bad direction, an orphan block-
defn entity at WCS x = -510k while real geometry sits at WCS x = +510k.
Header EXTMIN/EXTMAX bakes the same min/max midpoint, and the entity
scan we added in 59da4f3 reproduced it: both lined up perfectly on the
broken midpoint (drift = 0.000 in tracing) and parked world_offset
508 km from the dense cluster. Wires then rendered at f32 magnitudes
~5e5 — 6 cm precision on a drawing where the user is editing at mm.
Switch the entity scan to per-entity centroids and take the **median**
of x and y. Median is immune to a single far outlier regardless of how
far it sits, so the same file that previously parked world_offset at
(1445, 4277904) — halfway between the real drawing and a stray entity
512 km away — now resolves to (509099, 4274221), within 1 km of the
actual drawing. Wire magnitudes drop from ~5e5 to ~4e3, restoring
precision from 6 cm to ~0.5 mm (~120× improvement).
local_extent_max also derives from the centroid spread (95th-percentile
distance from the median × 2) rather than the raw min/max span, so the
fit_all clamp rejects the same outliers that were previously inflating
it. The header EXTMIN/EXTMAX path remains as a fallback for the case
where the entity scan found nothing (truly empty MSPACE).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
compute_world_offset rejected any entity whose owner_handle didn't
equal model_block, but belongs_to_visible_block (the render-time
filter) treats owner_handle = null as a legitimate MSPACE entity
whenever block_record.entity_handles is empty and the file enumerates
no block contents anywhere. DXF writers commonly omit group-code 330,
so on those files the render path drew the entities while the offset
scan missed them — direct WCS-coordinate wires then drove world_offset
to the header (sentinel → [0,0,0]) and f32 precision collapsed for
UTM-authored content.
Replicate belongs_to_visible_block's decision exactly:
* If model_block's BlockRecord enumerates entity_handles, use that
set as the authoritative MSPACE membership list.
* Otherwise: owner==model_block always counts; owner==null counts
only when no other block_record enumerated either (legacy DXF
without 330 codes anywhere); a non-null owner pointing elsewhere
or an entity enumerated by some other block is always rejected.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
world_offset (the precision-preserving translation subtracted from every
f32 wire point) came purely from the DXF $EXTMIN/$EXTMAX header. Three
real-world failure modes drove geometry far from the local origin and
killed f32 precision on MSPACE wires:
* Sentinel header (1e20 / -1e20) when the writer never computed
extents → world_offset = [0,0,0] → UTM-authored drawings render at
WCS 4M with single-pixel jitter.
* Stale header — drawing was edited but extents weren't refreshed →
offset center sits well outside the actual entity centroid.
* Civil-3D-style header that reports only a single Insert footprint,
leaving direct MSPACE Line/Polyline/Hatch geometry far from the
chosen offset.
Block defns already self-correct via `block_cache::build_defn`'s
per-defn bbox scan; top-level had no equivalent and was the inconsistent
half of the system. Add `compute_world_offset()` that scans MSPACE
entity bounding boxes (same `bounding_box()` API + SANE_EXTENT and
zero-placeholder filters that `build_defn` uses) and:
* Header invalid + entity scan ok → use entity scan.
* Header ok + entity scan empty → keep header (truly empty file).
* Both ok → cross-check: if the header center drifts more than 10×
its own half-span from the entity centroid, the header is stale —
trust the entity scan.
* Both unavailable → ([0,0,0], 1e9) sentinel.
`compute_and_set_world_offset` now delegates to the same helper so the
load path and any later recompute path can't diverge.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
belongs_to_visible_block's reverse-map fallback returned `true` on a
map miss, which let block-defn entities with a null owner_handle leak
into model space whenever their owning BlockRecord also had an empty
entity_handles list. The visible symptom was a stray HATCH (and
formerly BLOCK / BLOCKEND) selectable far from the drawing.
The legacy permissive default existed because some DXF files omit
group-code 330 everywhere, and dropping unknown-owner entities would
empty MSPACE. Detect that case explicitly: if the entity_block_map is
empty, no BlockRecord enumerated its contents — keep the legacy
permissive behavior. If the map has at least one entry, the file is
capable of declaring ownership, so an unknown-owner entity is an
orphan and must not leak into the queried block.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The absolute `local_extent_max` filter degrades to 1e9 whenever EXTMIN/
EXTMAX are the DXF "no geometry" sentinel (1e20 / -1e20), which lets a
single orphan wire — a stray hatch boundary at WCS origin, a Ray with a
bogus direction, a block-defn entity that leaked into MSPACE — drag
fit_all's bounding box across the world and collapse the real drawing
to sub-pixel size on ZOOM EXTENTS.
Compute a per-wire centroid first, then reject any wire whose centroid
sits more than 10× the inter-quartile span outside the X/Y consensus
cluster. The IQR pass only runs when there are ≥8 wires (so the
quartiles are meaningful); below that the legacy absolute-magnitude
gate is the only filter. The point-level `lim` check is preserved as a
final per-point guard.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Block and BlockEnd are block-defn markers, not drawable geometry.
wires_for_block did not filter them, so they fell through to
legacy_geometry's `_` arm and emitted a 1-unit phantom horizontal
segment at world_offset. The phantom was invisible on a real drawing
but selectable and — worse — its points poisoned fit_all, contributing
to the "ZOOM EXTENTS shrinks the model" symptom on files with weak
EXTMIN/EXTMAX or with block-defn entities leaking into MSPACE.
Same skip already exists in commands.rs:5364 and block_cache.rs:271 —
the render path was the missing one.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
`text_obb_corners_native` derived MText line count from explicit `\n`
matches in `m.value`, so any MText that relied on `\P`/`\N` breaks or
word-wrap (rectangle_width-driven) reported a single line. The greek
emitter then split the OBB into one row and the user saw a single box
on top of a 5-line wrapped paragraph.
- text_support: new `mtext_line_count` — strip codes, split on
\n/\P/\N, word-wrap each paragraph against `rectangle_width`,
sum. Same line splitting the MText/MultiLeader renderers already
perform; centralized for reuse.
- text_obb_corners_native: takes `mtext_lines_override: Option<usize>`.
When supplied, OBB height = h_world × n × line_spacing_factor; this
also overrides a possibly stale `rectangle_height` on resaved DWGs.
- Top-level greek paths (scene/mod.rs) compute the line count via
`mtext_line_count` and pass it through to baseline / rect emission.
- block_cache: tessellate_sub_local computes the count at defn build
so the stored `text_obb_local` is wrap-aware; emit_text_baseline
and emit_greeked_text recover n_lines from `ulen / h_local` as
before and now produce the correct per-row split inside Inserts.
Co-Authored-By: Claude Opus 4.7 <[email protected]>