From 5e36e85e07f7fe3c50f88a3b3faacc763cee6a95 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 23 May 2026 23:42:26 +0300 Subject: [PATCH] =?UTF-8?q?refactor(common):=20unify=20bulge=20=E2=86=92?= =?UTF-8?q?=20arc=20math=20into=20BulgeArc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same DXF/DWG bulge → (center, radius, sweep) formula was inlined in six places (hatch outline, polyline tess + truck path, lwpolyline ×4, scene hatch fill, explode bulge_to_arc) — each with subtly different sweep-direction handling for the bulge sign. Add `entities::common::BulgeArc { center, radius, start_angle, end_angle, sweep }` with one `from_bulge(p0, p1, bulge)` constructor and a `sample(t)` helper. Callsites collapse to a few lines. Net: -121 lines, one canonical sweep-direction convention shared by all consumers. array.rs (the path-along-curve f32 helper) keeps its inline math — its sign convention is inverted relative to the others and changing it without exercising the array-path command risks regressing the output. Co-Authored-By: Claude Opus 4.7 --- src/entities/common.rs | 86 ++++++++++++++++++ src/entities/hatch.rs | 52 +++-------- src/entities/lwpolyline.rs | 137 ++++++----------------------- src/entities/polyline.rs | 75 ++++------------ src/modules/home/modify/explode.rs | 51 +++-------- src/scene/mod.rs | 70 +++++---------- 6 files changed, 175 insertions(+), 296 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 49a8f91b6..8148ed480 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -167,6 +167,92 @@ pub fn ro_prop(label: &'static str, field: &'static str, value: impl Into Option { value.trim().parse::().ok() } + +/// Bulge → arc geometry for a polyline segment. +/// +/// DXF/DWG polyline arcs are encoded as a bulge factor on each vertex — +/// `bulge = tan(theta/4)` where `theta` is the included angle of the arc +/// from `p0` to `p1`. Sign convention: positive bulge = CCW from p0 to p1, +/// negative = CW. `|bulge| = 1` is a half-circle. +/// +/// This struct centralises the (formerly duplicated) math that takes +/// `(p0, p1, bulge)` and produces the canonical `(center, radius, +/// start_angle, sweep)` quadruple. Callsites pick the fields they need. +#[derive(Clone, Copy, Debug)] +pub struct BulgeArc { + pub center: [f64; 2], + pub radius: f64, + /// Angle from center to p0 (atan2, range -π..π). + pub start_angle: f64, + /// Angle from center to p1 (atan2, range -π..π). + pub end_angle: f64, + /// Signed sweep from p0 to p1. Positive ⇒ CCW (bulge > 0), + /// negative ⇒ CW (bulge < 0). For exact half-turns the sign of + /// `bulge` decides the direction. + pub sweep: f64, +} + +impl BulgeArc { + /// Build from endpoints + bulge. Returns `None` for degenerate input + /// (chord ≈ 0 or |bulge| ≈ 0). + pub fn from_bulge(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> Option { + let chord_x = p1[0] - p0[0]; + let chord_y = p1[1] - p0[1]; + let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt(); + if chord_len < 1e-12 || bulge.abs() < 1e-12 { + return None; + } + let b = bulge; + let b2 = b * b; + // r = chord · (1 + b²) / (4·|b|) + let r = chord_len * (1.0 + b2) / (4.0 * b.abs()); + // d_perp = signed distance from chord midpoint to arc center + // = r · (1 - b²) / (1 + b²) = r · cos(theta/2) + let d_perp = r * (1.0 - b2) / (1.0 + b2); + let mx = (p0[0] + p1[0]) * 0.5; + let my = (p0[1] + p1[1]) * 0.5; + // Left perpendicular to chord (90° CCW). + let perp_x = -chord_y / chord_len; + let perp_y = chord_x / chord_len; + let sign = b.signum(); + let cx = mx + sign * d_perp * perp_x; + let cy = my + sign * d_perp * perp_y; + let a0 = (p0[1] - cy).atan2(p0[0] - cx); + let a1 = (p1[1] - cy).atan2(p1[0] - cx); + // Wrap sweep to match bulge sign: bulge>0 ⇒ positive (CCW), + // bulge<0 ⇒ negative (CW). Falls back to ±τ for half-turns. + const TAU: f64 = std::f64::consts::TAU; + let mut sweep = a1 - a0; + if bulge > 0.0 { + if sweep <= 0.0 { + sweep += TAU; + } + } else if sweep >= 0.0 { + sweep -= TAU; + } + if sweep.abs() < 1e-9 { + sweep = if bulge > 0.0 { TAU } else { -TAU }; + } + Some(Self { + center: [cx, cy], + radius: r, + start_angle: a0, + end_angle: a1, + sweep, + }) + } + + /// Sample a point on the arc at parameter `t ∈ [0, 1]`. `t=0` ↦ p0, + /// `t=1` ↦ p1, walks along the signed sweep direction. + pub fn sample(&self, t: f64) -> [f64; 2] { + let a = self.start_angle + self.sweep * t; + [ + self.center[0] + self.radius * a.cos(), + self.center[1] + self.radius * a.sin(), + ] + } +} + /// Compute the filled boundary polygon for one polyline segment. /// For straight segments: a rectangle/trapezoid. /// For arc segments: an arc band (outer arc + reversed inner arc). diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 59b5c03f7..82f429757 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -386,49 +386,25 @@ impl FallbackTess for Hatch { let v0 = &verts[i]; let v1 = &verts[(i + 1) % count]; let bulge = v0.z; - if bulge.abs() < 1e-9 { + let arc = if bulge.abs() < 1e-9 { + None + } else { + crate::entities::common::BulgeArc::from_bulge( + [v0.x, v0.y], + [v1.x, v1.y], + bulge, + ) + }; + let Some(arc) = arc else { let p = to_wcs(v0.x, v0.y); pts.push(p); key_verts.push(p); continue; - } - let theta = 4.0 * bulge.atan(); - let dx = v1.x - v0.x; - let dy = v1.y - v0.y; - let d = (dx * dx + dy * dy).sqrt(); - if d < 1e-12 { - let p = to_wcs(v0.x, v0.y); - pts.push(p); - key_verts.push(p); - continue; - } - let r = (d * 0.5) / (theta * 0.5).sin().abs(); - let mx = (v0.x + v1.x) * 0.5; - let my = (v0.y + v1.y) * 0.5; - let px = -dy / d; - let py = dx / d; - let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let center_offset = r * (theta * 0.5).cos(); - let cx = mx + sign * px * center_offset; - let cy = my + sign * py * center_offset; - let a0 = (v0.y - cy).atan2(v0.x - cx); - let a1 = (v1.y - cy).atan2(v1.x - cx); - let mut sweep = a1 - a0; - const TAU: f64 = std::f64::consts::TAU; - if bulge > 0.0 { - if sweep <= 0.0 { - sweep += TAU; - } - } else if sweep >= 0.0 { - sweep -= TAU; - } - if sweep.abs() < 1e-9 { - sweep = if bulge > 0.0 { TAU } else { -TAU }; - } - let segs = arc_segments(r, sweep.abs(), wire_chord_tol(r)); + }; + let segs = arc_segments(arc.radius, arc.sweep.abs(), wire_chord_tol(arc.radius)); 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()); + let s = arc.sample(j as f64 / segs as f64); + let p = to_wcs(s[0], s[1]); pts.push(p); if j == 0 { key_verts.push(p); diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index a2a1fe571..0e7ee2015 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -15,30 +15,10 @@ const TAU: f64 = std::f64::consts::TAU; /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { - let angle = 4.0 * bulge.atan(); - let dx = p1[0] - p0[0]; - let dy = p1[1] - p0[1]; - let d = (dx * dx + dy * dy).sqrt(); - if d < 1e-12 { - return [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5]; + match crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge) { + Some(arc) => arc.sample(0.5), + None => [(p0[0] + p1[0]) * 0.5, (p0[1] + p1[1]) * 0.5], } - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (p0[0] + p1[0]) * 0.5; - let my = (p0[1] + p1[1]) * 0.5; - let px = -dy / d; - let py = dx / d; - let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let cx = mx + sign * px * (r - h); - let cy = my + sign * py * (r - h); - let a0 = (p0[1] - cy).atan2(p0[0] - cx); - let a1 = (p1[1] - cy).atan2(p1[0] - cx); - let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) }; - if ea < sa { - ea += TAU; - } - let mid_a = sa + (ea - sa) * 0.5; - [cx + r * mid_a.cos(), cy + r * mid_a.sin()] } /// Compute the DXF bulge for an arc that passes through p0, mid_pt, and p1. @@ -174,39 +154,17 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity { p1: [p1_pt[0] as f32, p1_pt[1] as f32, p1_pt[2] as f32], p2: [p2_pt[0] as f32, p2_pt[1] as f32, p2_pt[2] as f32], }); - } else { - let angle = 4.0 * bulge.atan(); - let dx = ox1 - ox0; - let dy = oy1 - oy0; - let d = (dx * dx + dy * dy).sqrt().max(1e-12); - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (ox0 + ox1) * 0.5; - let my = (oy0 + oy1) * 0.5; - let px = -dy / d; - let py = dx / d; - let ss = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let ocx = mx + ss * px * (r - h); - let ocy = my + ss * py * (r - h); - let a0 = (oy0 - ocy).atan2(ox0 - ocx); - let mut a1 = (oy1 - ocy).atan2(ox1 - ocx); - if bulge > 0.0 { - if a1 < a0 { - a1 += TAU; - } - } else { - if a1 > a0 { - a1 -= TAU; - } - } - let (wcx, wcy, wcz) = to_wcs(ocx, ocy); + } else if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) + { + let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); tgs.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], - radius: r as f32, + radius: arc.radius as f32, }); for j in 1..=16usize { - let a = a0 + (a1 - a0) * (j as f64 / 16.0); - let (wx, wy, wz) = to_wcs(ocx + r * a.cos(), ocy + r * a.sin()); + let s = arc.sample(j as f64 / 16.0); + let (wx, wy, wz) = to_wcs(s[0], s[1]); path.push([wx, wy, wz]); } } @@ -244,42 +202,20 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity { p1: to_f32(p_start), p2: to_f32(p_end), }); - } else { - let angle = 4.0 * bulge.atan(); - let dx = ox1 - ox0; - let dy = oy1 - oy0; - let d = (dx * dx + dy * dy).sqrt().max(1e-12); - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (ox0 + ox1) * 0.5; - let my = (oy0 + oy1) * 0.5; - let px = -dy / d; - let py = dx / d; - let ss = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let ocx = mx + ss * px * (r - h); - let ocy = my + ss * py * (r - h); - let a0 = (oy0 - ocy).atan2(ox0 - ocx); - let mut a1 = (oy1 - ocy).atan2(ox1 - ocx); - if bulge > 0.0 { - if a1 < a0 { - a1 += TAU; - } - } else { - if a1 > a0 { - a1 -= TAU; - } - } + } else if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) + { for j in 1..=16usize { - let a = a0 + (a1 - a0) * (j as f64 / 16.0); - let (wx, wy, wz) = to_wcs(ocx + r * a.cos(), ocy + r * a.sin()); + let s = arc.sample(j as f64 / 16.0); + let (wx, wy, wz) = to_wcs(s[0], s[1]); pts.push([wx, wy, wz]); } let (wx1, wy1, wz1) = to_wcs(ox1, oy1); kv.push([wx1, wy1, wz1]); - let (wcx, wcy, wcz) = to_wcs(ocx, ocy); + let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); tgs.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], - radius: r as f32, + radius: arc.radius as f32, }); } if i + 1 < seg_count { @@ -310,42 +246,21 @@ fn to_truck(pline: &LwPolyline) -> TruckEntity { p1: [p0.x as f32, p0.y as f32, p0.z as f32], p2: [p1.x as f32, p1.y as f32, p1.z as f32], }); - } else { - // Arc centre/midpoint computed in OCS, then transformed to WCS. - let (ox0, oy0) = (v0.location.x, v0.location.y); - let (ox1, oy1) = (v1.location.x, v1.location.y); - let angle = 4.0 * (bulge as f64).atan(); - let dx = ox1 - ox0; - let dy = oy1 - oy0; - let d = (dx * dx + dy * dy).sqrt(); - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (ox0 + ox1) * 0.5; - let my = (oy0 + oy1) * 0.5; - let len = d.max(1e-12); - let px = -dy / len; - let py = dx / len; - let sagitta_sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let ocx = mx + sagitta_sign * px * (r - h); - let ocy = my + sagitta_sign * py * (r - h); - let mid_a = { - let a0 = (oy0 - ocy).atan2(ox0 - ocx); - let a1 = (oy1 - ocy).atan2(ox1 - ocx); - let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) }; - if ea < sa { - ea += TAU; - } - sa + (ea - sa) * 0.5 - }; - let (mid_wx, mid_wy, mid_wz) = to_wcs(ocx + r * mid_a.cos(), ocy + r * mid_a.sin()); + } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( + [v0.location.x, v0.location.y], + [v1.location.x, v1.location.y], + bulge as f64, + ) { + let mid_s = arc.sample(0.5); + let (mid_wx, mid_wy, mid_wz) = to_wcs(mid_s[0], mid_s[1]); let p_mid = Point3::new(mid_wx, mid_wy, mid_wz); let tv0 = builder::vertex(p0); let tv1 = builder::vertex(p1); edges.push(builder::circle_arc(&tv0, &tv1, p_mid)); - let (wcx, wcy, wcz) = to_wcs(ocx, ocy); + let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); tangents.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], - radius: r as f32, + radius: arc.radius as f32, }); } diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index b50cb3e0e..d6bf22a27 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -1,5 +1,3 @@ -use std::f64::consts::TAU; - use acadrust::entities::{Polyline, Polyline2D, Polyline3D}; use glam::Vec3; use truck_modeling::{builder, Edge, Point3, Wire}; @@ -182,38 +180,17 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity { p1: to_f32(p1_pt), p2: to_f32(p2_pt), }); - } else { - let angle = 4.0 * bulge.atan(); - let dx = ox1 - ox0; - let dy = oy1 - oy0; - let d = (dx * dx + dy * dy).sqrt().max(1e-12); - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (ox0 + ox1) * 0.5; - let my = (oy0 + oy1) * 0.5; - let px = -dy / d; - let ss = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let ocx = mx + ss * px * (r - h); - let ocy = my + ss * (dx / d) * (r - h); - let a0 = (oy0 - ocy).atan2(ox0 - ocx); - let mut a1 = (oy1 - ocy).atan2(ox1 - ocx); - if bulge > 0.0 { - if a1 < a0 { - a1 += TAU; - } - } else { - if a1 > a0 { - a1 -= TAU; - } - } - let (wcx, wcy, wcz) = to_wcs(ocx, ocy); + } else if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) + { + let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); tgs.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], - radius: r as f32, + radius: arc.radius as f32, }); for j in 1..=16usize { - let a = a0 + (a1 - a0) * (j as f64 / 16.0); - let (wx, wy, wz) = to_wcs(ocx + r * a.cos(), ocy + r * a.sin()); + let s = arc.sample(j as f64 / 16.0); + let (wx, wy, wz) = to_wcs(s[0], s[1]); path.push([wx, wy, wz]); } } @@ -260,41 +237,21 @@ fn tessellate_polyline2d(pl: &Polyline2D) -> TruckEntity { p1: [p0.x as f32, p0.y as f32, p0.z as f32], p2: [p1.x as f32, p1.y as f32, p1.z as f32], }); - } else { - let (ox0, oy0) = (v0.location.x, v0.location.y); - let (ox1, oy1) = (v1.location.x, v1.location.y); - let angle = 4.0 * bulge.atan(); - let dx = ox1 - ox0; - let dy = oy1 - oy0; - let d = (dx * dx + dy * dy).sqrt(); - let r = (d / 2.0) / (angle / 2.0).sin().abs(); - let mx = (ox0 + ox1) * 0.5; - let my = (oy0 + oy1) * 0.5; - let len = d.max(1e-12); - let px = -dy / len; - let py = dx / len; - let sagitta_sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let h = r - (r * r - d * d / 4.0).max(0.0).sqrt(); - let ocx = mx + sagitta_sign * px * (r - h); - let ocy = my + sagitta_sign * py * (r - h); - let mid_a = { - let a0 = (oy0 - ocy).atan2(ox0 - ocx); - let a1 = (oy1 - ocy).atan2(ox1 - ocx); - let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) }; - if ea < sa { - ea += TAU; - } - sa + (ea - sa) * 0.5 - }; - let (mid_wx, mid_wy, mid_wz) = to_wcs(ocx + r * mid_a.cos(), ocy + r * mid_a.sin()); + } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge( + [v0.location.x, v0.location.y], + [v1.location.x, v1.location.y], + bulge, + ) { + let mid_s = arc.sample(0.5); + let (mid_wx, mid_wy, mid_wz) = to_wcs(mid_s[0], mid_s[1]); let p_mid = Point3::new(mid_wx, mid_wy, mid_wz); let tv0 = builder::vertex(p0); let tv1 = builder::vertex(p1); edges.push(builder::circle_arc(&tv0, &tv1, p_mid)); - let (wcx, wcy, wcz) = to_wcs(ocx, ocy); + let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); tangents.push(TangentGeom::Circle { center: [wcx as f32, wcy as f32, wcz as f32], - radius: r as f32, + radius: arc.radius as f32, }); } diff --git a/src/modules/home/modify/explode.rs b/src/modules/home/modify/explode.rs index 230bbd22c..fe6b27624 100644 --- a/src/modules/home/modify/explode.rs +++ b/src/modules/home/modify/explode.rs @@ -217,47 +217,16 @@ fn bulge_to_arc( elevation: f64, common_src: &EntityCommon, ) -> Option { - let chord_x = p1[0] - p0[0]; - let chord_y = p1[1] - p0[1]; - let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt(); - if chord_len < 1e-12 { - return None; - } + let ba = crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)?; - // Included angle = 4 * atan(bulge) - let b = bulge; - let b2 = b * b; - - // Radius: r = chord * (1 + b²) / (4 * |b|) - let r = chord_len * (1.0 + b2) / (4.0 * b.abs()); - - // Perpendicular distance from midpoint to center: - // d = r * cos(theta/2), where cos(theta/2) = (1 - b²) / (1 + b²) - let d = r * (1.0 - b2) / (1.0 + b2); - - // Midpoint - let mx = (p0[0] + p1[0]) * 0.5; - let my = (p0[1] + p1[1]) * 0.5; - - // Left-perpendicular direction (rotate chord 90° CCW) - let perp_x = -chord_y / chord_len; - let perp_y = chord_x / chord_len; - - // Positive bulge → center is to the left of chord direction (CCW arc) - let sign = b.signum(); - let cx = mx + sign * d * perp_x; - let cy = my + sign * d * perp_y; - - // Angles from center to p0 and p1 - let a0_rad = (p0[1] - cy).atan2(p0[0] - cx); - let a1_rad = (p1[1] - cy).atan2(p1[0] - cx); - - // acadrust arcs are always CCW: positive bulge → CCW from p0 to p1 - // Negative bulge → CW from p0 to p1 = CCW from p1 to p0 - let (start_angle, end_angle) = if b > 0.0 { - (norm_angle(a0_rad), norm_angle(a1_rad)) + // acadrust Arc is always CCW from start_angle to end_angle. Negative + // bulge means the polyline goes p0→p1 the CW way around the centre, + // which is the same circular arc traversed p1→p0 the CCW way — so + // swap endpoints when bulge < 0. + let (start_angle, end_angle) = if bulge > 0.0 { + (norm_angle(ba.start_angle), norm_angle(ba.end_angle)) } else { - (norm_angle(a1_rad), norm_angle(a0_rad)) + (norm_angle(ba.end_angle), norm_angle(ba.start_angle)) }; let mut common = common_src.clone(); @@ -265,8 +234,8 @@ fn bulge_to_arc( let arc = ArcEnt { common, - center: Vector3::new(cx, cy, elevation), - radius: r, + center: Vector3::new(ba.center[0], ba.center[1], elevation), + radius: ba.radius, start_angle, end_angle, ..ArcEnt::new() diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 880e91d4b..8f97cee31 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2850,54 +2850,30 @@ impl Scene { let v0 = &verts[i]; let v1 = &verts[(i + 1) % count]; let bulge = v0.z; - if bulge.abs() < 1e-9 { - boundary.push(to_xy(v0.x, v0.y)); + // Tess in f64 to preserve ~1 cm precision at + // UTM-scale WCS (the f32 path used to produce + // visibly wavy hatch arcs at 1e5+ magnitude). + let arc = if bulge.abs() < 1e-9 { + None } else { - // Run the whole bulge → arc tessellation in - // f64. The old f32 path lost ~1 cm of - // precision at UTM-scale WCS coordinates - // (1e5 magnitude × 1 ULP), which showed up - // as visibly wavy hatch-boundary arcs. - let theta = 4.0 * bulge.atan(); - let dx = v1.x - v0.x; - let dy = v1.y - v0.y; - let d = (dx * dx + dy * dy).sqrt(); - if d < 1e-12 { - boundary.push(to_xy(v0.x, v0.y)); - continue; - } - let r = (d * 0.5) / (theta * 0.5).sin().abs(); - let mx = (v0.x + v1.x) * 0.5; - let my = (v0.y + v1.y) * 0.5; - let px = -dy / d; - let py = dx / d; - let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 }; - let center_offset = r * (theta * 0.5).cos(); - let cx = mx + sign * px * center_offset; - let cy = my + sign * py * center_offset; - let a0 = (v0.y - cy).atan2(v0.x - cx); - let a1 = (v1.y - cy).atan2(v1.x - cx); - let mut sweep = a1 - a0; - const TAU: f64 = std::f64::consts::TAU; - if bulge > 0.0 { - if sweep <= 0.0 { - sweep += TAU; - } - } else if sweep >= 0.0 { - sweep -= TAU; - } - if sweep.abs() < 1e-9 { - sweep = if bulge > 0.0 { TAU } else { -TAU }; - } - let segs = tess_util::arc_segments( - r, - sweep.abs(), - tess_util::fill_chord_tol(r), - ); - for j in 0..segs { - let a = a0 + sweep * (j as f64 / segs as f64); - boundary.push(to_xy(cx + r * a.cos(), cy + r * a.sin())); - } + crate::entities::common::BulgeArc::from_bulge( + [v0.x, v0.y], + [v1.x, v1.y], + bulge, + ) + }; + let Some(arc) = arc else { + boundary.push(to_xy(v0.x, v0.y)); + continue; + }; + let segs = tess_util::arc_segments( + arc.radius, + arc.sweep.abs(), + tess_util::fill_chord_tol(arc.radius), + ); + for j in 0..segs { + let s = arc.sample(j as f64 / segs as f64); + boundary.push(to_xy(s[0], s[1])); } } if poly.is_closed {