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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-16 07:55:46 +03:00
1 parent 9b4ded79be
commit dc80968023
3 files changed
+69 -17

No files matched your search

+16 -5
View File
@@ -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);
+39 -11
View File
@@ -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]);
}
+14 -1
View File
@@ -42,7 +42,7 @@ pub fn set_curve_tol_override(tol: Option<f64>) {
/// 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<f64> {
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.