From 92fa6610651e0d5142674bff18824fcc09514d36 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 01:56:03 -0700 Subject: [PATCH] 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,