fix(trim): snap trim cut parameters to exact boundary contact points

This commit is contained in:
yztink committed 2026-09-28 01:56:47 -07:00
1 parent 92fa661065
commit c827b87cf9
2 files changed
+841 -49

No files matched your search

+100 -1
View File
@@ -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;
+741 -48
View File
@@ -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<f64> {
.collect::<Vec<_>>()
})
.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<EntityType>
} 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<EntityType> {
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");
}
}
}
// ══════════════════════════════════════════════════════════════════════════