From dc80968023e641e5b0af1024c394216bd5007f0a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 16 May 2026 07:55:46 +0300 Subject: [PATCH] perf(scene): zoom-adaptive arc tessellation for hatch boundary outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/mod.rs | 21 ++++++++++++----- src/scene/tessellate.rs | 50 ++++++++++++++++++++++++++++++++--------- src/scene/truck_tess.rs | 15 ++++++++++++- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0432b9e2c..f50d1785b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2492,9 +2492,12 @@ impl Scene { -std::f32::consts::TAU }; } - let segs = ((sweep.abs() / std::f32::consts::TAU) * 16.0) - .ceil() - .max(4.0) as u32; + let r64 = r as f64; + let segs = tessellate::arc_segments( + r64, + sweep.abs() as f64, + tessellate::fill_chord_tol(r64), + ); for j in 0..segs { let a = a0 + sweep * (j as f32 / segs as f32); boundary.push(to_xy( @@ -2520,7 +2523,11 @@ impl Scene { arc.end_angle, arc.counter_clockwise, ); - let segs = tessellate::arc_segments(span.abs()); + let segs = tessellate::arc_segments( + arc.radius, + span.abs(), + tessellate::fill_chord_tol(arc.radius), + ); for i in 0..=segs { let t = sa + span * (i as f64 / segs as f64); boundary.push(to_xy( @@ -2543,7 +2550,11 @@ impl Scene { ell.end_angle, ell.counter_clockwise, ); - let segs = tessellate::arc_segments(span.abs()); + let segs = tessellate::arc_segments( + r_maj, + span.abs(), + tessellate::fill_chord_tol(r_maj), + ); let (cr, sr) = (rot.cos(), rot.sin()); for i in 0..=segs { let t = sa + span * (i as f64 / segs as f64); diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 53194a58b..87a0a8b4b 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -42,16 +42,44 @@ pub(super) fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) { (sa, ea - sa) } -/// Segment count for an arc targeting ~0.1% chord-height error. -/// Floors at 8 so very small arcs don't degenerate into triangles; cap -/// of 256 keeps pathological full-circle inputs bounded. -pub(super) fn arc_segments(span_abs: f64) -> u32 { - if span_abs < 1e-9 { +/// Segment count for an arc, targeting `chord_tol_world` chord-height +/// error in world units. Floor 8, cap 512. +/// +/// Two production callers: +/// - hatch fill polygon (built at load / on edit) passes a radius- +/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so +/// the polygon stays sharp at extreme zoom-in without re-tessellation. +/// - hatch wire outline (re-tessellated every frame inside the render +/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls +/// from the per-frame override set by `Scene::wires_for_block` so +/// far-out arcs collapse to a handful of segments. +pub(super) fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 { + if span_abs < 1e-9 || radius < 1e-9 { return 1; } - // 2 * acos(1 - 0.001) ≈ 0.0894 rad ≈ 5.12° - const MAX_STEP: f64 = 0.0894; - ((span_abs / MAX_STEP).ceil() as u32).clamp(8, 256) + let tol = chord_tol_world.max(1e-9).min(radius * 0.99); + // θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r). + let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6); + ((span_abs / max_step).ceil() as u32).clamp(8, 512) +} + +/// Chord tolerance for the load-time fill polygon: 0.1% of radius, +/// floor 1 µm so degenerate radii still produce a workable count. +pub(super) fn fill_chord_tol(radius: f64) -> f64 { + (radius * 0.001).max(1e-6) +} + +/// Chord tolerance for the per-frame wire outline: pulls the active +/// `truck_tess::set_curve_tol_override` value (Scene sets it to +/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at +/// the current zoom). When no override is active (snap / hit-test +/// passes, load-time builds), falls back to [`fill_chord_tol`] so we +/// never under-sample. +pub(super) fn wire_chord_tol(radius: f64) -> f64 { + match crate::scene::truck_tess::active_curve_tol() { + Some(t) => t.min(fill_chord_tol(radius)).max(1e-9), + None => fill_chord_tol(radius), + } } // ── Colour helper ────────────────────────────────────────────────────────── @@ -1245,7 +1273,7 @@ fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { if sweep.abs() < 1e-9 { sweep = if bulge > 0.0 { TAU } else { -TAU }; } - let segs = arc_segments(sweep.abs()); + let segs = arc_segments(r, sweep.abs(), wire_chord_tol(r)); for j in 0..segs { let a = a0 + sweep * (j as f64 / segs as f64); let p = to_wcs(cx + r * a.cos(), cy + r * a.sin()); @@ -1283,7 +1311,7 @@ fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { acadrust::entities::BoundaryEdge::CircularArc(arc) => { let (sa, span) = arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise); - let segs = arc_segments(span.abs()); + let segs = arc_segments(arc.radius, span.abs(), wire_chord_tol(arc.radius)); if !pts.is_empty() { pts.push([f32::NAN; 3]); } @@ -1314,7 +1342,7 @@ fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { .atan2(ell.major_axis_endpoint.x); let (sa, span) = arc_signed_span(ell.start_angle, ell.end_angle, ell.counter_clockwise); - let segs = arc_segments(span.abs()); + let segs = arc_segments(r_maj, span.abs(), wire_chord_tol(r_maj)); if !pts.is_empty() { pts.push([f32::NAN; 3]); } diff --git a/src/scene/truck_tess.rs b/src/scene/truck_tess.rs index 419153d9e..6eeb06c25 100644 --- a/src/scene/truck_tess.rs +++ b/src/scene/truck_tess.rs @@ -42,7 +42,7 @@ pub fn set_curve_tol_override(tol: Option) { /// Resolve the active curve tolerance. Clamped to the floor `CURVE_TOL` so /// extreme zoom-in never under-samples below the existing baseline quality. -fn current_curve_tol() -> f64 { +pub(crate) fn current_curve_tol() -> f64 { let bits = CURVE_TOL_BITS.load(Ordering::Relaxed); if bits == 0 { CURVE_TOL @@ -51,6 +51,19 @@ fn current_curve_tol() -> f64 { } } +/// Returns `Some(tol)` only when a Scene-driven per-frame override is +/// active (i.e. we're tessellating inside a render frame, not at load +/// time or in a snap / hit-test pass). Hatch boundary outlines use this +/// to apply zoom-adaptive sampling. +pub(crate) fn active_curve_tol() -> Option { + let bits = CURVE_TOL_BITS.load(Ordering::Relaxed); + if bits == 0 { + None + } else { + Some(f64::from_bits(bits).max(CURVE_TOL)) + } +} + // ── Public result type ──────────────────────────────────────────────────── /// Output of any truck topology tessellation.