Merge pull request #1714 from yztink/fix/arc-tangent-line

fix(draw): support line tangency in arc construction and resolve trim contact precision
This commit is contained in:
Hakan Seven committed 2026-10-11 23:42:48 +03:00
commit 3cc3ead3a6
3 files changed
+955 -19

No files matched your search

+589
View File
@@ -564,6 +564,354 @@ fn target_to_circle2d_pure(target: TangentObject, plane: WorkingPlane) -> Option
}
}
fn target_to_line2d(target: TangentObject, plane: WorkingPlane) -> Option<(DVec2, DVec2)> {
match target {
TangentObject::Line { p1, p2 } => {
let loc1 = plane.to_local(p1);
let loc2 = plane.to_local(p2);
Some((DVec2::new(loc1.x, loc1.y), DVec2::new(loc2.x, loc2.y)))
}
_ => None,
}
}
/// Solves PPC (Point, Point, Curve) where Curve is an infinite 2D Line passing through `l_p1` and `l_p2`.
/// Closed-form quadratic solution along the perpendicular bisector of `p1` and `p2`.
pub fn solve_line_ppc(
l_p1: DVec2,
l_p2: DVec2,
p1: DVec2,
p2: DVec2,
hint: DVec2,
) -> Option<DVec2> {
let line_vec = l_p2 - l_p1;
let line_len = line_vec.length();
if line_len < 1e-9 {
return None;
}
let v_line = line_vec / line_len;
let n_line = DVec2::new(-v_line.y, v_line.x);
let d_vec = p2 - p1;
let d_len = d_vec.length();
if d_len < 1e-9 {
return None;
}
let u_seg = d_vec / d_len;
let w_bisector = DVec2::new(-u_seg.y, u_seg.x);
let m = (p1 + p2) * 0.5;
let r0 = d_len * 0.5;
let r0_sq = r0 * r0;
let h0 = (m - l_p1).dot(n_line);
let k = w_bisector.dot(n_line);
let a = k * k - 1.0;
let b = 2.0 * h0 * k;
let c = h0 * h0 - r0_sq;
let mut s_candidates = Vec::with_capacity(2);
if a.abs() < 1e-9 {
if b.abs() > 1e-9 {
s_candidates.push(-c / b);
}
} else {
let disc = 4.0 * (h0 * h0 - r0_sq * (1.0 - k * k));
if disc >= -1e-9 {
let sqrt_disc = disc.max(0.0).sqrt();
s_candidates.push((-b + sqrt_disc) / (2.0 * a));
s_candidates.push((-b - sqrt_disc) / (2.0 * a));
}
}
let mut candidates = Vec::new();
for s in s_candidates {
let center = m + w_bisector * s;
let r = (r0_sq + s * s).sqrt();
if r < 1e-4 || r > 1e8 {
continue;
}
let t_pt = center - n_line * ((center - l_p1).dot(n_line));
// Ensure t_pt, p1, p2 are non-collinear
let cross = (p2.x - p1.x) * (t_pt.y - p1.y) - (p2.y - p1.y) * (t_pt.x - p1.x);
let norm_cross = cross.abs() / (d_len * (t_pt - p1).length().max(1.0));
if norm_cross < 1e-6 {
continue;
}
candidates.push((t_pt, r));
}
candidates
.into_iter()
.min_by(|(pt_a, r_a), (pt_b, r_b)| {
let dist_a = (*pt_a - hint).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 };
let dist_b = (*pt_b - hint).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 };
dist_a.total_cmp(&dist_b)
})
.map(|(pt, _)| pt)
}
/// Solves CCP (Curve, Curve, Point) where both curves are Lines.
pub fn solve_line_line_ccp(
l1_p1: DVec2,
l1_p2: DVec2,
l2_p1: DVec2,
l2_p2: DVec2,
p: DVec2,
hint1: DVec2,
hint2: DVec2,
) -> Option<(DVec2, DVec2)> {
let d1 = l1_p2 - l1_p1;
let d2 = l2_p2 - l2_p1;
let l1_len = d1.length();
let l2_len = d2.length();
if l1_len < 1e-9 || l2_len < 1e-9 {
return None;
}
let v1 = d1 / l1_len;
let v2 = d2 / l2_len;
let n1 = DVec2::new(-v1.y, v1.x);
let n2 = DVec2::new(-v2.y, v2.x);
let mut candidates = Vec::new();
let cross12 = v1.x * v2.y - v1.y * v2.x;
if cross12.abs() < 1e-6 {
// Parallel lines
let dist_lines = (l2_p1 - l1_p1).dot(n1).abs();
let r = dist_lines * 0.5;
if r > 1e-4 {
let mid_line_p = l1_p1 + n1 * ((l2_p1 - l1_p1).dot(n1) * 0.5);
let w = mid_line_p - p;
let a = 1.0;
let b = 2.0 * w.dot(v1);
let c = w.length_squared() - r * r;
let disc = b * b - 4.0 * a * c;
if disc >= 0.0 {
let sqrt_d = disc.sqrt();
for t in [(-b + sqrt_d) / (2.0 * a), (-b - sqrt_d) / (2.0 * a)] {
let center = mid_line_p + v1 * t;
let t1 = center - n1 * ((center - l1_p1).dot(n1));
let t2 = center - n2 * ((center - l2_p1).dot(n2));
candidates.push((t1, t2, r));
}
}
}
} else {
// Intersecting lines: find intersection point V
let delta = l2_p1 - l1_p1;
let t_intersect = (delta.x * v2.y - delta.y * v2.x) / cross12;
let v_intersect = l1_p1 + v1 * t_intersect;
let b1 = (v1 + v2).normalize_or_zero();
let b2 = (v1 - v2).normalize_or_zero();
for bisector in [b1, b2] {
if bisector.length_squared() < 0.5 {
continue;
}
let sigma = (bisector.x * v1.y - bisector.y * v1.x).abs();
if sigma < 1e-6 || (1.0 - sigma * sigma).abs() < 1e-6 {
continue;
}
let cos2 = 1.0 - sigma * sigma;
let w = v_intersect - p;
let a = cos2;
let b = 2.0 * w.dot(bisector);
let c = w.length_squared();
let disc = b * b - 4.0 * a * c;
if disc >= 0.0 {
let sqrt_d = disc.sqrt();
for t in [(-b + sqrt_d) / (2.0 * a), (-b - sqrt_d) / (2.0 * a)] {
let center = v_intersect + bisector * t;
let r = t.abs() * sigma;
if r < 1e-4 || r > 1e8 {
continue;
}
let t1 = center - n1 * ((center - l1_p1).dot(n1));
let t2 = center - n2 * ((center - l2_p1).dot(n2));
candidates.push((t1, t2, r));
}
}
}
}
candidates
.into_iter()
.min_by(|(t1_a, t2_a, r_a), (t1_b, t2_b, r_b)| {
let dist_a = (*t1_a - hint1).length_squared() + (*t2_a - hint2).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 };
let dist_b = (*t1_b - hint1).length_squared() + (*t2_b - hint2).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 };
dist_a.total_cmp(&dist_b)
})
.map(|(t1, t2, _)| (t1, t2))
}
/// Solves CCP (Curve, Curve, Point) where Curve 1 is a Circle and Curve 2 is a Line.
pub fn solve_circle_line_ccp(
c1: Circle2D,
l_p1: DVec2,
l_p2: DVec2,
p: DVec2,
hint_c: DVec2,
hint_l: DVec2,
) -> Option<(DVec2, DVec2)> {
let line_vec = l_p2 - l_p1;
let line_len = line_vec.length();
if line_len < 1e-9 {
return None;
}
let v_line = line_vec / line_len;
let n_line = DVec2::new(-v_line.y, v_line.x);
let n_steps = 144;
let dt = TAU / (n_steps as f64);
let mut candidates = Vec::new();
let eval_t = |theta: f64| -> Option<(f64, DVec2, f64, DVec2)> {
let n_hat = DVec2::new(theta.cos(), theta.sin());
let pt = c1.center + n_hat * c1.radius;
let w = pt - p;
let w_dot_n = w.dot(n_hat);
if w_dot_n.abs() < 1e-6 {
return None;
}
let lambda = -w.length_squared() / (2.0 * w_dot_n);
let r = lambda.abs();
if r < 1e-4 || r > 1e8 {
return None;
}
let center = pt + n_hat * lambda;
let d_line = (center - l_p1).dot(n_line).abs();
let res = d_line - r;
Some((res, pt, r, center))
};
for i in 0..n_steps {
let t_a = i as f64 * dt;
let t_b = (i + 1) as f64 * dt;
let Some((res_a, _, _, _)) = eval_t(t_a) else { continue; };
let Some((res_b, _, _, _)) = eval_t(t_b) else { continue; };
if res_a.signum() != res_b.signum() {
let mut lo = t_a;
let mut hi = t_b;
let mut best_t = (lo + hi) * 0.5;
for _ in 0..16 {
let mid = (lo + hi) * 0.5;
let Some((res_m, _, _, _)) = eval_t(mid) else { break; };
best_t = mid;
if res_m.abs() < 1e-8 {
break;
}
if res_m.signum() == res_a.signum() {
lo = mid;
} else {
hi = mid;
}
}
if let Some((res, pt_c, r, center)) = eval_t(best_t) {
if res.abs() < 1e-3 {
let pt_l = center - n_line * ((center - l_p1).dot(n_line));
candidates.push((pt_c, pt_l, r));
}
}
}
}
candidates
.into_iter()
.min_by(|(c_a, l_a, r_a), (c_b, l_b, r_b)| {
let dist_a = (*c_a - hint_c).length_squared() + (*l_a - hint_l).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 };
let dist_b = (*c_b - hint_c).length_squared() + (*l_b - hint_l).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 };
dist_a.total_cmp(&dist_b)
})
.map(|(pt_c, pt_l, _)| (pt_c, pt_l))
}
/// Solves CCP (Curve, Curve, Point) where Curve 1 is an Ellipse and Curve 2 is a Line.
pub fn solve_ellipse_line_ccp(
ell: &Ellipse2D,
l_p1: DVec2,
l_p2: DVec2,
p: DVec2,
hint_e: DVec2,
hint_l: DVec2,
) -> Option<(DVec2, DVec2)> {
let line_vec = l_p2 - l_p1;
let line_len = line_vec.length();
if line_len < 1e-9 {
return None;
}
let v_line = line_vec / line_len;
let n_line = DVec2::new(-v_line.y, v_line.x);
let n_steps = 144;
let dt = TAU / (n_steps as f64);
let mut candidates = Vec::new();
let eval_t = |t: f64| -> Option<(f64, DVec2, f64, DVec2)> {
let pt = ell.point_at(t);
let n_hat = ell.normal_at(t);
let w = pt - p;
let w_dot_n = w.dot(n_hat);
if w_dot_n.abs() < 1e-6 {
return None;
}
let lambda = -w.length_squared() / (2.0 * w_dot_n);
let r = lambda.abs();
if r < 1e-4 || r > 1e8 {
return None;
}
let center = pt + n_hat * lambda;
let d_line = (center - l_p1).dot(n_line).abs();
let res = d_line - r;
Some((res, pt, r, center))
};
for i in 0..n_steps {
let t_a = i as f64 * dt;
let t_b = (i + 1) as f64 * dt;
let Some((res_a, _, _, _)) = eval_t(t_a) else { continue; };
let Some((res_b, _, _, _)) = eval_t(t_b) else { continue; };
if res_a.signum() != res_b.signum() {
let mut lo = t_a;
let mut hi = t_b;
let mut best_t = (lo + hi) * 0.5;
for _ in 0..16 {
let mid = (lo + hi) * 0.5;
let Some((res_m, _, _, _)) = eval_t(mid) else { break; };
best_t = mid;
if res_m.abs() < 1e-8 {
break;
}
if res_m.signum() == res_a.signum() {
lo = mid;
} else {
hi = mid;
}
}
if let Some((res, pt_e, r, center)) = eval_t(best_t) {
if res.abs() < 1e-3 {
let pt_l = center - n_line * ((center - l_p1).dot(n_line));
candidates.push((pt_e, pt_l, r));
}
}
}
}
candidates
.into_iter()
.min_by(|(e_a, l_a, r_a), (e_b, l_b, r_b)| {
let dist_a = (*e_a - hint_e).length_squared() + (*l_a - hint_l).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 };
let dist_b = (*e_b - hint_e).length_squared() + (*l_b - hint_l).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 };
dist_a.total_cmp(&dist_b)
})
.map(|(pt_e, pt_l, _)| (pt_e, pt_l))
}
/// Solves PPC (Point, Point, Curve) where Curve is an Ellipse.
/// Scans the full perimeter t ∈ [0, 2π) to find all valid tangent circles passing through p1 and p2,
/// and selects the one closest to `hint`.
@@ -882,11 +1230,55 @@ fn solve_tangent_ccp(
let hint1 = DVec2::new(plane.to_local(h1).x, plane.to_local(h1).y);
let hint2 = DVec2::new(plane.to_local(h2).x, plane.to_local(h2).y);
let l1 = target_to_line2d(t1, plane);
let l2 = target_to_line2d(t2, plane);
let ell1 = target_to_ellipse2d(t1, plane);
let ell2 = target_to_ellipse2d(t2, plane);
let c1 = target_to_circle2d_pure(t1, plane);
let c2 = target_to_circle2d_pure(t2, plane);
// Line + Line
if let (Some((l1_a, l1_b)), Some((l2_a, l2_b))) = (l1, l2) {
let (w1, w2) = solve_line_line_ccp(l1_a, l1_b, l2_a, l2_b, p, hint1, hint2)?;
return Some((
plane.to_world(DVec3::new(w1.x, w1.y, z)),
plane.to_world(DVec3::new(w2.x, w2.y, z)),
));
}
// Line + Circle / Circle + Line
if let (Some((l1_a, l1_b)), Some(c2)) = (l1, c2) {
let (w2, w1) = solve_circle_line_ccp(c2, l1_a, l1_b, p, hint2, hint1)?;
return Some((
plane.to_world(DVec3::new(w1.x, w1.y, z)),
plane.to_world(DVec3::new(w2.x, w2.y, z)),
));
}
if let (Some(c1), Some((l2_a, l2_b))) = (c1, l2) {
let (w1, w2) = solve_circle_line_ccp(c1, l2_a, l2_b, p, hint1, hint2)?;
return Some((
plane.to_world(DVec3::new(w1.x, w1.y, z)),
plane.to_world(DVec3::new(w2.x, w2.y, z)),
));
}
// Line + Ellipse / Ellipse + Line
if let (Some((l1_a, l1_b)), Some(ell2)) = (l1, ell2) {
let (w2, w1) = solve_ellipse_line_ccp(&ell2, l1_a, l1_b, p, hint2, hint1)?;
return Some((
plane.to_world(DVec3::new(w1.x, w1.y, z)),
plane.to_world(DVec3::new(w2.x, w2.y, z)),
));
}
if let (Some(ell1), Some((l2_a, l2_b))) = (ell1, l2) {
let (w1, w2) = solve_ellipse_line_ccp(&ell1, l2_a, l2_b, p, hint1, hint2)?;
return Some((
plane.to_world(DVec3::new(w1.x, w1.y, z)),
plane.to_world(DVec3::new(w2.x, w2.y, z)),
));
}
// Ellipse + Circle / Circle + Ellipse
if let (Some(ell1), Some(c2)) = (ell1, c2) {
let (w1, w2) = solve_ellipse_circle_ccp(&ell1, c2, p, hint1, hint2)?;
return Some((
@@ -903,6 +1295,7 @@ fn solve_tangent_ccp(
));
}
// Ellipse + Ellipse
if let (Some(ell1), Some(ell2)) = (ell1, ell2) {
let (w1, w2) = solve_ellipse_ellipse_ccp(&ell1, &ell2, p, hint1, hint2)?;
return Some((
@@ -911,6 +1304,7 @@ fn solve_tangent_ccp(
));
}
// Circle + Circle
if let (Some(c1), Some(c2)) = (c1, c2) {
let sols = apollonius::solve_ccp(c1, c2, p);
if let Some(best) = apollonius::best_ccp(&sols, hint1, hint2) {
@@ -936,6 +1330,13 @@ fn solve_tangent_ppc(
let pt2 = DVec2::new(plane.to_local(p2).x, plane.to_local(p2).y);
let hint1 = DVec2::new(plane.to_local(h1).x, plane.to_local(h1).y);
if let Some((l1, l2)) = target_to_line2d(t1, plane) {
if let Some(w1_2d) = solve_line_ppc(l1, l2, pt1, pt2, hint1) {
return Some(plane.to_world(DVec3::new(w1_2d.x, w1_2d.y, z)));
}
return None;
}
if let Some(ell) = target_to_ellipse2d(t1, plane) {
if let Some(w1_2d) = solve_ellipse_ppc(&ell, pt1, pt2, hint1) {
return Some(plane.to_world(DVec3::new(w1_2d.x, w1_2d.y, z)));
@@ -3718,4 +4119,192 @@ mod acad_flow_tests {
"resolved point must lie on ellipse"
);
}
#[test]
fn arc_3p_fluid_tangent_start_from_line() {
let mut cmd = Arc3PCommand::new();
let line = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
// Pick 1: Tangent on line y = 0
let r1 = cmd.on_point_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
assert!(matches!(r1, Some(CmdResult::NeedPoint)));
// Pick 2: Pass-through point
let r2 = cmd.on_point_with_tangent(DVec3::new(0.0, 2.0, 0.0), None);
assert!(matches!(r2, Some(CmdResult::NeedPoint)));
// Live preview at (2, 2, 0)
let preview = cmd.on_preview_wires_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
assert!(!preview.is_empty(), "Live preview must generate arc wire");
assert!(
preview[0].tangent_geoms.iter().any(|g| matches!(g, TangentGeom::Arc { .. })),
"Preview must produce an analytical arc geometry"
);
// Pick 3: Second pass-through point
let r3 = cmd.on_point_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
let arc = committed_arc(r3.expect("command completed"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
// Expected center: (1.0, 1.25, 0.0), radius: 1.25
assert!((center.x - 1.0).abs() < 1e-3);
assert!((center.y - 1.25).abs() < 1e-3);
assert!((arc.radius - 1.25).abs() < 1e-3);
// Tangent to y = 0 line
assert!((center.y.abs() - arc.radius).abs() < 1e-3);
// Passes through (0, 2) and (2, 2)
assert!(((center - DVec3::new(0.0, 2.0, 0.0)).length() - arc.radius).abs() < 1e-3);
assert!(((center - DVec3::new(2.0, 2.0, 0.0)).length() - arc.radius).abs() < 1e-3);
}
#[test]
fn arc_3p_fluid_tangent_end_at_line() {
let mut cmd = Arc3PCommand::new();
let line = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
// Pick 1: Point 1
cmd.on_point_with_tangent(DVec3::new(0.0, 2.0, 0.0), None);
// Pick 2: Point 2
cmd.on_point_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
// Preview with tangent snap on line
let preview = cmd.on_preview_wires_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
assert!(!preview.is_empty(), "Live preview must generate arc wire");
assert!(
preview[0].tangent_geoms.iter().any(|g| matches!(g, TangentGeom::Arc { .. })),
"Preview must produce an analytical arc geometry"
);
// Pick 3: Tangent on line
let r3 = cmd.on_point_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
let arc = committed_arc(r3.expect("command completed"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
assert!((center.x - 1.0).abs() < 1e-3);
assert!((center.y - 1.25).abs() < 1e-3);
assert!((arc.radius - 1.25).abs() < 1e-3);
}
#[test]
fn arc_command_start_from_tangent_line() {
let mut cmd = ArcCommand::new();
let line = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
// Pick 1: Tangent snap to line
let r1 = cmd.on_point_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
assert!(matches!(r1, Some(CmdResult::NeedPoint)));
// Pick 2: Point
let r2 = cmd.on_point_with_tangent(DVec3::new(0.0, 2.0, 0.0), None);
assert!(matches!(r2, Some(CmdResult::NeedPoint)));
// Move to Pick 3: Live preview should show curved arc wire, NOT fallback straight line
let preview = cmd.on_preview_wires_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
assert!(!preview.is_empty(), "Live preview must exist");
assert!(
preview[0].tangent_geoms.iter().any(|g| matches!(g, TangentGeom::Arc { .. })),
"Preview must produce an arc, not a straight fallback line"
);
// Pick 3: Point -> Must commit CadArc!
let r3 = cmd.on_point_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
let arc = committed_arc(r3.expect("arc command must commit"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
assert!((center.y.abs() - arc.radius).abs() < 1e-3);
}
#[test]
fn arc_command_end_at_tangent_line() {
let mut cmd = ArcCommand::new();
let line = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
// Pick 1: Point
cmd.on_point_with_tangent(DVec3::new(0.0, 2.0, 0.0), None);
// Pick 2: Point
cmd.on_point_with_tangent(DVec3::new(2.0, 2.0, 0.0), None);
// Preview with tangent on line
let preview = cmd.on_preview_wires_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
assert!(!preview.is_empty());
assert!(
preview[0].tangent_geoms.iter().any(|g| matches!(g, TangentGeom::Arc { .. })),
"Preview must produce an arc, not a straight fallback line"
);
// Pick 3: Tangent on line -> commits CadArc!
let r3 = cmd.on_point_with_tangent(DVec3::new(1.0, 0.0, 0.0), Some(line));
let arc = committed_arc(r3.expect("arc command must commit"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
assert!((center.y.abs() - arc.radius).abs() < 1e-3);
}
#[test]
fn arc_3p_fluid_tangent_two_lines_and_point() {
let mut cmd = Arc3PCommand::new();
let line1 = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
let line2 = TangentObject::Line {
p1: DVec3::new(0.0, -10.0, 0.0),
p2: DVec3::new(0.0, 10.0, 0.0),
};
// Pick 1: Tangent on Line 1 (y = 0)
cmd.on_point_with_tangent(DVec3::new(2.0, 0.0, 0.0), Some(line1));
// Pick 2: Point in first quadrant
cmd.on_point_with_tangent(DVec3::new(1.0 - (0.5_f64).sqrt(), 1.0 - (0.5_f64).sqrt(), 0.0), None);
// Pick 3: Tangent on Line 2 (x = 0)
let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 2.0, 0.0), Some(line2));
let arc = committed_arc(r3.expect("two-line arc command must commit"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
// Center should be equidistant to both lines (x = y = radius)
assert!((center.x.abs() - arc.radius).abs() < 1e-3);
assert!((center.y.abs() - arc.radius).abs() < 1e-3);
}
#[test]
fn arc_3p_fluid_tangent_line_and_circle() {
let mut cmd = Arc3PCommand::new();
let line = TangentObject::Line {
p1: DVec3::new(-20.0, 0.0, 0.0),
p2: DVec3::new(20.0, 0.0, 0.0),
};
let circle = TangentObject::Circle {
center: DVec3::new(0.0, 10.0, 0.0),
radius: 2.0,
};
// Pick 1: Tangent on line
cmd.on_point_with_tangent(DVec3::new(0.0, 0.0, 0.0), Some(line));
// Pick 2: Middle point
cmd.on_point_with_tangent(DVec3::new(3.0, 4.0, 0.0), None);
// Pick 3: Tangent on circle
let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 8.0, 0.0), Some(circle));
let arc = committed_arc(r3.expect("line and circle arc must commit"));
let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z);
// Distance from center to line must equal radius
assert!((center.y.abs() - arc.radius).abs() < 1e-3);
// Distance from center to circle must equal radius +- circle.radius
let dist_to_circle = center.distance(DVec3::new(0.0, 10.0, 0.0));
assert!(
(dist_to_circle - (arc.radius + 2.0)).abs() < 1e-3
|| (dist_to_circle - (arc.radius - 2.0).abs()).abs() < 1e-3
);
}
}
+351
View File
@@ -4923,6 +4923,357 @@ mod tests {
);
}
}
#[test]
fn test_trim_line_tangent_to_arc() {
use codec::entities::{Arc as ArcEnt, Line as LineEnt};
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(1);
l1.start = Vector3::new(-10.0, 0.0, 0.0);
l1.end = Vector3::new(10.0, 0.0, 0.0);
let mut a = ArcEnt::new();
a.common.handle = Handle::new(2);
a.center = Vector3::new(0.0, 5.0, 0.0);
a.radius = 5.0;
a.start_angle = -std::f64::consts::FRAC_PI_2;
a.end_angle = std::f64::consts::PI;
let all = vec![EntityType::Line(l1.clone()), EntityType::Arc(a.clone())];
let geos = build_geos(&all);
let curve_line = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap();
let ts_line = cut_params(&curve_line, Handle::new(1), &geos);
assert!(!ts_line.is_empty(), "line must have cut point where tangent arc touches it: ts={:?}", ts_line);
let curve_arc = entity_curve_xy(&EntityType::Arc(a.clone())).unwrap();
let ts_arc = cut_params(&curve_arc, Handle::new(2), &geos);
println!("ts_arc: {:?}", ts_arc);
}
#[test]
fn test_trim_arc_tangent_to_two_lines() {
use codec::entities::{Arc as CadArc, Line as LineEnt};
use crate::command::{TangentObject, WorkingPlane};
use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick};
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(1);
l1.start = Vector3::new(-10.0, 0.0, 0.0);
l1.end = Vector3::new(10.0, 0.0, 0.0);
let mut l2 = LineEnt::new();
l2.common.handle = Handle::new(2);
l2.start = Vector3::new(0.0, -10.0, 0.0);
l2.end = Vector3::new(0.0, 10.0, 0.0);
let t1 = TangentObject::Line {
p1: DVec3::new(-10.0, 0.0, 0.0),
p2: DVec3::new(10.0, 0.0, 0.0),
};
let t2 = TangentObject::Line {
p1: DVec3::new(0.0, -10.0, 0.0),
p2: DVec3::new(0.0, 10.0, 0.0),
};
// Middle point: (1 - sqrt(0.5), 1 - sqrt(0.5)) -> radius ~ 1
let mid = DVec3::new(1.0 - (0.5_f64).sqrt(), 1.0 - (0.5_f64).sqrt(), 0.0);
let sol = solve_arc_3_inputs(
ArcPick::Tangent { target: t1, hit: DVec3::new(2.0, 0.0, 0.0) },
ArcPick::Point(mid),
ArcPick::Tangent { target: t2, hit: DVec3::new(0.0, 2.0, 0.0) },
WorkingPlane::default(),
);
let (center, radius, sa, ea) = sol.expect("must solve arc tangent to two lines");
let mut arc = CadArc::new();
arc.common.handle = Handle::new(3);
arc.center = Vector3::new(center.x, center.y, center.z);
arc.radius = radius;
arc.start_angle = sa;
arc.end_angle = ea;
let all = vec![
EntityType::Line(l1.clone()),
EntityType::Line(l2.clone()),
EntityType::Arc(arc.clone()),
];
let geos = build_geos(&all);
let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap();
let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos);
println!("ts_l1: {:?}", ts_l1);
let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap();
let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos);
println!("ts_l2: {:?}", ts_l2);
let curve_arc = entity_curve_xy(&EntityType::Arc(arc.clone())).unwrap();
let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos);
println!("ts_arc: {:?}", ts_arc);
assert!(!ts_l1.is_empty(), "Line 1 must be cut: ts_l1={:?}", ts_l1);
assert!(!ts_l2.is_empty(), "Line 2 must be cut: ts_l2={:?}", ts_l2);
// Test trimming line 1 arm between (1, 0) and (10, 0)
let trim_l1 = pick_trim_at(&all, &geos, Handle::new(1), 5.0, 0.0);
println!("trim_l1: {:?}", trim_l1);
assert!(trim_l1.is_some(), "trimming line 1 arm must succeed");
// Test trimming line 2 arm between (0, 1) and (0, 10)
let trim_l2 = pick_trim_at(&all, &geos, Handle::new(2), 0.0, 5.0);
println!("trim_l2: {:?}", trim_l2);
assert!(trim_l2.is_some(), "trimming line 2 arm must succeed");
// Test trimming the arc
let trim_arc = pick_trim_at(&all, &geos, Handle::new(3), mid.x, mid.y);
println!("trim_arc: {:?}", trim_arc);
}
#[test]
fn test_trim_arc_tangent_angled_lines() {
use codec::entities::{Arc as CadArc, Line as LineEnt};
use crate::command::{TangentObject, WorkingPlane};
use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick};
// Two lines meeting at (0, 0): one along (cos(20 deg), sin(20 deg)), other along (cos(80 deg), sin(80 deg))
let a1 = 20.0_f64.to_radians();
let a2 = 80.0_f64.to_radians();
let dir1 = DVec3::new(a1.cos(), a1.sin(), 0.0);
let dir2 = DVec3::new(a2.cos(), a2.sin(), 0.0);
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(1);
l1.start = Vector3::new(-10.0 * dir1.x, -10.0 * dir1.y, 0.0);
l1.end = Vector3::new(50.0 * dir1.x, 50.0 * dir1.y, 0.0);
let mut l2 = LineEnt::new();
l2.common.handle = Handle::new(2);
l2.start = Vector3::new(-10.0 * dir2.x, -10.0 * dir2.y, 0.0);
l2.end = Vector3::new(50.0 * dir2.x, 50.0 * dir2.y, 0.0);
let t1 = TangentObject::Line {
p1: DVec3::new(l1.start.x, l1.start.y, 0.0),
p2: DVec3::new(l1.end.x, l1.end.y, 0.0),
};
let t2 = TangentObject::Line {
p1: DVec3::new(l2.start.x, l2.start.y, 0.0),
p2: DVec3::new(l2.end.x, l2.end.y, 0.0),
};
// Midpoint between points at distance 20 on the bisector
let bisector_angle = (a1 + a2) * 0.5;
let mid = DVec3::new(15.0 * bisector_angle.cos(), 15.0 * bisector_angle.sin(), 0.0);
let sol = solve_arc_3_inputs(
ArcPick::Tangent { target: t1, hit: DVec3::new(20.0 * dir1.x, 20.0 * dir1.y, 0.0) },
ArcPick::Point(mid),
ArcPick::Tangent { target: t2, hit: DVec3::new(20.0 * dir2.x, 20.0 * dir2.y, 0.0) },
WorkingPlane::default(),
);
let (center, radius, sa, ea) = sol.expect("must solve arc tangent to two angled lines");
let mut arc = CadArc::new();
arc.common.handle = Handle::new(3);
arc.center = Vector3::new(center.x, center.y, center.z);
arc.radius = radius;
arc.start_angle = sa;
arc.end_angle = ea;
let all = vec![
EntityType::Line(l1.clone()),
EntityType::Line(l2.clone()),
EntityType::Arc(arc.clone()),
];
let geos = build_geos(&all);
let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap();
let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos);
println!("angled ts_l1: {:?}", ts_l1);
let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap();
let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos);
println!("angled ts_l2: {:?}", ts_l2);
// We expect l1 to have at least 2 cuts: the intersection with l2 at (0,0) and the tangent point with arc!
assert!(ts_l1.len() >= 2, "Line 1 must have intersection and tangent cut: got {:?}", ts_l1);
assert!(ts_l2.len() >= 2, "Line 2 must have intersection and tangent cut: got {:?}", ts_l2);
// Trimming l1 arm past the tangent point
let click_l1 = 40.0 * dir1;
let trim_l1 = pick_trim_at(&all, &geos, Handle::new(1), click_l1.x, click_l1.y);
println!("angled trim_l1: {:?}", trim_l1);
assert!(trim_l1.is_some(), "trimming line 1 must succeed");
}
#[test]
fn test_kernel_intersect_tangency() {
use std::f64::consts::FRAC_PI_2;
let line = Curve::Line(KernelLine { start: [-10.0, 0.0], end: [10.0, 0.0] });
// Case 1: Exact tangency at interior of arc
let arc_exact = Curve::Arc(KernelArc {
centre: [0.0, 5.0],
radius: 5.0,
start_angle: -FRAC_PI_2 - 0.2,
end_angle: -FRAC_PI_2 + 0.2,
});
let hits_exact = kernel_intersect(&line, &arc_exact, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_exact: {:?}", hits_exact);
// Case 2: Distance slightly greater than R by 1e-12 (within tolerance 1e-7)
let arc_eps = Curve::Arc(KernelArc {
centre: [0.0, 5.0 + 1e-12],
radius: 5.0,
start_angle: -FRAC_PI_2 - 0.2,
end_angle: -FRAC_PI_2 + 0.2,
});
let hits_eps = kernel_intersect(&line, &arc_eps, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_eps: {:?}", hits_eps);
// Case 3: Distance slightly greater than R by 1e-9
let arc_eps9 = Curve::Arc(KernelArc {
centre: [0.0, 5.0 + 1e-9],
radius: 5.0,
start_angle: -FRAC_PI_2 - 0.2,
end_angle: -FRAC_PI_2 + 0.2,
});
let hits_eps9 = kernel_intersect(&line, &arc_eps9, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_eps9: {:?}", hits_eps9);
// Case 4: Tangency at arc start endpoint
let arc_start = Curve::Arc(KernelArc {
centre: [0.0, 5.0],
radius: 5.0,
start_angle: -FRAC_PI_2,
end_angle: 0.0,
});
let hits_start = kernel_intersect(&line, &arc_start, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_start (-PI/2): {:?}", hits_start);
// Case 5: Tangency at arc start endpoint with normalized angle 3*PI/2
let arc_norm = Curve::Arc(KernelArc {
centre: [0.0, 5.0],
radius: 5.0,
start_angle: 3.0 * FRAC_PI_2,
end_angle: 0.0, // wait! In CCW, from 3*PI/2 to 0 is span PI/2, or 2*PI?
});
let hits_norm = kernel_intersect(&line, &arc_norm, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_norm (3*PI/2): {:?}", hits_norm);
// Case 6: Tangency at arc end endpoint
let arc_end = Curve::Arc(KernelArc {
centre: [0.0, 5.0],
radius: 5.0,
start_angle: std::f64::consts::PI,
end_angle: -FRAC_PI_2,
});
let hits_end = kernel_intersect(&line, &arc_end, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_end (-PI/2): {:?}", hits_end);
// Case 7: Large coordinates (e.g. 5000, 5005)
let line_large = Curve::Line(KernelLine { start: [4990.0, 5000.0], end: [5010.0, 5000.0] });
let arc_large = Curve::Arc(KernelArc {
centre: [5000.0, 5005.0],
radius: 5.0,
start_angle: -FRAC_PI_2,
end_angle: 0.0,
});
let hits_large = kernel_intersect(&line_large, &arc_large, KernelTolerance::new(CUT_TOLERANCE));
println!("hits_large: {:?}", hits_large);
// Test with cut_params:
let geos_large = vec![Geo::Arc {
handle: Handle::new(2),
cx: 5000.0,
cy: 5005.0,
r: 5.0,
a0: -FRAC_PI_2,
a1: 0.0,
}];
let cuts_large = cut_params(&line_large, Handle::new(1), &geos_large);
let _ = cuts_large;
}
#[test]
fn test_arc_tangent_line_precision_sweep() {
use codec::entities::{Arc as CadArc, Line as LineEnt};
use crate::command::{TangentObject, WorkingPlane};
use crate::modules::draw::draw::arc::{solve_arc_3_inputs, ArcPick};
let mut max_dist = 0.0_f64;
let mut failed_cuts = 0;
for i in 1..36 {
let angle_deg = i as f64 * 5.0; // 5 to 175 degrees
let a2 = angle_deg.to_radians();
let dir1 = DVec3::new(1.0, 0.0, 0.0);
let dir2 = DVec3::new(a2.cos(), a2.sin(), 0.0);
let mut l1 = LineEnt::new();
l1.common.handle = Handle::new(1);
l1.start = Vector3::new(-50.0, 0.0, 0.0);
l1.end = Vector3::new(150.0, 0.0, 0.0);
let mut l2 = LineEnt::new();
l2.common.handle = Handle::new(2);
l2.start = Vector3::new(-50.0 * dir2.x, -50.0 * dir2.y, 0.0);
l2.end = Vector3::new(150.0 * dir2.x, 150.0 * dir2.y, 0.0);
let t1 = TangentObject::Line {
p1: DVec3::new(l1.start.x, l1.start.y, 0.0),
p2: DVec3::new(l1.end.x, l1.end.y, 0.0),
};
let t2 = TangentObject::Line {
p1: DVec3::new(l2.start.x, l2.start.y, 0.0),
p2: DVec3::new(l2.end.x, l2.end.y, 0.0),
};
let bisector = (dir1 + dir2).normalize();
let mid = bisector * 30.0;
let sol = solve_arc_3_inputs(
ArcPick::Tangent { target: t1, hit: DVec3::new(50.0, 0.0, 0.0) },
ArcPick::Point(mid),
ArcPick::Tangent { target: t2, hit: dir2 * 50.0 },
WorkingPlane::default(),
);
if let Some((center, radius, sa, ea)) = sol {
let mut arc = CadArc::new();
arc.common.handle = Handle::new(3);
arc.center = Vector3::new(center.x, center.y, center.z);
arc.radius = radius;
arc.start_angle = sa;
arc.end_angle = ea;
let all = vec![
EntityType::Line(l1.clone()),
EntityType::Line(l2.clone()),
EntityType::Arc(arc.clone()),
];
let geos = build_geos(&all);
let curve_l1 = entity_curve_xy(&EntityType::Line(l1.clone())).unwrap();
let ts_l1 = cut_params(&curve_l1, Handle::new(1), &geos);
let curve_l2 = entity_curve_xy(&EntityType::Line(l2.clone())).unwrap();
let ts_l2 = cut_params(&curve_l2, Handle::new(2), &geos);
if ts_l1.len() < 2 || ts_l2.len() < 2 {
failed_cuts += 1;
println!("FAILED at angle {angle_deg} deg: ts_l1={:?}, ts_l2={:?}", ts_l1, ts_l2);
}
// Check distance of arc endpoints to line
let ep1 = [center.x + radius * sa.cos(), center.y + radius * sa.sin()];
let d1 = (ep1[1] - 0.0).abs(); // distance to l1 (y=0)
if d1 > max_dist { max_dist = d1; }
}
}
println!("Sweep results: failed_cuts={failed_cuts}, max_dist={max_dist:e}");
assert_eq!(failed_cuts, 0, "No cuts should fail across angle sweep");
}
}
// ══════════════════════════════════════════════════════════════════════════