From af1e6552539af8305f011de2e185156212f65f2e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 16 May 2026 07:20:45 +0300 Subject: [PATCH] fix(scene): smooth + correct arc edges in hatch boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/scene/mod.rs | 56 +++++++++++++-------------- src/scene/tessellate.rs | 83 ++++++++++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 58 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4a08e2e05..0432b9e2c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2515,43 +2515,43 @@ impl Scene { boundary.push(to_xy(line.end.x, line.end.y)); } BoundaryEdge::CircularArc(arc) => { - const TAU: f64 = std::f64::consts::TAU; - let cx = arc.center.x; - let cy = arc.center.y; - let r = arc.radius; - let sa = arc.start_angle; - let ea = arc.end_angle; - let ccw = arc.counter_clockwise; - let (sa, ea) = if ccw { (sa, ea) } else { (TAU - sa, TAU - ea) }; - let span = ea - sa; - let segs = ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32; + let (sa, span) = tessellate::arc_signed_span( + arc.start_angle, + arc.end_angle, + arc.counter_clockwise, + ); + let segs = tessellate::arc_segments(span.abs()); for i in 0..=segs { let t = sa + span * (i as f64 / segs as f64); - boundary.push(to_xy(cx + r * t.cos(), cy + r * t.sin())); + boundary.push(to_xy( + arc.center.x + arc.radius * t.cos(), + arc.center.y + arc.radius * t.sin(), + )); } } BoundaryEdge::EllipticArc(ell) => { - const TAU: f32 = std::f32::consts::TAU; - let cx = ell.center.x; - let cy = ell.center.y; - let maj_x = ell.major_axis_endpoint.x as f32; - let maj_y = ell.major_axis_endpoint.y as f32; - let r_maj = (maj_x * maj_x + maj_y * maj_y).sqrt(); - let r_min = r_maj * ell.minor_axis_ratio as f32; - let rot = maj_y.atan2(maj_x); - let ccw = ell.counter_clockwise; - let sa = ell.start_angle as f32; - let ea = ell.end_angle as f32; - let (sa, ea) = if ccw { (sa, ea) } else { (TAU - sa, TAU - ea) }; - let span = ea - sa; - let segs = ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32; + let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x + + ell.major_axis_endpoint.y * ell.major_axis_endpoint.y) + .sqrt(); + let r_min = r_maj * ell.minor_axis_ratio; + let rot = ell + .major_axis_endpoint + .y + .atan2(ell.major_axis_endpoint.x); + let (sa, span) = tessellate::arc_signed_span( + ell.start_angle, + ell.end_angle, + ell.counter_clockwise, + ); + let segs = tessellate::arc_segments(span.abs()); + let (cr, sr) = (rot.cos(), rot.sin()); for i in 0..=segs { - let t = sa + span * (i as f32 / segs as f32); + let t = sa + span * (i as f64 / segs as f64); let lx = r_maj * t.cos(); let ly = r_min * t.sin(); boundary.push(to_xy( - cx + (lx * rot.cos() - ly * rot.sin()) as f64, - cy + (lx * rot.sin() + ly * rot.cos()) as f64, + ell.center.x + lx * cr - ly * sr, + ell.center.y + lx * sr + ly * cr, )); } } diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index d0bb4608b..9dbee37c6 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -28,7 +28,38 @@ use crate::scene::truck_tess::{ }; use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel}; -const TAU: f64 = std::f64::consts::TAU; +// ── Arc tessellation helpers ───────────────────────────────────────────── + +/// Convert arc `(start, end, ccw)` into a `(start, signed_span)` that +/// (a) sweeps in the correct direction and (b) wraps through 2π when the +/// short path crosses the seam. The legacy `(TAU - sa, TAU - ea)` flip +/// was direction-correct for non-wrap arcs but silently rendered the +/// long-way-around when the short path crossed 0 / 2π. +pub(super) fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) { + const TAU: f64 = std::f64::consts::TAU; + let mut span = end - start; + if ccw { + if span <= 0.0 { + span += TAU; + } + } else if span >= 0.0 { + span -= TAU; + } + (start, span) +} + +/// Segment count for an arc targeting ~0.1% chord-height error vs. +/// radius. Floors at 8 so very small arcs don't degenerate into +/// triangles; rough cap of 256 prevents pathological huge-radius + +/// near-full-circle inputs from exploding the boundary vertex count. +pub(super) fn arc_segments(span_abs: f64) -> u32 { + if span_abs < 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) +} // ── Colour helper ────────────────────────────────────────────────────────── @@ -1189,20 +1220,17 @@ fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { key_verts.push(p1); } acadrust::entities::BoundaryEdge::CircularArc(arc) => { - let sa = arc.start_angle; - let ea = arc.end_angle; - let ccw = arc.counter_clockwise; - let (sa, ea) = if ccw { (sa, ea) } else { (TAU - sa, TAU - ea) }; - let span = ea - sa; - let segs = ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32; + let (sa, span) = + arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise); + let segs = arc_segments(span.abs()); if !pts.is_empty() { pts.push([f32::NAN; 3]); } for i in 0..=segs { let t = sa + span * (i as f64 / segs as f64); let p = to_wcs( - arc.center.x + arc.radius * t.cos() as f64, - arc.center.y + arc.radius * t.sin() as f64, + arc.center.x + arc.radius * t.cos(), + arc.center.y + arc.radius * t.sin(), ); pts.push(p); if i == 0 || i == segs { @@ -1215,34 +1243,29 @@ fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { )); } acadrust::entities::BoundaryEdge::EllipticArc(ell) => { - let r_maj = ((ell.major_axis_endpoint.x * ell.major_axis_endpoint.x + let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x + ell.major_axis_endpoint.y * ell.major_axis_endpoint.y) - .sqrt()) as f32; - let r_min = r_maj * ell.minor_axis_ratio as f32; - let rot = (ell.major_axis_endpoint.y as f32) - .atan2(ell.major_axis_endpoint.x as f32); - const TAU: f32 = std::f32::consts::TAU; - let ccw = ell.counter_clockwise; - let sa = ell.start_angle as f32; - let ea = ell.end_angle as f32; - let (sa, ea) = if ccw { - (sa, ea) - } else { - (TAU - sa, TAU - ea) - }; - let span = ea - sa; - let segs = - ((span.abs() / TAU) * 32.0).ceil().max(4.0) as u32; + .sqrt(); + let r_min = r_maj * ell.minor_axis_ratio; + let rot = ell + .major_axis_endpoint + .y + .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()); if !pts.is_empty() { pts.push([f32::NAN; 3]); } + let (cr, sr) = (rot.cos(), rot.sin()); for i in 0..=segs { - let t = sa + span * (i as f32 / segs as f32); + let t = sa + span * (i as f64 / segs as f64); let lx = r_maj * t.cos(); let ly = r_min * t.sin(); - let ocs_x = ell.center.x + (lx * rot.cos() - ly * rot.sin()) as f64; - let ocs_y = ell.center.y + (lx * rot.sin() + ly * rot.cos()) as f64; - let p = to_wcs(ocs_x, ocs_y); + let p = to_wcs( + ell.center.x + lx * cr - ly * sr, + ell.center.y + lx * sr + ly * cr, + ); pts.push(p); if i == 0 || i == segs { key_verts.push(p);