From 92fa6610651e0d5142674bff18824fcc09514d36 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:03 -0700 Subject: [PATCH 1/2] feat(draw): add fluid tangent arc, circle, and line construction with ellipse support --- src/app/update/viewport.rs | 95 ++- src/app/update/viewport_snap.rs | 2 +- src/command.rs | 19 +- src/modules/draw/draw/apollonius.rs | 332 +++++++ src/modules/draw/draw/arc.rs | 1234 +++++++++++++++++++++++++-- src/modules/draw/draw/circle.rs | 599 ++++++++++++- src/modules/draw/draw/line.rs | 500 +++++++++-- src/modules/draw/draw/mod.rs | 1 + src/modules/model/cylinder_cmd.rs | 11 +- src/modules/model/primitive_cmd.rs | 45 +- src/snap.rs | 124 ++- 11 files changed, 2750 insertions(+), 212 deletions(-) create mode 100644 src/modules/draw/draw/apollonius.rs diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index bb34d867c..6c4165f77 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1415,7 +1415,7 @@ impl OpenCADStudio { // The engaged grip is the rubber-band origin. Perpendicular // snapping must drop its foot from this point, including when a // hot-grip set is moved by the same drag vector. - self.snapper.from_point = Some(grip.origin_world.as_vec3()); + self.snapper.from_point = Some(grip.origin_world); let (go, gr) = self.drafting_grid_basis(i); let base = grip.origin_world; let construction_ray = @@ -2135,10 +2135,9 @@ impl OpenCADStudio { ) } else { let (go, gr) = self.drafting_grid_basis(i); - // The snapper is a screen-space (f32) engine; the f64 - // base only matters for typed-input precision, so hand it - // the downcast point here. - self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); + // Construction anchor for perpendicular and tangent snaps + // in full f64 precision. + self.snapper.from_point = self.last_point; let construction_ray = if is_window_corner { None @@ -2554,8 +2553,15 @@ impl OpenCADStudio { .as_ref() .map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective)) .unwrap_or_default(); + let live_tangent = self.tabs[i].snap_result.and_then(|s| { + if s.snap_type == crate::snap::SnapType::Tangent { + s.tangent_obj + } else { + None + } + }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { - p.extend(cmd.on_preview_wires(effective)); + p.extend(cmd.on_preview_wires_with_tangent(effective, live_tangent)); } p } else if needs_entity && deferred_command_hover { @@ -2691,10 +2697,17 @@ impl OpenCADStudio { } else if let Some(wires) = self.dimension_preview_wires(i, effective) { wires } else { + let live_tangent = self.tabs[i].snap_result.and_then(|s| { + if s.snap_type == crate::snap::SnapType::Tangent { + s.tangent_obj + } else { + None + } + }); self.tabs[i] .active_cmd .as_mut() - .map(|c| c.on_preview_wires(effective)) + .map(|c| c.on_preview_wires_with_tangent(effective, live_tangent)) .unwrap_or_default() }; // Polar tracking guide line: dotted line from last_point along @@ -2940,7 +2953,7 @@ impl OpenCADStudio { // Perpendicular and tangent measure from `from`, else the running // command's last point; the live cursor's base point is restored. let live_from = self.snapper.from_point; - self.snapper.from_point = from.or(self.last_point).map(|p| p.as_vec3()); + self.snapper.from_point = from.or(self.last_point); let (go, gr) = self.drafting_grid_basis(i); let hit = self .snapper @@ -3781,7 +3794,7 @@ impl OpenCADStudio { ) } else { let (go, gr) = self.drafting_grid_basis(i); - self.snapper.from_point = self.last_point.map(|p| p.as_vec3()); + self.snapper.from_point = self.last_point; let construction_ray = if is_window_corner { None @@ -6995,6 +7008,70 @@ mod selection_preview_tests { ); } + #[test] + fn test_coincident_line_and_arc_grip_move() { + use codec::{types::Vector3, EntityType}; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let pt = glam::DVec3::new(-2.8146711761917635, 12.22681563180158, 0.0); + + // Line ending at pt + let mut line = codec::entities::Line::new(); + line.start = Vector3::new(-20.0, 12.22681563180158, 0.0); + line.end = Vector3::new(pt.x, pt.y, pt.z); + let line_h = app.tabs[i].scene.add_entity(EntityType::Line(line)); + + // Arc starting at pt + let mut arc = codec::entities::Arc::new(); + arc.center = Vector3::new(0.0, 0.0, 0.0); + arc.radius = (pt.x * pt.x + pt.y * pt.y).sqrt(); + let sa = (pt.y).atan2(pt.x); + arc.start_angle = sa; + arc.end_angle = sa + 1.0; + let arc_h = app.tabs[i].scene.add_entity(EntityType::Arc(arc)); + + app.tabs[i].scene.selected.insert(line_h); + app.tabs[i].scene.selected.insert(arc_h); + app.refresh_selected_grips(); + + println!("selected grips count: {}", app.tabs[i].selected_grips.len()); + for (g_idx, g) in app.tabs[i].selected_grips.iter().enumerate() { + let h = app.tabs[i].selected_grip_handles[g_idx]; + println!("grip idx {g_idx}: handle {:?}, id {}, world {:?}", h, g.id, g.world); + } + + let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); + let cursor = app.tabs[i].scene.camera.borrow().project(pt, bounds).unwrap(); + app.tabs[i].scene.selection.borrow_mut().last_move_pos = + Some(iced::Point::new(cursor.x, cursor.y)); + let _ = app.on_viewport_left_press(); + + let active = app.tabs[i].active_grip.as_ref().expect("grip must engage"); + println!("active grip targets len: {}", active.targets.len()); + for t in &active.targets { + println!("target: handle {:?}, grip_id {}, last_world {:?}", t.handle, t.grip_id, t.last_world); + } + + // Drag cursor by (5.0, 5.0) + let new_target = pt + glam::DVec3::new(5.0, 5.0, 0.0); + let new_cursor = app.tabs[i].scene.camera.borrow().project(new_target, bounds).unwrap(); + let _ = app.on_viewport_move(iced::Point::new(new_cursor.x, new_cursor.y)); + + let EntityType::Line(moved_line) = app.tabs[i].scene.document.get_entity(line_h).unwrap() else { panic!() }; + let EntityType::Arc(moved_arc) = app.tabs[i].scene.document.get_entity(arc_h).unwrap() else { panic!() }; + + println!("moved line end: {:?}", moved_line.end); + let arc_start_moved = glam::DVec3::new( + moved_arc.center.x + moved_arc.radius * moved_arc.start_angle.cos(), + moved_arc.center.y + moved_arc.radius * moved_arc.start_angle.sin(), + 0.0, + ); + println!("moved arc start: {:?}", arc_start_moved); + } + #[test] fn constraint_glyph_tooltip_appears_after_hover_dwell() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 0070c54b3..c9dc920f2 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -234,7 +234,7 @@ impl OpenCADStudio { let saved_from = self.snapper.from_point; self.snapper.grid_snap_on = false; self.snapper.from_point = - saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3()); + saved_from.map(|p| frame.paper_to_model(p)); let hit = self.snapper.snap( model_cursor, local, diff --git a/src/command.rs b/src/command.rs index e15c4c0ac..0adeb7463 100644 --- a/src/command.rs +++ b/src/command.rs @@ -255,12 +255,19 @@ pub enum EntityTransform { // ── Tangent object ───────────────────────────────────────────────────────── /// Geometric representation of a tangent-snap target. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum TangentObject { /// Infinite line through two world-space XZ-plane points. Line { p1: DVec3, p2: DVec3 }, /// Circle in the world XY plane. Circle { center: DVec3, radius: f64 }, + /// Planar ellipse in world space. + Ellipse { + center: DVec3, + major_axis: DVec3, + normal: DVec3, + minor_axis_ratio: f64, + }, } /// One unit of input to the active command's step machine. @@ -2728,6 +2735,16 @@ pub trait CadCommand: Send { self.on_mouse_move(pt).into_iter().collect() } + /// Called on every mouse-move with optional live tangent snap target under the cursor. + /// Default: forwards to `on_preview_wires(pt)`. + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + _tangent: Option, + ) -> Vec { + self.on_preview_wires(pt) + } + /// Source entities replaced by the current live preview. The host removes /// these from the resident render until the command commits or cancels. /// Commands such as COPY keep their sources visible and use the default. diff --git a/src/modules/draw/draw/apollonius.rs b/src/modules/draw/draw/apollonius.rs new file mode 100644 index 000000000..1e2688bef --- /dev/null +++ b/src/modules/draw/draw/apollonius.rs @@ -0,0 +1,332 @@ +// Apollonius Circle Solvers for Fluid Tangent Arcs. +// +// Solves: +// - CCP (Curve-Curve-Point): Circle passing through point P and tangent to two circles. +// - PPC (Point-Point-Curve): Circle passing through points P1, P2 and tangent to a circle. +// +// Both use Circle Inversion centered at the pass-through point, mapping circles to +// circles/lines and reducing the problem to basic bitangent or point-tangent lines, +// then inverting back. Closed-form, O(1), and numerically stable. + +use glam::DVec2; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Circle2D { + pub center: DVec2, + pub radius: f64, +} + +#[derive(Clone, Copy, Debug)] +pub struct ApolloniusSolution { + pub center: DVec2, + pub radius: f64, + /// Tangent contact point on the first curve. + pub tangent1: DVec2, + /// Tangent contact point on the second curve (for CCP) or None (for PPC). + pub tangent2: Option, +} + +/// Invert a circle about origin with inversion radius k. +/// If circle does not pass through origin, returns inverted Circle2D. +fn invert_circle(c: Circle2D, k: f64) -> Option { + let d2 = c.center.length_squared(); + let denom = d2 - c.radius * c.radius; + if denom.abs() < 1e-9 { + // Circle passes through origin -> inverts to a straight line + return None; + } + let scale = (k * k) / denom; + Some(Circle2D { + center: c.center * scale, + radius: (scale * c.radius).abs(), + }) +} + +/// Invert a line `n.x * x + n.y * y + c = 0` (where |n| = 1) about origin with radius k. +/// Line inverts to a circle through origin. +fn invert_line_to_circle(n: DVec2, c: f64, k: f64) -> Option { + if c.abs() < 1e-9 { + // Line passes through origin -> inverts to itself + return None; + } + let radius = (k * k) / (2.0 * c.abs()); + let center = -n * (k * k / (2.0 * c)); + Some(Circle2D { center, radius }) +} + +/// Find the contact point on `original` closest to tangency with `sol`. +fn compute_contact_point(original: Circle2D, sol: Circle2D) -> DVec2 { + let dir = sol.center - original.center; + let dist = dir.length(); + if dist < 1e-9 { + return original.center + DVec2::new(original.radius, 0.0); + } + let u = dir / dist; + // Choose sign + or - depending on whether sol is external or enclosing + let p_plus = original.center + u * original.radius; + let p_minus = original.center - u * original.radius; + if ((p_plus - sol.center).length() - sol.radius).abs() < 1e-3 { + p_plus + } else { + p_minus + } +} + +/// Bitangent lines between two circles: returns line normals `n` (|n| = 1) and constants `c` +/// such that `n . x + c = 0`. +fn bitangent_lines(c1: Circle2D, c2: Circle2D) -> Vec<(DVec2, f64)> { + let mut lines = Vec::with_capacity(4); + let d_vec = c2.center - c1.center; + let d = d_vec.length(); + if d < 1e-9 { + return lines; + } + let u = d_vec / d; + let v = DVec2::new(-u.y, u.x); + + // 1. External bitangents (r_diff = r1 - r2) + let r_diff = c1.radius - c2.radius; + if d >= r_diff.abs() - 1e-9 { + let sin_a = (r_diff / d).clamp(-1.0, 1.0); + let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt(); + for sign in [-1.0, 1.0] { + let n = u * sin_a + v * (cos_a * sign); + let c = -(n.dot(c1.center) + c1.radius); + lines.push((n, c)); + } + } + + // 2. Internal bitangents (r_sum = r1 + r2) + let r_sum = c1.radius + c2.radius; + if d >= r_sum - 1e-9 { + let sin_a = (r_sum / d).clamp(-1.0, 1.0); + let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt(); + for sign in [-1.0, 1.0] { + let n = u * sin_a + v * (cos_a * sign); + let c = -(n.dot(c1.center) + c1.radius); + lines.push((n, c)); + } + } + + lines +} + +/// Tangent lines from a point to a circle: returns lines `n . x + c = 0`. +fn point_circle_tangent_lines(pt: DVec2, circle: Circle2D) -> Vec<(DVec2, f64)> { + let mut lines = Vec::with_capacity(2); + let d_vec = circle.center - pt; + let d = d_vec.length(); + if d < circle.radius - 1e-9 { + return lines; + } + if d < 1e-9 { + return lines; + } + let u = d_vec / d; + let v = DVec2::new(-u.y, u.x); + + let sin_a = (circle.radius / d).clamp(-1.0, 1.0); + let cos_a = (1.0 - sin_a * sin_a).max(0.0).sqrt(); + + for sign in [-1.0, 1.0] { + let n = u * sin_a + v * (cos_a * sign); + let c = -n.dot(pt); + lines.push((n, c)); + } + + lines +} + +/// Solve Apollonius CCP: circle passing through point `p` and tangent to circles `c1` and `c2`. +pub fn solve_ccp(c1: Circle2D, c2: Circle2D, p: DVec2) -> Vec { + let k = 100.0; // Inversion radius + // Translate origin to p + let c1_local = Circle2D { + center: c1.center - p, + radius: c1.radius, + }; + let c2_local = Circle2D { + center: c2.center - p, + radius: c2.radius, + }; + + let Some(c1_inv) = invert_circle(c1_local, k) else { + return Vec::new(); + }; + let Some(c2_inv) = invert_circle(c2_local, k) else { + return Vec::new(); + }; + + let lines = bitangent_lines(c1_inv, c2_inv); + let mut solutions = Vec::new(); + + for (n, c) in lines { + if let Some(sol_local) = invert_line_to_circle(n, c, k) { + let sol = Circle2D { + center: sol_local.center + p, + radius: sol_local.radius, + }; + // Verify sol is actually tangent to c1 and c2 within tolerance + let d1 = (sol.center - c1.center).length(); + let d2 = (sol.center - c2.center).length(); + let tan1_valid = ((d1 - (sol.radius + c1.radius)).abs() < 1e-4) + || ((d1 - (sol.radius - c1.radius).abs()).abs() < 1e-4); + let tan2_valid = ((d2 - (sol.radius + c2.radius)).abs() < 1e-4) + || ((d2 - (sol.radius - c2.radius).abs()).abs() < 1e-4); + + if tan1_valid && tan2_valid { + let tangent1 = compute_contact_point(c1, sol); + let tangent2 = compute_contact_point(c2, sol); + solutions.push(ApolloniusSolution { + center: sol.center, + radius: sol.radius, + tangent1, + tangent2: Some(tangent2), + }); + } + } + } + + solutions +} + +/// Solve Apollonius PPC: circle passing through points `p1` and `p2`, and tangent to circle `c1`. +pub fn solve_ppc(c1: Circle2D, p1: DVec2, p2: DVec2) -> Vec { + if (p1 - p2).length_squared() < 1e-12 { + return Vec::new(); + } + let k = 100.0; + // Translate origin to p1 + let c1_local = Circle2D { + center: c1.center - p1, + radius: c1.radius, + }; + let p2_local = p2 - p1; + + let Some(c1_inv) = invert_circle(c1_local, k) else { + return Vec::new(); + }; + let p2_inv_len2 = p2_local.length_squared(); + if p2_inv_len2 < 1e-12 { + return Vec::new(); + } + let p2_inv = p2_local * (k * k / p2_inv_len2); + + let lines = point_circle_tangent_lines(p2_inv, c1_inv); + let mut solutions = Vec::new(); + + for (n, c) in lines { + if let Some(sol_local) = invert_line_to_circle(n, c, k) { + let sol = Circle2D { + center: sol_local.center + p1, + radius: sol_local.radius, + }; + let d1 = (sol.center - c1.center).length(); + let tan1_valid = ((d1 - (sol.radius + c1.radius)).abs() < 1e-4) + || ((d1 - (sol.radius - c1.radius).abs()).abs() < 1e-4); + let passes_p2 = ((sol.center - p2).length() - sol.radius).abs() < 1e-4; + + if tan1_valid && passes_p2 { + let tangent1 = compute_contact_point(c1, sol); + solutions.push(ApolloniusSolution { + center: sol.center, + radius: sol.radius, + tangent1, + tangent2: None, + }); + } + } + } + + solutions +} + +/// Choose the best CCP candidate closest to pick hints `hint1` and `hint2`. +pub fn best_ccp( + candidates: &[ApolloniusSolution], + hint1: DVec2, + hint2: DVec2, +) -> Option { + candidates + .iter() + .filter(|sol| sol.radius.is_finite() && sol.radius > 1e-4) + .min_by(|a, b| { + let score = |s: &ApolloniusSolution| -> f64 { + let d1 = (s.tangent1 - hint1).length(); + let d2 = s.tangent2.map_or(0.0, |t| (t - hint2).length()); + let r_pen = if s.radius > 50000.0 { 10000.0 } else { 0.0 }; + d1 + d2 + r_pen + }; + score(a).total_cmp(&score(b)) + }) + .copied() +} + +/// Choose the best PPC candidate closest to pick hint `hint1`. +pub fn best_ppc( + candidates: &[ApolloniusSolution], + hint1: DVec2, +) -> Option { + candidates + .iter() + .filter(|sol| sol.radius.is_finite() && sol.radius > 1e-4) + .min_by(|a, b| { + let score = |s: &ApolloniusSolution| -> f64 { + let d1 = (s.tangent1 - hint1).length(); + let r_pen = if s.radius > 50000.0 { 10000.0 } else { 0.0 }; + d1 + r_pen + }; + score(a).total_cmp(&score(b)) + }) + .copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ccp_two_equal_circles() { + // Two circles of radius 10 at (-20, 0) and (20, 0). + // Middle point P at (0, 10). + // A circle through (0, 10) tangent to both circles at their top (+Y). + let c1 = Circle2D { + center: DVec2::new(-20.0, 0.0), + radius: 10.0, + }; + let c2 = Circle2D { + center: DVec2::new(20.0, 0.0), + radius: 10.0, + }; + let p = DVec2::new(0.0, 10.0); + + let sols = solve_ccp(c1, c2, p); + assert!(!sols.is_empty(), "Should find CCP solutions"); + + let best = best_ccp(&sols, DVec2::new(-20.0, 10.0), DVec2::new(20.0, 10.0)).unwrap(); + // Check that distance to c1 center is radius +- 10 + let d1 = (best.center - c1.center).length(); + assert!((d1 - (best.radius + c1.radius)).abs() < 1e-3 || (d1 - (best.radius - c1.radius).abs()).abs() < 1e-3); + // Check that point P is on the circle + assert!(((best.center - p).length() - best.radius).abs() < 1e-3); + } + + #[test] + fn test_ppc_circle_and_two_points() { + // Circle of radius 10 at (0, 0). + // Points at (0, 20) and (20, 20). + let c = Circle2D { + center: DVec2::new(0.0, 0.0), + radius: 10.0, + }; + let p1 = DVec2::new(0.0, 20.0); + let p2 = DVec2::new(20.0, 20.0); + + let sols = solve_ppc(c, p1, p2); + assert!(!sols.is_empty(), "Should find PPC solutions"); + + let best = best_ppc(&sols, DVec2::new(0.0, 10.0)).unwrap(); + assert!(((best.center - p1).length() - best.radius).abs() < 1e-3); + assert!(((best.center - p2).length() - best.radius).abs() < 1e-3); + } +} diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index e3623b6b1..5f835fef2 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -5,10 +5,11 @@ use codec::{Arc as CadArc, EntityType}; use crate::t; use kernel::geom2d::{self, Curve as KernelCurve}; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, TangentObject, WorkingPlane}; use crate::modules::IconKind; use crate::scene::model::wire_model::{TangentGeom, WireModel}; -use glam::DVec3; +use super::apollonius::{self, Circle2D}; +use glam::{DVec2, DVec3}; const TAU: f64 = std::f64::consts::TAU; @@ -272,6 +273,753 @@ fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option geom2d::arc_sweep_from_chord(r, chord).map(|sweep| start_angle + sweep) } +/// An input point or a fluid tangent snap constraint for arc construction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ArcPick { + Point(DVec3), + Tangent { + target: TangentObject, + hit: DVec3, + }, +} + +impl ArcPick { + pub fn point(&self) -> DVec3 { + match *self { + ArcPick::Point(pt) => pt, + ArcPick::Tangent { hit, .. } => hit, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EllipseGeom { + pub center: DVec3, + pub major_axis: DVec3, + pub normal: DVec3, + pub minor_axis_ratio: f64, +} + +impl EllipseGeom { + pub fn new(center: DVec3, major_axis: DVec3, normal: DVec3, minor_axis_ratio: f64) -> Self { + Self { + center, + major_axis, + normal, + minor_axis_ratio, + } + } + + pub fn a(&self) -> f64 { + self.major_axis.length() + } + + pub fn b(&self) -> f64 { + (self.a() * self.minor_axis_ratio).max(1e-9) + } + + pub fn u_hat(&self) -> DVec3 { + let a = self.a(); + if a > 1e-9 { + self.major_axis / a + } else { + DVec3::X + } + } + + pub fn n_hat(&self) -> DVec3 { + self.normal.normalize_or_zero() + } + + pub fn v_hat(&self) -> DVec3 { + self.n_hat().cross(self.u_hat()).normalize_or_zero() + } + + pub fn param_at(&self, pt: DVec3) -> f64 { + let d = pt - self.center; + let u_proj = d.dot(self.u_hat()); + let v_proj = d.dot(self.v_hat()); + let a = self.a().max(1e-9); + let b = self.b(); + (v_proj / b).atan2(u_proj / a) + } + + pub fn point_at(&self, t: f64) -> DVec3 { + self.center + self.u_hat() * (self.a() * t.cos()) + self.v_hat() * (self.b() * t.sin()) + } + + pub fn normal_at(&self, t: f64) -> DVec3 { + let a = self.a(); + let b = self.b(); + (self.u_hat() * (b * t.cos()) + self.v_hat() * (a * t.sin())).normalize_or_zero() + } + + pub fn osculating_circle_at(&self, t: f64) -> (DVec3, f64) { + let a = self.a(); + let b = self.b(); + let pt = self.point_at(t); + let n = self.normal_at(t); + let num = (a * a * t.sin() * t.sin() + b * b * t.cos() * t.cos()).powf(1.5); + let rho = (num / (a * b)).clamp(1e-3, 1e7); + let osc_center = pt - n * rho; + (osc_center, rho) + } + + pub fn project_point(&self, pt: DVec3) -> DVec3 { + let t = self.param_at(pt); + self.point_at(t) + } +} + +fn fluid_anchor_point(pick: ArcPick, cursor: DVec3, plane: WorkingPlane) -> DVec3 { + match pick { + ArcPick::Point(pt) => pt, + ArcPick::Tangent { target, hit } => match target { + TangentObject::Circle { center, radius } => { + let loc_center = plane.to_local(center); + let loc_pt = plane.to_local(cursor); + let d = (DVec2::new(loc_pt.x, loc_pt.y) - DVec2::new(loc_center.x, loc_center.y)).length(); + if d > 1e-6 { + let dir = (DVec2::new(loc_pt.x, loc_pt.y) - DVec2::new(loc_center.x, loc_center.y)).normalize(); + plane.to_world(DVec3::new(loc_center.x + dir.x * radius, loc_center.y + dir.y * radius, loc_center.z)) + } else { + hit + } + } + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => { + let ell = EllipseGeom::new(center, major_axis, normal, minor_axis_ratio); + ell.project_point(cursor) + } + _ => hit, + }, + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Ellipse2D { + pub center: DVec2, + pub major_dir: DVec2, + pub minor_dir: DVec2, + pub a: f64, + pub b: f64, +} + +impl Ellipse2D { + pub fn new(center: DVec2, major_axis: DVec2, normal_z: f64, minor_ratio: f64) -> Self { + let a = major_axis.length().max(1e-9); + let b = (a * minor_ratio).max(1e-9); + let major_dir = major_axis / a; + let sign = if normal_z < 0.0 { -1.0 } else { 1.0 }; + let minor_dir = DVec2::new(-major_dir.y, major_dir.x) * sign; + Self { + center, + major_dir, + minor_dir, + a, + b, + } + } + + pub fn point_at(&self, t: f64) -> DVec2 { + self.center + self.major_dir * (self.a * t.cos()) + self.minor_dir * (self.b * t.sin()) + } + + pub fn normal_at(&self, t: f64) -> DVec2 { + (self.major_dir * (self.b * t.cos()) + self.minor_dir * (self.a * t.sin())).normalize_or_zero() + } + + pub fn param_at(&self, pt: DVec2) -> f64 { + let d = pt - self.center; + let u_proj = d.dot(self.major_dir); + let v_proj = d.dot(self.minor_dir); + (v_proj / self.b).atan2(u_proj / self.a) + } + + pub fn project_point(&self, pt: DVec2) -> DVec2 { + let t = self.param_at(pt); + self.point_at(t) + } + + /// Returns all points on the ellipse whose normal lines pass through `center`. + /// For any such point P, (P - center) is collinear with the ellipse normal at P. + pub fn normal_points_for_center(&self, center: DVec2) -> Vec { + let d = center - self.center; + let uc = d.dot(self.major_dir); + let vc = d.dot(self.minor_dir); + let a = self.a; + let b = self.b; + let a2_b2 = a * a - b * b; + + // Normal equation: g(t) = (a^2 - b^2) * sin(t)*cos(t) - a*uc*sin(t) + b*vc*cos(t) = 0 + let g = |t: f64| -> f64 { + 0.5 * a2_b2 * (2.0 * t).sin() - a * uc * t.sin() + b * vc * t.cos() + }; + let g_prime = |t: f64| -> f64 { + a2_b2 * (2.0 * t).cos() - a * uc * t.cos() - b * vc * t.sin() + }; + + let n_slices = 16; + let dt = std::f64::consts::TAU / (n_slices as f64); + let mut roots = Vec::with_capacity(4); + + for i in 0..n_slices { + let t0 = i as f64 * dt; + let t1 = (i + 1) as f64 * dt; + let g0 = g(t0); + let g1 = g(t1); + + if g0.abs() < 1e-12 { + let norm = t0.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + continue; + } + + if g0.signum() != g1.signum() { + let mut lo = t0; + let mut hi = t1; + for _ in 0..8 { + let mid = 0.5 * (lo + hi); + let gm = g(mid); + if gm.signum() == g0.signum() { + lo = mid; + } else { + hi = mid; + } + } + let mut root = 0.5 * (lo + hi); + for _ in 0..6 { + let val = g(root); + let der = g_prime(root); + if der.abs() > 1e-12 { + let step = val / der; + root -= step; + if step.abs() < 1e-12 { + break; + } + } + } + let norm = root.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + } + } + + // Also check Newton iteration starting from radial guess + let t_guess = (vc / b).atan2(uc / a); + let mut root_guess = t_guess; + for _ in 0..6 { + let val = g(root_guess); + let der = g_prime(root_guess); + if der.abs() > 1e-12 { + let step = val / der; + root_guess -= step; + if step.abs() < 1e-12 { + break; + } + } + } + if g(root_guess).abs() < 1e-6 { + let norm = root_guess.rem_euclid(std::f64::consts::TAU); + if !roots.iter().any(|&r: &f64| (r - norm).abs() < 1e-4) { + roots.push(norm); + } + } + + roots.into_iter().map(|t| self.point_at(t)).collect() + } +} + +fn target_to_ellipse2d(target: TangentObject, plane: WorkingPlane) -> Option { + match target { + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => { + let loc_center = plane.to_local(center); + let loc_major = plane.vector_to_local(major_axis); + let loc_normal = plane.vector_to_local(normal); + Some(Ellipse2D::new( + DVec2::new(loc_center.x, loc_center.y), + DVec2::new(loc_major.x, loc_major.y), + loc_normal.z, + minor_axis_ratio, + )) + } + _ => None, + } +} + +fn target_to_circle2d_pure(target: TangentObject, plane: WorkingPlane) -> Option { + match target { + TangentObject::Circle { center, radius } => { + let loc = plane.to_local(center); + Some(Circle2D { + center: DVec2::new(loc.x, loc.y), + radius, + }) + } + _ => None, + } +} + +/// 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`. +pub fn solve_ellipse_ppc( + ell: &Ellipse2D, + p1: DVec2, + p2: DVec2, + hint: DVec2, +) -> Option { + let d_vec = p2 - p1; + let d_len = d_vec.length(); + if d_len < 1e-9 { + return None; + } + let m = (p1 + p2) * 0.5; + let n_bisect = DVec2::new(-d_vec.y, d_vec.x) / d_len; + + let eval_t = |t: f64| -> Option<(f64, DVec2, f64, DVec2)> { + let pt = ell.point_at(t); + let n_hat = ell.normal_at(t); + let delta = m - pt; + let det = n_hat.y * n_bisect.x - n_hat.x * n_bisect.y; + if det.abs() < 1e-6 { + return None; + } + let lambda = (-delta.x * n_bisect.y + delta.y * n_bisect.x) / det; + let center = pt + n_hat * lambda; + let r_p = (center - p1).length(); + let r_t = lambda.abs(); + let res = r_t - r_p; + Some((res, pt, lambda.abs(), center)) + }; + + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + 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; }; + + let n_hat_a = ell.normal_at(t_a); + let det_a = n_hat_a.y * n_bisect.x - n_hat_a.x * n_bisect.y; + let n_hat_b = ell.normal_at(t_b); + let det_b = n_hat_b.y * n_bisect.x - n_hat_b.x * n_bisect.y; + if det_a.signum() != det_b.signum() { + 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, r, center)) = eval_t(best_t) { + if res.abs() < 1e-3 && r.is_finite() && r > 1e-4 { + candidates.push((pt, r, center)); + } + } + } + } + + 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 Curve 1 is an Ellipse and Curve 2 is a Circle. +pub fn solve_ellipse_circle_ccp( + ell: &Ellipse2D, + c2: Circle2D, + p: DVec2, + hint_ell: DVec2, + hint_c2: DVec2, +) -> Option<(DVec2, DVec2)> { + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + let eval_t = |t: f64| -> Option<(DVec2, DVec2, f64, f64, f64)> { + 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 > 1e7 { + return None; + } + let center = pt + n_hat * lambda; + let d2 = (center - c2.center).length(); + let res_ext = d2 - (r + c2.radius); + let res_int = d2 - (r - c2.radius).abs(); + Some((pt, center, r, res_ext, res_int)) + }; + + let mut check_mode = |is_ext: bool| { + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((_, _, _, res_ext_a, res_int_a)) = eval_t(t_a) else { continue; }; + let Some((_, _, _, res_ext_b, res_int_b)) = eval_t(t_b) else { continue; }; + + let pt_a = ell.point_at(t_a); + let n_hat_a = ell.normal_at(t_a); + let w_a = pt_a - p; + let pt_b = ell.point_at(t_b); + let n_hat_b = ell.normal_at(t_b); + let w_b = pt_b - p; + if (w_a.dot(n_hat_a)).signum() != (w_b.dot(n_hat_b)).signum() { + continue; + } + + let (ra, rb) = if is_ext { (res_ext_a, res_ext_b) } else { (res_int_a, res_int_b) }; + if ra.signum() != rb.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_ext_m, res_int_m)) = eval_t(mid) else { break; }; + let rm = if is_ext { res_ext_m } else { res_int_m }; + best_t = mid; + if rm.abs() < 1e-8 { + break; + } + if rm.signum() == ra.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((pt1, center, r, res_ext_final, res_int_final)) = eval_t(best_t) { + let res_final = if is_ext { res_ext_final } else { res_int_final }; + if res_final.abs() < 1e-3 { + let d2_vec = center - c2.center; + let d2_len = d2_vec.length(); + if d2_len > 1e-9 { + let dir = d2_vec / d2_len; + let pt2 = if is_ext { + c2.center + dir * c2.radius + } else if r > c2.radius { + c2.center - dir * c2.radius + } else { + c2.center + dir * c2.radius + }; + candidates.push((pt1, pt2, r)); + } + } + } + } + } + }; + + check_mode(true); + check_mode(false); + + candidates + .into_iter() + .min_by(|(pt1_a, pt2_a, r_a), (pt1_b, pt2_b, r_b)| { + let dist_a = (*pt1_a - hint_ell).length_squared() + (*pt2_a - hint_c2).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 }; + let dist_b = (*pt1_b - hint_ell).length_squared() + (*pt2_b - hint_c2).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 }; + dist_a.total_cmp(&dist_b) + }) + .map(|(pt1, pt2, _)| (pt1, pt2)) +} + +/// Solves CCP (Curve, Curve, Point) where both Curves are Ellipses. +pub fn solve_ellipse_ellipse_ccp( + ell1: &Ellipse2D, + ell2: &Ellipse2D, + p: DVec2, + hint1: DVec2, + hint2: DVec2, +) -> Option<(DVec2, DVec2)> { + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + let eval_t = |t: f64| -> Option<(DVec2, DVec2, f64, f64)> { + let pt1 = ell1.point_at(t); + let n_hat = ell1.normal_at(t); + let w = pt1 - 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 > 1e7 { + return None; + } + let center = pt1 + n_hat * lambda; + let normal_pts = ell2.normal_points_for_center(center); + let pt2 = normal_pts + .into_iter() + .min_by(|a, b| (*a - hint2).length_squared().total_cmp(&(*b - hint2).length_squared()))?; + let d2 = (center - pt2).length(); + let res = d2 - r; + Some((pt1, pt2, r, res)) + }; + + 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; }; + + let pt_a = ell1.point_at(t_a); + let n_hat_a = ell1.normal_at(t_a); + let w_a = pt_a - p; + let pt_b = ell1.point_at(t_b); + let n_hat_b = ell1.normal_at(t_b); + let w_b = pt_b - p; + if (w_a.dot(n_hat_a)).signum() != (w_b.dot(n_hat_b)).signum() { + 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((pt1, pt2, r, res_final)) = eval_t(best_t) { + if res_final.abs() < 1e-3 { + candidates.push((pt1, pt2, r)); + } + } + } + } + + candidates + .into_iter() + .min_by(|(pt1_a, pt2_a, r_a), (pt1_b, pt2_b, r_b)| { + let dist_a = (*pt1_a - hint1).length_squared() + (*pt2_a - hint2).length_squared() + if *r_a > 50000.0 { 1e8 } else { 0.0 }; + let dist_b = (*pt1_b - hint1).length_squared() + (*pt2_b - hint2).length_squared() + if *r_b > 50000.0 { 1e8 } else { 0.0 }; + dist_a.total_cmp(&dist_b) + }) + .map(|(pt1, pt2, _)| (pt1, pt2)) +} + +fn solve_tangent_ccp( + t1: TangentObject, + h1: DVec3, + t2: TangentObject, + h2: DVec3, + mid: DVec3, + plane: WorkingPlane, +) -> Option<(DVec3, DVec3)> { + let z = plane.to_local(mid).z; + let p = DVec2::new(plane.to_local(mid).x, plane.to_local(mid).y); + 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 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); + + if let (Some(ell1), Some(c2)) = (ell1, c2) { + let (w1, w2) = solve_ellipse_circle_ccp(&ell1, c2, 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)), + )); + } + + if let (Some(c1), Some(ell2)) = (c1, ell2) { + let (w2, w1) = solve_ellipse_circle_ccp(&ell2, c1, 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(ell2)) = (ell1, ell2) { + let (w1, w2) = solve_ellipse_ellipse_ccp(&ell1, &ell2, 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)), + )); + } + + 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) { + let w1 = plane.to_world(DVec3::new(best.tangent1.x, best.tangent1.y, z)); + let tan2 = best.tangent2.unwrap_or(best.tangent1); + let w2 = plane.to_world(DVec3::new(tan2.x, tan2.y, z)); + return Some((w1, w2)); + } + } + + None +} + +fn solve_tangent_ppc( + t1: TangentObject, + h1: DVec3, + p1: DVec3, + p2: DVec3, + plane: WorkingPlane, +) -> Option { + let z = plane.to_local(p1).z; + let pt1 = DVec2::new(plane.to_local(p1).x, plane.to_local(p1).y); + 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(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))); + } + return None; + } + + if let Some(c1) = target_to_circle2d_pure(t1, plane) { + let sols = apollonius::solve_ppc(c1, pt1, pt2); + if let Some(best) = apollonius::best_ppc(&sols, hint1) { + return Some(plane.to_world(DVec3::new(best.tangent1.x, best.tangent1.y, z))); + } + } + + None +} + +/// Solves an arc from 3 inputs, solving fluid tangencies (Apollonius CCP / PPC) +/// when one or more inputs are Tangent constraints. +pub(crate) fn solve_arc_3_inputs( + p1: ArcPick, + p2: ArcPick, + p3: ArcPick, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64)> { + match (p1, p2, p3) { + // Case 1: Two tangents and one point (Apollonius CCP) + // (Tangent, Point, Tangent) -> bridging arc through middle point! + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(mid), + ArcPick::Tangent { target: t2, hit: h2 }, + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, mid, plane) { + return arc_through_points(w1, mid, w2, plane); + } + } + // (Tangent, Tangent, Point) + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Tangent { target: t2, hit: h2 }, + ArcPick::Point(end), + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, end, plane) { + return arc_through_points(w1, end, w2, plane); + } + } + // (Point, Tangent, Tangent) + ( + ArcPick::Point(start), + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Tangent { target: t2, hit: h2 }, + ) => { + if let Some((w1, w2)) = solve_tangent_ccp(t1, h1, t2, h2, start, plane) { + return arc_through_points(w1, start, w2, plane); + } + } + + // Case 2: One tangent and two points (Apollonius PPC) + // (Tangent, Point, Point) + ( + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(mid), + ArcPick::Point(end), + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, mid, end, plane) { + return arc_through_points(w1, mid, end, plane); + } + } + // (Point, Point, Tangent) + ( + ArcPick::Point(start), + ArcPick::Point(mid), + ArcPick::Tangent { target: t1, hit: h1 }, + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, start, mid, plane) { + return arc_through_points(start, mid, w1, plane); + } + } + // (Point, Tangent, Point) + ( + ArcPick::Point(start), + ArcPick::Tangent { target: t1, hit: h1 }, + ArcPick::Point(end), + ) => { + if let Some(w1) = solve_tangent_ppc(t1, h1, start, end, plane) { + return arc_through_points(start, w1, end, plane); + } + } + + _ => {} + } + + let has_tangent = matches!(p1, ArcPick::Tangent { .. }) + || matches!(p2, ArcPick::Tangent { .. }) + || matches!(p3, ArcPick::Tangent { .. }); + if has_tangent { + None + } else { + arc_through_points(p1.point(), p2.point(), p3.point(), plane) + } +} + // ── Command 1: Center, Start, End ───────────────────────────────────────── pub struct ArcCSECommand { @@ -426,8 +1174,8 @@ impl CadCommand for ArcCSECommand { #[derive(Clone, Copy, Debug, PartialEq)] enum ArcStep { Start, - Second { s: DVec3 }, - End3 { s: DVec3, m: DVec3 }, + Second { s: ArcPick }, + End3 { s: ArcPick, m: ArcPick }, CenterFirst, StartAfterCenter { c: DVec3 }, CenterAfterStart { s: DVec3 }, @@ -594,53 +1342,65 @@ impl CadCommand for ArcCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + let pick = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; match self.step { ArcStep::Start => { - self.step = ArcStep::Second { s: pt }; - CmdResult::NeedPoint + self.step = ArcStep::Second { s: pick }; + Some(CmdResult::NeedPoint) } ArcStep::Second { s } => { - self.step = ArcStep::End3 { s, m: pt }; - CmdResult::NeedPoint + self.step = ArcStep::End3 { s, m: pick }; + Some(CmdResult::NeedPoint) } - ArcStep::End3 { s, m } => match arc_through_points(s, m, pt, self.plane) { - Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), - None => CmdResult::NeedPoint, + ArcStep::End3 { s, m } => match solve_arc_3_inputs(s, m, pick, self.plane) { + Some((c, r, sa, ea)) => Some(arc_result(c, r, sa, ea, self.plane)), + None => Some(CmdResult::NeedPoint), }, ArcStep::CenterFirst => { self.step = ArcStep::StartAfterCenter { c: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } ArcStep::StartAfterCenter { c } => { self.step = ArcStep::EndSC { s: pt, c }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } ArcStep::CenterAfterStart { s } => { self.step = ArcStep::EndSC { s, c: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - ArcStep::EndSC { s, c } => self.commit(self.sc_end(s, c, pt)), + ArcStep::EndSC { s, c } => Some(self.commit(self.sc_end(s, c, pt))), // At a typed prompt a pick reads as commercial solutions do: the cursor's // direction from the centre (angle) or its distance (length, // radius), the cursor itself for a direction / sagitta. ArcStep::AngleSC { s, c } => { let span = self.cursor_span(s, c, pt); - self.commit(self.sc_angle(s, c, span)) + Some(self.commit(self.sc_angle(s, c, span))) } - ArcStep::LengthSC { s, c } => self.commit(self.sc_length(s, c, s.distance(pt))), + ArcStep::LengthSC { s, c } => Some(self.commit(self.sc_length(s, c, s.distance(pt)))), ArcStep::EndAfterStart { s } => { self.step = ArcStep::CenterSE { s, e: pt }; - CmdResult::NeedPoint + Some(CmdResult::NeedPoint) } - ArcStep::CenterSE { s, e } => self.commit(self.sc_end(s, pt, e)), + ArcStep::CenterSE { s, e } => Some(self.commit(self.sc_end(s, pt, e))), ArcStep::AngleSE { s, e } => { - self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane)) + Some(self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane))) } ArcStep::DirectionSE { s, e } => { - self.commit(arc_continue(s, pt - s, e, self.cw, self.plane)) + Some(self.commit(arc_continue(s, pt - s, e, self.cw, self.plane))) } ArcStep::RadiusSE { s, e } => { - self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane)) + Some(self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane))) } } } @@ -657,11 +1417,11 @@ impl CadCommand for ArcCommand { }, ArcStep::Second { s } => match upper.as_str() { "C" | "CE" | "CENTER" | "CENTRE" => { - self.step = ArcStep::CenterAfterStart { s }; + self.step = ArcStep::CenterAfterStart { s: s.point() }; Some(CmdResult::NeedPoint) } "E" | "END" => { - self.step = ArcStep::EndAfterStart { s }; + self.step = ArcStep::EndAfterStart { s: s.point() }; Some(CmdResult::NeedPoint) } _ => None, @@ -811,38 +1571,58 @@ impl CadCommand for ArcCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { match self.step { - ArcStep::Start | ArcStep::CenterFirst => None, - ArcStep::Second { s } | ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { - Some(line_wire(s, pt)) + ArcStep::Start | ArcStep::CenterFirst => Vec::new(), + ArcStep::Second { s } => { + let start = fluid_anchor_point(s, pt, self.plane); + vec![line_wire(start, pt)] } - ArcStep::StartAfterCenter { c } => Some(line_wire(c, pt)), + ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { + vec![line_wire(s, pt)] + } + ArcStep::StartAfterCenter { c } => vec![line_wire(c, pt)], ArcStep::End3 { s, m } => { + let p3 = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; let fallback = WireModel::solid_f64( "rubber_band".into(), - vec![[s.x, s.y, s.z], [m.x, m.y, m.z], [pt.x, pt.y, pt.z]], + vec![ + [s.point().x, s.point().y, s.point().z], + [m.point().x, m.point().y, m.point().z], + [pt.x, pt.y, pt.z], + ], WireModel::CYAN, false, ); - self.preview(arc_through_points(s, m, pt, self.plane), fallback) + self.preview(solve_arc_3_inputs(s, m, p3, self.plane), fallback).into_iter().collect() } - ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)), + ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)).into_iter().collect(), ArcStep::AngleSC { s, c } => { let span = self.cursor_span(s, c, pt); - self.preview(self.sc_angle(s, c, span), line_wire(c, pt)) + self.preview(self.sc_angle(s, c, span), line_wire(c, pt)).into_iter().collect() } ArcStep::LengthSC { s, c } => { - self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)) + self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)).into_iter().collect() } - ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)), + ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)).into_iter().collect(), ArcStep::AngleSE { s, e } => { - self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } ArcStep::DirectionSE { s, e } => { - self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } ArcStep::RadiusSE { s, e } => { - self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)) + self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)).into_iter().collect() } } } @@ -851,14 +1631,14 @@ impl CadCommand for ArcCommand { // ── Command 2: 3-Point (ARC_3P) ────────────────────────────────────────── pub struct Arc3PCommand { - pts: Vec, + picks: Vec, plane: WorkingPlane, } impl Arc3PCommand { pub fn new() -> Self { Self { - pts: Vec::new(), + picks: Vec::new(), plane: WorkingPlane::default(), } } @@ -873,30 +1653,43 @@ impl CadCommand for Arc3PCommand { "ARC_3P" } fn prompt(&self) -> String { - match self.pts.len() { + match self.picks.len() { 0 => t!("ARC 3P Specify start point:").into_owned(), 1 => t!("ARC 3P Specify second point on arc:").into_owned(), _ => t!("ARC 3P Specify end point:").into_owned(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - self.pts.push(pt); - if self.pts.len() < 3 { - return CmdResult::NeedPoint; + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + let pick = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; + self.picks.push(pick); + if self.picks.len() < 3 { + return Some(CmdResult::NeedPoint); } - let (p1, p2, p3) = (self.pts[0], self.pts[1], self.pts[2]); - match arc_through_points(p1, p2, p3, self.plane) { + let (p1, p2, p3) = (self.picks[0], self.picks[1], self.picks[2]); + match solve_arc_3_inputs(p1, p2, p3, self.plane) { None => { - self.pts.pop(); - CmdResult::NeedPoint - } // collinear — retry + self.picks.pop(); + Some(CmdResult::NeedPoint) + } // collinear or no solution — retry Some((center, radius, start, end)) => { - arc_result(center, radius, start, end, self.plane) + Some(arc_result(center, radius, start, end, self.plane)) } } } + fn on_enter(&mut self) -> CmdResult { - if self.pts.is_empty() { + if self.picks.is_empty() { CmdResult::Dispatch("ARC_CONT".into()) } else { CmdResult::Cancel @@ -909,26 +1702,49 @@ impl CadCommand for Arc3PCommand { CmdResult::Cancel } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - match self.pts.len() { - 0 => None, - 1 => Some(line_wire(self.pts[0], pt)), + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { + match self.picks.len() { + 0 => Vec::new(), + 1 => { + let start = fluid_anchor_point(self.picks[0], pt, self.plane); + vec![line_wire(start, pt)] + } _ => { - let (p1, p2) = (self.pts[0], self.pts[1]); + let (p1, p2) = (self.picks[0], self.picks[1]); + let p3 = match tangent { + Some(target) => ArcPick::Tangent { target, hit: pt }, + None => ArcPick::Point(pt), + }; if let Some((center, radius, start, end)) = - arc_through_points(p1, p2, pt, self.plane) + solve_arc_3_inputs(p1, p2, p3, self.plane) { - arc_preview(center, radius, start, end, self.plane) + arc_preview(center, radius, start, end, self.plane).into_iter().collect() } else { - Some(WireModel::solid_f64( + vec![WireModel::solid_f64( "rubber_band".into(), - vec![[p1.x, p1.y, p1.z], [p2.x, p2.y, p2.z], [pt.x, pt.y, pt.z]], + vec![ + [p1.point().x, p1.point().y, p1.point().z], + [p2.point().x, p2.point().y, p2.point().z], + [pt.x, pt.y, pt.z], + ], WireModel::CYAN, false, - )) + )] } } } } + + fn resolved_anchor(&self) -> Option { + self.picks.last().map(|p| p.point()) + } } // ── Command 3: Start, Center, End (ARC_SCE) ────────────────────────────── @@ -2098,4 +2914,304 @@ mod acad_flow_tests { let mut cmd = ArcCommand::new(); assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(c) if c == "ARC_CONT")); } + + #[test] + fn arc_3p_fluid_tangent_between_two_circles() { + let mut cmd = Arc3PCommand::new(); + let c1 = TangentObject::Circle { + center: DVec3::new(-20.0, 0.0, 0.0), + radius: 10.0, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(20.0, 0.0, 0.0), + radius: 10.0, + }; + // Pick 1: Tangent on circle 1 + let r1 = cmd.on_point_with_tangent(DVec3::new(-20.0, 10.0, 0.0), Some(c1)); + assert!(matches!(r1, Some(CmdResult::NeedPoint))); + + // Pick 2: Middle pass-through point + let r2 = cmd.on_point_with_tangent(DVec3::new(0.0, 10.0, 0.0), None); + assert!(matches!(r2, Some(CmdResult::NeedPoint))); + + // Live preview with tangent on circle 2 + let preview = cmd.on_preview_wires_with_tangent(DVec3::new(20.0, 10.0, 0.0), Some(c2)); + assert!(!preview.is_empty(), "Live preview should generate arc wire"); + + // Pick 3: Tangent on circle 2 + let r3 = cmd.on_point_with_tangent(DVec3::new(20.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("command completed")); + // The arc must pass through (0, 10) + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let dist_to_mid = (center - DVec3::new(0.0, 10.0, 0.0)).length(); + assert!((dist_to_mid - arc.radius).abs() < 1e-3); + // The arc must be tangent to c1: |center - c1.center| = |arc.radius +- 10| + let dist_to_c1 = (center - DVec3::new(-20.0, 0.0, 0.0)).length(); + assert!((dist_to_c1 - (arc.radius + 10.0)).abs() < 1e-3 || (dist_to_c1 - (arc.radius - 10.0).abs()).abs() < 1e-3); + } + + #[test] + fn arc_3p_fluid_tangent_to_one_circle_and_two_points() { + let mut cmd = Arc3PCommand::new(); + let c1 = TangentObject::Circle { + center: DVec3::new(0.0, 0.0, 0.0), + radius: 10.0, + }; + // Pick 1: Point 1 + cmd.on_point_with_tangent(DVec3::new(0.0, 20.0, 0.0), None); + // Pick 2: Point 2 + cmd.on_point_with_tangent(DVec3::new(20.0, 20.0, 0.0), None); + // Pick 3: Tangent on circle 1 + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 10.0, 0.0), Some(c1)); + let arc = committed_arc(r3.expect("command completed")); + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + // Passes through p1 and p2 + assert!(((center - DVec3::new(0.0, 20.0, 0.0)).length() - arc.radius).abs() < 1e-3); + assert!(((center - DVec3::new(20.0, 20.0, 0.0)).length() - arc.radius).abs() < 1e-3); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_and_circle() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + + // Pick 1: Tangent on Ellipse 1 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Middle point + cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + // Live preview with tangent on circle 2 + let preview = cmd.on_preview_wires_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + assert!(!preview.is_empty()); + // Pick 3: Tangent on circle 2 + let r3 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("command completed")); + + // Verify the arc's start point lies exactly on the ellipse! + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + // One of the endpoints must lie on the ellipse (x/20)^2 + (y/10)^2 = 1 (relative to center -30,0) + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "One arc endpoint must lie on the ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_two_points_wrong_click_point() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(0.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + // Point 1 and Point 2 + cmd.on_point_with_tangent(DVec3::new(-30.0, 30.0, 0.0), None); + cmd.on_point_with_tangent(DVec3::new(30.0, 30.0, 0.0), None); + + // Pick 3: Tangent on Ellipse 1 at the "wrong" place (e.g. bottom vertex (0, -10)) + // The real tangent solution closest to the points is near the top (0, 10). + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, -10.0, 0.0), Some(e1)); + let arc = committed_arc(r3.expect("command should find valid tangent arc")); + + // Verify the arc endpoints or contact point is tangent to the ellipse + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + // Must pass through p1 and p2 + assert!(((center - DVec3::new(-30.0, 30.0, 0.0)).length() - arc.radius).abs() < 1e-3); + assert!(((center - DVec3::new(30.0, 30.0, 0.0)).length() - arc.radius).abs() < 1e-3); + + // One of the arc endpoints must touch the ellipse (x/20)^2 + (y/10)^2 = 1 + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + let check_on_ellipse = |pt: DVec3| { + ((pt.x / 20.0).powi(2) + (pt.y / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "Arc must terminate exactly on ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_ellipse_and_circle_arbitrary_click() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + + // Pick 1: User clicks on the FAR side of the ellipse (-50, 0) + cmd.on_point_with_tangent(DVec3::new(-50.0, 0.0, 0.0), Some(e1)); + // Pick 2: Middle point (0, 15) + cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + // Pick 3: Circle (30, 10) + let r3 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + let arc = committed_arc(r3.expect("must find closest valid tangent arc")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let d_start = check_on_ellipse(arc_start); + let d_end = check_on_ellipse(arc_end); + assert!(d_start < 1e-3 || d_end < 1e-3, "Arc must terminate exactly on ellipse: d_start={d_start}, d_end={d_end}"); + } + + #[test] + fn arc_3p_fluid_tangent_two_tangents_then_point_all_endpoints_tangent() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + + // Pick 1: Tangent on Ellipse 1 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Tangent on Circle 2 + cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + // Pick 3: Point in between (0, 15) + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + let arc = committed_arc(r3.expect("command completed")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + let check_on_ellipse = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let check_on_circle = |pt: DVec3| { + ((pt - DVec3::new(30.0, 0.0, 0.0)).length() - 10.0).abs() + }; + + // BOTH endpoints must be tangent: one on ellipse 1, one on circle 2! + let start_on_e1 = check_on_ellipse(arc_start) < 1e-3; + let end_on_e1 = check_on_ellipse(arc_end) < 1e-3; + let start_on_c2 = check_on_circle(arc_start) < 1e-3; + let end_on_c2 = check_on_circle(arc_end) < 1e-3; + + assert!( + (start_on_e1 && end_on_c2) || (start_on_c2 && end_on_e1), + "Both arc endpoints must terminate tangentially on the respective curves! start: {arc_start:?}, end: {arc_end:?}" + ); + } + + #[test] + fn arc_3p_fluid_tangent_two_ellipses() { + let mut cmd = Arc3PCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let e2 = TangentObject::Ellipse { + center: DVec3::new(30.0, 0.0, 0.0), + major_axis: DVec3::new(15.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.6, + }; + + // Pick 1: Tangent on Ellipse 1 + cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + // Pick 2: Tangent on Ellipse 2 + cmd.on_point_with_tangent(DVec3::new(30.0, 9.0, 0.0), Some(e2)); + // Pick 3: Point in between (0, 15) + let r3 = cmd.on_point_with_tangent(DVec3::new(0.0, 15.0, 0.0), None); + let arc = committed_arc(r3.expect("command completed for two ellipses")); + + let arc_start = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + let check_on_e1 = |pt: DVec3| { + let dx = pt.x - (-30.0); + let dy = pt.y - 0.0; + ((dx / 20.0).powi(2) + (dy / 10.0).powi(2) - 1.0).abs() + }; + let check_on_e2 = |pt: DVec3| { + let dx = pt.x - 30.0; + let dy = pt.y - 0.0; + ((dx / 15.0).powi(2) + (dy / 9.0).powi(2) - 1.0).abs() + }; + + let start_on_e1 = check_on_e1(arc_start) < 1e-3; + let end_on_e1 = check_on_e1(arc_end) < 1e-3; + let start_on_e2 = check_on_e2(arc_start) < 1e-3; + let end_on_e2 = check_on_e2(arc_end) < 1e-3; + + assert!( + (start_on_e1 && end_on_e2) || (start_on_e2 && end_on_e1), + "Both endpoints must terminate tangentially on the respective ellipses! start: {arc_start:?}, end: {arc_end:?}" + ); + } } + diff --git a/src/modules/draw/draw/circle.rs b/src/modules/draw/draw/circle.rs index cd2da0c22..cdc7ce80e 100644 --- a/src/modules/draw/draw/circle.rs +++ b/src/modules/draw/draw/circle.rs @@ -12,7 +12,8 @@ use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlan use crate::modules::draw::defaults; use crate::modules::IconKind; use crate::scene::model::wire_model::{TangentGeom, WireModel}; -use glam::DVec3; +use super::arc::Ellipse2D; +use glam::{DVec2, DVec3}; const TAU: f64 = std::f64::consts::TAU; @@ -115,6 +116,17 @@ pub(crate) fn tangent_object_local(object: TangentObject, plane: WorkingPlane) - center: plane.to_local(center), radius, }, + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => TangentObject::Ellipse { + center: plane.to_local(center), + major_axis: plane.vector_to_local(major_axis), + normal: plane.vector_to_local(normal), + minor_axis_ratio, + }, } } @@ -601,6 +613,7 @@ fn solve_quadratic(a: f64, b: f64, c: f64) -> Vec { vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)] } +#[allow(dead_code)] pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option { candidates.iter().copied().min_by(|a, b| { a.distance(hint) @@ -609,6 +622,169 @@ pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option { }) } +fn closest_point_on_obj(obj: &TangentObject, pt: DVec3, radius: f64) -> DVec3 { + match obj { + TangentObject::Line { p1, p2 } => { + let d = *p2 - *p1; + let len2 = d.x * d.x + d.y * d.y; + if len2 < 1e-12 { + *p1 + } else { + let t = ((pt.x - p1.x) * d.x + (pt.y - p1.y) * d.y) / len2; + DVec3::new(p1.x + d.x * t, p1.y + d.y * t, 0.0) + } + } + TangentObject::Circle { center, radius: r_obj } => { + let dx = pt.x - center.x; + let dy = pt.y - center.y; + let len = dx.hypot(dy); + if len < 1e-9 { + DVec3::new(center.x + r_obj, center.y, 0.0) + } else { + let p_plus = DVec3::new(center.x + dx * (r_obj / len), center.y + dy * (r_obj / len), 0.0); + let p_minus = DVec3::new(center.x - dx * (r_obj / len), center.y - dy * (r_obj / len), 0.0); + if (p_plus.distance(pt) - radius).abs() < (p_minus.distance(pt) - radius).abs() { + p_plus + } else { + p_minus + } + } + } + TangentObject::Ellipse { center, major_axis, normal, minor_axis_ratio } => { + let ell = Ellipse2D::new( + DVec2::new(center.x, center.y), + DVec2::new(major_axis.x, major_axis.y), + normal.z, + *minor_axis_ratio, + ); + let p_2d = DVec2::new(pt.x, pt.y); + let norm_pts = ell.normal_points_for_center(p_2d); + let closest_2d = norm_pts + .into_iter() + .min_by(|a, b| { + let da = ((*a - p_2d).length() - radius).abs(); + let db = ((*b - p_2d).length() - radius).abs(); + da.total_cmp(&db) + }) + .unwrap_or_else(|| ell.project_point(p_2d)); + DVec3::new(closest_2d.x, closest_2d.y, 0.0) + } + } +} + +fn object_side_alignment(obj: &TangentObject, c: DVec3, target: DVec3) -> f64 { + match obj { + TangentObject::Line { p1, p2 } => { + let dx = p2.x - p1.x; + let dy = p2.y - p1.y; + let len = dx.hypot(dy); + if len < 1e-12 { + return 1.0; + } + let nx = -dy / len; + let ny = dx / len; + let h_c = (c.x - p1.x) * nx + (c.y - p1.y) * ny; + let h_t = (target.x - p1.x) * nx + (target.y - p1.y) * ny; + if h_t.abs() < 1e-9 { + 1.0 + } else { + (h_c * h_t).signum() + } + } + TangentObject::Circle { center, radius: r_obj } => { + let dc = DVec2::new(c.x - center.x, c.y - center.y); + let dt = DVec2::new(target.x - center.x, target.y - center.y); + let lc = dc.length(); + let lt = dt.length(); + if lc < 1e-9 || lt < 1e-9 { + return 1.0; + } + let dir_dot = dc.dot(dt) / (lc * lt); + let c_is_external = lc > *r_obj; + let t_is_external = lt > *r_obj; + if c_is_external == t_is_external { + dir_dot + } else { + -1.0 + } + } + TangentObject::Ellipse { center, major_axis, normal, minor_axis_ratio } => { + let ell = Ellipse2D::new( + DVec2::new(center.x, center.y), + DVec2::new(major_axis.x, major_axis.y), + normal.z, + *minor_axis_ratio, + ); + let c_2d = DVec2::new(c.x, c.y); + let t_2d = DVec2::new(target.x, target.y); + let p_c = ell.project_point(c_2d); + let n_c = (c_2d - p_c).normalize_or_zero(); + let p_t = ell.project_point(t_2d); + let n_t = (t_2d - p_t).normalize_or_zero(); + n_c.dot(n_t) + } + } +} + +fn score_ttr_candidate( + c: DVec3, + obj1: &TangentObject, + obj2: &TangentObject, + radius: f64, + r_inv: f64, + hit1: DVec3, + hit2: DVec3, + cursor: Option, +) -> f64 { + let t1 = closest_point_on_obj(obj1, c, radius); + let t2 = closest_point_on_obj(obj2, c, radius); + + let (s1, s2, d_cur) = if let Some(cur) = cursor { + let s1 = object_side_alignment(obj1, c, cur); + let s2 = object_side_alignment(obj2, c, cur); + let d_cur = ((cur - c).length() - radius).abs(); + (s1, s2, d_cur) + } else { + // Without cursor, obj1's desired side is towards hit2, obj2's desired side is towards hit1 + let s1 = object_side_alignment(obj1, c, hit2); + let s2 = object_side_alignment(obj2, c, hit1); + (s1, s2, 0.0) + }; + + let mismatch = (if s1 < 0.0 { 1.0 } else { 0.0 }) + (if s2 < 0.0 { 1.0 } else { 0.0 }); + let d_pick = (t1 - hit1).length() + (t2 - hit2).length(); + + mismatch * 1000.0 - (s1 + s2) * 20.0 + (d_cur + d_pick) * r_inv +} + +pub(crate) fn pick_best_ttr_candidate( + obj1: TangentObject, + obj2: TangentObject, + radius: f64, + hit1: DVec3, + hit2: DVec3, + cursor: Option, +) -> Option { + let candidates = ttr_candidates(obj1, obj2, radius); + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return Some(candidates[0]); + } + + let r_inv = 1.0 / radius.max(1e-6); + + candidates + .into_iter() + .min_by(|&c_a, &c_b| { + let score_a = score_ttr_candidate(c_a, &obj1, &obj2, radius, r_inv, hit1, hit2, cursor); + let score_b = score_ttr_candidate(c_b, &obj1, &obj2, radius, r_inv, hit1, hit2, cursor); + score_a.total_cmp(&score_b) + }) +} + + fn best_circle_of(candidates: &[(DVec3, f64)], hint: DVec3) -> Option<(DVec3, f64)> { candidates .iter() @@ -631,10 +807,242 @@ fn tangent_curve(object: TangentObject) -> KernelCurve { centre: [center.x, center.y], radius, }), + TangentObject::Ellipse { + center, + major_axis, + minor_axis_ratio, + .. + } => { + let a = major_axis.length(); + let major_axis_2d = if a > 1e-9 { + [major_axis.x / a, major_axis.y / a] + } else { + [1.0, 0.0] + }; + KernelCurve::Ellipse(kernel::geom2d::EllipseArc { + ellipse: kernel::geom2d::Ellipse { + centre: [center.x, center.y], + major_radius: a, + minor_radius: a * minor_axis_ratio, + major_axis: major_axis_2d, + }, + start_parameter: 0.0, + end_parameter: std::f64::consts::TAU, + }) + } } } +fn to_ellipse2d(obj: TangentObject) -> Option { + match obj { + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => Some(Ellipse2D::new( + DVec2::new(center.x, center.y), + DVec2::new(major_axis.x, major_axis.y), + normal.z, + minor_axis_ratio, + )), + _ => None, + } +} + +fn solve_1d_roots( + n_steps: usize, + mut eval: impl FnMut(f64) -> Option, +) -> Vec { + let dt = TAU / (n_steps as f64); + let mut samples: Vec<(f64, Option)> = Vec::with_capacity(n_steps); + for i in 0..n_steps { + let t = i as f64 * dt; + let val = eval(t); + samples.push((t, val)); + } + + let mut roots = Vec::new(); + + for i in 0..n_steps { + let next_i = (i + 1) % n_steps; + let (ta, fa_opt) = samples[i]; + let (tb_raw, fb_opt) = samples[next_i]; + let tb = if next_i == 0 { TAU } else { tb_raw }; + + let (Some(fa), Some(fb)) = (fa_opt, fb_opt) else { continue }; + + // 1. Sign crossing + if fa.signum() != fb.signum() { + let mut lo = ta; + let mut hi = tb; + let mut best_t = 0.5 * (lo + hi); + for _ in 0..24 { + let mid = 0.5 * (lo + hi); + let Some(fm) = eval(mid) else { break }; + best_t = mid; + if fm.abs() < 1e-12 { + break; + } + if fm.signum() == fa.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some(f_val) = eval(best_t) { + if f_val.abs() < 1e-3 { + roots.push(best_t.rem_euclid(TAU)); + } + } + } else { + // 2. Local extremum touching zero (minimum or maximum) + let prev_i = if i == 0 { n_steps - 1 } else { i - 1 }; + if let Some(f_prev) = samples[prev_i].1 { + if fa.abs() <= f_prev.abs() && fa.abs() <= fb.abs() && fa.abs() < 0.2 { + let mut a = ta - dt; + let mut b = tb; + let phi = (5.0f64.sqrt() - 1.0) * 0.5; + let mut c = b - phi * (b - a); + let mut d = a + phi * (b - a); + for _ in 0..24 { + let fc = eval(c).map(|v| v.abs()).unwrap_or(f64::MAX); + let fd = eval(d).map(|v| v.abs()).unwrap_or(f64::MAX); + if fc < fd { + b = d; + } else { + a = c; + } + c = b - phi * (b - a); + d = a + phi * (b - a); + } + let best_t = 0.5 * (a + b); + if let Some(f_val) = eval(best_t) { + if f_val.abs() < 1e-3 { + roots.push(best_t.rem_euclid(TAU)); + } + } + } + } + } + } + + roots +} + +fn ttr_ellipse_candidates( + ell: &Ellipse2D, + other: TangentObject, + radius: f64, +) -> Vec { + let mut candidates = Vec::new(); + let n_steps = 180; + + let other_ell = to_ellipse2d(other); + + // s = +1 (outer offset) and s = -1 (inner offset) + for &s in &[1.0, -1.0] { + let center_at = |t: f64| -> DVec2 { + let pt = ell.point_at(t); + let n = ell.normal_at(t); + pt + n * (s * radius) + }; + + match other { + TangentObject::Line { p1, p2 } => { + let dx = p2.x - p1.x; + let dy = p2.y - p1.y; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + continue; + } + let n_line = DVec2::new(-dy / len, dx / len); + let p1_2d = DVec2::new(p1.x, p1.y); + + for &line_side in &[1.0, -1.0] { + let eval = |t: f64| -> Option { + let c = center_at(t); + let h = n_line.dot(c - p1_2d); + Some(h - line_side * radius) + }; + + for t_root in solve_1d_roots(n_steps, eval) { + let c = center_at(t_root); + candidates.push(DVec3::new(c.x, c.y, 0.0)); + } + } + } + TangentObject::Circle { center, radius: r2 } => { + let c2_2d = DVec2::new(center.x, center.y); + + for &is_int in &[false, true] { + let target_dist = if is_int { + (radius - r2).abs() + } else { + radius + r2 + }; + if target_dist < 1e-6 { + continue; + } + let eval = |t: f64| -> Option { + let c = center_at(t); + Some((c - c2_2d).length() - target_dist) + }; + + for t_root in solve_1d_roots(n_steps, eval) { + let c = center_at(t_root); + candidates.push(DVec3::new(c.x, c.y, 0.0)); + } + } + } + TangentObject::Ellipse { .. } => { + if let Some(ell2) = &other_ell { + for &is_enclosing in &[false, true] { + let eval = |t: f64| -> Option { + let c = center_at(t); + let norm_pts = ell2.normal_points_for_center(c); + let pt2 = if is_enclosing { + norm_pts + .into_iter() + .max_by(|a, b| (*a - c).length_squared().total_cmp(&(*b - c).length_squared()))? + } else { + norm_pts + .into_iter() + .min_by(|a, b| (*a - c).length_squared().total_cmp(&(*b - c).length_squared()))? + }; + let d2 = (c - pt2).length(); + Some(d2 - radius) + }; + + for t_root in solve_1d_roots(n_steps, eval) { + let c = center_at(t_root); + candidates.push(DVec3::new(c.x, c.y, 0.0)); + } + } + } + } + } + } + + let mut unique = Vec::new(); + for c in candidates { + if !unique.iter().any(|u: &DVec3| (*u - c).length_squared() < 1e-4) { + unique.push(c); + } + } + unique +} + pub(crate) fn ttr_candidates(first: TangentObject, second: TangentObject, radius: f64) -> Vec { + if radius <= 1e-9 { + return Vec::new(); + } + if let Some(ell1) = to_ellipse2d(first) { + return ttr_ellipse_candidates(&ell1, second, radius); + } + if let Some(ell2) = to_ellipse2d(second) { + return ttr_ellipse_candidates(&ell2, first, radius); + } let first = tangent_curve(first); let second = tangent_curve(second); fillets_between(&first, &second, radius, Tolerance::default()) @@ -697,6 +1105,7 @@ fn ttt_solve_sign(objs: &[TangentObject; 3], eps: &[f64; 3]) -> Vec<(DVec3, f64) TangentObject::Circle { .. } => { circle_idx.push(i); } + _ => {} } } @@ -829,6 +1238,7 @@ pub struct CircleTTRCommand { step: StepTTR, default_r: f64, plane: WorkingPlane, + last_cursor: Option, } enum StepTTR { @@ -851,10 +1261,11 @@ impl CircleTTRCommand { step: StepTTR::First, default_r: defaults::get_circle_radius(), plane: WorkingPlane::default(), + last_cursor: None, } } - fn result_for_radius(&self, radius: f64) -> CmdResult { + fn result_for_radius(&self, radius: f64, cursor: Option) -> CmdResult { if radius <= 1.0e-9 { return CmdResult::NeedPoint; } @@ -867,9 +1278,14 @@ impl CircleTTRCommand { else { return CmdResult::NeedPoint; }; - let hint = (*hit1 + *hit2) * 0.5; - let candidates = ttr_candidates(*obj1, *obj2, radius); - let Some(center) = best_of(&candidates, hint) else { + let Some(center) = pick_best_ttr_candidate( + *obj1, + *obj2, + radius, + *hit1, + *hit2, + cursor, + ) else { return CmdResult::NeedPoint; }; defaults::set_circle_radius(radius); @@ -927,8 +1343,10 @@ impl CadCommand for CircleTTRCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + let cursor_local = self.plane.to_local(pt); + self.last_cursor = Some(cursor_local); self.radius_from_point(pt) - .map(|radius| self.result_for_radius(radius)) + .map(|radius| self.result_for_radius(radius, Some(cursor_local))) .unwrap_or(CmdResult::NeedPoint) } @@ -951,6 +1369,7 @@ impl CadCommand for CircleTTRCommand { hit1: h1, hit2: hit, }; + self.last_cursor = None; CmdResult::NeedPoint } StepTTR::Radius { .. } => CmdResult::NeedPoint, @@ -962,12 +1381,13 @@ impl CadCommand for CircleTTRCommand { if r <= 0.0 { return Some(CmdResult::NeedPoint); } - matches!(self.step, StepTTR::Radius { .. }).then(|| self.result_for_radius(r)) + matches!(self.step, StepTTR::Radius { .. }) + .then(|| self.result_for_radius(r, self.last_cursor)) } fn on_enter(&mut self) -> CmdResult { if matches!(self.step, StepTTR::Radius { .. }) { - self.result_for_radius(self.default_r) + self.result_for_radius(self.default_r, self.last_cursor) } else { CmdResult::Cancel } @@ -986,7 +1406,16 @@ impl CadCommand for CircleTTRCommand { else { return None; }; - let center = best_of(&ttr_candidates(*obj1, *obj2, radius), (*hit1 + *hit2) * 0.5)?; + let cursor_local = self.plane.to_local(pt); + self.last_cursor = Some(cursor_local); + let center = pick_best_ttr_candidate( + *obj1, + *obj2, + radius, + *hit1, + *hit2, + Some(cursor_local), + )?; Some(circle_wire( self.plane.to_world(center), radius, @@ -1180,4 +1609,156 @@ mod tests { assert!(ok, "no annulus candidate, got {cands:?}"); } } + + #[test] + fn ttr_ellipse_and_circle_test() { + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + let cands = ttr_candidates(e1, c2, 20.0); + assert!(!cands.is_empty(), "ttr_candidates with ellipse + circle should find solutions"); + for c in &cands { + let dist_to_circle_center = (c.truncate() - DVec2::new(30.0, 0.0)).length(); + assert!( + (dist_to_circle_center - (20.0 + 10.0)).abs() < 1e-3 + || (dist_to_circle_center - 10.0).abs() < 1e-3, + "candidate center {c:?} must be tangent to circle" + ); + } + + // Test reverse order: circle first, ellipse second + let cands_rev = ttr_candidates(c2, e1, 20.0); + assert_eq!(cands.len(), cands_rev.len()); + } + + #[test] + fn ttr_ellipse_and_line_test() { + let e = TangentObject::Ellipse { + center: DVec3::ZERO, + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, // b = 10.0, bottom vertex is (0, -10, 0) + }; + let line = TangentObject::Line { + p1: DVec3::new(-50.0, -20.0, 0.0), + p2: DVec3::new(50.0, -20.0, 0.0), + }; + // Gap at bottom vertex (0, -10) to y = -20 is 10. Radius = 5 fits with center (0, -15, 0). + let cands = ttr_candidates(e, line, 5.0); + assert!(!cands.is_empty(), "ttr_candidates with ellipse + line should find solutions"); + assert!( + cands.iter().any(|c| (c.x.abs() < 1e-3) && ((c.y - (-15.0)).abs() < 1e-3)), + "should contain bottom vertex solution (0, -15, 0), got: {cands:?}" + ); + + // Reverse order + let cands_rev = ttr_candidates(line, e, 5.0); + assert!(!cands_rev.is_empty()); + } + + #[test] + fn ttr_ellipse_and_ellipse_test() { + let e1 = TangentObject::Ellipse { + center: DVec3::new(-25.0, 0.0, 0.0), + major_axis: DVec3::new(15.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.6, + }; + let e2 = TangentObject::Ellipse { + center: DVec3::new(25.0, 0.0, 0.0), + major_axis: DVec3::new(15.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.6, + }; + // Gap between right vertex of e1 (-10, 0) and left vertex of e2 (10, 0) is 20. + // A circle with radius 10 fits at (0, 0, 0). + let cands_10 = ttr_candidates(e1, e2, 10.0); + assert!(!cands_10.is_empty(), "ttr_candidates with ellipse + ellipse should find solutions for R=10"); + assert!( + cands_10.iter().any(|c| c.length() < 1e-2), + "should contain center solution at approx (0,0,0), got: {cands_10:?}" + ); + + // Radius 15 should yield symmetric solutions above and below x-axis + let cands_15 = ttr_candidates(e1, e2, 15.0); + assert!(!cands_15.is_empty(), "ttr_candidates with ellipse + ellipse should find solutions for R=15"); + assert!(cands_15.iter().any(|c| c.y > 1.0)); + assert!(cands_15.iter().any(|c| c.y < -1.0)); + } + + #[test] + fn ttr_large_radius_stays_on_cursor_side_acute_angle() { + let l1 = TangentObject::Line { + p1: DVec3::ZERO, + p2: DVec3::new(100.0, 0.0, 0.0), + }; + let ang = 30.0f64.to_radians(); + let l2 = TangentObject::Line { + p1: DVec3::ZERO, + p2: DVec3::new(100.0 * ang.cos(), 100.0 * ang.sin(), 0.0), + }; + let hit1 = DVec3::new(20.0, 0.0, 0.0); + let hit2 = DVec3::new(20.0 * ang.cos(), 20.0 * ang.sin(), 0.0); + + // Test across a wide range of radii from small to very large + for &r in &[5.0, 10.0, 30.0, 50.0, 100.0, 500.0, 2000.0] { + // Cursor in the acute 30-degree wedge (x > 0, y > 0) + let cursor = DVec3::new(r * 0.8, r * 0.2, 0.0); + let center = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, Some(cursor)) + .expect("should find candidate"); + assert!( + center.x > 0.0 && center.y > 0.0, + "center should stay in acute wedge (x > 0, y > 0) for r={r}, but got {center:?}" + ); + + // Also test when cursor is None (using pick points): should also stay in acute wedge + let center_no_cur = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, None) + .expect("should find candidate without cursor"); + assert!( + center_no_cur.x > 0.0 && center_no_cur.y > 0.0, + "center without cursor should stay in acute wedge for r={r}, but got {center_no_cur:?}" + ); + + // If cursor intentionally moves into obtuse 150-degree wedge (x < 0, y > 0) + let cursor_obtuse = DVec3::new(-r * 0.5, r * 0.5, 0.0); + let center_obtuse = pick_best_ttr_candidate(l1, l2, r, hit1, hit2, Some(cursor_obtuse)) + .expect("should find candidate"); + assert!( + center_obtuse.x < 0.0 && center_obtuse.y > 0.0, + "center should follow cursor into obtuse wedge (x < 0, y > 0) for r={r}, got {center_obtuse:?}" + ); + } + } + + #[test] + fn ttr_two_circles_large_radius_stays_on_cursor_side() { + let c1 = TangentObject::Circle { + center: DVec3::new(-20.0, 0.0, 0.0), + radius: 10.0, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(20.0, 0.0, 0.0), + radius: 10.0, + }; + let hit1 = DVec3::new(-20.0, 10.0, 0.0); + let hit2 = DVec3::new(20.0, 10.0, 0.0); + + for &r in &[25.0, 35.0, 50.0, 100.0, 500.0, 2000.0] { + // Cursor above both circles + let cursor = DVec3::new(0.0, r + 10.0, 0.0); + let center = pick_best_ttr_candidate(c1, c2, r, hit1, hit2, Some(cursor)) + .expect("should find candidate"); + assert!( + center.y > 10.0, + "circle should stay externally tangent above circles for r={r}, but got {center:?}" + ); + } + } } diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index 5403b7372..87f7fb9d8 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -13,7 +13,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, TangentObject}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; +use glam::{DVec2, DVec3}; // ── Ribbon definition ───────────────────────────────────────────────────── @@ -102,46 +102,41 @@ impl CadCommand for LineCommand { pt: DVec3, tangent: Option, ) -> Option { - use TangentObject::Circle; match (self.points.last().copied(), self.deferred_tangent, tangent) { - // (A) The FIRST point is tangent to a circle: defer it. The tangent - // point depends on the line's direction, which the next pick gives. + // (A) The FIRST point is tangent to a circle or ellipse: defer it. + // The tangent point depends on the line's direction, which the next pick gives. // Store a provisional point so the rubber band has an origin. - (None, _, Some(Circle { center, radius })) => { + (None, _, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => { self.points.push(pt); - self.deferred_tangent = Some((Circle { center, radius }, pt)); + self.deferred_tangent = Some((target, pt)); Some(CmdResult::NeedPoint) } - // (B) A deferred first tangent AND this point is also tangent to a - // circle: the line is the common tangent of the two circles. Pick - // the one of the (up to four) whose endpoints sit nearest the two - // cursor hits, so the user chooses inner/outer + side by where they - // hover. + // (B) A deferred first tangent AND this point is also tangent to a curve (circle or ellipse): + // The line is the common tangent of the two curves. ( Some(_), - Some((Circle { center: c1, radius: r1 }, hit1)), - Some(Circle { center: c2, radius: r2 }), + Some((target1, hit1)), + Some(target2 @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. })), ) => { self.deferred_tangent = None; - let (t1, t2) = circle_circle_tangents(c1, r1, c2, r2) + let (t1, t2) = common_curve_tangents(target1, target2) .into_iter() .min_by(|a, b| { - let da = (a.0 - hit1).length() + (a.1 - pt).length(); - let db = (b.0 - hit1).length() + (b.1 - pt).length(); + let da = (a.0 - hit1).length_squared() + (a.1 - pt).length_squared(); + let db = (b.0 - hit1).length_squared() + (b.1 - pt).length_squared(); da.total_cmp(&db) }) .unwrap_or((self.points[0], pt)); self.points = vec![t1, t2]; Some(CmdResult::CommitEntity(Self::line_between(t1, t2))) } - // (C) A deferred first tangent, but this point is NOT a circle - // tangent: the line runs from this point, tangent to the deferred - // circle (the nearer of the two tangent points to the first hit). - (Some(_), Some((Circle { center: c1, radius: r1 }, hit1)), _) => { + // (C) A deferred first tangent, but this point is NOT a curve tangent pick: + // The line runs from the tangent point on the deferred curve to this point. + (Some(_), Some((target1, hit1)), _) => { self.deferred_tangent = None; - let start = point_circle_tangents(pt, c1, r1) + let start = point_target_tangents(pt, target1) .map(|(t0, t1)| { - if (t0 - hit1).length() <= (t1 - hit1).length() { + if (t0 - hit1).length_squared() <= (t1 - hit1).length_squared() { t0 } else { t1 @@ -151,9 +146,23 @@ impl CadCommand for LineCommand { self.points = vec![start, pt]; Some(CmdResult::CommitEntity(Self::line_between(start, pt))) } - // No deferred tangent (or a Line tangent, which needs no special - // handling — a line is tangent to itself everywhere): fall back to - // the plain point path. + // (D) First point was already placed (ordinary point or previous segment end), + // and this pick IS tangent to a circle or ellipse: + (Some(last), None, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => { + let end = point_target_tangents(last, target) + .map(|(t0, t1)| { + if (t0 - pt).length_squared() <= (t1 - pt).length_squared() { + t0 + } else { + t1 + } + }) + .unwrap_or(pt); + let line = Self::line_between(last, end); + self.points.push(end); + Some(CmdResult::CommitEntity(line)) + } + // No deferred tangent (or a Line tangent): fall back to the plain point path. _ => None, } } @@ -171,6 +180,7 @@ impl CadCommand for LineCommand { } fn on_escape(&mut self) -> CmdResult { + self.deferred_tangent = None; CmdResult::Cancel } @@ -212,6 +222,7 @@ impl CadCommand for LineCommand { // Only the start is placed (nothing committed yet) — clear // it so the next pick restarts the line. self.points.clear(); + self.deferred_tangent = None; Some(CmdResult::NeedPoint) } else { Some(CmdResult::NeedPoint) @@ -232,90 +243,262 @@ impl CadCommand for LineCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let last = *self.points.last()?; - // With a deferred first tangent, slide the rubber band's start point - // around the circle so it stays tangent toward the moving cursor, - // instead of sticking at the provisional pick point. Keep the side the - // user first picked (nearest that hit) for a stable preview. (#274) - let start = match self.deferred_tangent { - Some((TangentObject::Circle { center, radius }, hit)) => { - point_circle_tangents(pt, center, radius) + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { + let last = match self.points.last() { + Some(&p) => p, + None => return Vec::new(), + }; + + let (start, end) = match (self.deferred_tangent, tangent) { + (Some((target1, hit1)), Some(target2 @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => { + common_curve_tangents(target1, target2) + .into_iter() + .min_by(|a, b| { + let da = (a.0 - hit1).length_squared() + (a.1 - pt).length_squared(); + let db = (b.0 - hit1).length_squared() + (b.1 - pt).length_squared(); + da.total_cmp(&db) + }) + .unwrap_or((last, pt)) + } + (Some((target1, hit1)), _) => { + let start = point_target_tangents(pt, target1) .map(|(t0, t1)| { - if (t0 - hit).length() <= (t1 - hit).length() { + if (t0 - hit1).length_squared() <= (t1 - hit1).length_squared() { t0 } else { t1 } }) - .unwrap_or(last) + .unwrap_or(last); + (start, pt) } - _ => last, + (None, Some(target @ (TangentObject::Circle { .. } | TangentObject::Ellipse { .. }))) => { + let end = point_target_tangents(last, target) + .map(|(t0, t1)| { + if (t0 - pt).length_squared() <= (t1 - pt).length_squared() { + t0 + } else { + t1 + } + }) + .unwrap_or(pt); + (last, end) + } + _ => (last, pt), }; - Some(WireModel::solid_f64( + + vec![WireModel::solid_f64( "rubber_band".to_string(), - vec![[start.x, start.y, start.z], [pt.x, pt.y, pt.z]], + vec![[start.x, start.y, start.z], [end.x, end.y, end.z]], WireModel::CYAN, false, - )) + )] } } // ── Tangent geometry (f64) ──────────────────────────────────────────────── -/// The two points on a circle (`center`, `radius`, XY plane) where a line from -/// external point `p` touches tangentially, or `None` when `p` is inside/on the -/// circle. Each tangent point's radius is perpendicular to the line from `p`, -/// so it sits at the half-angle `acos(radius / |center→p|)` either side of the -/// center→p direction. (#274) -fn point_circle_tangents(p: DVec3, center: DVec3, radius: f64) -> Option<(DVec3, DVec3)> { - let vx = p.x - center.x; - let vy = p.y - center.y; - let d = (vx * vx + vy * vy).sqrt(); - if d <= radius + 1e-9 { - return None; - } - let base = vy.atan2(vx); - let off = (radius / d).acos(); - let at = - |a: f64| DVec3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z); - Some((at(base + off), at(base - off))) +#[derive(Clone, Copy, Debug)] +struct Curve3D { + center: DVec3, + axis_a: DVec3, + axis_b: DVec3, } -/// Up to four common tangent lines of two circles (XY plane), each returned as -/// (tangent point on circle 1, tangent point on circle 2): two external (both -/// radii point the same way) and two internal (opposite ways, present only when -/// the circles are separated). For a unit normal `n` to the tangent line, -/// `n·(c2−c1)` equals `r2−r1` (external) or `−(r1+r2)` (internal); each tangent -/// point is its centre offset by `∓r·n`. (#274) +impl Curve3D { + fn from_tangent_object(obj: TangentObject) -> Option { + match obj { + TangentObject::Circle { center, radius } => { + if radius < 1e-9 { + return None; + } + Some(Self { + center, + axis_a: DVec3::new(radius, 0.0, 0.0), + axis_b: DVec3::new(0.0, radius, 0.0), + }) + } + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => { + let a = major_axis.length(); + if a < 1e-9 { + return None; + } + let mut n = normal.normalize_or_zero(); + if n.length_squared() < 0.5 { + n = DVec3::Z; + } + let u = major_axis / a; + let v = n.cross(u); + let b = a * minor_axis_ratio; + Some(Self { + center, + axis_a: major_axis, + axis_b: v * b, + }) + } + TangentObject::Line { .. } => None, + } + } +} + +/// The two points on a curve (Circle or Ellipse) where a line from external point `p` +/// touches tangentially, or `None` when `p` is inside or on the curve. +fn point_target_tangents(p: DVec3, target: TangentObject) -> Option<(DVec3, DVec3)> { + let curve = Curve3D::from_tangent_object(target)?; + let d = p - curve.center; + let a = curve.axis_a.length(); + let b = curve.axis_b.length(); + if a < 1e-9 || b < 1e-9 { + return None; + } + let u_dir = curve.axis_a / a; + let v_dir = curve.axis_b / b; + + let u0 = d.dot(u_dir); + let v0 = d.dot(v_dir); + let u_norm = u0 / a; + let v_norm = v0 / b; + let dist = (u_norm * u_norm + v_norm * v_norm).sqrt(); + if dist <= 1.0 + 1e-9 { + return None; + } + + let theta0 = v_norm.atan2(u_norm); + let delta = (1.0 / dist).acos(); + let t0 = theta0 + delta; + let t1 = theta0 - delta; + + let pt0 = curve.center + curve.axis_a * t0.cos() + curve.axis_b * t0.sin(); + let pt1 = curve.center + curve.axis_a * t1.cos() + curve.axis_b * t1.sin(); + Some((pt0, pt1)) +} + +#[cfg(test)] +fn point_circle_tangents(p: DVec3, center: DVec3, radius: f64) -> Option<(DVec3, DVec3)> { + point_target_tangents(p, TangentObject::Circle { center, radius }) +} + +/// Common tangent lines between any two curves (Circle-Circle, Circle-Ellipse, Ellipse-Ellipse). +/// Each tangent is returned as (tangent point on curve 1, tangent point on curve 2). +fn common_curve_tangents(target1: TangentObject, target2: TangentObject) -> Vec<(DVec3, DVec3)> { + let c1 = match Curve3D::from_tangent_object(target1) { + Some(c) => c, + None => return Vec::new(), + }; + let c2 = match Curve3D::from_tangent_object(target2) { + Some(c) => c, + None => return Vec::new(), + }; + + let c1_2d = DVec2::new(c1.center.x, c1.center.y); + let a1_2d = DVec2::new(c1.axis_a.x, c1.axis_a.y); + let b1_2d = DVec2::new(c1.axis_b.x, c1.axis_b.y); + + let c2_2d = DVec2::new(c2.center.x, c2.center.y); + let a2_2d = DVec2::new(c2.axis_a.x, c2.axis_a.y); + let b2_2d = DVec2::new(c2.axis_b.x, c2.axis_b.y); + + let delta_c = c2_2d - c1_2d; + if delta_c.length_squared() < 1e-9 { + return Vec::new(); + } + + let eval = |theta: f64, is_int: bool| -> (f64, DVec2, DVec2) { + let n = DVec2::new(theta.cos(), theta.sin()); + let su1 = n.dot(a1_2d); + let sv1 = n.dot(b1_2d); + let r1 = (su1 * su1 + sv1 * sv1).sqrt().max(1e-9); + + let su2 = n.dot(a2_2d); + let sv2 = n.dot(b2_2d); + let r2 = (su2 * su2 + sv2 * sv2).sqrt().max(1e-9); + + let f = if is_int { + n.dot(delta_c) - r2 - r1 + } else { + n.dot(delta_c) + r2 - r1 + }; + + let t1 = c1_2d + (a1_2d * su1 + b1_2d * sv1) / r1; + let t2 = if is_int { + c2_2d - (a2_2d * su2 + b2_2d * sv2) / r2 + } else { + c2_2d + (a2_2d * su2 + b2_2d * sv2) / r2 + }; + (f, t1, t2) + }; + + let mut results = Vec::new(); + let n_steps = 72; + let dt = std::f64::consts::TAU / (n_steps as f64); + + for &is_int in &[false, true] { + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + let (fa, _, _) = eval(t_a, is_int); + let (fb, _, _) = eval(t_b, is_int); + + let mut root = None; + if fa.abs() < 1e-12 { + root = Some(t_a); + } else if fa.signum() != fb.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut f_lo = fa; + let mut best_t = 0.5 * (lo + hi); + for _ in 0..28 { + let mid = 0.5 * (lo + hi); + let (fm, _, _) = eval(mid, is_int); + best_t = mid; + if fm.abs() < 1e-13 { + break; + } + if fm.signum() == f_lo.signum() { + lo = mid; + f_lo = fm; + } else { + hi = mid; + } + } + root = Some(best_t); + } + + if let Some(r_angle) = root { + let (f_final, t1_2d, t2_2d) = eval(r_angle, is_int); + if f_final.abs() < 1e-6 { + let t1 = DVec3::new(t1_2d.x, t1_2d.y, c1.center.z); + let t2 = DVec3::new(t2_2d.x, t2_2d.y, c2.center.z); + if !results.iter().any(|(p1, p2): &(DVec3, DVec3)| { + (*p1 - t1).length_squared() < 1e-6 && (*p2 - t2).length_squared() < 1e-6 + }) { + results.push((t1, t2)); + } + } + } + } + } + results +} + +#[cfg(test)] fn circle_circle_tangents(c1: DVec3, r1: f64, c2: DVec3, r2: f64) -> Vec<(DVec3, DVec3)> { - let dx = c2.x - c1.x; - let dy = c2.y - c1.y; - let dist = (dx * dx + dy * dy).sqrt(); - if dist < 1e-9 { - return Vec::new(); // concentric: no common tangent - } - let (ux, uy) = (dx / dist, dy / dist); // centre→centre unit - let (px, py) = (-uy, ux); // its perpendicular - let mut out = Vec::new(); - for (num, internal) in [(r2 - r1, false), (-(r1 + r2), true)] { - let k = num / dist; - if k.abs() > 1.0 { - continue; // circles too close for this tangent family - } - let s = (1.0 - k * k).max(0.0).sqrt(); - for pm in [1.0_f64, -1.0] { - let nx = k * ux + pm * s * px; - let ny = k * uy + pm * s * py; - let t1 = DVec3::new(c1.x - r1 * nx, c1.y - r1 * ny, c1.z); - let t2 = if internal { - DVec3::new(c2.x + r2 * nx, c2.y + r2 * ny, c2.z) - } else { - DVec3::new(c2.x - r2 * nx, c2.y - r2 * ny, c2.z) - }; - out.push((t1, t2)); - } - } - out + common_curve_tangents( + TangentObject::Circle { center: c1, radius: r1 }, + TangentObject::Circle { center: c2, radius: r2 }, + ) } #[cfg(test)] @@ -360,6 +543,143 @@ mod tangent_tests { let tans = circle_circle_tangents(DVec3::ZERO, 2.0, DVec3::new(1.5, 0.0, 0.0), 2.0); assert_eq!(tans.len(), 2, "overlap → the 2 internal tangents vanish"); } + + #[test] + fn point_ellipse_tangents_are_tangent_to_ellipse() { + let e = TangentObject::Ellipse { + center: DVec3::ZERO, + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, // minor radius = 10 + }; + let p = DVec3::new(20.0, 20.0, 0.0); + let (t0, t1) = point_target_tangents(p, e).expect("point is outside ellipse"); + + for t in [t0, t1] { + // Must lie on ellipse (x/20)^2 + (y/10)^2 = 1 + let on_ell = ((t.x / 20.0).powi(2) + (t.y / 10.0).powi(2) - 1.0).abs(); + assert!(on_ell < 1e-6, "point {t:?} not on ellipse: {on_ell}"); + + // The line (p - t) must be perpendicular to the ellipse normal at t. + // For (x/a)^2 + (y/b)^2 = 1, gradient/normal is (x/a^2, y/b^2). + let grad = DVec3::new(t.x / (20.0 * 20.0), t.y / (10.0 * 10.0), 0.0); + let line_dir = p - t; + let dot = grad.dot(line_dir); + assert!(dot.abs() < 1e-6, "line is not tangent to ellipse: dot={dot}"); + } + + // A point inside the ellipse has no tangents + assert!(point_target_tangents(DVec3::new(5.0, 5.0, 0.0), e).is_none()); + } + + #[test] + fn separated_ellipse_and_circle_have_four_common_tangents() { + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + + let tans = common_curve_tangents(e1, c2); + assert_eq!(tans.len(), 4, "separated ellipse and circle should have 4 common tangents"); + + for (t1, t2) in &tans { + // t1 on ellipse + let dx1 = t1.x - (-30.0); + let dy1 = t1.y; + let on_e1 = ((dx1 / 20.0).powi(2) + (dy1 / 10.0).powi(2) - 1.0).abs(); + assert!(on_e1 < 1e-4, "t1 not on ellipse: {on_e1}"); + + // t2 on circle + let on_c2 = ((*t2 - DVec3::new(30.0, 0.0, 0.0)).length() - 10.0).abs(); + assert!(on_c2 < 1e-4, "t2 not on circle: {on_c2}"); + + // Line t1->t2 is tangent to ellipse at t1 + let grad_e = DVec3::new(dx1 / 400.0, dy1 / 100.0, 0.0); + let line_dir = *t2 - *t1; + assert!(grad_e.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse"); + + // Line t1->t2 is tangent to circle at t2 (perpendicular to radius) + let rad_c = *t2 - DVec3::new(30.0, 0.0, 0.0); + assert!(rad_c.dot(line_dir).abs() < 1e-4, "line not tangent to circle"); + } + } + + #[test] + fn separated_two_ellipses_have_four_common_tangents() { + let e1 = TangentObject::Ellipse { + center: DVec3::new(-35.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let e2 = TangentObject::Ellipse { + center: DVec3::new(35.0, 0.0, 0.0), + major_axis: DVec3::new(15.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.6, + }; + + let tans = common_curve_tangents(e1, e2); + assert_eq!(tans.len(), 4, "separated two ellipses should have 4 common tangents"); + + for (t1, t2) in &tans { + let dx1 = t1.x - (-35.0); + let dy1 = t1.y; + let on_e1 = ((dx1 / 20.0).powi(2) + (dy1 / 10.0).powi(2) - 1.0).abs(); + assert!(on_e1 < 1e-4, "t1 not on ellipse 1: {on_e1}"); + + let dx2 = t2.x - 35.0; + let dy2 = t2.y; + let on_e2 = ((dx2 / 15.0).powi(2) + (dy2 / 9.0).powi(2) - 1.0).abs(); + assert!(on_e2 < 1e-4, "t2 not on ellipse 2: {on_e2}"); + + let line_dir = *t2 - *t1; + let grad1 = DVec3::new(dx1 / 400.0, dy1 / 100.0, 0.0); + assert!(grad1.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse 1"); + + let grad2 = DVec3::new(dx2 / 225.0, dy2 / 81.0, 0.0); + assert!(grad2.dot(line_dir).abs() < 1e-4, "line not tangent to ellipse 2"); + } + } + + #[test] + fn line_command_tangent_to_ellipse_workflow() { + let mut cmd = LineCommand::new(); + let e1 = TangentObject::Ellipse { + center: DVec3::new(-30.0, 0.0, 0.0), + major_axis: DVec3::new(20.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + let c2 = TangentObject::Circle { + center: DVec3::new(30.0, 0.0, 0.0), + radius: 10.0, + }; + + // Pick 1: Tangent on Ellipse 1 (top vertex area) + let r1 = cmd.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + assert!(matches!(r1, Some(CmdResult::NeedPoint))); + + // Live preview with tangent on circle 2 + let preview = cmd.on_preview_wires_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + assert_eq!(preview.len(), 1); + + // Pick 2: Tangent on Circle 2 + let r2 = cmd.on_point_with_tangent(DVec3::new(30.0, 10.0, 0.0), Some(c2)); + assert!(matches!(r2, Some(CmdResult::CommitEntity(_)))); + + // Start a new line: Pick 1 as normal point, Pick 2 as tangent to ellipse + let mut cmd2 = LineCommand::new(); + cmd2.on_point(DVec3::new(0.0, 30.0, 0.0)); + let r_second = cmd2.on_point_with_tangent(DVec3::new(-30.0, 10.0, 0.0), Some(e1)); + assert!(matches!(r_second, Some(CmdResult::CommitEntity(_)))); + } } diff --git a/src/modules/draw/draw/mod.rs b/src/modules/draw/draw/mod.rs index acf5e2de7..bbcd86720 100644 --- a/src/modules/draw/draw/mod.rs +++ b/src/modules/draw/draw/mod.rs @@ -1,3 +1,4 @@ +pub mod apollonius; pub mod arc; pub mod attdef; pub mod centerline; diff --git a/src/modules/model/cylinder_cmd.rs b/src/modules/model/cylinder_cmd.rs index efed69a4e..6b1b11df4 100644 --- a/src/modules/model/cylinder_cmd.rs +++ b/src/modules/model/cylinder_cmd.rs @@ -248,10 +248,13 @@ impl CylinderCommand { else { return None; }; - let candidates = crate::modules::draw::draw::circle::ttr_candidates(first, second, radius); - let local = crate::modules::draw::draw::circle::best_of( - &candidates, - (first_hit + second_hit) * 0.5, + let local = crate::modules::draw::draw::circle::pick_best_ttr_candidate( + first, + second, + radius, + first_hit, + second_hit, + None, )?; Some(self.plane.to_world(local)) } diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index c707b9531..c99d8d9f1 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -272,6 +272,17 @@ fn sphere_tangent_local(object: TangentObject, plane: WorkingPlane) -> TangentOb center: plane.to_local(center), radius, }, + TangentObject::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + } => TangentObject::Ellipse { + center: plane.to_local(center), + major_axis: plane.vector_to_local(major_axis), + normal: plane.vector_to_local(normal), + minor_axis_ratio, + }, } } @@ -285,6 +296,29 @@ fn sphere_tangent_curve(object: TangentObject) -> KernelCurve { centre: [center.x, center.y], radius, }), + TangentObject::Ellipse { + center, + major_axis, + minor_axis_ratio, + .. + } => { + let a = major_axis.length(); + let major_axis_2d = if a > 1e-9 { + [major_axis.x / a, major_axis.y / a] + } else { + [1.0, 0.0] + }; + KernelCurve::Ellipse(kernel::geom2d::EllipseArc { + ellipse: kernel::geom2d::Ellipse { + centre: [center.x, center.y], + major_radius: a, + minor_radius: a * minor_axis_ratio, + major_axis: major_axis_2d, + }, + start_parameter: 0.0, + end_parameter: std::f64::consts::TAU, + }) + } } } @@ -757,10 +791,13 @@ impl PrimitiveCommand { else { return None; }; - let candidates = crate::modules::draw::draw::circle::ttr_candidates(first, second, radius); - let local = crate::modules::draw::draw::circle::best_of( - &candidates, - (first_hit + second_hit) * 0.5, + let local = crate::modules::draw::draw::circle::pick_best_ttr_candidate( + first, + second, + radius, + first_hit, + second_hit, + None, )?; Some(self.plane.to_world(local)) } diff --git a/src/snap.rs b/src/snap.rs index 1bbdc57f8..243d1feae 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -246,7 +246,7 @@ pub struct Snapper { /// origin), if any. Perpendicular snap drops its foot from here so the new /// segment is genuinely perpendicular to the target — without it, perp /// would just give the nearest point on the line. Set before each `snap`. - pub from_point: Option, + pub from_point: Option, /// Parallel snap: the acquired reference as (unit direction, a point on the /// line). When the cursor's direction from the command's start point runs /// parallel to this, the point locks onto that parallel line; the point half @@ -1601,7 +1601,7 @@ impl Snapper { // ── Perpendicular — foot of perpendicular from the drawing base ── // Perpendicular requires a drawing base; cursor fallback acts like Nearest. (#716) if self.is_on(SnapType::Perpendicular) { - if let Some(q) = self.from_point.map(|v| v.as_dvec3()) { + if let Some(q) = self.from_point { if let Some(segments) = &local_segments { for seg in segments { if let Some(foot) = perp_foot(q, seg.a, seg.b) { @@ -2049,15 +2049,24 @@ impl Snapper { let dc = dist2(cursor_screen, sc).sqrt(); let edge_d = (dc - sr).abs(); let w = if let Some(from) = self.from_point { - let Some((t0, t1)) = circle_tangent_points(from, cv, r) else { - continue; - }; - let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds); - let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds); - if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) { - t0 + if let Some((t0, t1)) = circle_tangent_points(from.as_vec3(), cv, r) { + let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds); + let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds); + if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) { + t0 + } else { + t1 + } } else { - t1 + let dx = cursor_screen.x - sc.x; + let dy = cursor_screen.y - sc.y; + let dl = (dx * dx + dy * dy).sqrt(); + let (nx, ny) = if dl > 1e-6 { + (dx / dl, -dy / dl) + } else { + (1.0, 0.0) + }; + Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z) } } else { let dx = cursor_screen.x - sc.x; @@ -2080,26 +2089,36 @@ impl Snapper { start_angle, end_angle, } => { - let Some(from) = self.from_point else { - continue; - }; - let Some(world) = arc_tangent_points( - from.as_dvec3(), - *center, - *axis_x, - *axis_y, - *radius, - *start_angle, - *end_angle, - ) - .into_iter() - .min_by(|a, b| { - let sa = world_to_screen(*a, view_rot, eye, bounds); - let sb = world_to_screen(*b, view_rot, eye, bounds); - dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen)) - }) else { - continue; + let candidate = self.from_point.and_then(|from| { + arc_tangent_points( + from, + *center, + *axis_x, + *axis_y, + *radius, + *start_angle, + *end_angle, + ) + .into_iter() + .min_by(|a, b| { + let sa = world_to_screen(*a, view_rot, eye, bounds); + let sb = world_to_screen(*b, view_rot, eye, bounds); + dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen)) + }) + }); + let fallback = || { + (0..wire.points.len()) + .map(|index| wp_f64(wire, index)) + .filter(|point| point.is_finite()) + .min_by(|a, b| { + let sa = world_to_screen(*a, view_rot, eye, bounds); + let sb = world_to_screen(*b, view_rot, eye, bounds); + dist2(sa, cursor_screen) + .total_cmp(&dist2(sb, cursor_screen)) + }) + .unwrap_or_else(|| DVec3::from_array(*center)) }; + let world = candidate.unwrap_or_else(fallback); let edge_d2 = wire .points .windows(2) @@ -2127,7 +2146,7 @@ impl Snapper { let cv = DVec3::from_array(*center); let candidate = self.from_point.and_then(|from| { planar_circle_tangent_points( - from.as_dvec3(), + from, *center, *axis_x, *axis_y, @@ -2140,9 +2159,6 @@ impl Snapper { dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen)) }) }); - if self.from_point.is_some() && candidate.is_none() { - continue; - } let fallback = || { (0..wire.points.len()) .map(|index| wp_f64(wire, index)) @@ -2174,7 +2190,34 @@ impl Snapper { .fold(f32::INFINITY, f32::min); (world, edge_d2) } - TangentGeom::PlanarEllipse { .. } => continue, + TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + minor_axis_ratio, + .. + } => { + let c = DVec3::from_array(*center); + let u_vec = DVec3::from_array(*major_axis); + let a = u_vec.length(); + if a > 1e-9 { + let u_hat = u_vec / a; + let n_hat = DVec3::from_array(*normal).normalize_or_zero(); + let v_hat = n_hat.cross(u_hat).normalize_or_zero(); + let b = (a * minor_axis_ratio).max(1e-9); + + let d_vec = cursor_world - c; + let u_proj = d_vec.dot(u_hat); + let v_proj = d_vec.dot(v_hat); + let t = (v_proj / b).atan2(u_proj / a); + let world = c + u_hat * (a * t.cos()) + v_hat * (b * t.sin()); + let screen_pt = world_to_screen(world, view_rot, eye, bounds); + let edge_d2 = dist2(screen_pt, cursor_screen); + (world, edge_d2) + } else { + (c, f32::INFINITY) + } + } }; let (tier, sub) = ( snap_tier(SnapType::Tangent), @@ -2211,7 +2254,18 @@ impl Snapper { radius: *radius, } } - TangentGeom::PlanarEllipse { .. } => unreachable!(), + TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + minor_axis_ratio, + .. + } => TangentObject::Ellipse { + center: glam::DVec3::from_array(*center), + major_axis: glam::DVec3::from_array(*major_axis), + normal: glam::DVec3::from_array(*normal), + minor_axis_ratio: *minor_axis_ratio, + }, }; best = Some(SnapResult { world: world_pt, From c827b87cf9b9a8b008d6ec2a379b957067385028 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:47 -0700 Subject: [PATCH 2/2] fix(trim): snap trim cut parameters to exact boundary contact points --- src/modules/draw/modify/geom.rs | 101 +++- src/modules/draw/modify/trim.rs | 789 ++++++++++++++++++++++++++++++-- 2 files changed, 841 insertions(+), 49 deletions(-) diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index 61a1d59b3..f5bb524b9 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -15,7 +15,106 @@ use kernel::geom2d::{self, Ellipse}; /// Re-exported unchanged: these already speak in plain `f64`, so there is no /// call-shape difference for this module to absorb. -pub use kernel::geom2d::{arc_parameter, lerp, normalize_angle}; +pub use kernel::geom2d::{lerp, normalize_angle}; + +/// Where `angle` sits along the arc from `start` to `end`, as `0.0..=1.0`. +/// +/// Unlike `kernel::geom2d::arc_parameter` which clamps any angle outside the arc to 1.0, +/// this clamps angles in the gap outside the arc to 0.0 or 1.0 depending on which endpoint +/// they are closer to. This prevents cursor jitter near `start` from snapping to 1.0. +pub fn arc_parameter(angle: f64, start: f64, end: f64) -> f64 { + let span = kernel::geom2d::arc_span(start, end); + if span < 1e-12 { + return 0.0; + } + let norm_angle = kernel::geom2d::normalize_angle(angle); + let norm_start = kernel::geom2d::normalize_angle(start); + let delta = (norm_angle - norm_start).rem_euclid(std::f64::consts::TAU); + if delta <= span { + (delta / span).clamp(0.0, 1.0) + } else { + let gap = std::f64::consts::TAU - span; + let past_end = delta - span; + if past_end <= gap * 0.5 { + 1.0 + } else { + 0.0 + } + } +} + +/// Finds the closest parametric parameter `t` on an origin-centered ellipse +/// `(x/a)^2 + (y/b)^2 = 1` for an arbitrary query point `(u, v)` in local coordinates, +/// using David Eberly's exact orthogonal projection algorithm. +/// +/// Unlike radial projection `(v/b).atan2(u/a)` which deviates by up to 20-40 degrees +/// when the query point is slightly off the ellipse perimeter (e.g. within screen pick box), +/// this finds the true orthogonal projection on the ellipse regardless of zoom level. +pub fn ellipse_closest_parameter(a: f64, b: f64, u: f64, v: f64) -> f64 { + if a < 1e-12 || b < 1e-12 { + return 0.0; + } + if (a - b).abs() < 1e-9 { + return v.atan2(u); + } + if a < b { + let t_swapped = ellipse_closest_parameter(b, a, v, u); + return std::f64::consts::FRAC_PI_2 - t_swapped; + } + + let sign_x = if u >= 0.0 { 1.0 } else { -1.0 }; + let sign_y = if v >= 0.0 { 1.0 } else { -1.0 }; + let u_abs = u.abs(); + let v_abs = v.abs(); + + if v_abs < 1e-12 { + let x = if u_abs < (a * a - b * b) / a { + a * a * u_abs / (a * a - b * b) + } else { + a + }; + let y = b * (1.0 - (x / a).powi(2)).max(0.0).sqrt(); + return (sign_y * y / b).atan2(sign_x * x / a); + } + + if u_abs < 1e-12 { + return (sign_y * 1.0).atan2(0.0); + } + + let a2 = a * a; + let b2 = b * b; + let mut t0 = -b2 + b * v_abs; + let mut t1 = -b2 + (a * u_abs).hypot(b * v_abs); + let mut t = t0; + + for _ in 0..25 { + let t_a = t + a2; + let t_b = t + b2; + if t_a.abs() < 1e-12 || t_b.abs() < 1e-12 { + break; + } + let f = (a * u_abs / t_a).powi(2) + (b * v_abs / t_b).powi(2) - 1.0; + if f.abs() < 1e-12 { + break; + } + let df = -2.0 * (a2 * u_abs * u_abs / (t_a.powi(3)) + b2 * v_abs * v_abs / (t_b.powi(3))); + let t_next = t - f / df; + if t_next > t0 && t_next < t1 { + t = t_next; + } else { + if f > 0.0 { + t0 = t; + } else { + t1 = t; + } + t = 0.5 * (t0 + t1); + } + } + + let x = a2 * u_abs / (t + a2); + let y = b2 * v_abs / (t + b2); + (sign_y * y / b).atan2(sign_x * x / a) +} /// Preview geometry keeps the density the commands have always used. const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN; diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index baf068f25..17a9e971a 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -14,8 +14,8 @@ use std::f64::consts::TAU; // its call shapes to the loose scalars and f32 render vertices used here. use super::geom; use super::geom::{ - arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts, - lerp as lerp2, normalize_angle as norm, + arc_parameter as arc_t, arc_points as arc_pts, ellipse_closest_parameter, + ellipse_points as ellipse_pts, lerp as lerp2, normalize_angle as norm, }; use crate::modules::draw::fence::{crossing_box_preview, FencePick}; @@ -148,6 +148,37 @@ impl Geo { | Self::Spline { handle, .. } => *handle, } } + + fn endpoints(&self) -> Vec<[f64; 2]> { + match self { + Self::Line { p1, p2, .. } => vec![*p1, *p2], + Self::Arc { cx, cy, r, a0, a1, .. } => vec![ + [cx + r * a0.cos(), cy + r * a0.sin()], + [cx + r * a1.cos(), cy + r * a1.sin()], + ], + Self::Ray { bx, by, .. } => vec![[*bx, *by]], + Self::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => { + if (t1 - t0).abs() < TAU - 1e-6 { + let pt_at = |t: f64| { + let ct = t.cos(); + let st = t.sin(); + [ + cx + nx * a * ct - ny * b * st, + cy + ny * a * ct + nx * b * st, + ] + }; + vec![pt_at(*t0), pt_at(*t1)] + } else { + vec![] + } + } + Self::Spline { curve, .. } => vec![ + curve.point_at(0.0), + curve.point_at(1.0), + ], + Self::Circle { .. } | Self::InfLine { .. } => vec![], + } + } } pub fn is_trim_boundary_entity(entity: &EntityType) -> bool { matches!( @@ -355,8 +386,98 @@ fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { .collect::>() }) .collect(); + + // ── Endpoint contact detection ── + // When a boundary entity terminates on (or within snap tolerance of) the + // target curve, register that contact point as a cut parameter. + const ENDPOINT_TOLERANCE: f64 = CUT_TOLERANCE; + let mut contacts = Vec::new(); + + for geo in geos.iter().filter(|g| geo_handle(g) != handle) { + // 1. Boundary endpoints touching target + for pt in geo.endpoints() { + let t = target.parameter_at(pt); + let valid_t = if bounded { + let slack = CUT_TOLERANCE; + if (-slack..=1.0 + slack).contains(&t) { + Some(t.clamp(0.0, 1.0)) + } else { + None + } + } else { + Some(t) + }; + + if let Some(t_cand) = valid_t { + let proj = target.point_at(t_cand); + let dx = pt[0] - proj[0]; + let dy = pt[1] - proj[1]; + if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { + contacts.push(t_cand); + } + } + } + + // 2. Target endpoints touching boundary + if bounded && !target.is_closed() { + if let Some(boundary_curve) = geo_to_curve(geo) { + let boundary_bounded = matches!(boundary_curve.extent(), KernelExtent::Bounded); + for &t_end in &[0.0, 1.0] { + let pt = target.point_at(t_end); + let u = boundary_curve.parameter_at(pt); + let valid_u = if boundary_bounded { + let slack = CUT_TOLERANCE; + if (-slack..=1.0 + slack).contains(&u) { + Some(u.clamp(0.0, 1.0)) + } else { + None + } + } else { + Some(u) + }; + if let Some(u_cand) = valid_u { + let proj = boundary_curve.point_at(u_cand); + let dx = pt[0] - proj[0]; + let dy = pt[1] - proj[1]; + if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE { + contacts.push(t_end); + } + } + } + } + } + } + + // Prioritize exact endpoint contact parameters over raw ray intersections, + // ensuring trimmed endpoints match boundary endpoints down to machine precision. + let is_closed = target.is_closed(); + for t_contact in contacts { + if let Some(pos) = ts.iter().position(|&t| { + let diff = (t - t_contact).abs(); + let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; + cyclic < 1e-6 + }) { + ts[pos] = t_contact; + } else { + ts.push(t_contact); + } + } + ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + ts.dedup_by(|a, b| { + let diff = (*a - *b).abs(); + let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff }; + cyclic < 1e-6 + }); + + // For closed curves, ensure the first and last cuts don't duplicate across the 0/1 seam + if is_closed && ts.len() >= 2 { + let seam_diff = (ts[ts.len() - 1] - (ts[0] + 1.0)).abs(); + if seam_diff < 1e-6 { + ts.pop(); + } + } + ts } @@ -517,7 +638,7 @@ fn trim_ellipse(orig: &EllipseEnt, ts: &[f64], t_click: f64) -> Vec } else { ts[0] + 1.0 }; - let holds = |t: f64| t >= from - 1e-9 && t <= to + 1e-9; + let holds = |t: f64| t >= from && t < to; (holds(click) || holds(click + 1.0)).then_some((from, to)) }); let Some((from, to)) = gap else { @@ -853,9 +974,7 @@ fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec { for i in 0..n { let ta = ts[i]; let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 }; - if (tc >= ta - 1e-9 && tc <= tb + 1e-9) - || (tc + 1.0 >= ta - 1e-9 && tc + 1.0 <= tb + 1e-9) - { + if (tc >= ta && tc < tb) || (tc + 1.0 >= ta && tc + 1.0 < tb) { removed = Some((ta, tb)); break; } @@ -1037,12 +1156,24 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< let b = seg_bulge(i); let (d, u) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) { let angle = (cy - ba.center[1]).atan2(cx - ba.center[0]); - let travelled = if ba.sweep >= 0.0 { - (angle - ba.start_angle).rem_euclid(TAU) + let (travelled, sweep) = if ba.sweep >= 0.0 { + ((angle - ba.start_angle).rem_euclid(TAU), ba.sweep) } else { - -((ba.start_angle - angle).rem_euclid(TAU)) + ((ba.start_angle - angle).rem_euclid(TAU), -ba.sweep) + }; + let u = if sweep < 1e-12 { + 0.0 + } else if travelled <= sweep { + (travelled / sweep).clamp(0.0, 1.0) + } else { + let gap = TAU - sweep; + let past_end = travelled - sweep; + if past_end <= gap * 0.5 { + 1.0 + } else { + 0.0 + } }; - let u = (travelled / ba.sweep).clamp(0.0, 1.0); let pt = ba.sample(u); let dist2 = (pt[0] - cx).powi(2) + (pt[1] - cy).powi(2); (dist2, u) @@ -1070,17 +1201,19 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< if cuts.len() < 2 { return None; } - let hi = cuts - .iter() - .cloned() - .find(|&c| c > t_click + 1e-9) - .unwrap_or(cuts[0] + total); - let lo = cuts - .iter() - .cloned() - .rev() - .find(|&c| c < t_click - 1e-9) - .unwrap_or(cuts[cuts.len() - 1] - total); + let n = cuts.len(); + let mut removed = None; + for i in 0..n { + let a = cuts[i]; + let b = if i + 1 < n { cuts[i + 1] } else { cuts[0] + total }; + if (t_click >= a && t_click < b) || (t_click + total >= a && t_click + total < b) { + removed = Some((a, b)); + break; + } + } + let Some((lo, hi)) = removed else { + return None; + }; let mut s1 = lo; while s1 <= hi { s1 += total; @@ -1089,14 +1222,29 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option< out.push(EntityType::LwPolyline(piece)); } } else { - let lo = cuts.iter().cloned().rev().find(|&c| c < t_click - 1e-9); - let hi = cuts.iter().cloned().find(|&c| c > t_click + 1e-9); - if let Some(lo) = lo { + let mut bounds = vec![0.0]; + bounds.extend(cuts.iter().copied()); + bounds.push(total); + bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let n = bounds.len(); + let mut removed = None; + for i in 0..n - 1 { + let a = bounds[i]; + let b = bounds[i + 1]; + if (t_click >= a && t_click < b) || (i + 2 == n && t_click <= b) { + removed = Some((a, b)); + break; + } + } + let Some((lo, hi)) = removed else { + return None; + }; + if lo > 1e-6 { if let Some(piece) = extract_sub_polyline(poly, 0.0, lo) { out.push(EntityType::LwPolyline(piece)); } } - if let Some(hi) = hi { + if hi < total - 1e-6 { if let Some(piece) = extract_sub_polyline(poly, hi, total) { out.push(EntityType::LwPolyline(piece)); } @@ -1699,6 +1847,9 @@ fn pick_trim_at( return None; } let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return None; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; @@ -1716,7 +1867,7 @@ fn pick_trim_at( let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_ell = yl.atan2(xl); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); let t_click = arc_t(t_ell, t0, t1); Some(trim_ellipse(e, &ts, t_click)) } @@ -1781,12 +1932,17 @@ fn pick_extend_at( if a < 1e-9 { return None; } + let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return None; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let rx = px - e.center.x; let ry = py - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); let _ = span; extend_ellipse(e, t_click, geos) } @@ -2841,6 +2997,9 @@ impl CadCommand for TrimCommand { return vec![]; } let b = a * e.minor_axis_ratio; + if b < 1e-9 { + return vec![]; + } let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); let t0 = e.start_parameter; let mut t1 = e.end_parameter; @@ -2857,7 +3016,8 @@ impl CadCommand for TrimCommand { let ry = pt.y as f64 - e.center.y; let xl = rx * nx + ry * ny; let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); let survivors = trim_ellipse(e, &ts, t_click); let orig_pts = ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z); @@ -3343,25 +3503,29 @@ impl CadCommand for ExtendCommand { Some(EntityType::Ellipse(e)) => { let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt(); if a >= 1e-9 { - let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); - let t0 = e.start_parameter; - let mut t1 = e.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - let rx = pt.x as f64 - e.center.x; - let ry = pt.y as f64 - e.center.y; - let xl = rx * nx + ry * ny; - let yl = -rx * ny + ry * nx; - let t_click = arc_t(yl.atan2(xl), t0, t1); - if let Some(ext) = extend_ellipse(e, t_click, &self.geos) { - return extend_hover_wires( - &EntityType::Ellipse(e.clone()), - &ext, - &self.all_entities, - handle, - self.implied_edges, - ); + let b = a * e.minor_axis_ratio; + if b >= 1e-9 { + let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a); + let t0 = e.start_parameter; + let mut t1 = e.end_parameter; + if t1 <= t0 { + t1 += TAU; + } + let rx = pt.x as f64 - e.center.x; + let ry = pt.y as f64 - e.center.y; + let xl = rx * nx + ry * ny; + let yl = -rx * ny + ry * nx; + let t_ell = ellipse_closest_parameter(a, b, xl, yl); + let t_click = arc_t(t_ell, t0, t1); + if let Some(ext) = extend_ellipse(e, t_click, &self.geos) { + return extend_hover_wires( + &EntityType::Ellipse(e.clone()), + &ext, + &self.all_entities, + handle, + self.implied_edges, + ); + } } } } @@ -3876,6 +4040,535 @@ mod tests { "edge-on arc segments are skipped instead of panicking: {pts:?}" ); } + + #[test] + fn trim_circle_with_tangent_arcs_touching_at_endpoints() { + use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; + let mut c = CircleEnt::new(); + c.common.handle = Handle::new(1); + c.center = Vector3::new(0.0, 0.0, 0.0); + c.radius = 10.0; + + // Arc 1: center (20, 0), r = 10, from PI/2 to PI (endpoint at (10, 0)) + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(2); + a1.center = Vector3::new(20.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = std::f64::consts::FRAC_PI_2; + a1.end_angle = std::f64::consts::PI; + + // Arc 2: center (-20, 0), r = 10, from 0 to PI/2 (endpoint at (-10, 0)) + let mut a2 = ArcEnt::new(); + a2.common.handle = Handle::new(3); + a2.center = Vector3::new(-20.0, 0.0, 0.0); + a2.radius = 10.0; + a2.start_angle = 0.0; + a2.end_angle = std::f64::consts::FRAC_PI_2; + + let all = vec![ + EntityType::Circle(c), + EntityType::Arc(a1), + EntityType::Arc(a2), + ]; + let geos = build_geos(&all); + + // Click on top of the circle at (0, 10) + let survivors = pick_trim_at(&all, &geos, Handle::new(1), 0.0, 10.0); + assert!(survivors.is_some(), "trimming circle between two tangent arc endpoints must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + if let EntityType::Arc(arc) = &pieces[0] { + assert!((arc.radius - 10.0).abs() < 1e-6); + let span = { + let s = (arc.end_angle - arc.start_angle).rem_euclid(TAU); + if s == 0.0 { TAU } else { s } + }; + let mid_ang = arc.start_angle + span * 0.5; + let mid_y = mid_ang.sin(); + assert!(mid_y < 0.0, "bottom half should survive when clicking top half"); + } else { + panic!("expected Arc survivor"); + } + } + + #[test] + fn test_trim_circle_between_two_tangent_lines() { + use codec::entities::{Circle as CircleEnt, Line as LineEnt}; + let mut c = CircleEnt::new(); + c.common.handle = Handle::new(1); + c.center = Vector3::new(0.0, 0.0, 0.0); + c.radius = 10.0; + + // Line 1: tangent at top (0, 10), from (-20, 10) to (20, 10) + let mut l1 = LineEnt::new(); + l1.common.handle = Handle::new(2); + l1.start = Vector3::new(-20.0, 10.0, 0.0); + l1.end = Vector3::new(20.0, 10.0, 0.0); + + // Line 2: tangent at bottom (0, -10), from (-20, -10) to (20, -10) + let mut l2 = LineEnt::new(); + l2.common.handle = Handle::new(3); + l2.start = Vector3::new(-20.0, -10.0, 0.0); + l2.end = Vector3::new(20.0, -10.0, 0.0); + + let all = vec![ + EntityType::Circle(c), + EntityType::Line(l1), + EntityType::Line(l2), + ]; + let geos = build_geos(&all); + + // Trim circle by clicking right side at (10, 0) + let survivors = pick_trim_at(&all, &geos, Handle::new(1), 10.0, 0.0); + assert!(survivors.is_some(), "trimming circle between two tangent lines must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + let EntityType::Arc(arc) = &pieces[0] else { panic!("expected Arc") }; + + let arc_start = glam::DVec3::new( + arc.radius * arc.start_angle.cos(), + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let arc_end = glam::DVec3::new( + arc.radius * arc.end_angle.cos(), + arc.radius * arc.end_angle.sin(), + 0.0, + ); + + println!("arc start: {:?}, end: {:?}", arc_start, arc_end); + + // Now trim line 1 by clicking at (10, 10) + let l1_survivors = pick_trim_at(&all, &geos, Handle::new(2), 10.0, 10.0); + assert!(l1_survivors.is_some(), "trimming line 1 must succeed"); + let l1_pieces = l1_survivors.unwrap(); + assert_eq!(l1_pieces.len(), 1); + let EntityType::Line(trimmed_l1) = &l1_pieces[0] else { panic!("expected Line") }; + println!("trimmed line 1 start: {:?}, end: {:?}", trimmed_l1.start, trimmed_l1.end); + + // Check distance between trimmed line 1 end and arc start + let line_end = glam::DVec3::new(trimmed_l1.end.x, trimmed_l1.end.y, trimmed_l1.end.z); + let diff = (line_end - arc_start).length(); + println!("Distance between line endpoint and arc endpoint: {:e}", diff); + + // Now test an angled / non-axis-aligned case from external point + let c_center = glam::DVec3::new(123.45, 67.89, 0.0); + let c_radius = 25.0; + let p_ext1 = glam::DVec3::new(200.0, 150.0, 0.0); + let p_ext2 = glam::DVec3::new(50.0, -20.0, 0.0); + + let get_tangents = |p: glam::DVec3| { + let k_curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle { + centre: [c_center.x, c_center.y], + radius: c_radius, + }); + let pts = kernel::geom2d::tangent_from(&k_curve, [p.x, p.y]); + (glam::DVec3::new(pts[0].point[0], pts[0].point[1], 0.0), glam::DVec3::new(pts[1].point[0], pts[1].point[1], 0.0)) + }; + let t1 = get_tangents(p_ext1).0; + let t2 = get_tangents(p_ext2).1; + + let mut c_rot = CircleEnt::new(); + c_rot.common.handle = Handle::new(10); + c_rot.center = Vector3::new(c_center.x, c_center.y, c_center.z); + c_rot.radius = c_radius; + + // Line 1 from p_ext1 extending PAST t1 to make it a boundary line crossing tangent + let dir1 = (t1 - p_ext1).normalize(); + let mut l_rot1 = LineEnt::new(); + l_rot1.common.handle = Handle::new(11); + l_rot1.start = Vector3::new(p_ext1.x, p_ext1.y, 0.0); + l_rot1.end = Vector3::new((t1 + dir1 * 20.0).x, (t1 + dir1 * 20.0).y, 0.0); + + let dir2 = (t2 - p_ext2).normalize(); + let mut l_rot2 = LineEnt::new(); + l_rot2.common.handle = Handle::new(12); + l_rot2.start = Vector3::new(p_ext2.x, p_ext2.y, 0.0); + l_rot2.end = Vector3::new((t2 + dir2 * 20.0).x, (t2 + dir2 * 20.0).y, 0.0); + + let all_rot = vec![ + EntityType::Circle(c_rot.clone()), + EntityType::Line(l_rot1.clone()), + EntityType::Line(l_rot2.clone()), + ]; + let geos_rot = build_geos(&all_rot); + + // Click circle between them + let mid_click = c_center + (t1 + t2 - 2.0 * c_center).normalize() * c_radius; + let rot_survivors = pick_trim_at(&all_rot, &geos_rot, Handle::new(10), mid_click.x, mid_click.y); + assert!(rot_survivors.is_some(), "trimming circle between angled tangent lines must succeed"); + let pieces = rot_survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + let EntityType::Arc(a) = &pieces[0] else { panic!("expected Arc") }; + let a_start = c_center + glam::DVec3::new(a.radius * a.start_angle.cos(), a.radius * a.start_angle.sin(), 0.0); + let a_end = c_center + glam::DVec3::new(a.radius * a.end_angle.cos(), a.radius * a.end_angle.sin(), 0.0); + let err_t1 = (a_start - t1).length().min((a_end - t1).length()); + let err_t2 = (a_start - t2).length().min((a_end - t2).length()); + assert!(err_t1 < 1e-9, "cut point must land within grip tolerance of t1: got {err_t1:e}"); + assert!(err_t2 < 1e-9, "cut point must land within grip tolerance of t2: got {err_t2:e}"); + } + + #[test] + fn trim_ellipse_with_tangent_arcs_touching_at_endpoints() { + use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(10); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(20.0, 0.0, 0.0); + e.minor_axis_ratio = 0.5; // b = 10 + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::TAU; + + // Arc 1 touches right vertex at (20, 0): center (30, 0), r = 10, from PI/2 to PI + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(11); + a1.center = Vector3::new(30.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = std::f64::consts::FRAC_PI_2; + a1.end_angle = std::f64::consts::PI; + + // Arc 2 touches left vertex at (-20, 0): center (-30, 0), r = 10, from 0 to PI/2 + let mut a2 = ArcEnt::new(); + a2.common.handle = Handle::new(12); + a2.center = Vector3::new(-30.0, 0.0, 0.0); + a2.radius = 10.0; + a2.start_angle = 0.0; + a2.end_angle = std::f64::consts::FRAC_PI_2; + + let all = vec![ + EntityType::Ellipse(e), + EntityType::Arc(a1), + EntityType::Arc(a2), + ]; + let geos = build_geos(&all); + + // Click top of ellipse at (0, 10) + let survivors = pick_trim_at(&all, &geos, Handle::new(10), 0.0, 10.0); + assert!(survivors.is_some(), "trimming ellipse between two tangent arc endpoints must succeed"); + let pieces = survivors.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving ellipse piece"); + if let EntityType::Ellipse(ell) = &pieces[0] { + assert!((ell.major_axis.x - 20.0).abs() < 1e-6); + } else { + panic!("expected Ellipse survivor"); + } + } + + #[test] + fn trim_ellipse_selection_tracking_near_breakpoint() { + use codec::entities::{Ellipse as EllipseEnt, Line as LineEnt}; + use std::f64::consts::TAU; + + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(10); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(20.0, 0.0, 0.0); + e.minor_axis_ratio = 0.5; // a = 20, b = 10 + e.start_parameter = 0.0; + e.end_parameter = TAU; + + // Cutting line 1 crossing at t = PI/4 (45 degrees parametric): + // At t = PI/4: x = 20 * cos(PI/4) = 14.142, y = 10 * sin(PI/4) = 7.071 + // (Polar angle is atan2(7.071, 14.142) = 26.56 degrees!) + let mut l1 = LineEnt::new(); + l1.common.handle = Handle::new(11); + l1.start = Vector3::new(0.0, 0.0, 0.0); + l1.end = Vector3::new(30.0, 15.0, 0.0); // passes through (14.142, 7.071) + + // Cutting line 2 crossing at t = 3*PI/4: + let mut l2 = LineEnt::new(); + l2.common.handle = Handle::new(12); + l2.start = Vector3::new(0.0, 0.0, 0.0); + l2.end = Vector3::new(-30.0, 15.0, 0.0); + + let all = vec![EntityType::Ellipse(e), EntityType::Line(l1), EntityType::Line(l2)]; + let geos = build_geos(&all); + + // Click 1: Click at t = 40 deg parametric (x = 20*cos(40°), y = 10*sin(40°)) + // Polar angle is atan(0.5 * tan(40°)) ≈ 22.7°. + // This is before PI/4. Surviving piece should run between the cuts. + let t_before = 40.0_f64.to_radians(); + let px_before = 20.0 * t_before.cos(); + let py_before = 10.0 * t_before.sin(); + let res_before = pick_trim_at(&all, &geos, Handle::new(10), px_before, py_before); + assert!(res_before.is_some(), "trimming at 40 deg must succeed"); + let pieces_before = res_before.unwrap(); + assert_eq!(pieces_before.len(), 1); + + // Click 2: Click at t = 50 deg parametric (x = 20*cos(50°), y = 10*sin(50°)) + // Polar angle is atan(0.5 * tan(50°)) ≈ 30.77°. + // Visually and parametrically, 50° is in the top segment (between PI/4 and 3*PI/4). + // Without the fix, 30.77° polar angle would be < 45° parametric, selecting the WRONG segment! + let t_after = 50.0_f64.to_radians(); + let px_after = 20.0 * t_after.cos(); + let py_after = 10.0 * t_after.sin(); + let res_after = pick_trim_at(&all, &geos, Handle::new(10), px_after, py_after); + assert!(res_after.is_some(), "trimming at 50 deg must succeed"); + let pieces_after = res_after.unwrap(); + assert_eq!(pieces_after.len(), 1); + + // Click 3 & 4: Click slightly INSIDE and OUTSIDE the ellipse near 50 deg. + // Even when displaced by 3 units (pick-box offset when zoomed out), + // orthogonal projection must still correctly identify the top segment! + let nx = 10.0 * t_after.cos(); + let ny = 20.0 * t_after.sin(); + let nlen = nx.hypot(ny); + let (unx, uny) = (nx / nlen, ny / nlen); + + // Displaced outside: + let res_out = pick_trim_at(&all, &geos, Handle::new(10), px_after + 3.0 * unx, py_after + 3.0 * uny); + assert!(res_out.is_some(), "trimming at displaced outside point must succeed"); + + // Displaced inside: + let res_in = pick_trim_at(&all, &geos, Handle::new(10), px_after - 3.0 * unx, py_after - 3.0 * uny); + assert!(res_in.is_some(), "trimming at displaced inside point must succeed"); + + if let (EntityType::Ellipse(surv_after), EntityType::Ellipse(surv_out), EntityType::Ellipse(surv_in)) = + (&pieces_after[0], &res_out.unwrap()[0], &res_in.unwrap()[0]) + { + assert!( + (surv_out.start_parameter - surv_after.start_parameter).abs() < 1e-4, + "displaced outside point should select the same segment as point on curve" + ); + assert!( + (surv_in.start_parameter - surv_after.start_parameter).abs() < 1e-4, + "displaced inside point should select the same segment as point on curve" + ); + } + } + + #[test] + fn trim_arc_selection_tracking_near_start_endpoint() { + use codec::entities::{Arc as ArcEnt, Line as LineEnt}; + // Arc from 0.5 to 2.0 rad (~28.6° to 114.6°), radius 10.0 + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(20); + a.center = Vector3::new(0.0, 0.0, 0.0); + a.radius = 10.0; + a.start_angle = 0.5; + a.end_angle = 2.0; + + // Boundary line crossing arc at midpoint ~ 1.25 rad + let mid = 1.25_f64; + let mut l = LineEnt::new(); + l.common.handle = Handle::new(21); + l.start = Vector3::new(0.0, 0.0, 0.0); + l.end = Vector3::new(20.0 * mid.cos(), 20.0 * mid.sin(), 0.0); + + let all = vec![EntityType::Arc(a), EntityType::Line(l)]; + let geos = build_geos(&all); + + // Hover slightly CW of start endpoint (e.g. angle = 0.49 rad, outside the arc by ~0.5 degree) + let click_ang = 0.49_f64; + let px = 10.0 * click_ang.cos(); + let py = 10.0 * click_ang.sin(); + let res = pick_trim_at(&all, &geos, Handle::new(20), px, py); + assert!(res.is_some(), "trim near start endpoint must succeed"); + let pieces = res.unwrap(); + assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece"); + if let EntityType::Arc(surv) = &pieces[0] { + // It must remove the start-half [0.5, 1.25] and keep the end-half [1.25, 2.0]. + // If the bug were present, it would snap to 1.0 (end-half removed, start-half kept). + assert!( + (surv.end_angle - 2.0).abs() < 0.05, + "survivor should be the end-half [1.25, 2.0], got start={} end={}", + surv.start_angle, + surv.end_angle + ); + } else { + panic!("expected Arc survivor"); + } + } + + #[test] + fn test_tangent_circles_or_arcs_trim() { + use codec::entities::{Arc as ArcEnt, Circle as CircleEnt}; + let mut c1 = CircleEnt::new(); + c1.common.handle = Handle::new(1); + c1.center = Vector3::new(0.0, 0.0, 0.0); + c1.radius = 10.0; + + let mut c2 = CircleEnt::new(); + c2.common.handle = Handle::new(2); + c2.center = Vector3::new(20.0, 0.0, 0.0); + c2.radius = 10.0; + + let all = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone())]; + let geos = build_geos(&all); + let curve1 = entity_curve_xy(&EntityType::Circle(c1.clone())).unwrap(); + let ts = cut_params(&curve1, Handle::new(1), &geos); + assert_eq!(ts, vec![0.0], "circle1 tangent to circle2 must have cut at t=0"); + + // Arc1 touching circle2 tangently + let mut a1 = ArcEnt::new(); + a1.common.handle = Handle::new(3); + a1.center = Vector3::new(0.0, 0.0, 0.0); + a1.radius = 10.0; + a1.start_angle = -1.0; + a1.end_angle = 1.0; + + let all_arc = vec![EntityType::Arc(a1.clone()), EntityType::Circle(c2.clone())]; + let geos_arc = build_geos(&all_arc); + let curve_arc = entity_curve_xy(&EntityType::Arc(a1.clone())).unwrap(); + let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos_arc); + assert_eq!(ts_arc, vec![0.5], "arc1 tangent to circle2 at midpoint must have cut at t=0.5"); + + let trim_res = pick_trim_at(&all_arc, &geos_arc, Handle::new(3), 10.0, 5.0); + assert!(trim_res.is_some(), "trimming arc1 at tangent circle2 must succeed"); + let pieces = trim_res.unwrap(); + assert_eq!(pieces.len(), 1, "trimming half of arc should leave 1 survivor piece"); + + // 1. External tangency: Test across multiple angles and slight epsilons + for angle in [0.0f64, 0.5, 1.0, 1.57, 2.5, 3.14, 4.0, 5.0] { + for eps in [-1e-8, 0.0, 1e-8] { + let dist = 20.0 + eps; + let dx = dist * angle.cos(); + let dy = dist * angle.sin(); + let mut c_off = CircleEnt::new(); + c_off.common.handle = Handle::new(4); + c_off.center = Vector3::new(dx, dy, 0.0); + c_off.radius = 10.0; + let all_off = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_off)]; + let geos_off = build_geos(&all_off); + let ts_off = cut_params(&curve1, Handle::new(1), &geos_off); + assert_eq!(ts_off.len(), 1, "must find exactly 1 tangency cut at angle {angle}, eps {eps}"); + let expected_t = (angle / TAU).rem_euclid(1.0); + let actual_t = ts_off[0]; + let diff = (actual_t - expected_t).abs(); + let cyclic_diff = diff.min(1.0 - diff); + assert!(cyclic_diff < 1e-5, "tangency t must match angle: got {actual_t}, expected {expected_t}"); + } + } + + // 2. Internal tangency: c1 (r=10) and c_inner (r=4) at (6, 0) + let mut c_inner = CircleEnt::new(); + c_inner.common.handle = Handle::new(5); + c_inner.center = Vector3::new(6.0, 0.0, 0.0); + c_inner.radius = 4.0; + let all_int = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_inner.clone())]; + let geos_int = build_geos(&all_int); + let ts_int = cut_params(&curve1, Handle::new(1), &geos_int); + assert_eq!(ts_int, vec![0.0], "internal tangency on c1 must have cut at t=0"); + + // And from c_inner's perspective (r=4 inside r=10): + let curve_inner = entity_curve_xy(&EntityType::Circle(c_inner.clone())).unwrap(); + let ts_inner = cut_params(&curve_inner, Handle::new(5), &geos_int); + assert_eq!(ts_inner, vec![0.0], "internal tangency on c_inner must have cut at t=0"); + + // 3. Two arcs touching tangently at (10, 0) + // a_left: center (0, 0), radius 10, start=0, end=PI (passes through (10,0) at start t=0) + let mut a_left = ArcEnt::new(); + a_left.common.handle = Handle::new(6); + a_left.center = Vector3::new(0.0, 0.0, 0.0); + a_left.radius = 10.0; + a_left.start_angle = 0.0; + a_left.end_angle = std::f64::consts::PI; + + // a_right: center (20, 0), radius 10, start=PI/2, end=3*PI/2 (passes through (10,0) at angle PI, midpoint t=0.5) + let mut a_right = ArcEnt::new(); + a_right.common.handle = Handle::new(7); + a_right.center = Vector3::new(20.0, 0.0, 0.0); + a_right.radius = 10.0; + a_right.start_angle = std::f64::consts::FRAC_PI_2; + a_right.end_angle = 3.0 * std::f64::consts::FRAC_PI_2; + + let all_arcs = vec![EntityType::Arc(a_left.clone()), EntityType::Arc(a_right.clone())]; + let geos_arcs = build_geos(&all_arcs); + let curve_right = entity_curve_xy(&EntityType::Arc(a_right.clone())).unwrap(); + let ts_right = cut_params(&curve_right, Handle::new(7), &geos_arcs); + assert_eq!(ts_right, vec![0.5], "a_right must be cut at midpoint t=0.5 by tangent a_left"); + + let trim_right = pick_trim_at(&all_arcs, &geos_arcs, Handle::new(7), 15.0, 8.0); + assert!(trim_right.is_some(), "trimming a_right at tangent a_left must succeed"); + + // 4. Circle trimmed by 2 tangent circles + // c1 at (0, 0) r=10. c_right at (20, 0) r=10. c_left at (-20, 0) r=10. + let mut c_left = CircleEnt::new(); + c_left.common.handle = Handle::new(8); + c_left.center = Vector3::new(-20.0, 0.0, 0.0); + c_left.radius = 10.0; + let all_3c = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), EntityType::Circle(c_left.clone())]; + let geos_3c = build_geos(&all_3c); + let ts_3c = cut_params(&curve1, Handle::new(1), &geos_3c); + assert_eq!(ts_3c.len(), 2, "c1 touched by 2 tangent circles must have 2 cut points"); + assert_eq!(ts_3c, vec![0.0, 0.5]); + + let trim_3c = pick_trim_at(&all_3c, &geos_3c, Handle::new(1), 0.0, 10.0); + assert!(trim_3c.is_some(), "c1 with 2 tangent circles must be trimmable"); + if let Some(surv) = trim_3c { + assert_eq!(surv.len(), 1, "trimming c1 should leave 1 arc survivor"); + } + + // 5. Extending an arc to meet a tangent circle + // a_short: center (0, 0), radius 10, start=0.5, end=1.5 (span ~ 1.0 rad) + // Tangent circle c2 is at (20, 0), tangent point is at (10, 0) which is angle 0. + // Clicking near start extends start CW towards 0.0. + let mut a_short = ArcEnt::new(); + a_short.common.handle = Handle::new(9); + a_short.center = Vector3::new(0.0, 0.0, 0.0); + a_short.radius = 10.0; + a_short.start_angle = 0.5; + a_short.end_angle = 1.5; + let all_ext = vec![EntityType::Arc(a_short.clone()), EntityType::Circle(c2.clone())]; + let geos_ext = build_geos(&all_ext); + let ext_res = pick_extend_at(&all_ext, &geos_ext, Handle::new(9), 10.0 * 0.6_f64.cos(), 10.0 * 0.6_f64.sin()); + assert!(ext_res.is_some(), "extending arc towards tangent circle must succeed"); + if let Some(EntityType::Arc(ext_arc)) = ext_res { + let start_norm = norm(ext_arc.start_angle); + assert!(start_norm < 1e-5 || (TAU - start_norm) < 1e-5, "extended arc start must reach 0.0 (tangent point)"); + } + } + + #[test] + fn test_skinny_ellipse_crossing_arc() { + use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt}; + let mut e = EllipseEnt::new(); + e.common.handle = Handle::new(100); + e.center = Vector3::new(0.0, 0.0, 0.0); + e.major_axis = Vector3::new(100.0, 0.0, 0.0); + e.minor_axis_ratio = 0.05; // b = 5, very skinny! + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::TAU; + + // Test arcs that physically cross the ellipse near its ends (x = 80, 90, 95, 98, 99) + for &r in &[80.0, 90.0, 95.0, 98.0, 99.0] { + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(101); + a.center = Vector3::new(0.0, 0.0, 0.0); + a.radius = r; + a.start_angle = -0.3; + a.end_angle = 0.3; + + let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; + let geos = build_geos(&all); + + // Trim the ellipse tip beyond the arc (near x = 99.5, y = 0) + let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.9, 0.0); + // Trim the arc near y = 0 (x = r, y = 0) + let arc_res = pick_trim_at(&all, &geos, Handle::new(101), r, 0.0); + assert!(ell_res.is_some(), "ellipse trim should succeed for crossing arc at r={r}"); + assert!(arc_res.is_some(), "arc trim should succeed for crossing arc at r={r}"); + } + + // Test off-axis arc crossing near the tip (center at (96, 20), radius 20, passing near (96, 0)) + { + let mut a = ArcEnt::new(); + a.common.handle = Handle::new(102); + a.center = Vector3::new(96.0, 20.0, 0.0); + a.radius = 20.0; + // Angle around -PI/2 (bottom point is (96, 0)) + a.start_angle = -std::f64::consts::FRAC_PI_2 - 0.4; + a.end_angle = -std::f64::consts::FRAC_PI_2 + 0.4; + + let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)]; + let geos = build_geos(&all); + + let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.0, 0.0); + let arc_res = pick_trim_at(&all, &geos, Handle::new(102), 96.0, 0.0); + assert!(ell_res.is_some(), "ellipse trim should succeed with off-axis crossing arc"); + assert!(arc_res.is_some(), "arc trim should succeed with off-axis crossing arc"); + } + } } // ══════════════════════════════════════════════════════════════════════════