Commit graph

418 commits

Author SHA1 Message Date
Hakan Seven
60c742e21a feat(pipeline): batched-hatch WGSL shader (Phase 4-B, step 2)
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 <noreply@anthropic.com>
2026-05-17 19:46:51 +03:00
Hakan Seven
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 Seven
a0412534ae chore: release v0.3.1 2026-05-17 19:35:37 +03:00
Hakan Seven
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 Seven
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 Seven
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 Seven
0c10ca9dc1 chore: release v0.3.0 2026-05-17 15:50:17 +03:00
Hakan Seven
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 Seven
38f9733eb1 docs(rendering): drop completed Phase 2
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 <noreply@anthropic.com>
2026-05-17 15:03:33 +03:00
Hakan Seven
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 Seven
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 Seven
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 Seven
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 Seven
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
Hakan Seven
3e874602e2 feat(scene): Scene::entity_index — lazy quadtree build (Phase 2.1, step 2)
Adds `entity_index_cache: RefCell<Option<(u64, QuadTree)>>` and an
accessor that lazily (re)builds on geometry_epoch change. Skips
`Insert`/`Viewport`/`Block`/`BlockEnd` and entities whose
`bounding_box()` is degenerate or non-finite — those callers must
iterate separately. Root bounds derived from the union of indexed
entity AABBs with a 1% margin, so the tree is always tight.

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 14:40:11 +03:00
Hakan Seven
86f5ba7b62 feat: in-app update notice — checks GitHub releases on startup
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 <noreply@anthropic.com>
2026-05-16 19:55:57 +03:00
Hakan Seven
de37ee04bf fix(scene): viewport auto-fit fallback + f64-precision projection
Three changes to make paper-space viewports render correctly on
UTM-scale drawings whose viewports were saved with default
view_target=(0, 0, 0) and a view_center pointing at empty WCS:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:32:36 +03:00
Hakan Seven
68fe77c133 chore: release v0.2.9
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 13:04:58 +03:00
Hakan Seven
c815be9c6f fix(hit_test): correct hatch selection — NaN-aware ray-cast + block-insert resolve
Two related click bugs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 07:48:21 +03:00
Hakan Seven
cb7e6e3cc3 fix(scene): revert wrap-aware arc span — restore legacy CW direction
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 <noreply@anthropic.com>
2026-05-16 07:26:09 +03:00
Hakan Seven
af1e655253 fix(scene): smooth + correct arc edges in hatch boundaries
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 <noreply@anthropic.com>
2026-05-16 07:20:45 +03:00
Hakan Seven
412fb857ba chore: drop legacy comments and the last unused dead_code allows
* 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 <noreply@anthropic.com>
2026-05-16 02:04:41 +03:00
Hakan Seven
a1ed87da3d chore: drop dead scene/IO helpers + simplify XATTACH command state
* 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 <noreply@anthropic.com>
2026-05-16 00:10:20 +03:00
Hakan Seven
22543e4f97 chore: remove dead UI helpers and orphan toolbar stubs
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 <noreply@anthropic.com>
2026-05-16 00:04:44 +03:00
Hakan Seven
15bddb2f58 chore(scene): drop INSERT_SUB_LIMIT guard from fallback explode path
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 <noreply@anthropic.com>
2026-05-15 23:53:26 +03:00
Hakan Seven
a997490814 chore: release v0.2.8
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:39:19 +03:00
Hakan Seven
41cf2ffe11 docs(rendering): drop completed roadmap phases (1.4 / 3.3 / 3.4)
Phase 1.4 (hatch/image scissor), Phase 3.3 (hatch LOD), and Phase 3.4
(mesh LOD) landed in commits fbbac42, e7675be, and 8c08ae1. Removed
their roadmap sections and pruned the Implementation Order / Key Files
/ Success Metrics tables to reflect what's still outstanding (Phase 2
spatial index, Phase 4 GPU compute cull + Hi-Z).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:34:42 +03:00
Hakan Seven
8c08ae104a perf(scene): multi-resolution mesh LOD (Phase 3.4)
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 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
Hakan Seven
e7675be0bc perf(scene): hatch LOD — solid-fill substitute + sub-pixel skip (Phase 3.3)
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 <noreply@anthropic.com>
2026-05-15 23:18:21 +03:00
Hakan Seven
fbbac423d4 perf(scene): extend viewport scissor to hatch / image / wipeout passes (Phase 1.4)
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 <noreply@anthropic.com>
2026-05-15 23:13:16 +03:00
Hakan Seven
9a4531bd8e fix(scene): use median of MSPACE entity centroids for world_offset
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 <noreply@anthropic.com>
2026-05-15 22:58:32 +03:00
Hakan Seven
7ee93f6aea fix(scene): align compute_world_offset filter with belongs_to_visible_block
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 <noreply@anthropic.com>
2026-05-15 22:08:09 +03:00
Hakan Seven
59da4f35a9 fix(scene): cross-check world_offset against MSPACE entity AABB, not just DXF header
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 <noreply@anthropic.com>
2026-05-15 21:56:19 +03:00
Hakan Seven
2716bcbbd8 fix(scene): drop unknown-owner entities when the file enumerates ownership elsewhere
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 <noreply@anthropic.com>
2026-05-15 21:09:56 +03:00
Hakan Seven
708fdec941 fix(scene): IQR outlier reject in fit_all so one orphan wire can't poison bbox
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 <noreply@anthropic.com>
2026-05-15 21:08:49 +03:00
Hakan Seven
f592c5f1a2 fix(scene): skip Block/BlockEnd sentinels in wires_for_block filter
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 <noreply@anthropic.com>
2026-05-15 21:07:01 +03:00
Hakan
f4ae0e7773 fix(scene): per-line text greek honors word-wrap subline count
`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 <noreply@anthropic.com>
2026-05-15 17:41:56 +03:00
Hakan
ddd0a2de16 fix(scene): greek text rect renders filled at the text's own color
Greek rects rendered through fill_tris went through the face3d 0.45
dim path; the pre-boost (color × 1/0.45) compensated only until clamp
hit at 1.0, leaving bright user colors washed out. The earlier outline
workaround dodged the dim but lost the filled box visual that AutoCAD
uses for "text lives here".

  - face3d pipeline now skips the 0.45 dim when `wire.points` is
    empty. PolyfaceMesh / PolygonMesh keep their dim (both points and
    fill_tris populated); greek and MultiLeader background-fill emit
    `points: vec![]` so they render at the literal color.
  - All three greek paths (top-level, block-defn, MultiLeader) emit a
    2-triangle filled rect instead of an outline / boosted fill.
  - Greek rects are clamped to single-line height so 5-line MText
    doesn't blow the box up to the full text-block height. MultiLeader
    emits one rect per line — keeps the per-row visual hint.
  - BatchEntry/StyleKey gain an `is_fill_only` discriminator so greek
    batches never collide with regular wire batches; otherwise the
    finalized WireModel could carry both points and fill_tris and
    defeat the pipeline-side empty-points check.

Xref fade still applies to the resolved color via the existing
`fade_toward_bg` path, so xref greek stays faded as expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:32:27 +03:00
Hakan
1f1aa80f17 perf(scene): apply text LOD ladder to MultiLeader text content
MultiLeader's text strokes go through cxf::tessellate_text_ex per-glyph
regardless of zoom — same waste the top-level Text/MText path used to
have. Mirror the 1/5 px ladder here:

  h_px < 1   → top-line baseline (skipped if its own length is < 2 px)
  1 ≤ h < 5  → greek rect at the tight text bbox, color pre-boosted for
               the face3d 0.45 fill_tris dim
  h_px ≥ 5   → full per-glyph stroke (existing path, untouched)

Frame and background-fill rects keep rendering at every LOD — only the
glyph payload is collapsed.

Plain Leader entities don't bake their own text (annotation lives in a
separate linked MText), so they already inherit the top-level Text/MText
ladder; no change there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:22:05 +03:00
Hakan
26271b8c63 perf(scene): zoom-adaptive curve tolerance for top-level wires (Phase 3.2)
Edge tessellation used a fixed 0.005 world-unit chord-height tolerance
regardless of zoom — a 1000 m radius arc viewed at 50 mm/px still emitted
hundreds of segments that all collapsed onto the same screen pixel.

`truck_tess` now reads tolerance from a process-wide AtomicU64 override.
`Scene::wires_for_block` sets the override to ~0.5 px chord height
(world_per_pixel × 0.5) before the par_iter, then a Drop guard clears it
back to the default. Floor stays at the original CURVE_TOL so extreme
zoom-in keeps full quality. Block-cache rebuilds and off-render paths
(snap, hit-test) always see the default — the override is bracketed to
the render call only.

Roadmap entry for Phase 3.2 removed; Implementation Order / Key Files
tables updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:07:28 +03:00
Hakan
0584e3c862 perf(scene): text LOD ladder 1/5 px with baseline-line tier
Replaces the 2/4 drop/greek/full ladder with 1/5 baseline/greek/full so
zoomed-out text still leaves a visual hint instead of disappearing:

  h_px < 1   → OBB baseline line in the text color
  1 ≤ h < 5  → greeked OBB rect
  h_px ≥ 5   → full per-glyph stroke

Extra guard: in the baseline tier, the line itself is dropped when its
projected length is under 2 px (single-char text seen edge-on would
otherwise emit a sub-pixel segment).

Text/MText is exempted from the generic 5 px AABB cull (top-level and
block-defn paths) so it can reach this ladder even when its bounding
box projects below the threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 13:19:43 +03:00
Hakan
5344d0d6d6 docs(rendering): drop completed roadmap phases
Phase 1.1 (entity AABB), 1.2 (ViewVolume), 1.3 (CPU cull), 3.1 (sub-pixel
cull), and 3.5 (text simplification) are all live in tree — remove them
from the roadmap. Implementation Order and Key Files tables trimmed to
the still-pending work. TEMPORARY Insert sub-entity guard section now
notes which "proper fix" already landed (per-block cache, def72e4) and
which is still open.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 13:19:35 +03:00
Hakan
4d58a905e0 chore: release v0.2.7
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:46:47 +03:00
Hakan
6cc2323905 fix(scene): skip pixel-size LOD in paper space
PaperCanvas (Iced 2-D canvas) never feeds set_render_pixel_scale, so
last_world_per_pixel held either zero or a stale model-world value.
Paper-space entity AABBs (mm) tested against model-scale wpp all
projected below the 5 px threshold and got culled — leaving only
Viewports and Inserts (which bypass the cull). world_per_pixel() now
returns None in paper space, mirroring view_world_aabb()'s existing
skip with the same reasoning: the paper canvas is small enough that
LOD culling buys nothing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:45:48 +03:00
Hakan
f738a20457 feat(properties): label xref Insert as "External Reference"
Match AutoCAD properties panel — Insert whose block_record carries
is_xref/is_xref_overlay now shows "External Reference" instead of
"Block Reference". Silence dead_code warning on XrefStatus::Unloaded
(intentional, awaiting on-disk Unload-bit detection).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 09:19:57 +03:00