forked from 1b-agent/opencadstudio-upstream
Merge pull request #1511 from yztink/feat/fluid-tangent-arc
feat(draw): add fluid tangent arc, circle, and line construction and snap trim cuts to boundary contacts
This commit is contained in:
commit
66ac8c4ec0
13 files changed
+3591
-261
No files matched your search
@@ -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
|
||||
@@ -2941,7 +2954,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
|
||||
@@ -3782,7 +3795,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
|
||||
@@ -6996,6 +7009,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};
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+1175
-59
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,3 +1,4 @@
|
||||
pub mod apollonius;
|
||||
pub mod arc;
|
||||
pub mod attdef;
|
||||
pub mod centerline;
|
||||
|
||||
@@ -15,7 +15,106 @@ use kernel::geom2d::{self, Ellipse};
|
||||
|
||||
/// Re-exported unchanged: these already speak in plain `f64`, so there is no
|
||||
/// call-shape difference for this module to absorb.
|
||||
pub use kernel::geom2d::{arc_parameter, lerp, normalize_angle};
|
||||
pub use kernel::geom2d::{lerp, normalize_angle};
|
||||
|
||||
/// Where `angle` sits along the arc from `start` to `end`, as `0.0..=1.0`.
|
||||
///
|
||||
/// Unlike `kernel::geom2d::arc_parameter` which clamps any angle outside the arc to 1.0,
|
||||
/// this clamps angles in the gap outside the arc to 0.0 or 1.0 depending on which endpoint
|
||||
/// they are closer to. This prevents cursor jitter near `start` from snapping to 1.0.
|
||||
pub fn arc_parameter(angle: f64, start: f64, end: f64) -> f64 {
|
||||
let span = kernel::geom2d::arc_span(start, end);
|
||||
if span < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
let norm_angle = kernel::geom2d::normalize_angle(angle);
|
||||
let norm_start = kernel::geom2d::normalize_angle(start);
|
||||
let delta = (norm_angle - norm_start).rem_euclid(std::f64::consts::TAU);
|
||||
if delta <= span {
|
||||
(delta / span).clamp(0.0, 1.0)
|
||||
} else {
|
||||
let gap = std::f64::consts::TAU - span;
|
||||
let past_end = delta - span;
|
||||
if past_end <= gap * 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the closest parametric parameter `t` on an origin-centered ellipse
|
||||
/// `(x/a)^2 + (y/b)^2 = 1` for an arbitrary query point `(u, v)` in local coordinates,
|
||||
/// using David Eberly's exact orthogonal projection algorithm.
|
||||
///
|
||||
/// Unlike radial projection `(v/b).atan2(u/a)` which deviates by up to 20-40 degrees
|
||||
/// when the query point is slightly off the ellipse perimeter (e.g. within screen pick box),
|
||||
/// this finds the true orthogonal projection on the ellipse regardless of zoom level.
|
||||
pub fn ellipse_closest_parameter(a: f64, b: f64, u: f64, v: f64) -> f64 {
|
||||
if a < 1e-12 || b < 1e-12 {
|
||||
return 0.0;
|
||||
}
|
||||
if (a - b).abs() < 1e-9 {
|
||||
return v.atan2(u);
|
||||
}
|
||||
if a < b {
|
||||
let t_swapped = ellipse_closest_parameter(b, a, v, u);
|
||||
return std::f64::consts::FRAC_PI_2 - t_swapped;
|
||||
}
|
||||
|
||||
let sign_x = if u >= 0.0 { 1.0 } else { -1.0 };
|
||||
let sign_y = if v >= 0.0 { 1.0 } else { -1.0 };
|
||||
let u_abs = u.abs();
|
||||
let v_abs = v.abs();
|
||||
|
||||
if v_abs < 1e-12 {
|
||||
let x = if u_abs < (a * a - b * b) / a {
|
||||
a * a * u_abs / (a * a - b * b)
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let y = b * (1.0 - (x / a).powi(2)).max(0.0).sqrt();
|
||||
return (sign_y * y / b).atan2(sign_x * x / a);
|
||||
}
|
||||
|
||||
if u_abs < 1e-12 {
|
||||
return (sign_y * 1.0).atan2(0.0);
|
||||
}
|
||||
|
||||
let a2 = a * a;
|
||||
let b2 = b * b;
|
||||
let mut t0 = -b2 + b * v_abs;
|
||||
let mut t1 = -b2 + (a * u_abs).hypot(b * v_abs);
|
||||
let mut t = t0;
|
||||
|
||||
for _ in 0..25 {
|
||||
let t_a = t + a2;
|
||||
let t_b = t + b2;
|
||||
if t_a.abs() < 1e-12 || t_b.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
let f = (a * u_abs / t_a).powi(2) + (b * v_abs / t_b).powi(2) - 1.0;
|
||||
if f.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
let df = -2.0 * (a2 * u_abs * u_abs / (t_a.powi(3)) + b2 * v_abs * v_abs / (t_b.powi(3)));
|
||||
let t_next = t - f / df;
|
||||
if t_next > t0 && t_next < t1 {
|
||||
t = t_next;
|
||||
} else {
|
||||
if f > 0.0 {
|
||||
t0 = t;
|
||||
} else {
|
||||
t1 = t;
|
||||
}
|
||||
t = 0.5 * (t0 + t1);
|
||||
}
|
||||
}
|
||||
|
||||
let x = a2 * u_abs / (t + a2);
|
||||
let y = b2 * v_abs / (t + b2);
|
||||
(sign_y * y / b).atan2(sign_x * x / a)
|
||||
}
|
||||
|
||||
/// Preview geometry keeps the density the commands have always used.
|
||||
const SEGMENTS_PER_RADIAN: f64 = geom2d::DEFAULT_SEGMENTS_PER_RADIAN;
|
||||
|
||||
+741
-48
@@ -14,8 +14,8 @@ use std::f64::consts::TAU;
|
||||
// its call shapes to the loose scalars and f32 render vertices used here.
|
||||
use super::geom;
|
||||
use super::geom::{
|
||||
arc_parameter as arc_t, arc_points as arc_pts, ellipse_points as ellipse_pts,
|
||||
lerp as lerp2, normalize_angle as norm,
|
||||
arc_parameter as arc_t, arc_points as arc_pts, ellipse_closest_parameter,
|
||||
ellipse_points as ellipse_pts, lerp as lerp2, normalize_angle as norm,
|
||||
};
|
||||
|
||||
use crate::modules::draw::fence::{crossing_box_preview, FencePick};
|
||||
@@ -148,6 +148,37 @@ impl Geo {
|
||||
| Self::Spline { handle, .. } => *handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoints(&self) -> Vec<[f64; 2]> {
|
||||
match self {
|
||||
Self::Line { p1, p2, .. } => vec![*p1, *p2],
|
||||
Self::Arc { cx, cy, r, a0, a1, .. } => vec![
|
||||
[cx + r * a0.cos(), cy + r * a0.sin()],
|
||||
[cx + r * a1.cos(), cy + r * a1.sin()],
|
||||
],
|
||||
Self::Ray { bx, by, .. } => vec![[*bx, *by]],
|
||||
Self::Ellipse { cx, cy, a, b, nx, ny, t0, t1, .. } => {
|
||||
if (t1 - t0).abs() < TAU - 1e-6 {
|
||||
let pt_at = |t: f64| {
|
||||
let ct = t.cos();
|
||||
let st = t.sin();
|
||||
[
|
||||
cx + nx * a * ct - ny * b * st,
|
||||
cy + ny * a * ct + nx * b * st,
|
||||
]
|
||||
};
|
||||
vec![pt_at(*t0), pt_at(*t1)]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
Self::Spline { curve, .. } => vec![
|
||||
curve.point_at(0.0),
|
||||
curve.point_at(1.0),
|
||||
],
|
||||
Self::Circle { .. } | Self::InfLine { .. } => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn is_trim_boundary_entity(entity: &EntityType) -> bool {
|
||||
matches!(
|
||||
@@ -355,8 +386,98 @@ fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec<f64> {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Endpoint contact detection ──
|
||||
// When a boundary entity terminates on (or within snap tolerance of) the
|
||||
// target curve, register that contact point as a cut parameter.
|
||||
const ENDPOINT_TOLERANCE: f64 = CUT_TOLERANCE;
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
for geo in geos.iter().filter(|g| geo_handle(g) != handle) {
|
||||
// 1. Boundary endpoints touching target
|
||||
for pt in geo.endpoints() {
|
||||
let t = target.parameter_at(pt);
|
||||
let valid_t = if bounded {
|
||||
let slack = CUT_TOLERANCE;
|
||||
if (-slack..=1.0 + slack).contains(&t) {
|
||||
Some(t.clamp(0.0, 1.0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(t)
|
||||
};
|
||||
|
||||
if let Some(t_cand) = valid_t {
|
||||
let proj = target.point_at(t_cand);
|
||||
let dx = pt[0] - proj[0];
|
||||
let dy = pt[1] - proj[1];
|
||||
if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE {
|
||||
contacts.push(t_cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Target endpoints touching boundary
|
||||
if bounded && !target.is_closed() {
|
||||
if let Some(boundary_curve) = geo_to_curve(geo) {
|
||||
let boundary_bounded = matches!(boundary_curve.extent(), KernelExtent::Bounded);
|
||||
for &t_end in &[0.0, 1.0] {
|
||||
let pt = target.point_at(t_end);
|
||||
let u = boundary_curve.parameter_at(pt);
|
||||
let valid_u = if boundary_bounded {
|
||||
let slack = CUT_TOLERANCE;
|
||||
if (-slack..=1.0 + slack).contains(&u) {
|
||||
Some(u.clamp(0.0, 1.0))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(u)
|
||||
};
|
||||
if let Some(u_cand) = valid_u {
|
||||
let proj = boundary_curve.point_at(u_cand);
|
||||
let dx = pt[0] - proj[0];
|
||||
let dy = pt[1] - proj[1];
|
||||
if (dx * dx + dy * dy) <= ENDPOINT_TOLERANCE * ENDPOINT_TOLERANCE {
|
||||
contacts.push(t_end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritize exact endpoint contact parameters over raw ray intersections,
|
||||
// ensuring trimmed endpoints match boundary endpoints down to machine precision.
|
||||
let is_closed = target.is_closed();
|
||||
for t_contact in contacts {
|
||||
if let Some(pos) = ts.iter().position(|&t| {
|
||||
let diff = (t - t_contact).abs();
|
||||
let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff };
|
||||
cyclic < 1e-6
|
||||
}) {
|
||||
ts[pos] = t_contact;
|
||||
} else {
|
||||
ts.push(t_contact);
|
||||
}
|
||||
}
|
||||
|
||||
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
ts.dedup_by(|a, b| {
|
||||
let diff = (*a - *b).abs();
|
||||
let cyclic = if is_closed { diff.min(1.0 - diff) } else { diff };
|
||||
cyclic < 1e-6
|
||||
});
|
||||
|
||||
// For closed curves, ensure the first and last cuts don't duplicate across the 0/1 seam
|
||||
if is_closed && ts.len() >= 2 {
|
||||
let seam_diff = (ts[ts.len() - 1] - (ts[0] + 1.0)).abs();
|
||||
if seam_diff < 1e-6 {
|
||||
ts.pop();
|
||||
}
|
||||
}
|
||||
|
||||
ts
|
||||
}
|
||||
|
||||
@@ -517,7 +638,7 @@ fn trim_ellipse(orig: &EllipseEnt, ts: &[f64], t_click: f64) -> Vec<EntityType>
|
||||
} else {
|
||||
ts[0] + 1.0
|
||||
};
|
||||
let holds = |t: f64| t >= from - 1e-9 && t <= to + 1e-9;
|
||||
let holds = |t: f64| t >= from && t < to;
|
||||
(holds(click) || holds(click + 1.0)).then_some((from, to))
|
||||
});
|
||||
let Some((from, to)) = gap else {
|
||||
@@ -853,9 +974,7 @@ fn trim_circle(orig: &CircleEnt, ts: &[f64], t_click: f64) -> Vec<EntityType> {
|
||||
for i in 0..n {
|
||||
let ta = ts[i];
|
||||
let tb = if i + 1 < n { ts[i + 1] } else { ts[0] + 1.0 };
|
||||
if (tc >= ta - 1e-9 && tc <= tb + 1e-9)
|
||||
|| (tc + 1.0 >= ta - 1e-9 && tc + 1.0 <= tb + 1e-9)
|
||||
{
|
||||
if (tc >= ta && tc < tb) || (tc + 1.0 >= ta && tc + 1.0 < tb) {
|
||||
removed = Some((ta, tb));
|
||||
break;
|
||||
}
|
||||
@@ -1037,12 +1156,24 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
|
||||
let b = seg_bulge(i);
|
||||
let (d, u) = if let Some(ba) = BulgeArc::from_bulge(p0, p1, b) {
|
||||
let angle = (cy - ba.center[1]).atan2(cx - ba.center[0]);
|
||||
let travelled = if ba.sweep >= 0.0 {
|
||||
(angle - ba.start_angle).rem_euclid(TAU)
|
||||
let (travelled, sweep) = if ba.sweep >= 0.0 {
|
||||
((angle - ba.start_angle).rem_euclid(TAU), ba.sweep)
|
||||
} else {
|
||||
-((ba.start_angle - angle).rem_euclid(TAU))
|
||||
((ba.start_angle - angle).rem_euclid(TAU), -ba.sweep)
|
||||
};
|
||||
let u = if sweep < 1e-12 {
|
||||
0.0
|
||||
} else if travelled <= sweep {
|
||||
(travelled / sweep).clamp(0.0, 1.0)
|
||||
} else {
|
||||
let gap = TAU - sweep;
|
||||
let past_end = travelled - sweep;
|
||||
if past_end <= gap * 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
let u = (travelled / ba.sweep).clamp(0.0, 1.0);
|
||||
let pt = ba.sample(u);
|
||||
let dist2 = (pt[0] - cx).powi(2) + (pt[1] - cy).powi(2);
|
||||
(dist2, u)
|
||||
@@ -1070,17 +1201,19 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
|
||||
if cuts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let hi = cuts
|
||||
.iter()
|
||||
.cloned()
|
||||
.find(|&c| c > t_click + 1e-9)
|
||||
.unwrap_or(cuts[0] + total);
|
||||
let lo = cuts
|
||||
.iter()
|
||||
.cloned()
|
||||
.rev()
|
||||
.find(|&c| c < t_click - 1e-9)
|
||||
.unwrap_or(cuts[cuts.len() - 1] - total);
|
||||
let n = cuts.len();
|
||||
let mut removed = None;
|
||||
for i in 0..n {
|
||||
let a = cuts[i];
|
||||
let b = if i + 1 < n { cuts[i + 1] } else { cuts[0] + total };
|
||||
if (t_click >= a && t_click < b) || (t_click + total >= a && t_click + total < b) {
|
||||
removed = Some((a, b));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((lo, hi)) = removed else {
|
||||
return None;
|
||||
};
|
||||
let mut s1 = lo;
|
||||
while s1 <= hi {
|
||||
s1 += total;
|
||||
@@ -1089,14 +1222,29 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
|
||||
out.push(EntityType::LwPolyline(piece));
|
||||
}
|
||||
} else {
|
||||
let lo = cuts.iter().cloned().rev().find(|&c| c < t_click - 1e-9);
|
||||
let hi = cuts.iter().cloned().find(|&c| c > t_click + 1e-9);
|
||||
if let Some(lo) = lo {
|
||||
let mut bounds = vec![0.0];
|
||||
bounds.extend(cuts.iter().copied());
|
||||
bounds.push(total);
|
||||
bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let n = bounds.len();
|
||||
let mut removed = None;
|
||||
for i in 0..n - 1 {
|
||||
let a = bounds[i];
|
||||
let b = bounds[i + 1];
|
||||
if (t_click >= a && t_click < b) || (i + 2 == n && t_click <= b) {
|
||||
removed = Some((a, b));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((lo, hi)) = removed else {
|
||||
return None;
|
||||
};
|
||||
if lo > 1e-6 {
|
||||
if let Some(piece) = extract_sub_polyline(poly, 0.0, lo) {
|
||||
out.push(EntityType::LwPolyline(piece));
|
||||
}
|
||||
}
|
||||
if let Some(hi) = hi {
|
||||
if hi < total - 1e-6 {
|
||||
if let Some(piece) = extract_sub_polyline(poly, hi, total) {
|
||||
out.push(EntityType::LwPolyline(piece));
|
||||
}
|
||||
@@ -1699,6 +1847,9 @@ fn pick_trim_at(
|
||||
return None;
|
||||
}
|
||||
let b = a * e.minor_axis_ratio;
|
||||
if b < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
|
||||
let t0 = e.start_parameter;
|
||||
let mut t1 = e.end_parameter;
|
||||
@@ -1716,7 +1867,7 @@ fn pick_trim_at(
|
||||
let ry = py - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let t_ell = yl.atan2(xl);
|
||||
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
|
||||
let t_click = arc_t(t_ell, t0, t1);
|
||||
Some(trim_ellipse(e, &ts, t_click))
|
||||
}
|
||||
@@ -1781,12 +1932,17 @@ fn pick_extend_at(
|
||||
if a < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let b = a * e.minor_axis_ratio;
|
||||
if b < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
|
||||
let rx = px - e.center.x;
|
||||
let ry = py - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let t_click = arc_t(yl.atan2(xl), t0, t1);
|
||||
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
|
||||
let t_click = arc_t(t_ell, t0, t1);
|
||||
let _ = span;
|
||||
extend_ellipse(e, t_click, geos)
|
||||
}
|
||||
@@ -2841,6 +2997,9 @@ impl CadCommand for TrimCommand {
|
||||
return vec![];
|
||||
}
|
||||
let b = a * e.minor_axis_ratio;
|
||||
if b < 1e-9 {
|
||||
return vec![];
|
||||
}
|
||||
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
|
||||
let t0 = e.start_parameter;
|
||||
let mut t1 = e.end_parameter;
|
||||
@@ -2857,7 +3016,8 @@ impl CadCommand for TrimCommand {
|
||||
let ry = pt.y as f64 - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let t_click = arc_t(yl.atan2(xl), t0, t1);
|
||||
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
|
||||
let t_click = arc_t(t_ell, t0, t1);
|
||||
let survivors = trim_ellipse(e, &ts, t_click);
|
||||
let orig_pts =
|
||||
ellipse_pts(e.center.x, e.center.y, a, b, nx, ny, t0, t1, e.center.z);
|
||||
@@ -3343,25 +3503,29 @@ impl CadCommand for ExtendCommand {
|
||||
Some(EntityType::Ellipse(e)) => {
|
||||
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
|
||||
if a >= 1e-9 {
|
||||
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
|
||||
let t0 = e.start_parameter;
|
||||
let mut t1 = e.end_parameter;
|
||||
if t1 <= t0 {
|
||||
t1 += TAU;
|
||||
}
|
||||
let rx = pt.x as f64 - e.center.x;
|
||||
let ry = pt.y as f64 - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let t_click = arc_t(yl.atan2(xl), t0, t1);
|
||||
if let Some(ext) = extend_ellipse(e, t_click, &self.geos) {
|
||||
return extend_hover_wires(
|
||||
&EntityType::Ellipse(e.clone()),
|
||||
&ext,
|
||||
&self.all_entities,
|
||||
handle,
|
||||
self.implied_edges,
|
||||
);
|
||||
let b = a * e.minor_axis_ratio;
|
||||
if b >= 1e-9 {
|
||||
let (nx, ny) = (e.major_axis.x / a, e.major_axis.y / a);
|
||||
let t0 = e.start_parameter;
|
||||
let mut t1 = e.end_parameter;
|
||||
if t1 <= t0 {
|
||||
t1 += TAU;
|
||||
}
|
||||
let rx = pt.x as f64 - e.center.x;
|
||||
let ry = pt.y as f64 - e.center.y;
|
||||
let xl = rx * nx + ry * ny;
|
||||
let yl = -rx * ny + ry * nx;
|
||||
let t_ell = ellipse_closest_parameter(a, b, xl, yl);
|
||||
let t_click = arc_t(t_ell, t0, t1);
|
||||
if let Some(ext) = extend_ellipse(e, t_click, &self.geos) {
|
||||
return extend_hover_wires(
|
||||
&EntityType::Ellipse(e.clone()),
|
||||
&ext,
|
||||
&self.all_entities,
|
||||
handle,
|
||||
self.implied_edges,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3876,6 +4040,535 @@ mod tests {
|
||||
"edge-on arc segments are skipped instead of panicking: {pts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_circle_with_tangent_arcs_touching_at_endpoints() {
|
||||
use codec::entities::{Arc as ArcEnt, Circle as CircleEnt};
|
||||
let mut c = CircleEnt::new();
|
||||
c.common.handle = Handle::new(1);
|
||||
c.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
c.radius = 10.0;
|
||||
|
||||
// Arc 1: center (20, 0), r = 10, from PI/2 to PI (endpoint at (10, 0))
|
||||
let mut a1 = ArcEnt::new();
|
||||
a1.common.handle = Handle::new(2);
|
||||
a1.center = Vector3::new(20.0, 0.0, 0.0);
|
||||
a1.radius = 10.0;
|
||||
a1.start_angle = std::f64::consts::FRAC_PI_2;
|
||||
a1.end_angle = std::f64::consts::PI;
|
||||
|
||||
// Arc 2: center (-20, 0), r = 10, from 0 to PI/2 (endpoint at (-10, 0))
|
||||
let mut a2 = ArcEnt::new();
|
||||
a2.common.handle = Handle::new(3);
|
||||
a2.center = Vector3::new(-20.0, 0.0, 0.0);
|
||||
a2.radius = 10.0;
|
||||
a2.start_angle = 0.0;
|
||||
a2.end_angle = std::f64::consts::FRAC_PI_2;
|
||||
|
||||
let all = vec![
|
||||
EntityType::Circle(c),
|
||||
EntityType::Arc(a1),
|
||||
EntityType::Arc(a2),
|
||||
];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Click on top of the circle at (0, 10)
|
||||
let survivors = pick_trim_at(&all, &geos, Handle::new(1), 0.0, 10.0);
|
||||
assert!(survivors.is_some(), "trimming circle between two tangent arc endpoints must succeed");
|
||||
let pieces = survivors.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
|
||||
if let EntityType::Arc(arc) = &pieces[0] {
|
||||
assert!((arc.radius - 10.0).abs() < 1e-6);
|
||||
let span = {
|
||||
let s = (arc.end_angle - arc.start_angle).rem_euclid(TAU);
|
||||
if s == 0.0 { TAU } else { s }
|
||||
};
|
||||
let mid_ang = arc.start_angle + span * 0.5;
|
||||
let mid_y = mid_ang.sin();
|
||||
assert!(mid_y < 0.0, "bottom half should survive when clicking top half");
|
||||
} else {
|
||||
panic!("expected Arc survivor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_circle_between_two_tangent_lines() {
|
||||
use codec::entities::{Circle as CircleEnt, Line as LineEnt};
|
||||
let mut c = CircleEnt::new();
|
||||
c.common.handle = Handle::new(1);
|
||||
c.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
c.radius = 10.0;
|
||||
|
||||
// Line 1: tangent at top (0, 10), from (-20, 10) to (20, 10)
|
||||
let mut l1 = LineEnt::new();
|
||||
l1.common.handle = Handle::new(2);
|
||||
l1.start = Vector3::new(-20.0, 10.0, 0.0);
|
||||
l1.end = Vector3::new(20.0, 10.0, 0.0);
|
||||
|
||||
// Line 2: tangent at bottom (0, -10), from (-20, -10) to (20, -10)
|
||||
let mut l2 = LineEnt::new();
|
||||
l2.common.handle = Handle::new(3);
|
||||
l2.start = Vector3::new(-20.0, -10.0, 0.0);
|
||||
l2.end = Vector3::new(20.0, -10.0, 0.0);
|
||||
|
||||
let all = vec![
|
||||
EntityType::Circle(c),
|
||||
EntityType::Line(l1),
|
||||
EntityType::Line(l2),
|
||||
];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Trim circle by clicking right side at (10, 0)
|
||||
let survivors = pick_trim_at(&all, &geos, Handle::new(1), 10.0, 0.0);
|
||||
assert!(survivors.is_some(), "trimming circle between two tangent lines must succeed");
|
||||
let pieces = survivors.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
|
||||
let EntityType::Arc(arc) = &pieces[0] else { panic!("expected Arc") };
|
||||
|
||||
let arc_start = glam::DVec3::new(
|
||||
arc.radius * arc.start_angle.cos(),
|
||||
arc.radius * arc.start_angle.sin(),
|
||||
0.0,
|
||||
);
|
||||
let arc_end = glam::DVec3::new(
|
||||
arc.radius * arc.end_angle.cos(),
|
||||
arc.radius * arc.end_angle.sin(),
|
||||
0.0,
|
||||
);
|
||||
|
||||
println!("arc start: {:?}, end: {:?}", arc_start, arc_end);
|
||||
|
||||
// Now trim line 1 by clicking at (10, 10)
|
||||
let l1_survivors = pick_trim_at(&all, &geos, Handle::new(2), 10.0, 10.0);
|
||||
assert!(l1_survivors.is_some(), "trimming line 1 must succeed");
|
||||
let l1_pieces = l1_survivors.unwrap();
|
||||
assert_eq!(l1_pieces.len(), 1);
|
||||
let EntityType::Line(trimmed_l1) = &l1_pieces[0] else { panic!("expected Line") };
|
||||
println!("trimmed line 1 start: {:?}, end: {:?}", trimmed_l1.start, trimmed_l1.end);
|
||||
|
||||
// Check distance between trimmed line 1 end and arc start
|
||||
let line_end = glam::DVec3::new(trimmed_l1.end.x, trimmed_l1.end.y, trimmed_l1.end.z);
|
||||
let diff = (line_end - arc_start).length();
|
||||
println!("Distance between line endpoint and arc endpoint: {:e}", diff);
|
||||
|
||||
// Now test an angled / non-axis-aligned case from external point
|
||||
let c_center = glam::DVec3::new(123.45, 67.89, 0.0);
|
||||
let c_radius = 25.0;
|
||||
let p_ext1 = glam::DVec3::new(200.0, 150.0, 0.0);
|
||||
let p_ext2 = glam::DVec3::new(50.0, -20.0, 0.0);
|
||||
|
||||
let get_tangents = |p: glam::DVec3| {
|
||||
let k_curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle {
|
||||
centre: [c_center.x, c_center.y],
|
||||
radius: c_radius,
|
||||
});
|
||||
let pts = kernel::geom2d::tangent_from(&k_curve, [p.x, p.y]);
|
||||
(glam::DVec3::new(pts[0].point[0], pts[0].point[1], 0.0), glam::DVec3::new(pts[1].point[0], pts[1].point[1], 0.0))
|
||||
};
|
||||
let t1 = get_tangents(p_ext1).0;
|
||||
let t2 = get_tangents(p_ext2).1;
|
||||
|
||||
let mut c_rot = CircleEnt::new();
|
||||
c_rot.common.handle = Handle::new(10);
|
||||
c_rot.center = Vector3::new(c_center.x, c_center.y, c_center.z);
|
||||
c_rot.radius = c_radius;
|
||||
|
||||
// Line 1 from p_ext1 extending PAST t1 to make it a boundary line crossing tangent
|
||||
let dir1 = (t1 - p_ext1).normalize();
|
||||
let mut l_rot1 = LineEnt::new();
|
||||
l_rot1.common.handle = Handle::new(11);
|
||||
l_rot1.start = Vector3::new(p_ext1.x, p_ext1.y, 0.0);
|
||||
l_rot1.end = Vector3::new((t1 + dir1 * 20.0).x, (t1 + dir1 * 20.0).y, 0.0);
|
||||
|
||||
let dir2 = (t2 - p_ext2).normalize();
|
||||
let mut l_rot2 = LineEnt::new();
|
||||
l_rot2.common.handle = Handle::new(12);
|
||||
l_rot2.start = Vector3::new(p_ext2.x, p_ext2.y, 0.0);
|
||||
l_rot2.end = Vector3::new((t2 + dir2 * 20.0).x, (t2 + dir2 * 20.0).y, 0.0);
|
||||
|
||||
let all_rot = vec![
|
||||
EntityType::Circle(c_rot.clone()),
|
||||
EntityType::Line(l_rot1.clone()),
|
||||
EntityType::Line(l_rot2.clone()),
|
||||
];
|
||||
let geos_rot = build_geos(&all_rot);
|
||||
|
||||
// Click circle between them
|
||||
let mid_click = c_center + (t1 + t2 - 2.0 * c_center).normalize() * c_radius;
|
||||
let rot_survivors = pick_trim_at(&all_rot, &geos_rot, Handle::new(10), mid_click.x, mid_click.y);
|
||||
assert!(rot_survivors.is_some(), "trimming circle between angled tangent lines must succeed");
|
||||
let pieces = rot_survivors.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
|
||||
let EntityType::Arc(a) = &pieces[0] else { panic!("expected Arc") };
|
||||
let a_start = c_center + glam::DVec3::new(a.radius * a.start_angle.cos(), a.radius * a.start_angle.sin(), 0.0);
|
||||
let a_end = c_center + glam::DVec3::new(a.radius * a.end_angle.cos(), a.radius * a.end_angle.sin(), 0.0);
|
||||
let err_t1 = (a_start - t1).length().min((a_end - t1).length());
|
||||
let err_t2 = (a_start - t2).length().min((a_end - t2).length());
|
||||
assert!(err_t1 < 1e-9, "cut point must land within grip tolerance of t1: got {err_t1:e}");
|
||||
assert!(err_t2 < 1e-9, "cut point must land within grip tolerance of t2: got {err_t2:e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_ellipse_with_tangent_arcs_touching_at_endpoints() {
|
||||
use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt};
|
||||
let mut e = EllipseEnt::new();
|
||||
e.common.handle = Handle::new(10);
|
||||
e.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
e.major_axis = Vector3::new(20.0, 0.0, 0.0);
|
||||
e.minor_axis_ratio = 0.5; // b = 10
|
||||
e.start_parameter = 0.0;
|
||||
e.end_parameter = std::f64::consts::TAU;
|
||||
|
||||
// Arc 1 touches right vertex at (20, 0): center (30, 0), r = 10, from PI/2 to PI
|
||||
let mut a1 = ArcEnt::new();
|
||||
a1.common.handle = Handle::new(11);
|
||||
a1.center = Vector3::new(30.0, 0.0, 0.0);
|
||||
a1.radius = 10.0;
|
||||
a1.start_angle = std::f64::consts::FRAC_PI_2;
|
||||
a1.end_angle = std::f64::consts::PI;
|
||||
|
||||
// Arc 2 touches left vertex at (-20, 0): center (-30, 0), r = 10, from 0 to PI/2
|
||||
let mut a2 = ArcEnt::new();
|
||||
a2.common.handle = Handle::new(12);
|
||||
a2.center = Vector3::new(-30.0, 0.0, 0.0);
|
||||
a2.radius = 10.0;
|
||||
a2.start_angle = 0.0;
|
||||
a2.end_angle = std::f64::consts::FRAC_PI_2;
|
||||
|
||||
let all = vec![
|
||||
EntityType::Ellipse(e),
|
||||
EntityType::Arc(a1),
|
||||
EntityType::Arc(a2),
|
||||
];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Click top of ellipse at (0, 10)
|
||||
let survivors = pick_trim_at(&all, &geos, Handle::new(10), 0.0, 10.0);
|
||||
assert!(survivors.is_some(), "trimming ellipse between two tangent arc endpoints must succeed");
|
||||
let pieces = survivors.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "expected 1 surviving ellipse piece");
|
||||
if let EntityType::Ellipse(ell) = &pieces[0] {
|
||||
assert!((ell.major_axis.x - 20.0).abs() < 1e-6);
|
||||
} else {
|
||||
panic!("expected Ellipse survivor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_ellipse_selection_tracking_near_breakpoint() {
|
||||
use codec::entities::{Ellipse as EllipseEnt, Line as LineEnt};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
let mut e = EllipseEnt::new();
|
||||
e.common.handle = Handle::new(10);
|
||||
e.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
e.major_axis = Vector3::new(20.0, 0.0, 0.0);
|
||||
e.minor_axis_ratio = 0.5; // a = 20, b = 10
|
||||
e.start_parameter = 0.0;
|
||||
e.end_parameter = TAU;
|
||||
|
||||
// Cutting line 1 crossing at t = PI/4 (45 degrees parametric):
|
||||
// At t = PI/4: x = 20 * cos(PI/4) = 14.142, y = 10 * sin(PI/4) = 7.071
|
||||
// (Polar angle is atan2(7.071, 14.142) = 26.56 degrees!)
|
||||
let mut l1 = LineEnt::new();
|
||||
l1.common.handle = Handle::new(11);
|
||||
l1.start = Vector3::new(0.0, 0.0, 0.0);
|
||||
l1.end = Vector3::new(30.0, 15.0, 0.0); // passes through (14.142, 7.071)
|
||||
|
||||
// Cutting line 2 crossing at t = 3*PI/4:
|
||||
let mut l2 = LineEnt::new();
|
||||
l2.common.handle = Handle::new(12);
|
||||
l2.start = Vector3::new(0.0, 0.0, 0.0);
|
||||
l2.end = Vector3::new(-30.0, 15.0, 0.0);
|
||||
|
||||
let all = vec![EntityType::Ellipse(e), EntityType::Line(l1), EntityType::Line(l2)];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Click 1: Click at t = 40 deg parametric (x = 20*cos(40°), y = 10*sin(40°))
|
||||
// Polar angle is atan(0.5 * tan(40°)) ≈ 22.7°.
|
||||
// This is before PI/4. Surviving piece should run between the cuts.
|
||||
let t_before = 40.0_f64.to_radians();
|
||||
let px_before = 20.0 * t_before.cos();
|
||||
let py_before = 10.0 * t_before.sin();
|
||||
let res_before = pick_trim_at(&all, &geos, Handle::new(10), px_before, py_before);
|
||||
assert!(res_before.is_some(), "trimming at 40 deg must succeed");
|
||||
let pieces_before = res_before.unwrap();
|
||||
assert_eq!(pieces_before.len(), 1);
|
||||
|
||||
// Click 2: Click at t = 50 deg parametric (x = 20*cos(50°), y = 10*sin(50°))
|
||||
// Polar angle is atan(0.5 * tan(50°)) ≈ 30.77°.
|
||||
// Visually and parametrically, 50° is in the top segment (between PI/4 and 3*PI/4).
|
||||
// Without the fix, 30.77° polar angle would be < 45° parametric, selecting the WRONG segment!
|
||||
let t_after = 50.0_f64.to_radians();
|
||||
let px_after = 20.0 * t_after.cos();
|
||||
let py_after = 10.0 * t_after.sin();
|
||||
let res_after = pick_trim_at(&all, &geos, Handle::new(10), px_after, py_after);
|
||||
assert!(res_after.is_some(), "trimming at 50 deg must succeed");
|
||||
let pieces_after = res_after.unwrap();
|
||||
assert_eq!(pieces_after.len(), 1);
|
||||
|
||||
// Click 3 & 4: Click slightly INSIDE and OUTSIDE the ellipse near 50 deg.
|
||||
// Even when displaced by 3 units (pick-box offset when zoomed out),
|
||||
// orthogonal projection must still correctly identify the top segment!
|
||||
let nx = 10.0 * t_after.cos();
|
||||
let ny = 20.0 * t_after.sin();
|
||||
let nlen = nx.hypot(ny);
|
||||
let (unx, uny) = (nx / nlen, ny / nlen);
|
||||
|
||||
// Displaced outside:
|
||||
let res_out = pick_trim_at(&all, &geos, Handle::new(10), px_after + 3.0 * unx, py_after + 3.0 * uny);
|
||||
assert!(res_out.is_some(), "trimming at displaced outside point must succeed");
|
||||
|
||||
// Displaced inside:
|
||||
let res_in = pick_trim_at(&all, &geos, Handle::new(10), px_after - 3.0 * unx, py_after - 3.0 * uny);
|
||||
assert!(res_in.is_some(), "trimming at displaced inside point must succeed");
|
||||
|
||||
if let (EntityType::Ellipse(surv_after), EntityType::Ellipse(surv_out), EntityType::Ellipse(surv_in)) =
|
||||
(&pieces_after[0], &res_out.unwrap()[0], &res_in.unwrap()[0])
|
||||
{
|
||||
assert!(
|
||||
(surv_out.start_parameter - surv_after.start_parameter).abs() < 1e-4,
|
||||
"displaced outside point should select the same segment as point on curve"
|
||||
);
|
||||
assert!(
|
||||
(surv_in.start_parameter - surv_after.start_parameter).abs() < 1e-4,
|
||||
"displaced inside point should select the same segment as point on curve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_arc_selection_tracking_near_start_endpoint() {
|
||||
use codec::entities::{Arc as ArcEnt, Line as LineEnt};
|
||||
// Arc from 0.5 to 2.0 rad (~28.6° to 114.6°), radius 10.0
|
||||
let mut a = ArcEnt::new();
|
||||
a.common.handle = Handle::new(20);
|
||||
a.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
a.radius = 10.0;
|
||||
a.start_angle = 0.5;
|
||||
a.end_angle = 2.0;
|
||||
|
||||
// Boundary line crossing arc at midpoint ~ 1.25 rad
|
||||
let mid = 1.25_f64;
|
||||
let mut l = LineEnt::new();
|
||||
l.common.handle = Handle::new(21);
|
||||
l.start = Vector3::new(0.0, 0.0, 0.0);
|
||||
l.end = Vector3::new(20.0 * mid.cos(), 20.0 * mid.sin(), 0.0);
|
||||
|
||||
let all = vec![EntityType::Arc(a), EntityType::Line(l)];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Hover slightly CW of start endpoint (e.g. angle = 0.49 rad, outside the arc by ~0.5 degree)
|
||||
let click_ang = 0.49_f64;
|
||||
let px = 10.0 * click_ang.cos();
|
||||
let py = 10.0 * click_ang.sin();
|
||||
let res = pick_trim_at(&all, &geos, Handle::new(20), px, py);
|
||||
assert!(res.is_some(), "trim near start endpoint must succeed");
|
||||
let pieces = res.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "expected 1 surviving arc piece");
|
||||
if let EntityType::Arc(surv) = &pieces[0] {
|
||||
// It must remove the start-half [0.5, 1.25] and keep the end-half [1.25, 2.0].
|
||||
// If the bug were present, it would snap to 1.0 (end-half removed, start-half kept).
|
||||
assert!(
|
||||
(surv.end_angle - 2.0).abs() < 0.05,
|
||||
"survivor should be the end-half [1.25, 2.0], got start={} end={}",
|
||||
surv.start_angle,
|
||||
surv.end_angle
|
||||
);
|
||||
} else {
|
||||
panic!("expected Arc survivor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tangent_circles_or_arcs_trim() {
|
||||
use codec::entities::{Arc as ArcEnt, Circle as CircleEnt};
|
||||
let mut c1 = CircleEnt::new();
|
||||
c1.common.handle = Handle::new(1);
|
||||
c1.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
c1.radius = 10.0;
|
||||
|
||||
let mut c2 = CircleEnt::new();
|
||||
c2.common.handle = Handle::new(2);
|
||||
c2.center = Vector3::new(20.0, 0.0, 0.0);
|
||||
c2.radius = 10.0;
|
||||
|
||||
let all = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone())];
|
||||
let geos = build_geos(&all);
|
||||
let curve1 = entity_curve_xy(&EntityType::Circle(c1.clone())).unwrap();
|
||||
let ts = cut_params(&curve1, Handle::new(1), &geos);
|
||||
assert_eq!(ts, vec![0.0], "circle1 tangent to circle2 must have cut at t=0");
|
||||
|
||||
// Arc1 touching circle2 tangently
|
||||
let mut a1 = ArcEnt::new();
|
||||
a1.common.handle = Handle::new(3);
|
||||
a1.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
a1.radius = 10.0;
|
||||
a1.start_angle = -1.0;
|
||||
a1.end_angle = 1.0;
|
||||
|
||||
let all_arc = vec![EntityType::Arc(a1.clone()), EntityType::Circle(c2.clone())];
|
||||
let geos_arc = build_geos(&all_arc);
|
||||
let curve_arc = entity_curve_xy(&EntityType::Arc(a1.clone())).unwrap();
|
||||
let ts_arc = cut_params(&curve_arc, Handle::new(3), &geos_arc);
|
||||
assert_eq!(ts_arc, vec![0.5], "arc1 tangent to circle2 at midpoint must have cut at t=0.5");
|
||||
|
||||
let trim_res = pick_trim_at(&all_arc, &geos_arc, Handle::new(3), 10.0, 5.0);
|
||||
assert!(trim_res.is_some(), "trimming arc1 at tangent circle2 must succeed");
|
||||
let pieces = trim_res.unwrap();
|
||||
assert_eq!(pieces.len(), 1, "trimming half of arc should leave 1 survivor piece");
|
||||
|
||||
// 1. External tangency: Test across multiple angles and slight epsilons
|
||||
for angle in [0.0f64, 0.5, 1.0, 1.57, 2.5, 3.14, 4.0, 5.0] {
|
||||
for eps in [-1e-8, 0.0, 1e-8] {
|
||||
let dist = 20.0 + eps;
|
||||
let dx = dist * angle.cos();
|
||||
let dy = dist * angle.sin();
|
||||
let mut c_off = CircleEnt::new();
|
||||
c_off.common.handle = Handle::new(4);
|
||||
c_off.center = Vector3::new(dx, dy, 0.0);
|
||||
c_off.radius = 10.0;
|
||||
let all_off = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_off)];
|
||||
let geos_off = build_geos(&all_off);
|
||||
let ts_off = cut_params(&curve1, Handle::new(1), &geos_off);
|
||||
assert_eq!(ts_off.len(), 1, "must find exactly 1 tangency cut at angle {angle}, eps {eps}");
|
||||
let expected_t = (angle / TAU).rem_euclid(1.0);
|
||||
let actual_t = ts_off[0];
|
||||
let diff = (actual_t - expected_t).abs();
|
||||
let cyclic_diff = diff.min(1.0 - diff);
|
||||
assert!(cyclic_diff < 1e-5, "tangency t must match angle: got {actual_t}, expected {expected_t}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Internal tangency: c1 (r=10) and c_inner (r=4) at (6, 0)
|
||||
let mut c_inner = CircleEnt::new();
|
||||
c_inner.common.handle = Handle::new(5);
|
||||
c_inner.center = Vector3::new(6.0, 0.0, 0.0);
|
||||
c_inner.radius = 4.0;
|
||||
let all_int = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c_inner.clone())];
|
||||
let geos_int = build_geos(&all_int);
|
||||
let ts_int = cut_params(&curve1, Handle::new(1), &geos_int);
|
||||
assert_eq!(ts_int, vec![0.0], "internal tangency on c1 must have cut at t=0");
|
||||
|
||||
// And from c_inner's perspective (r=4 inside r=10):
|
||||
let curve_inner = entity_curve_xy(&EntityType::Circle(c_inner.clone())).unwrap();
|
||||
let ts_inner = cut_params(&curve_inner, Handle::new(5), &geos_int);
|
||||
assert_eq!(ts_inner, vec![0.0], "internal tangency on c_inner must have cut at t=0");
|
||||
|
||||
// 3. Two arcs touching tangently at (10, 0)
|
||||
// a_left: center (0, 0), radius 10, start=0, end=PI (passes through (10,0) at start t=0)
|
||||
let mut a_left = ArcEnt::new();
|
||||
a_left.common.handle = Handle::new(6);
|
||||
a_left.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
a_left.radius = 10.0;
|
||||
a_left.start_angle = 0.0;
|
||||
a_left.end_angle = std::f64::consts::PI;
|
||||
|
||||
// a_right: center (20, 0), radius 10, start=PI/2, end=3*PI/2 (passes through (10,0) at angle PI, midpoint t=0.5)
|
||||
let mut a_right = ArcEnt::new();
|
||||
a_right.common.handle = Handle::new(7);
|
||||
a_right.center = Vector3::new(20.0, 0.0, 0.0);
|
||||
a_right.radius = 10.0;
|
||||
a_right.start_angle = std::f64::consts::FRAC_PI_2;
|
||||
a_right.end_angle = 3.0 * std::f64::consts::FRAC_PI_2;
|
||||
|
||||
let all_arcs = vec![EntityType::Arc(a_left.clone()), EntityType::Arc(a_right.clone())];
|
||||
let geos_arcs = build_geos(&all_arcs);
|
||||
let curve_right = entity_curve_xy(&EntityType::Arc(a_right.clone())).unwrap();
|
||||
let ts_right = cut_params(&curve_right, Handle::new(7), &geos_arcs);
|
||||
assert_eq!(ts_right, vec![0.5], "a_right must be cut at midpoint t=0.5 by tangent a_left");
|
||||
|
||||
let trim_right = pick_trim_at(&all_arcs, &geos_arcs, Handle::new(7), 15.0, 8.0);
|
||||
assert!(trim_right.is_some(), "trimming a_right at tangent a_left must succeed");
|
||||
|
||||
// 4. Circle trimmed by 2 tangent circles
|
||||
// c1 at (0, 0) r=10. c_right at (20, 0) r=10. c_left at (-20, 0) r=10.
|
||||
let mut c_left = CircleEnt::new();
|
||||
c_left.common.handle = Handle::new(8);
|
||||
c_left.center = Vector3::new(-20.0, 0.0, 0.0);
|
||||
c_left.radius = 10.0;
|
||||
let all_3c = vec![EntityType::Circle(c1.clone()), EntityType::Circle(c2.clone()), EntityType::Circle(c_left.clone())];
|
||||
let geos_3c = build_geos(&all_3c);
|
||||
let ts_3c = cut_params(&curve1, Handle::new(1), &geos_3c);
|
||||
assert_eq!(ts_3c.len(), 2, "c1 touched by 2 tangent circles must have 2 cut points");
|
||||
assert_eq!(ts_3c, vec![0.0, 0.5]);
|
||||
|
||||
let trim_3c = pick_trim_at(&all_3c, &geos_3c, Handle::new(1), 0.0, 10.0);
|
||||
assert!(trim_3c.is_some(), "c1 with 2 tangent circles must be trimmable");
|
||||
if let Some(surv) = trim_3c {
|
||||
assert_eq!(surv.len(), 1, "trimming c1 should leave 1 arc survivor");
|
||||
}
|
||||
|
||||
// 5. Extending an arc to meet a tangent circle
|
||||
// a_short: center (0, 0), radius 10, start=0.5, end=1.5 (span ~ 1.0 rad)
|
||||
// Tangent circle c2 is at (20, 0), tangent point is at (10, 0) which is angle 0.
|
||||
// Clicking near start extends start CW towards 0.0.
|
||||
let mut a_short = ArcEnt::new();
|
||||
a_short.common.handle = Handle::new(9);
|
||||
a_short.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
a_short.radius = 10.0;
|
||||
a_short.start_angle = 0.5;
|
||||
a_short.end_angle = 1.5;
|
||||
let all_ext = vec![EntityType::Arc(a_short.clone()), EntityType::Circle(c2.clone())];
|
||||
let geos_ext = build_geos(&all_ext);
|
||||
let ext_res = pick_extend_at(&all_ext, &geos_ext, Handle::new(9), 10.0 * 0.6_f64.cos(), 10.0 * 0.6_f64.sin());
|
||||
assert!(ext_res.is_some(), "extending arc towards tangent circle must succeed");
|
||||
if let Some(EntityType::Arc(ext_arc)) = ext_res {
|
||||
let start_norm = norm(ext_arc.start_angle);
|
||||
assert!(start_norm < 1e-5 || (TAU - start_norm) < 1e-5, "extended arc start must reach 0.0 (tangent point)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skinny_ellipse_crossing_arc() {
|
||||
use codec::entities::{Arc as ArcEnt, Ellipse as EllipseEnt};
|
||||
let mut e = EllipseEnt::new();
|
||||
e.common.handle = Handle::new(100);
|
||||
e.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
e.major_axis = Vector3::new(100.0, 0.0, 0.0);
|
||||
e.minor_axis_ratio = 0.05; // b = 5, very skinny!
|
||||
e.start_parameter = 0.0;
|
||||
e.end_parameter = std::f64::consts::TAU;
|
||||
|
||||
// Test arcs that physically cross the ellipse near its ends (x = 80, 90, 95, 98, 99)
|
||||
for &r in &[80.0, 90.0, 95.0, 98.0, 99.0] {
|
||||
let mut a = ArcEnt::new();
|
||||
a.common.handle = Handle::new(101);
|
||||
a.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
a.radius = r;
|
||||
a.start_angle = -0.3;
|
||||
a.end_angle = 0.3;
|
||||
|
||||
let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
// Trim the ellipse tip beyond the arc (near x = 99.5, y = 0)
|
||||
let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.9, 0.0);
|
||||
// Trim the arc near y = 0 (x = r, y = 0)
|
||||
let arc_res = pick_trim_at(&all, &geos, Handle::new(101), r, 0.0);
|
||||
assert!(ell_res.is_some(), "ellipse trim should succeed for crossing arc at r={r}");
|
||||
assert!(arc_res.is_some(), "arc trim should succeed for crossing arc at r={r}");
|
||||
}
|
||||
|
||||
// Test off-axis arc crossing near the tip (center at (96, 20), radius 20, passing near (96, 0))
|
||||
{
|
||||
let mut a = ArcEnt::new();
|
||||
a.common.handle = Handle::new(102);
|
||||
a.center = Vector3::new(96.0, 20.0, 0.0);
|
||||
a.radius = 20.0;
|
||||
// Angle around -PI/2 (bottom point is (96, 0))
|
||||
a.start_angle = -std::f64::consts::FRAC_PI_2 - 0.4;
|
||||
a.end_angle = -std::f64::consts::FRAC_PI_2 + 0.4;
|
||||
|
||||
let all = vec![EntityType::Ellipse(e.clone()), EntityType::Arc(a)];
|
||||
let geos = build_geos(&all);
|
||||
|
||||
let ell_res = pick_trim_at(&all, &geos, Handle::new(100), 99.0, 0.0);
|
||||
let arc_res = pick_trim_at(&all, &geos, Handle::new(102), 96.0, 0.0);
|
||||
assert!(ell_res.is_some(), "ellipse trim should succeed with off-axis crossing arc");
|
||||
assert!(arc_res.is_some(), "arc trim should succeed with off-axis crossing arc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user