feat(draw): add fluid tangent arc, circle, and line construction with ellipse support

This commit is contained in:
yztink committed 2026-09-28 01:56:03 -07:00
1 parent 7c0af700e8
commit 92fa661065
11 files changed
+2750 -212

No files matched your search

+86 -9
View File
@@ -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};
+1 -1
View File
@@ -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,
+18 -1
View File
@@ -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<TangentObject>,
) -> Vec<WireModel> {
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.
+332
View File
@@ -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<DVec2>,
}
/// 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<Circle2D> {
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<Circle2D> {
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<ApolloniusSolution> {
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<ApolloniusSolution> {
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<ApolloniusSolution> {
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<ApolloniusSolution> {
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);
}
}
File diff suppressed because it is too large. Load diff
+590 -9
View File
@@ -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<f64> {
vec![(-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a)]
}
#[allow(dead_code)]
pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option<DVec3> {
candidates.iter().copied().min_by(|a, b| {
a.distance(hint)
@@ -609,6 +622,169 @@ pub(crate) fn best_of(candidates: &[DVec3], hint: DVec3) -> Option<DVec3> {
})
}
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<DVec3>,
) -> 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<DVec3>,
) -> Option<DVec3> {
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<Ellipse2D> {
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<f64>,
) -> Vec<f64> {
let dt = TAU / (n_steps as f64);
let mut samples: Vec<(f64, Option<f64>)> = 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<DVec3> {
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<f64> {
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<f64> {
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<f64> {
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<DVec3> {
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<DVec3>,
}
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<DVec3>) -> 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:?}"
);
}
}
}
+410 -90
View File
@@ -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<TangentObject>,
) -> Option<CmdResult> {
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<WireModel> {
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<TangentObject>,
) -> Vec<WireModel> {
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<Self> {
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(_))));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod apollonius;
pub mod arc;
pub mod attdef;
pub mod centerline;
+7 -4
View File
@@ -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))
}
+41 -4
View File
@@ -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))
}
+89 -35
View File
@@ -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<Vec3>,
pub from_point: Option<glam::DVec3>,
/// 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,