From 2f10a66bff064cf411415c2fc5ce50f108854cd9 Mon Sep 17 00:00:00 2001 From: yztink Date: Sat, 10 Oct 2026 23:01:22 -0700 Subject: [PATCH] fix(draw): support line tangency in arc construction and resolve trim contact precision - Implement Apollonius PPC (Point-Point-Curve) and multi-tangent CCP solvers for lines in arc.rs - Support tangent line snapping for all 3-input arc combinations (Tangent-Point-Point, Point-Tangent-Point, Tangent-Tangent-Point, Tangent-Point-Tangent, Point-Tangent-Tangent) - Resolve TangentGeom::Line f32 truncation in snap.rs to preserve true f64 key vertices during tangent evaluation and TangentObject construction - Preserve strict CUT_TOLERANCE (1e-7) in trim.rs while ensuring tangent contact points are detected accurately - Add unit tests for line tangency solvers, tangent trim detection, and angular precision sweeps --- src/modules/draw/draw/arc.rs | 589 ++++++++++++++++++++++++++++++++ src/modules/draw/modify/trim.rs | 351 +++++++++++++++++++ src/snap.rs | 34 +- 3 files changed, 955 insertions(+), 19 deletions(-) diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 29a1ed58b..c023bf88e 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -572,6 +572,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 { + 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`. @@ -863,11 +1211,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(( @@ -884,6 +1276,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(( @@ -892,6 +1285,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) { @@ -917,6 +1311,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))); @@ -3551,5 +3952,193 @@ mod acad_flow_tests { let dy = (resolved.y - 10.0) / 5.0; assert!((dx * dx + dy * dy - 1.0).abs() < 1e-3, "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 + ); + } } diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 130151dad..d4754f264 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -4745,6 +4745,357 @@ mod tests { assert!(arc_res.is_some(), "arc trim should succeed with off-axis crossing arc"); } } + + #[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"); + } } // ══════════════════════════════════════════════════════════════════════════ diff --git a/src/snap.rs b/src/snap.rs index 78d11b6ac..ecc7a3bc6 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -2171,25 +2171,17 @@ impl Snapper { // direct-evaluation pass). if self.snap_enabled && self.is_on(SnapType::Tangent) { let mut eval_tangent = |wire: &WireModel| { + let mut line_cursor = 0usize; for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { TangentGeom::Line { p1, p2 } => { - let sp0 = world_to_screen( - glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64), - view_rot, - eye, - bounds, - ); - let sp1 = world_to_screen( - glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), - view_rot, - eye, - bounds, - ); + let (lp1, lp2) = tangent_line_endpoints(wire, *p1, *p2, &mut line_cursor); + let sp0 = world_to_screen(lp1, view_rot, eye, bounds); + let sp1 = world_to_screen(lp2, view_rot, eye, bounds); let d2 = dist2_to_segment(cursor_screen, sp0, sp1); - let t = t_on_segment(cursor_screen, sp0, sp1); - let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1)); - (w.as_dvec3(), d2) + let t = t_on_segment(cursor_screen, sp0, sp1) as f64; + let w = lp1 + t * (lp2 - lp1); + (w, d2) } TangentGeom::Circle { center, radius } => { let cv = Vec3::from(*center); @@ -2440,10 +2432,14 @@ impl Snapper { best_sub = sub; best_d2 = d2; let tangent_obj = match tg { - TangentGeom::Line { p1, p2 } => TangentObject::Line { - p1: glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64), - p2: glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64), - }, + TangentGeom::Line { p1, p2 } => { + let mut cursor = 0usize; + let (lp1, lp2) = tangent_line_endpoints(wire, *p1, *p2, &mut cursor); + TangentObject::Line { + p1: lp1, + p2: lp2, + } + } TangentGeom::Circle { center, radius } => TangentObject::Circle { center: glam::DVec3::new( center[0] as f64,