From cf6dd174fc721ac0da6ea20e6807d7aeba3121be Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 23:43:14 +0300 Subject: [PATCH] fix(arraypath): correct arc paths and align items to the tangent Path arrays along arc segments came out mirrored: the LwPolyline bulge sampler placed the arc centre on the wrong side of the chord and ignored the polyline normal. Sample bulge arcs through the canonical BulgeArc and map OCS->WCS, matching the renderer. Sample standalone Arc/Circle paths via their OCS basis too. Also align each copy to the path tangent (the first item keeps its orientation), so items follow a curve instead of staying upright. Co-Authored-By: Claude Opus 4.8 --- src/modules/home/modify/array.rs | 230 ++++++++++++++++++++----------- 1 file changed, 150 insertions(+), 80 deletions(-) diff --git a/src/modules/home/modify/array.rs b/src/modules/home/modify/array.rs index 0d0b2e277..636d990ee 100644 --- a/src/modules/home/modify/array.rs +++ b/src/modules/home/modify/array.rs @@ -383,17 +383,31 @@ impl CadCommand for ArrayPolarCommand { // → Returns BatchCopy with Translate transforms derived from path samples. use acadrust::EntityType; +use std::f32::consts::PI as FPI; use std::f32::consts::TAU as FTAU; // ── Path geometry helpers ────────────────────────────────────────────────── -/// Tessellate an LwPolyline. +/// Tessellate an LwPolyline into dense WCS points, matching the renderer: +/// bulge arcs use the canonical `BulgeArc` (correct centre side + signed +/// sweep) and every point is mapped OCS→WCS via the polyline's normal. The +/// previous ad-hoc bulge math placed the arc centre on the wrong side of the +/// chord and ignored the extrusion, so arc-segment paths came out mirrored. fn lw_dense_pts(p: &acadrust::entities::LwPolyline) -> Vec { - let z = p.elevation as f32; + use crate::entities::common::BulgeArc; + use crate::scene::transform::ocs_point_to_wcs; + let verts = &p.vertices; if verts.is_empty() { return vec![]; } + let normal = (p.normal.x, p.normal.y, p.normal.z); + let elev = p.elevation; + let to_wcs = |x: f64, y: f64| -> Vec3 { + let (wx, wy, wz) = ocs_point_to_wcs((x, y, elev), normal); + Vec3::new(wx as f32, wy as f32, wz as f32) + }; + let n = verts.len(); let segs = if p.is_closed { n } else { n.saturating_sub(1) }; let mut out: Vec = vec![]; @@ -401,58 +415,26 @@ fn lw_dense_pts(p: &acadrust::entities::LwPolyline) -> Vec { for i in 0..segs { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; - let x0 = v0.location.x as f32; - let y0 = v0.location.y as f32; - let x1 = v1.location.x as f32; - let y1 = v1.location.y as f32; + let p0 = [v0.location.x, v0.location.y]; + let p1 = [v1.location.x, v1.location.y]; if out.is_empty() { - out.push(Vec3::new(x0, y0, z)); + out.push(to_wcs(p0[0], p0[1])); } - let bulge = v0.bulge as f32; - if bulge.abs() < 1e-9 { - out.push(Vec3::new(x1, y1, z)); - } else { - let dx = x1 - x0; - let dy = y1 - y0; - let d = (dx * dx + dy * dy).sqrt(); - if d < 1e-9 { - out.push(Vec3::new(x1, y1, z)); - continue; - } - let angle = 4.0 * bulge.atan(); - let r = (d * 0.5) / (angle * 0.5).sin().abs(); - let mx = (x0 + x1) * 0.5; - let my = (y0 + y1) * 0.5; - let inv = 1.0 / d; - let (px, py) = (-dy * inv, dx * inv); - let sign = if bulge > 0.0 { 1.0f32 } else { -1.0 }; - let h_val = r - (r * r - d * d * 0.25).max(0.0).sqrt(); - let (cx, cy) = (mx - sign * px * (r - h_val), my - sign * py * (r - h_val)); - let a0 = (y0 - cy).atan2(x0 - cx); - let a1 = (y1 - cy).atan2(x1 - cx); - // Signed span: CCW for bulge > 0, CW (negative) for bulge < 0. - let span = if bulge > 0.0 { - let s = a1 - a0; - if s <= 0.0 { - s + FTAU - } else { - s + let bulge = v0.bulge; + match (bulge.abs() >= 1e-9) + .then(|| BulgeArc::from_bulge(p0, p1, bulge)) + .flatten() + { + Some(arc) => { + let steps = ((arc.radius * arc.sweep.abs() / 2.0).ceil() as usize).clamp(4, 64); + for j in 1..=steps { + let s = arc.sample(j as f64 / steps as f64); + out.push(to_wcs(s[0], s[1])); } - } else { - let s = a1 - a0; - if s >= 0.0 { - s - FTAU - } else { - s - } - }; - let steps = ((r * span.abs() / 2.0).ceil() as usize).clamp(4, 64); - for j in 1..=steps { - let ang = a0 + span * (j as f32 / steps as f32); - out.push(Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), z)); } + None => out.push(to_wcs(p1[0], p1[1])), } } out @@ -533,7 +515,6 @@ impl ArrayPathCommand { if count == 0 { return vec![]; } - let fn_norm = |x: f32| -> f32 { ((x % FTAU) + FTAU) % FTAU }; match entity { EntityType::Line(l) => { let p0 = Vec3::new(l.start.x as f32, l.start.y as f32, 0.0); @@ -542,31 +523,48 @@ impl ArrayPathCommand { (0..count).map(|i| p0.lerp(p1, i as f32 / d)).collect() } EntityType::Arc(a) => { - let (cx, cy, r) = (a.center.x as f32, a.center.y as f32, a.radius as f32); - let a0 = a.start_angle as f32; - let a1 = a.end_angle as f32; - let span = { - let s = fn_norm(a1) - fn_norm(a0); - if s <= 0.0 { - s + FTAU - } else { - s - } - }; - let d = (count - 1).max(1) as f32; + // Sample in WCS via the arc's OCS basis so flipped-normal arcs + // (normal = -Z) are traced on the same side the renderer draws, + // matching `entities::arc::to_truck`. Sampling the raw centre + + // angle ignores the extrusion and lands on the mirrored arc. + use crate::scene::transform::{ocs_axes, ocs_point_to_wcs}; + let normal = (a.normal.x, a.normal.y, a.normal.z); + let (ax, ay) = ocs_axes(normal); + let (cx, cy, cz) = + ocs_point_to_wcs((a.center.x, a.center.y, a.center.z), normal); + let r = a.radius; + let (sa, ea) = (a.start_angle, a.end_angle); + let end = if ea >= sa { ea } else { ea + std::f64::consts::TAU }; + let span = end - sa; + let d = (count - 1).max(1) as f64; (0..count) .map(|i| { - let ang = fn_norm(a0) + span * (i as f32 / d); - Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), 0.0) + let ang = sa + span * (i as f64 / d); + let (c, s) = (ang.cos(), ang.sin()); + Vec3::new( + (cx + r * c * ax.0 + r * s * ay.0) as f32, + (cy + r * c * ax.1 + r * s * ay.1) as f32, + (cz + r * c * ax.2 + r * s * ay.2) as f32, + ) }) .collect() } EntityType::Circle(c) => { - let (cx, cy, r) = (c.center.x as f32, c.center.y as f32, c.radius as f32); + use crate::scene::transform::{ocs_axes, ocs_point_to_wcs}; + let normal = (c.normal.x, c.normal.y, c.normal.z); + let (ax, ay) = ocs_axes(normal); + let (cx, cy, cz) = + ocs_point_to_wcs((c.center.x, c.center.y, c.center.z), normal); + let r = c.radius; (0..count) .map(|i| { - let ang = i as f32 / count as f32 * FTAU; - Vec3::new(cx + r * ang.cos(), cy + r * ang.sin(), 0.0) + let ang = i as f64 / count as f64 * std::f64::consts::TAU; + let (cs, sn) = (ang.cos(), ang.sin()); + Vec3::new( + (cx + r * cs * ax.0 + r * sn * ay.0) as f32, + (cy + r * cs * ax.1 + r * sn * ay.1) as f32, + (cz + r * cs * ax.2 + r * sn * ay.2) as f32, + ) }) .collect() } @@ -575,17 +573,86 @@ impl ArrayPathCommand { } } - /// Build Translate transforms: delta from pts[0] to each subsequent point. + /// Continuous path-tangent angle (XY) at each sample, via central + /// differences and unwrapped so the sequence stays smooth across an arc + /// that turns more than ±π in total. + fn tangents(pts: &[Vec3]) -> Vec { + let n = pts.len(); + let mut a = vec![0.0f32; n]; + for i in 0..n { + let prev = if i > 0 { pts[i - 1] } else { pts[i] }; + let next = if i + 1 < n { pts[i + 1] } else { pts[i] }; + let d = next - prev; + a[i] = if d.x.abs() < 1e-12 && d.y.abs() < 1e-12 { + if i > 0 { + a[i - 1] + } else { + 0.0 + } + } else { + d.y.atan2(d.x) + }; + } + // Unwrap to remove ±2π jumps. + for i in 1..n { + while a[i] - a[i - 1] > FPI { + a[i] -= FTAU; + } + while a[i] - a[i - 1] <= -FPI { + a[i] += FTAU; + } + } + a + } + + /// Build per-copy transforms that array the selection along the path with + /// the items aligned to the path tangent (the first item keeps its drawn + /// orientation; each copy rotates by the tangent change at its sample). + /// On a straight path the tangent never changes, so every copy is a pure + /// `Translate` — identical to a non-aligned array. fn build_transforms(pts: &[Vec3]) -> Vec { if pts.len() < 2 { return vec![]; } - let origin = pts[0]; - pts[1..] - .iter() - .map(|&p| EntityTransform::Translate(p - origin)) + let p0 = pts[0]; + let tans = Self::tangents(pts); + let t0 = tans[0]; + pts.iter() + .enumerate() + .skip(1) + .map(|(i, &p)| { + let dth = tans[i] - t0; + if dth.abs() < 1e-5 { + EntityTransform::Translate(p - p0) + } else { + // The aligned copy is the rigid motion x' = p + R(x - p0), + // which is a pure rotation by `dth` about its fixed point. + let center = Self::rigid_center(p0, p, dth); + EntityTransform::Rotate { + center, + angle_rad: dth, + } + } + }) .collect() } + + /// Fixed point of the rigid motion `x' = pi + R(dth)·(x - p0)`; rotating an + /// entity by `dth` about this point reproduces that motion exactly. + fn rigid_center(p0: Vec3, pi: Vec3, dth: f32) -> Vec3 { + let (s, c) = dth.sin_cos(); + // b = pi - R·p0 + let bx = pi.x - (c * p0.x - s * p0.y); + let by = pi.y - (s * p0.x + c * p0.y); + // c = (I - R)^{-1} · b, with (I-R)^{-1} = 1/(2(1-c)) [[1-c, -s],[s, 1-c]] + let one_c = 1.0 - c; + let det = 2.0 * one_c; + Vec3::new( + (one_c * bx - s * by) / det, + (s * bx + one_c * by) / det, + p0.z, + ) + } } impl CadCommand for ArrayPathCommand { @@ -692,15 +759,18 @@ impl CadCommand for ArrayPathCommand { let transforms = Self::build_transforms(&pts); transforms .iter() - .flat_map(|t| { - if let EntityTransform::Translate(delta) = t { - self.wire_models - .iter() - .map(|w| w.translated(*delta)) - .collect::>() - } else { - vec![] - } + .flat_map(|t| match t { + EntityTransform::Translate(delta) => self + .wire_models + .iter() + .map(|w| w.translated(*delta)) + .collect::>(), + EntityTransform::Rotate { center, angle_rad } => self + .wire_models + .iter() + .map(|w| w.rotated(*center, *angle_rad)) + .collect::>(), + _ => vec![], }) .collect() }