forked from 1b-agent/opencadstudio-upstream
fix(trim): snap trim cut parameters to exact boundary contact points
This commit is contained in:
1 parent
92fa661065
commit
c827b87cf9
2 files changed
+841
-49
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in new issue
Block a user