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]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
`Entity::bounding_box()` for empty / vertex-less entities (Polyline,
LwPolyline, Hatch, etc.) returns `BoundingBox3D::from_point(Vector3::ZERO)` —
the (0, 0, 0)→(0, 0, 0) placeholder. Including those in the LO centroid
scan dragged it toward the origin even for UTM-authored blocks, defeating
the precision-preservation: stored f32 then sat at UTM-minus-near-zero,
losing precision before tessellate even returned. Reject the zero
placeholder so only real geometry contributes.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Two latent traps in the first-pass centroid scan:
- A bbox where x/y were finite but z was NaN would skip the z update;
if NO entity contributed a finite z, `min[2]` stayed at `INFINITY` and
`max[2]` at `NEG_INFINITY`. `(INF + NEG_INF) * 0.5 = NaN`, so the defn's
`local_offset.z` was NaN, propagating to every cast point in the defn.
Track `have` per axis and default missing axes to 0.
- A single corrupt entity at parser-garbage coords (1e15+) silently
dragged the centroid out of the real geometry's neighbourhood. Reject
per-axis values above `SANE_EXTENT` so one bad bbox doesn't ruin the
whole defn.
Also include `Insert::insert_point` as a contributing sample so a
composite block (only nested Inserts, no direct wires) still gets a
non-zero offset — useful for the nested-defn AABB transforms even
though no direct LocalWire stores against it.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Xref work:
- merge_xref_into_block now copies the xref's layer / linetype / nested
block_record tables into the host under `{xref}|{name}` prefixes and
remaps entity references, so ByLayer/ByBlock resolution lands on the
right entry instead of falling back to WHITE + 1 px.
- expand_insert (and the explode fallback) detect xref BlockRecords by
flag and apply `fade_toward_bg(color, bg, 0.5)` to every emitted final
color (selection bypasses the fade). ExpandCtx propagates `is_xref` +
`bg_color` through nested expansions.
Block precision groundwork:
- BlockDefn gains `local_offset: [f64; 3]` set in build_defn from the
AABB centre of the defn's direct entities. tessellate_sub_local now
passes that offset to tessellate(), so LocalWire.points / fill_tris /
aabb_local / text_obb_local are stored in offset-frame f32 and don't
lose precision for distant content.
- emit_wire / emit_greeked_text / transform_tangent take the defn offset
and re-add it in f64 before composing with accum_xform. defn_aabb_
recursive transforms nested children from their offset frame into the
parent's. transform_offset_aabb_xy replaces transform_aabb_xy.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Top-level OBB used insertion_point as anchor and hardcoded MText anchor to
bottom-left, so greeked rects landed on the opposite side of the actual
text whenever MText used a non-default attachment_point or TEXT used a
non-Left/Baseline alignment. Block-defn subs never set or read
`text_obb_local`, so greeked text inside Inserts fell back to the
axis-aligned AABB and ignored entity rotation entirely.
- Share OBB corner computation via `text_obb_corners_native`.
- Map all 9 MText AttachmentPoint variants to (h_anchor, v_anchor).
- For TEXT, anchor on `alignment_point` (DXF 11) when alignment != Left+Baseline.
- Populate `LocalWire.text_obb_local` in `tessellate_sub_local`.
- `emit_greeked_text` transforms OBB corners by `accum_xform`, falls back
to AABB only when no OBB was precomputed.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Text greeking previously only fired for top-level Text/MText — text
that was baked into a block defn at cache build time still ran the
full glyph stroke path on every expand, so cadastral parcel labels
(typically inside xrefed blocks) saw none of the LOD benefit.
- `LocalWire` now records `text_height_local` for Text / MText subs.
- `expand_defn`'s wire arm applies the same 5 / 10 px ladder: cull
below 5 px (after scaling the local glyph height by the accumulated
transform's Y-scale), greek between 5 and 10, full glyphs above.
- `emit_greeked_text` reuses the batch infrastructure so multiple
greeked rects in the same style still collapse into one buffer.
Per real-world feedback the previous greeking threshold (4 px → full)
left a lot of unreadable text still going through the glyph path
without any visible payoff. Sharper ladder now:
- h < 5 px → drop (illegible, not worth even a rect)
- 5–10 px → greeked rect in text color
- h ≥ 10 px → full per-glyph tessellation
When a Text/MText entity's anno-scaled glyph height projects to fewer
than 4 screen pixels, drop the per-glyph stroke tessellation and emit
a single filled rectangle in the text's color instead. The user still
sees "text lives here" at the original color and footprint, but the
expensive font-stroke generation is skipped for thousands of unreadable
labels at fit-all / zoom-out views.
Pre-boosts the fill color to compensate for the 0.45 ambient-occlusion
dim the face3d pipeline applies (it's meant for 3D-shaded mesh solids,
not 2D text greeking).
Note: applies only to top-level entities; Text/MText *inside* block
defs is already tessellated into the block cache at build time, so
greeking those would require either a cache rebuild on zoom or a
parallel "low-detail" cache. Left for later if real-world test
suggests it's needed.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Frustum culling only helps at zoom-in (offscreen geometry); zoom-out
still tessellated and uploaded every wire even when an entire entity
projected to a single pixel. Now we also drop wires whose world AABB
projects smaller than 0.5px in the active view — a fit-all view of a
city plan skips sub-pixel symbols, lines, and curves before they ever
hit the GPU.
- `Scene` captures `world_per_pixel` each render from camera ortho_size
/ widget height.
- `expand_insert` / `tessellate_entity` take an optional
`world_per_pixel`; per-LocalWire / per-nested / whole-Insert /
top-level entity AABB → pixel-size test → skip if < MIN_PIXEL_SIZE.
- One-off paths (hit-test, viewport pane) pass `None` so they still
see full geometry regardless of zoom.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Empirically the DWG \"loaded\" bit doesn't track AutoCAD's XREF Manager
\"Loaded\"/\"Unloaded\" state reliably:
- File A: manager \"Loaded\", on-disk is_loaded = Some(true)
- File B: manager \"Loaded\", on-disk is_loaded = Some(false)
Same UI state, opposite bit values, so we were incorrectly skipping
xrefs the user wants loaded. Drop the `is_loaded == Some(false)` skip
and auto-resolve every xref again. The OOM problem this was meant to
mitigate is now addressed by the frustum culler, so loading more
xrefs no longer threatens the GPU budget.
The `BlockRecord::is_loaded` field + acadrust patch stay in place so
we can revisit once we identify the actual on-disk representation of
XREF→Unload (likely an XREF dependency record or
ACAD_XREF_PANEL_DATA xrecord).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Two bugs in the new frustum culler that left the GUI rendering nothing
on file open:
1. `cam.target` is already in **local** f32 space — `fit_to_bounds`
populates it from local wire coords — so subtracting `world_offset`
again pushed the view rect to the negated centroid, with the actual
geometry far outside.
2. The first call to `entity_wires()` after open comes from `fit_all()`
itself, while `camera_generation` is still 0 and the camera is at
its default origin. Culling against that rect discarded every wire
→ fit_all saw an empty list → camera never moved → the user-visible
render stayed empty forever.
`view_world_aabb` now returns `None` until `camera_generation > 0`
(letting fit_all collect a real extent), then derives the view rect
directly from `cam.target` in local space.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Block defns and top-level entities now know their world-space XY
AABB, and `tessellate_entity` / `expand_insert` early-out for anything
that doesn't intersect the camera's view rect. Tested commit-only on
time_open (correct subset emitted with default camera); GUI run
pending user verification.
Cache plumbing:
- `LocalWire` / `BlockDefn` carry `aabb_local` (post-build recursive
union over nested defns).
- `Scene::view_world_aabb()` derives a 25%-margined world rect from
the active camera + last-seen render aspect.
- `wire_cache` / `paper_sheet_cache` keys widen to
`(geometry_epoch, camera_generation)` so pan/zoom invalidates the
cached wire list — and pipeline `cached_epoch` widens to match
so the GPU re-uploads on view change.
- `XrefStatus::Unloaded` ack already shipped — orthogonal to this.
Also adds `examples/xref_states.rs` to dump per-block_record xref
metadata for diagnosing the is_loaded / unloaded distinction.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
When the host DWG marks an xref as Unloaded (via XREF→Unload in
AutoCAD), the user explicitly chose NOT to load that reference. We
were ignoring that and pulling the external file in anyway, which
on large drawings could push GPU memory past the device budget.
Bump the acadrust dep to the branch that exposes
`BlockRecord::is_loaded`, then short-circuit `resolve_xrefs` for
any entry with `is_loaded == Some(false)`. New `XrefStatus::Unloaded`
surfaces this in the command-line log so the user can see which
references were skipped (and can re-load them via the XREF dialog).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Tessellates each block_record once into local-coord LocalWires; Insert
references transform-copy + ByBlock-resolve the cache rather than
explode+re-tessellate per reference. Same-style wires inside one Insert
are NaN-batched into a single WireModel to keep wgpu buffer counts
sane — large blocks that previously bailed to a marker now render in
a handful of buffers each.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Examples can't import from a bin-only crate, so add a thin lib.rs that
re-exports the existing modules. Used by the new examples that helped
diagnose the freeze fixed in the previous commit.
- examples/time_open: times parse / purge / cache / first-wires phases
- examples/inspect_insert: lists Insert blocks with sub-entity counts
- examples/find_raster: dumps RasterImage path + dimensions
Co-Authored-By: Claude Opus 4.7 <[email protected]>
A real DWG with an xref containing ~74k sub-entities froze on the first
render: Insert tessellation exploded the block, style-resolved every sub
serially, and one bad sub could hang the loop forever.
- Insert short-circuits to an insertion marker when the referenced block
has more than INSERT_SUB_LIMIT (5_000) sub-entities. Documented as
temporary in RENDERING_OPTIMIZATION.md.
- Re-run purge_corrupt_entities after resolve_xrefs so parser-garbage
inside xref content is caught (initial purge runs before xref load).
- build_derived_caches: single pass over entities collecting handles
per kind; tessellation reads via doc.get_entity instead of cloning
every EntityType. Image decode now parallel too.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Remove MAX_SEGS_PER_WIRE = 100_000 and MAX_TOTAL_SEGS = 3_000_000.
These were stop-gap GPU-memory guards that mutated visible output:
single complex entities were cut mid-geometry, and entire wires past
the 3M total were dropped by arrival order with no spatial awareness.
Either is worse than honestly drawing whatever the file contains —
truncated arcs and missing road segments look like bugs to the user,
not "we ran out of GPU memory."
The matching "Temporary Workarounds" section in
RENDERING_OPTIMIZATION.md goes with them. Per-entity LOD and
viewport culling (Phase 1 + 3.2 in the same doc) remain the right
long-term answer; until they land, prefer correctness over a soft
crash on pathological files.
Co-Authored-By: Claude Opus 4.7 <[email protected]>