From c827b87cf9b9a8b008d6ec2a379b957067385028 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:47 -0700 Subject: [PATCH] fix(trim): snap trim cut parameters to exact boundary contact points --- src/modules/draw/modify/geom.rs | 101 +++- src/modules/draw/modify/trim.rs | 789 ++++++++++++++++++++++++++++++-- 2 files changed, 841 insertions(+), 49 deletions(-) diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index 61a1d59b3..f5bb524b9 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -15,7 +15,106 @@ use kernel::geom2d::{self, Ellipse}; /// Re-exported unchanged: these already speak in plain `f64`, so there is no /// call-shape difference for this module to absorb. -pub use kernel::geom2d::{arc_parameter, lerp, normalize_angle}; +pub use kernel::geom2d::{lerp, normalize_angle}; + +/// Where `angle` sits along the arc from `start` to `end`, as `0.0..=1.0`. +/// +/// Unlike `kernel::geom2d::arc_parameter` which clamps any angle outside the arc to 1.0, +/// this clamps angles in the gap outside the arc to 0.0 or 1.0 depending on which endpoint +/// they are closer to. This prevents cursor jitter near `start` from snapping to 1.0. +pub fn arc_parameter(angle: f64, start: f64, end: f64) -> f64 { + let span = kernel::geom2d::arc_span(start, end); + if span < 1e-12 { + return 0.0; + } + let norm_angle = kernel::geom2d::normalize_angle(angle); + let norm_start = kernel::geom2d::normalize_angle(start); + let delta = (norm_angle - norm_start).rem_euclid(std::f64::consts::TAU); + if delta <= span { + (delta / span).clamp(0.0, 1.0) + } else { + let gap = std::f64::consts::TAU - span; + let past_end = delta - span; + if past_end <= gap * 0.5 { + 1.0 + } else { + 0.0 + } + } +} + +/// Finds the closest parametric parameter `t` on an origin-centered ellipse +/// `(x/a)^2 + (y/b)^2 = 1` for an arbitrary query point `(u, v)` in local coordinates, +/// using David Eberly's exact orthogonal projection algorithm. +/// +/// Unlike radial projection `(v/b).atan2(u/a)` which deviates by up to 20-40 degrees +/// when the query point is slightly off the ellipse perimeter (e.g. within screen pick box), +/// this finds the true orthogonal projection on the ellipse regardless of zoom level. +pub fn ellipse_closest_parameter(a: f64, b: f64, u: f64, v: f64) -> f64 { + if a < 1e-12 || b < 1e-12 { + return 0.0; + } + if (a - b).abs() < 1e-9 { + return v.atan2(u); + } + if a < b { + let t_swapped = ellipse_closest_parameter(b, a, v, u); + return std::f64::consts::FRAC_PI_2 - t_swapped; + } + + let sign_x = if u >= 0.0 { 1.0 } else { -1.0 }; + let sign_y = if v >= 0.0 { 1.0 } else { -1.0 }; + let u_abs = u.abs(); + let v_abs = v.abs(); + + if v_abs < 1e-12 { + let x = if u_abs < (a * a - b * b) / a { + a * a * u_abs / (a * a - b * b) + } else { + a + }; + let y = b * (1.0 - (x / a).powi(2)).max(0.0).sqrt(); + return (sign_y * y / b).atan2(sign_x * x / a); + } + + if u_abs < 1e-12 { + return (sign_y * 1.0).atan2(0.0); + } + + let a2 = a * a; + let b2 = b * b; + let mut t0 = -b2 + b * v_abs; + let mut t1 = -b2 + (a * u_abs).hypot(b * v_abs); + let mut t = t0; + + for _ in 0..25 { + let t_a = t + a2; + let t_b = t + b2; + if t_a.abs() < 1e-12 || t_b.abs() < 1e-12 { + break; + } + let f = (a * u_abs / t_a).powi(2) + (b * v_abs / t_b).powi(2) - 1.0; + if f.abs() < 1e-12 { + break; + } + let df = -2.0 * (a2 * u_abs * u_abs / (t_a.powi(3)) + b2 * v_abs * v_abs / (t_b.powi(3))); + let t_next = t - f / df; + if t_next > t0 && t_next < t1 { + t = t_next; + } else { + if f > 0.0 { + t0 = t; + } else { + t1 = t; + } + t = 0.5 * (t0 + t1); + } + } + + let x = a2 * u_abs / (t + a2); + let y = b2 * v_abs / (t + b2); + (sign_y * y / b).atan2(sign_x * x / a) +} /// Preview geometry keeps the density the commands have always used. const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN; diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index baf068f25..17a9e971a 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -14,8 +14,8 @@ use std::f64::consts::TAU; // its call shapes to the loose scalars and f32 render vertices used here. use super::geom; use super::geom::{ - arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts, - lerp as lerp2, normalize_angle as norm, + arc_parameter as arc_t, arc_points as arc_pts, ellipse_closest_parameter, + ellipse_points as ellipse_pts, lerp as lerp2, normalize_angle as norm, }; use crate::modules::draw::fence::{crossing_box_preview, FencePick}; @@ -148,6 +148,37 @@ impl Geo { | Self::Spline { handle, .. } => *handle, } } + + fn endpoints(&self) -> Vec<[f64; 2]> { + match self { + Self::Line { p1, p2, .. } => vec![*p1, *p2], + Self::Arc { cx, cy, r, a0, a1, .. } => vec![ + [cx + r * a0.cos(), cy + r * a0.sin()], + [cx + r * a1.cos(), cy + r * a1.sin()], + ], + Self::Ray { bx, by, .. } => vec![[*bx, *by]], + Self::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => { + if (t1 - t0).abs() < TAU - 1e-6 { + let pt_at = |t: f64| { + let ct = t.cos(); + let st = t.sin(); + [ + cx + nx * a * ct - ny * b * st, + cy + ny * a * ct + nx * b * st, + ] + }; + vec![pt_at(*t0), pt_at(*t1)] + } else { + vec![] + } + } + Self::Spline { curve, .. } => vec![ + curve.point_at(0.0), + curve.point_at(1.0), + ], + Self::Circle { .. } | Self::InfLine { .. } => vec![], + } + } } pub fn is_trim_boundary_entity(entity: &EntityType) -> bool { matches!( @@ -355,8 +386,98 @@ fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { .collect::>() }) .collect(); + + // ── Endpoint contact detection ── + // When a boundary entity terminates on (or within snap tolerance of) the + // target curve, register that contact point as a cut parameter. + const ENDPOINT_TOLERANCE: f64 = CUT_TOLERANCE; + let mut contacts = Vec::new(); + + for geo in geos.iter().filter(|g| geo_handle(g) != handle) { + // 1. Boundary endpoints touching target + for pt in geo.endpoints() { + let t = target.parameter_at(pt); + let valid_t = if bounded { + let slack = CUT_TOLERANCE; + if (-slack..=1.0 + slack).contains(&t) { + Some(t.clamp(0.0, 1.0)) + } else { + None + } + } else { + Some(t) + }; + + if let Some(t_cand) = valid_t { + let proj = target.point_at(t_cand); + let dx = pt[0] - proj[0]; + let dy = pt[1] - proj[1]; + if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { + contacts.push(t_cand); + } + } + } + + // 2. Target endpoints touching boundary + if bounded && !target.is_closed() { + if let Some(boundary_curve) = geo_to_curve(geo) { + let boundary_bounded = matches!(boundary_curve.extent(), KernelExtent::Bounded); + for &t_end in &[0.0, 1.0] { + let pt = target.point_at(t_end); + let u = boundary_curve.parameter_at(pt); + let valid_u = if boundary_bounded { + let slack = CUT_TOLERANCE; + if (-slack..=1.0 + slack).contains(&u) { + Some(u.clamp(0.0, 1.0)) + } else { + None + } + } else { + Some(u) + }; + if let Some(u_cand) = valid_u { + let proj = boundary_curve.point_at(u_cand); + let dx = pt[0] - proj[0]; + let dy = pt[1] - proj[1]; + if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { + contacts.push(t_end); + } + } + } + } + } + } + + // Prioritize exact endpoint contact parameters over raw ray intersections, + // ensuring trimmed endpoints match boundary endpoints down to machine precision. + let is_closed = target.is_closed(); + for t_contact in contacts { + if let Some(pos) = ts.iter().position(|&t| { + let diff = (t - t_contact).abs(); + let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; + cyclic < 1e-6 + }) { + ts[pos] = t_contact; + } else { + ts.push(t_contact); + } + } + ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + ts.dedup_by(|a, b| { + let diff = (*a - *b).abs(); + let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; + cyclic < 1e-6 + }); + + // For closed curves, ensure the first and last cuts don't duplicate across the 0/1 seam + if is_closed && ts.len() >= 2 { + let seam_diff = (ts[ts.len() - 1] - (ts[0] + 1.0)).abs(); + if seam_diff < 1e-6 { + ts.pop(); + } + } + ts } @@ -517,7 +638,7 @@ fn trim_ellipse(orig: &EllipseEnt, ts: &[f64], t_click: f64) -> Vec } else { ts[0] + 1.0 }; - let holds = |t: f64| t >= from - 1e-9 && t <= to + 1e-9; + let holds = |t: f64| t >= from && t < to; (holds(click) || holds(click + 1.0)).then_some((from, to)) }); let Some((from, to)) = gap else { @@ -853,9 +974,7 @@ fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec { for i in 0..n { let ta = ts[i]; let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 }; - if (tc >= ta - 1e-9 && tc <= tb + 1e-9) - || (tc + 1.0 >= ta - 1e-9 && tc + 1.0 <= tb + 1e-9) - { + if (tc >= ta && tc < tb) || (tc + 1.0 >= ta && tc + 1.0 < tb) { removed = Some((ta, tb)); break; } @@ -1037,12 +1156,24 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< let b = seg_bulge(i); let (d, u) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) { let angle = (cy - ba.center[1]).atan2(cx - ba.center[0]); - let travelled = if ba.sweep >= 0.0 { - (angle - ba.start_angle).rem_euclid(TAU) + let (travelled, sweep) = if ba.sweep >= 0.0 { + ((angle - ba.start_angle).rem_euclid(TAU), ba.sweep) } else { - -((ba.start_angle - angle).rem_euclid(TAU)) + ((ba.start_angle - angle).rem_euclid(TAU), -ba.sweep) + }; + let u = if sweep < 1e-12 { + 0.0 + } else if travelled <= sweep { + (travelled / sweep).clamp(0.0, 1.0) + } else { + let gap = TAU - sweep; + let past_end = travelled - sweep; + if past_end <= gap * 0.5 { + 1.0 + } else { + 0.0 + } }; - let u = (travelled / ba.sweep).clamp(0.0, 1.0); let pt = ba.sample(u); let dist2 = (pt[0] - cx).powi(2) + (pt[1] - cy).powi(2); (dist2, u) @@ -1070,17 +1201,19 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< if cuts.len() < 2 { return None; } - let hi = cuts - .iter() - .cloned() - .find(|&c| c > t_click + 1e-9) - .unwrap_or(cuts[0] + total); - let lo = cuts - .iter() - .cloned() - .rev() - .find(|&c| c < t_click - 1e-9) - .unwrap_or(cuts[cuts.len() - 1] - total); + let n = cuts.len(); + let mut removed = None; + for i in 0..n { + let a = cuts[i]; + let b = if i + 1 < n { cuts[i + 1] } else { cuts[0] + total }; + if (t_click >= a && t_click < b) || (t_click + total >= a && t_click + total < b) { + removed = Some((a, b)); + break; + } + } + let Some((lo, hi)) = removed else { + return None; + }; let mut s1 = lo; while s1 <= hi { s1 += total; @@ -1089,14 +1222,29 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< out.push(EntityType::LwPolyline(piece)); } } else { - let lo = cuts.iter().cloned().rev().find(|&c| c < t_click - 1e-9); - let hi = cuts.iter().cloned().find(|&c| c > t_click + 1e-9); - if let Some(lo) = lo { + let mut bounds = vec![0.0]; + bounds.extend(cuts.iter().copied()); + bounds.push(total); + bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let n = bounds.len(); + let mut removed = None; + for i in 0..n - 1 { + let a = bounds[i]; + let b = bounds[i + 1]; + if (t_click >= a && t_click < b) || (i + 2 == n && t_click <= b) { + removed = Some((a, b)); + break; + } + } + let Some((lo, hi)) = removed else { + return None; + }; + if lo > 1e-6 { if let Some(piece) = extract_sub_polyline(poly, 0.0, lo) { out.push(EntityType::LwPolyline(piece)); } } - if let Some(hi) = hi { + if hi < total - 1e-6 { if let Some(piece) = extract_sub_polyline(poly, hi, total) { out.push(EntityType::LwPolyline(piece)); } @@ -1699,6 +1847,9 @@ fn pick_trim_at( return None; } let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return None; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; @@ -1716,7 +1867,7 @@ fn pick_trim_at( let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_ell = yl.atan2(xl); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); let t_click = arc_t(t_ell, t0, t1); Some(trim_ellipse(e, &ts, t_click)) } @@ -1781,12 +1932,17 @@ fn pick_extend_at( if a < 1e-9 { return None; } + let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return None; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let rx = px - e.center.x; let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); let _ = span; extend_ellipse(e, t_click, geos) } @@ -2841,6 +2997,9 @@ impl CadCommand for TrimCommand { return vec![]; } let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return vec![]; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; @@ -2857,7 +3016,8 @@ impl CadCommand for TrimCommand { let ry = pt.y as f64 - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); let survivors = trim_ellipse(e, &ts, t_click); let orig_pts = ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z); @@ -3343,25 +3503,29 @@ impl CadCommand for ExtendCommand { Some(EntityType::Ellipse(e)) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a >= 1e-9 { - let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); - let t0 = e.start_parameter; - let mut t1 = e.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - let rx = pt.x as f64 - e.center.x; - let ry = pt.y as f64 - e.center.y; - let xl = rx * nx + ry * ny; - let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); - if let Some(ext) = extend_ellipse(e, t_click, &self.geos) { - return extend_hover_wires( - &EntityType::Ellipse(e.clone()), - &ext, - &self.all_entities, - handle, - self.implied_edges, - ); + let b = a * e.minor_axis_ratio; + if b >= 1e-9 { + let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); + let t0 = e.start_parameter; + let mut t1 = e.end_parameter; + if t1 <= t0 { + t1 += TAU; + } + let rx = pt.x as f64 - e.center.x; + let ry = pt.y as f64 - e.center.y; + let xl = rx * nx + ry * ny; + let yl = -rx * ny + ry * nx; + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); + if let Some(ext) = extend_ellipse(e, t_click, &self.geos) { + return extend_hover_wires( + &EntityType::Ellipse(e.clone()), + &ext, + &self.all_entities, + handle, + self.implied_edges, + ); + } } } } @@ -3876,6 +4040,535 @@ mod tests { "edge-on arc segments are skipped instead of panicking: {pts:?}" ); } + + #[test] + fn trim_circle_with_tangent_arcs_touching_at_endpoints() { + use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; + let mut c = CircleEnt::new(); + c.common.handle = Handle::new(1); + c.center = Vector3::new(0.0, 0.0, 0.0); + c.radius = 10.0; + + // Arc 1: center (20, 0), r = 10, from PI/2 to PI (endpoint at (10, 0)) + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(2); + a1.center = Vector3::new(20.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = std::f64::consts::FRAC_PI_2; + a1.end_angle = std::f64::consts::PI; + + // Arc 2: center (-20, 0), r = 10, from 0 to PI/2 (endpoint at (-10, 0)) + let mut a2 = ArcEnt::new(); + a2.common.handle = Handle::new(3); + a2.center = Vector3::new(-20.0, 0.0, 0.0); + a2.radius = 10.0; + a2.start_angle = 0.0; + a2.end_angle = std::f64::consts::FRAC_PI_2; + + let all = vec![ + EntityType::Circle(c), + EntityType::Arc(a1), + EntityType::Arc(a2), + ]; + let geos = build_geos(&all); + + // Click on top of the circle at (0, 10) + let survivors = pick_trim_at(&all, &geos, Handle::new(1), 0.0, 10.0); + assert!(survivors.is_some(), "trimming circle between two tangent arc endpoints must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + if let EntityType::Arc(arc) = &pieces[0] { + assert!((arc.radius - 10.0).abs() < 1e-6); + let span = { + let s = (arc.end_angle - arc.start_angle).rem_euclid(TAU); + if s == 0.0 { TAU } else { s } + }; + let mid_ang = arc.start_angle + span * 0.5; + let mid_y = mid_ang.sin(); + assert!(mid_y < 0.0, "bottom half should survive when clicking top half"); + } else { + panic!("expected Arc survivor"); + } + } + + #[test] + fn test_trim_circle_between_two_tangent_lines() { + use codec::entities::{Circle as CircleEnt, Line as LineEnt}; + let mut c = CircleEnt::new(); + c.common.handle = Handle::new(1); + c.center = Vector3::new(0.0, 0.0, 0.0); + c.radius = 10.0; + + // Line 1: tangent at top (0, 10), from (-20, 10) to (20, 10) + let mut l1 = LineEnt::new(); + l1.common.handle = Handle::new(2); + l1.start = Vector3::new(-20.0, 10.0, 0.0); + l1.end = Vector3::new(20.0, 10.0, 0.0); + + // Line 2: tangent at bottom (0, -10), from (-20, -10) to (20, -10) + let mut l2 = LineEnt::new(); + l2.common.handle = Handle::new(3); + l2.start = Vector3::new(-20.0, -10.0, 0.0); + l2.end = Vector3::new(20.0, -10.0, 0.0); + + let all = vec![ + EntityType::Circle(c), + EntityType::Line(l1), + EntityType::Line(l2), + ]; + let geos = build_geos(&all); + + // Trim circle by clicking right side at (10, 0) + let survivors = pick_trim_at(&all, &geos, Handle::new(1), 10.0, 0.0); + assert!(survivors.is_some(), "trimming circle between two tangent lines must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + let EntityType::Arc(arc) = &pieces[0] else { panic!("expected Arc") }; + + let arc_start = glam::DVec3::new( + arc.radius * arc.start_angle.cos(), + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = glam::DVec3::new( + arc.radius * arc.end_angle.cos(), + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + println!("arc start: {:?}, end: {:?}", arc_start, arc_end); + + // Now trim line 1 by clicking at (10, 10) + let l1_survivors = pick_trim_at(&all, &geos, Handle::new(2), 10.0, 10.0); + assert!(l1_survivors.is_some(), "trimming line 1 must succeed"); + let l1_pieces = l1_survivors.unwrap(); + assert_eq!(l1_pieces.len(), 1); + let EntityType::Line(trimmed_l1) = &l1_pieces[0] else { panic!("expected Line") }; + println!("trimmed line 1 start: {:?}, end: {:?}", trimmed_l1.start, trimmed_l1.end); + + // Check distance between trimmed line 1 end and arc start + let line_end = glam::DVec3::new(trimmed_l1.end.x, trimmed_l1.end.y, trimmed_l1.end.z); + let diff = (line_end - arc_start).length(); + println!("Distance between line endpoint and arc endpoint: {:e}", diff); + + // Now test an angled / non-axis-aligned case from external point + let c_center = glam::DVec3::new(123.45, 67.89, 0.0); + let c_radius = 25.0; + let p_ext1 = glam::DVec3::new(200.0, 150.0, 0.0); + let p_ext2 = glam::DVec3::new(50.0, -20.0, 0.0); + + let get_tangents = |p: glam::DVec3| { + let k_curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle { + centre: [c_center.x, c_center.y], + radius: c_radius, + }); + let pts = kernel::geom2d::tangent_from(&k_curve, [p.x, p.y]); + (glam::DVec3::new(pts[0].point[0], pts[0].point[1], 0.0), glam::DVec3::new(pts[1].point[0], pts[1].point[1], 0.0)) + }; + let t1 = get_tangents(p_ext1).0; + let t2 = get_tangents(p_ext2).1; + + let mut c_rot = CircleEnt::new(); + c_rot.common.handle = Handle::new(10); + c_rot.center = Vector3::new(c_center.x, c_center.y, c_center.z); + c_rot.radius = c_radius; + + // Line 1 from p_ext1 extending PAST t1 to make it a boundary line crossing tangent + let dir1 = (t1 - p_ext1).normalize(); + let mut l_rot1 = LineEnt::new(); + l_rot1.common.handle = Handle::new(11); + l_rot1.start = Vector3::new(p_ext1.x, p_ext1.y, 0.0); + l_rot1.end = Vector3::new((t1 + dir1 * 20.0).x, (t1 + dir1 * 20.0).y, 0.0); + + let dir2 = (t2 - p_ext2).normalize(); + let mut l_rot2 = LineEnt::new(); + l_rot2.common.handle = Handle::new(12); + l_rot2.start = Vector3::new(p_ext2.x, p_ext2.y, 0.0); + l_rot2.end = Vector3::new((t2 + dir2 * 20.0).x, (t2 + dir2 * 20.0).y, 0.0); + + let all_rot = vec![ + EntityType::Circle(c_rot.clone()), + EntityType::Line(l_rot1.clone()), + EntityType::Line(l_rot2.clone()), + ]; + let geos_rot = build_geos(&all_rot); + + // Click circle between them + let mid_click = c_center + (t1 + t2 - 2.0 * c_center).normalize() * c_radius; + let rot_survivors = pick_trim_at(&all_rot, &geos_rot, Handle::new(10), mid_click.x, mid_click.y); + assert!(rot_survivors.is_some(), "trimming circle between angled tangent lines must succeed"); + let pieces = rot_survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + let EntityType::Arc(a) = &pieces[0] else { panic!("expected Arc") }; + let a_start = c_center + glam::DVec3::new(a.radius * a.start_angle.cos(), a.radius * a.start_angle.sin(), 0.0); + let a_end = c_center + glam::DVec3::new(a.radius * a.end_angle.cos(), a.radius * a.end_angle.sin(), 0.0); + let err_t1 = (a_start - t1).length().min((a_end - t1).length()); + let err_t2 = (a_start - t2).length().min((a_end - t2).length()); + assert!(err_t1 < 1e-9, "cut point must land within grip tolerance of t1: got {err_t1:e}"); + assert!(err_t2 < 1e-9, "cut point must land within grip tolerance of t2: got {err_t2:e}"); + } + + #[test] + fn trim_ellipse_with_tangent_arcs_touching_at_endpoints() { + use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(10); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(20.0, 0.0, 0.0); + e.minor_axis_ratio = 0.5; // b = 10 + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::TAU; + + // Arc 1 touches right vertex at (20, 0): center (30, 0), r = 10, from PI/2 to PI + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(11); + a1.center = Vector3::new(30.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = std::f64::consts::FRAC_PI_2; + a1.end_angle = std::f64::consts::PI; + + // Arc 2 touches left vertex at (-20, 0): center (-30, 0), r = 10, from 0 to PI/2 + let mut a2 = ArcEnt::new(); + a2.common.handle = Handle::new(12); + a2.center = Vector3::new(-30.0, 0.0, 0.0); + a2.radius = 10.0; + a2.start_angle = 0.0; + a2.end_angle = std::f64::consts::FRAC_PI_2; + + let all = vec![ + EntityType::Ellipse(e), + EntityType::Arc(a1), + EntityType::Arc(a2), + ]; + let geos = build_geos(&all); + + // Click top of ellipse at (0, 10) + let survivors = pick_trim_at(&all, &geos, Handle::new(10), 0.0, 10.0); + assert!(survivors.is_some(), "trimming ellipse between two tangent arc endpoints must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving ellipse piece"); + if let EntityType::Ellipse(ell) = &pieces[0] { + assert!((ell.major_axis.x - 20.0).abs() < 1e-6); + } else { + panic!("expected Ellipse survivor"); + } + } + + #[test] + fn trim_ellipse_selection_tracking_near_breakpoint() { + use codec::entities::{Ellipse as EllipseEnt, Line as LineEnt}; + use std::f64::consts::TAU; + + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(10); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(20.0, 0.0, 0.0); + e.minor_axis_ratio = 0.5; // a = 20, b = 10 + e.start_parameter = 0.0; + e.end_parameter = TAU; + + // Cutting line 1 crossing at t = PI/4 (45 degrees parametric): + // At t = PI/4: x = 20 * cos(PI/4) = 14.142, y = 10 * sin(PI/4) = 7.071 + // (Polar angle is atan2(7.071, 14.142) = 26.56 degrees!) + let mut l1 = LineEnt::new(); + l1.common.handle = Handle::new(11); + l1.start = Vector3::new(0.0, 0.0, 0.0); + l1.end = Vector3::new(30.0, 15.0, 0.0); // passes through (14.142, 7.071) + + // Cutting line 2 crossing at t = 3*PI/4: + let mut l2 = LineEnt::new(); + l2.common.handle = Handle::new(12); + l2.start = Vector3::new(0.0, 0.0, 0.0); + l2.end = Vector3::new(-30.0, 15.0, 0.0); + + let all = vec![EntityType::Ellipse(e), EntityType::Line(l1), EntityType::Line(l2)]; + let geos = build_geos(&all); + + // Click 1: Click at t = 40 deg parametric (x = 20*cos(40°), y = 10*sin(40°)) + // Polar angle is atan(0.5 * tan(40°)) ≈ 22.7°. + // This is before PI/4. Surviving piece should run between the cuts. + let t_before = 40.0_f64.to_radians(); + let px_before = 20.0 * t_before.cos(); + let py_before = 10.0 * t_before.sin(); + let res_before = pick_trim_at(&all, &geos, Handle::new(10), px_before, py_before); + assert!(res_before.is_some(), "trimming at 40 deg must succeed"); + let pieces_before = res_before.unwrap(); + assert_eq!(pieces_before.len(), 1); + + // Click 2: Click at t = 50 deg parametric (x = 20*cos(50°), y = 10*sin(50°)) + // Polar angle is atan(0.5 * tan(50°)) ≈ 30.77°. + // Visually and parametrically, 50° is in the top segment (between PI/4 and 3*PI/4). + // Without the fix, 30.77° polar angle would be < 45° parametric, selecting the WRONG segment! + let t_after = 50.0_f64.to_radians(); + let px_after = 20.0 * t_after.cos(); + let py_after = 10.0 * t_after.sin(); + let res_after = pick_trim_at(&all, &geos, Handle::new(10), px_after, py_after); + assert!(res_after.is_some(), "trimming at 50 deg must succeed"); + let pieces_after = res_after.unwrap(); + assert_eq!(pieces_after.len(), 1); + + // Click 3 & 4: Click slightly INSIDE and OUTSIDE the ellipse near 50 deg. + // Even when displaced by 3 units (pick-box offset when zoomed out), + // orthogonal projection must still correctly identify the top segment! + let nx = 10.0 * t_after.cos(); + let ny = 20.0 * t_after.sin(); + let nlen = nx.hypot(ny); + let (unx, uny) = (nx / nlen, ny / nlen); + + // Displaced outside: + let res_out = pick_trim_at(&all, &geos, Handle::new(10), px_after + 3.0 * unx, py_after + 3.0 * uny); + assert!(res_out.is_some(), "trimming at displaced outside point must succeed"); + + // Displaced inside: + let res_in = pick_trim_at(&all, &geos, Handle::new(10), px_after - 3.0 * unx, py_after - 3.0 * uny); + assert!(res_in.is_some(), "trimming at displaced inside point must succeed"); + + if let (EntityType::Ellipse(surv_after), EntityType::Ellipse(surv_out), EntityType::Ellipse(surv_in)) = + (&pieces_after[0], &res_out.unwrap()[0], &res_in.unwrap()[0]) + { + assert!( + (surv_out.start_parameter - surv_after.start_parameter).abs() < 1e-4, + "displaced outside point should select the same segment as point on curve" + ); + assert!( + (surv_in.start_parameter - surv_after.start_parameter).abs() < 1e-4, + "displaced inside point should select the same segment as point on curve" + ); + } + } + + #[test] + fn trim_arc_selection_tracking_near_start_endpoint() { + use codec::entities::{Arc as ArcEnt, Line as LineEnt}; + // Arc from 0.5 to 2.0 rad (~28.6° to 114.6°), radius 10.0 + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(20); + a.center = Vector3::new(0.0, 0.0, 0.0); + a.radius = 10.0; + a.start_angle = 0.5; + a.end_angle = 2.0; + + // Boundary line crossing arc at midpoint ~ 1.25 rad + let mid = 1.25_f64; + let mut l = LineEnt::new(); + l.common.handle = Handle::new(21); + l.start = Vector3::new(0.0, 0.0, 0.0); + l.end = Vector3::new(20.0 * mid.cos(), 20.0 * mid.sin(), 0.0); + + let all = vec![EntityType::Arc(a), EntityType::Line(l)]; + let geos = build_geos(&all); + + // Hover slightly CW of start endpoint (e.g. angle = 0.49 rad, outside the arc by ~0.5 degree) + let click_ang = 0.49_f64; + let px = 10.0 * click_ang.cos(); + let py = 10.0 * click_ang.sin(); + let res = pick_trim_at(&all, &geos, Handle::new(20), px, py); + assert!(res.is_some(), "trim near start endpoint must succeed"); + let pieces = res.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + if let EntityType::Arc(surv) = &pieces[0] { + // It must remove the start-half [0.5, 1.25] and keep the end-half [1.25, 2.0]. + // If the bug were present, it would snap to 1.0 (end-half removed, start-half kept). + assert!( + (surv.end_angle - 2.0).abs() < 0.05, + "survivor should be the end-half [1.25, 2.0], got start={} end={}", + surv.start_angle, + surv.end_angle + ); + } else { + panic!("expected Arc survivor"); + } + } + + #[test] + fn test_tangent_circles_or_arcs_trim() { + use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; + let mut c1 = CircleEnt::new(); + c1.common.handle = Handle::new(1); + c1.center = Vector3::new(0.0, 0.0, 0.0); + c1.radius = 10.0; + + let mut c2 = CircleEnt::new(); + c2.common.handle = Handle::new(2); + c2.center = Vector3::new(20.0, 0.0, 0.0); + c2.radius = 10.0; + + let all = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone())]; + let geos = build_geos(&all); + let curve1 = entity_curve_xy(&EntityType::Circle(c1.clone())).unwrap(); + let ts = cut_params(&curve1, Handle::new(1), &geos); + assert_eq!(ts, vec![0.0], "circle1 tangent to circle2 must have cut at t=0"); + + // Arc1 touching circle2 tangently + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(3); + a1.center = Vector3::new(0.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = -1.0; + a1.end_angle = 1.0; + + let all_arc = vec![EntityType::Arc(a1.clone()), EntityType::Circle(c2.clone())]; + let geos_arc = build_geos(&all_arc); + let curve_arc = entity_curve_xy(&EntityType::Arc(a1.clone())).unwrap(); + let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos_arc); + assert_eq!(ts_arc, vec![0.5], "arc1 tangent to circle2 at midpoint must have cut at t=0.5"); + + let trim_res = pick_trim_at(&all_arc, &geos_arc, Handle::new(3), 10.0, 5.0); + assert!(trim_res.is_some(), "trimming arc1 at tangent circle2 must succeed"); + let pieces = trim_res.unwrap(); + assert_eq!(pieces.len(), 1, "trimming half of arc should leave 1 survivor piece"); + + // 1. External tangency: Test across multiple angles and slight epsilons + for angle in [0.0f64, 0.5, 1.0, 1.57, 2.5, 3.14, 4.0, 5.0] { + for eps in [-1e-8, 0.0, 1e-8] { + let dist = 20.0 + eps; + let dx = dist * angle.cos(); + let dy = dist * angle.sin(); + let mut c_off = CircleEnt::new(); + c_off.common.handle = Handle::new(4); + c_off.center = Vector3::new(dx, dy, 0.0); + c_off.radius = 10.0; + let all_off = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_off)]; + let geos_off = build_geos(&all_off); + let ts_off = cut_params(&curve1, Handle::new(1), &geos_off); + assert_eq!(ts_off.len(), 1, "must find exactly 1 tangency cut at angle {angle}, eps {eps}"); + let expected_t = (angle / TAU).rem_euclid(1.0); + let actual_t = ts_off[0]; + let diff = (actual_t - expected_t).abs(); + let cyclic_diff = diff.min(1.0 - diff); + assert!(cyclic_diff < 1e-5, "tangency t must match angle: got {actual_t}, expected {expected_t}"); + } + } + + // 2. Internal tangency: c1 (r=10) and c_inner (r=4) at (6, 0) + let mut c_inner = CircleEnt::new(); + c_inner.common.handle = Handle::new(5); + c_inner.center = Vector3::new(6.0, 0.0, 0.0); + c_inner.radius = 4.0; + let all_int = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_inner.clone())]; + let geos_int = build_geos(&all_int); + let ts_int = cut_params(&curve1, Handle::new(1), &geos_int); + assert_eq!(ts_int, vec![0.0], "internal tangency on c1 must have cut at t=0"); + + // And from c_inner's perspective (r=4 inside r=10): + let curve_inner = entity_curve_xy(&EntityType::Circle(c_inner.clone())).unwrap(); + let ts_inner = cut_params(&curve_inner, Handle::new(5), &geos_int); + assert_eq!(ts_inner, vec![0.0], "internal tangency on c_inner must have cut at t=0"); + + // 3. Two arcs touching tangently at (10, 0) + // a_left: center (0, 0), radius 10, start=0, end=PI (passes through (10,0) at start t=0) + let mut a_left = ArcEnt::new(); + a_left.common.handle = Handle::new(6); + a_left.center = Vector3::new(0.0, 0.0, 0.0); + a_left.radius = 10.0; + a_left.start_angle = 0.0; + a_left.end_angle = std::f64::consts::PI; + + // a_right: center (20, 0), radius 10, start=PI/2, end=3*PI/2 (passes through (10,0) at angle PI, midpoint t=0.5) + let mut a_right = ArcEnt::new(); + a_right.common.handle = Handle::new(7); + a_right.center = Vector3::new(20.0, 0.0, 0.0); + a_right.radius = 10.0; + a_right.start_angle = std::f64::consts::FRAC_PI_2; + a_right.end_angle = 3.0 * std::f64::consts::FRAC_PI_2; + + let all_arcs = vec![EntityType::Arc(a_left.clone()), EntityType::Arc(a_right.clone())]; + let geos_arcs = build_geos(&all_arcs); + let curve_right = entity_curve_xy(&EntityType::Arc(a_right.clone())).unwrap(); + let ts_right = cut_params(&curve_right, Handle::new(7), &geos_arcs); + assert_eq!(ts_right, vec![0.5], "a_right must be cut at midpoint t=0.5 by tangent a_left"); + + let trim_right = pick_trim_at(&all_arcs, &geos_arcs, Handle::new(7), 15.0, 8.0); + assert!(trim_right.is_some(), "trimming a_right at tangent a_left must succeed"); + + // 4. Circle trimmed by 2 tangent circles + // c1 at (0, 0) r=10. c_right at (20, 0) r=10. c_left at (-20, 0) r=10. + let mut c_left = CircleEnt::new(); + c_left.common.handle = Handle::new(8); + c_left.center = Vector3::new(-20.0, 0.0, 0.0); + c_left.radius = 10.0; + let all_3c = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), EntityType::Circle(c_left.clone())]; + let geos_3c = build_geos(&all_3c); + let ts_3c = cut_params(&curve1, Handle::new(1), &geos_3c); + assert_eq!(ts_3c.len(), 2, "c1 touched by 2 tangent circles must have 2 cut points"); + assert_eq!(ts_3c, vec![0.0, 0.5]); + + let trim_3c = pick_trim_at(&all_3c, &geos_3c, Handle::new(1), 0.0, 10.0); + assert!(trim_3c.is_some(), "c1 with 2 tangent circles must be trimmable"); + if let Some(surv) = trim_3c { + assert_eq!(surv.len(), 1, "trimming c1 should leave 1 arc survivor"); + } + + // 5. Extending an arc to meet a tangent circle + // a_short: center (0, 0), radius 10, start=0.5, end=1.5 (span ~ 1.0 rad) + // Tangent circle c2 is at (20, 0), tangent point is at (10, 0) which is angle 0. + // Clicking near start extends start CW towards 0.0. + let mut a_short = ArcEnt::new(); + a_short.common.handle = Handle::new(9); + a_short.center = Vector3::new(0.0, 0.0, 0.0); + a_short.radius = 10.0; + a_short.start_angle = 0.5; + a_short.end_angle = 1.5; + let all_ext = vec![EntityType::Arc(a_short.clone()), EntityType::Circle(c2.clone())]; + let geos_ext = build_geos(&all_ext); + let ext_res = pick_extend_at(&all_ext, &geos_ext, Handle::new(9), 10.0 * 0.6_f64.cos(), 10.0 * 0.6_f64.sin()); + assert!(ext_res.is_some(), "extending arc towards tangent circle must succeed"); + if let Some(EntityType::Arc(ext_arc)) = ext_res { + let start_norm = norm(ext_arc.start_angle); + assert!(start_norm < 1e-5 || (TAU - start_norm) < 1e-5, "extended arc start must reach 0.0 (tangent point)"); + } + } + + #[test] + fn test_skinny_ellipse_crossing_arc() { + use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(100); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(100.0, 0.0, 0.0); + e.minor_axis_ratio = 0.05; // b = 5, very skinny! + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::TAU; + + // Test arcs that physically cross the ellipse near its ends (x = 80, 90, 95, 98, 99) + for &r in &[80.0, 90.0, 95.0, 98.0, 99.0] { + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(101); + a.center = Vector3::new(0.0, 0.0, 0.0); + a.radius = r; + a.start_angle = -0.3; + a.end_angle = 0.3; + + let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; + let geos = build_geos(&all); + + // Trim the ellipse tip beyond the arc (near x = 99.5, y = 0) + let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.9, 0.0); + // Trim the arc near y = 0 (x = r, y = 0) + let arc_res = pick_trim_at(&all, &geos, Handle::new(101), r, 0.0); + assert!(ell_res.is_some(), "ellipse trim should succeed for crossing arc at r={r}"); + assert!(arc_res.is_some(), "arc trim should succeed for crossing arc at r={r}"); + } + + // Test off-axis arc crossing near the tip (center at (96, 20), radius 20, passing near (96, 0)) + { + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(102); + a.center = Vector3::new(96.0, 20.0, 0.0); + a.radius = 20.0; + // Angle around -PI/2 (bottom point is (96, 0)) + a.start_angle = -std::f64::consts::FRAC_PI_2 - 0.4; + a.end_angle = -std::f64::consts::FRAC_PI_2 + 0.4; + + let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; + let geos = build_geos(&all); + + let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.0, 0.0); + let arc_res = pick_trim_at(&all, &geos, Handle::new(102), 96.0, 0.0); + assert!(ell_res.is_some(), "ellipse trim should succeed with off-axis crossing arc"); + assert!(arc_res.is_some(), "arc trim should succeed with off-axis crossing arc"); + } + } } // ══════════════════════════════════════════════════════════════════════════