fix(snap): do not snap tangent across ellipse arc break

This commit is contained in:
yztink committed 2026-09-29 17:11:08 -07:00
1 parent ab0682153d
commit 3ed1bddcee
1 file changed
+255 -4
+255 -4
View File
@@ -2223,7 +2223,8 @@ impl Snapper {
major_axis,
normal,
minor_axis_ratio,
..
start_param,
end_param,
} => {
let c = DVec3::from_array(*center);
let u_vec = DVec3::from_array(*major_axis);
@@ -2234,13 +2235,64 @@ impl Snapper {
let v_hat = n_hat.cross(u_hat).normalize_or_zero();
let b = (a * minor_axis_ratio).max(1e-9);
let candidate = self.from_point.and_then(|from| {
planar_ellipse_tangent_points(
from,
c,
u_vec,
n_hat,
*minor_axis_ratio,
*start_param,
*end_param,
)
.into_iter()
.min_by(|pt_a, pt_b| {
let sa = world_to_screen(*pt_a, view_rot, eye, bounds);
let sb = world_to_screen(*pt_b, view_rot, eye, bounds);
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
})
});
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);
let fallback = if is_ellipse_param_on_arc(t, *start_param, *end_param) {
c + u_hat * (a * t.cos()) + v_hat * (b * t.sin())
} else {
let p_start = c + u_hat * (a * start_param.cos()) + v_hat * (b * start_param.sin());
let p_end = c + u_hat * (a * end_param.cos()) + v_hat * (b * end_param.sin());
let sa = world_to_screen(p_start, view_rot, eye, bounds);
let sb = world_to_screen(p_end, view_rot, eye, bounds);
if dist2(sa, cursor_screen) <= dist2(sb, cursor_screen) {
p_start
} else {
p_end
}
};
let world = candidate.unwrap_or(fallback);
let edge_d2 = if wire.points.len() >= 2 {
wire.points
.windows(2)
.enumerate()
.filter_map(|(index, _)| {
let p0 = wp_f64(wire, index);
let p1 = wp_f64(wire, index + 1);
(p0.is_finite() && p1.is_finite()).then(|| {
dist2_to_segment(
cursor_screen,
world_to_screen(p0, view_rot, eye, bounds),
world_to_screen(p1, view_rot, eye, bounds),
)
})
})
.fold(f32::INFINITY, f32::min)
} else {
let screen_pt = world_to_screen(world, view_rot, eye, bounds);
dist2(screen_pt, cursor_screen)
};
(world, edge_d2)
} else {
(c, f32::INFINITY)
@@ -3238,6 +3290,70 @@ fn arc_tangent_points(
.collect()
}
pub fn is_ellipse_param_on_arc(t: f64, start_param: f64, end_param: f64) -> bool {
let raw_sweep = end_param - start_param;
if (raw_sweep.abs() - std::f64::consts::TAU).abs() < 1e-9
|| raw_sweep.abs() >= std::f64::consts::TAU - 1e-9
|| raw_sweep.abs() < 1e-9
{
return true;
}
let sweep = raw_sweep.rem_euclid(std::f64::consts::TAU);
let sweep = if sweep.abs() <= 1e-9 && raw_sweep.abs() > 1e-5 {
std::f64::consts::TAU
} else {
sweep
};
if sweep >= std::f64::consts::TAU - 1e-9 {
return true;
}
let d_t = (t - start_param).rem_euclid(std::f64::consts::TAU);
d_t <= sweep + 1e-6 || d_t >= std::f64::consts::TAU - 1e-6
}
fn planar_ellipse_tangent_points(
from: DVec3,
center: DVec3,
major_axis: DVec3,
normal: DVec3,
minor_axis_ratio: f64,
start_param: f64,
end_param: f64,
) -> Vec<DVec3> {
let a = major_axis.length();
let b = a * minor_axis_ratio;
if a < 1e-9 || b < 1e-9 {
return Vec::new();
}
let u_dir = major_axis / a;
let n_hat = normal.normalize_or_zero();
let v_dir = n_hat.cross(u_dir).normalize_or_zero();
let d = from - center;
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 Vec::new();
}
let theta0 = v_norm.atan2(u_norm);
let delta = (1.0 / dist).acos();
let t0 = theta0 + delta;
let t1 = theta0 - delta;
let mut points = Vec::new();
if is_ellipse_param_on_arc(t0, start_param, end_param) {
points.push(center + u_dir * (a * t0.cos()) + v_dir * (b * t0.sin()));
}
if is_ellipse_param_on_arc(t1, start_param, end_param) {
points.push(center + u_dir * (a * t1.cos()) + v_dir * (b * t1.sin()));
}
points
}
/// Snap to the extension of a ray beyond `origin` in `dir` direction.
/// Returns `None` if the cursor is not near the extension line.
fn extension_snap(
@@ -4689,4 +4805,139 @@ mod ext_tests {
assert_eq!(cursor, i + 1, "cursor must land on the segment's end vertex");
}
}
#[test]
fn test_is_ellipse_param_on_arc() {
use std::f64::consts::{PI, TAU};
// Full ellipse
assert!(is_ellipse_param_on_arc(0.0, 0.0, TAU));
assert!(is_ellipse_param_on_arc(PI, 0.0, TAU));
assert!(is_ellipse_param_on_arc(PI, 0.0, 0.0));
assert!(is_ellipse_param_on_arc(0.5, -PI, PI));
// Upper half: 0 to PI
assert!(is_ellipse_param_on_arc(0.0, 0.0, PI));
assert!(is_ellipse_param_on_arc(PI / 2.0, 0.0, PI));
assert!(is_ellipse_param_on_arc(PI, 0.0, PI));
// Over the break
assert!(!is_ellipse_param_on_arc(3.0 * PI / 2.0, 0.0, PI));
assert!(!is_ellipse_param_on_arc(7.0 * PI / 4.0, 0.0, PI));
// Wrapping arc: 3*PI/2 to PI/2
let start = 3.0 * PI / 2.0;
let end = PI / 2.0;
assert!(is_ellipse_param_on_arc(start, start, end));
assert!(is_ellipse_param_on_arc(0.0, start, end));
assert!(is_ellipse_param_on_arc(end, start, end));
// Over the break (e.g. at PI)
assert!(!is_ellipse_param_on_arc(PI, start, end));
assert!(!is_ellipse_param_on_arc(3.0 * PI / 4.0, start, end));
}
#[test]
fn test_planar_ellipse_tangent_points() {
use std::f64::consts::{PI, TAU};
let center = DVec3::ZERO;
let major = DVec3::new(10.0, 0.0, 0.0);
let normal = DVec3::Z;
let ratio = 0.5; // a = 10, b = 5
// Point outside at (10, 10, 0)
let from = DVec3::new(10.0, 10.0, 0.0);
// Full ellipse should have 2 tangent points
let full_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 0.0, TAU);
assert_eq!(full_pts.len(), 2);
// Ellipse arc only spanning 0..PI (upper half)
// Tangent points from (10, 10): one is near (10, 0), one near (0, 5)
// Upper half includes [0, PI], so any tangent with t in [0, PI] is kept.
let arc_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 0.0, PI);
for pt in &arc_pts {
let t = (pt.y / 5.0).atan2(pt.x / 10.0);
assert!(is_ellipse_param_on_arc(t, 0.0, PI));
}
// Ellipse arc spanning 4.0..5.0 (third quadrant, far away from tangents)
let empty_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 4.0, 5.0);
assert!(empty_pts.is_empty());
}
#[test]
fn test_ellipse_arc_tangent_snap_ignores_break() {
use std::f64::consts::PI;
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Tangent].into_iter().collect();
// Upper semi-ellipse: center at (0, 0), a = 100, b = 50, theta from 0 to PI
let center = [0.0, 0.0, 0.0];
let major_axis = [100.0, 0.0, 0.0];
let normal = [0.0, 0.0, 1.0];
let minor_axis_ratio = 0.5;
let start_param = 0.0;
let end_param = PI;
// Sample points along the upper semi-ellipse (0..PI)
let mut points = Vec::new();
let segments = 32;
for i in 0..=segments {
let t = (i as f64 / segments as f64) * PI;
points.push([(100.0 * t.cos()) as f32, (50.0 * t.sin()) as f32, 0.0]);
}
let ellipse_arc = WireModel {
points,
aabb: [-100.0, -50.0, 100.0, 50.0],
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center,
major_axis,
normal,
minor_axis_ratio,
start_param,
end_param,
}],
..Default::default()
};
let wires = vec![ellipse_arc];
let view_rot = Mat4::from_scale(Vec3::new(0.005, 0.005, 1.0));
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let eye = DVec3::ZERO;
// 1. Cursor hovering directly over the drawn arc near apex (0, 50, 0)
let cursor_world_arc = DVec3::new(0.0, 50.0, 0.0);
let cursor_screen_arc = world_to_screen(cursor_world_arc, view_rot, eye, bounds);
let res_arc = snapper.snap(
cursor_world_arc,
cursor_screen_arc,
wires.as_slice(),
view_rot,
eye,
bounds,
Vec3::ZERO,
(Vec3::X, Vec3::Y, Vec3::Z),
None,
);
assert!(res_arc.is_some(), "cursor near valid ellipse arc must snap to tangent");
let snap = res_arc.unwrap();
assert_eq!(snap.snap_type, SnapType::Tangent);
assert!((snap.world.y - 50.0).abs() < 1.0);
// 2. Cursor hovering over the break at (0, -50, 0)
let cursor_world_break = DVec3::new(0.0, -50.0, 0.0);
let cursor_screen_break = world_to_screen(cursor_world_break, view_rot, eye, bounds);
let res_break = snapper.snap(
cursor_world_break,
cursor_screen_break,
wires.as_slice(),
view_rot,
eye,
bounds,
Vec3::ZERO,
(Vec3::X, Vec3::Y, Vec3::Z),
None,
);
assert!(res_break.is_none(), "cursor over ellipse arc break must NOT snap to tangent");
}
}