fix(osnap): intersection snap is inexact for polyline shapes (#1319)

The analytic intersection path accepted only wires carrying exactly
one tangent geom, so a triangle drawn as one closed LwPolyline (three
Line geoms) fell back to the segment sweep against the circle's
48-chord tessellation — every crossing sits r*(1-cos(pi/48)) ~ 5.3e-3
inside the true circle, and MEASURE reads 2.9947 instead of 3.0000
(END is exact because key_vertices are f64).

Decompose a wire into one analytic curve per geom (declining
entirely if any geom fails to convert, so nothing silently drops),
recover f64 line endpoints from the double-single tessellation for
survey-scale coordinates, and extend line pieces by ~|world|*4*eps so
crossings exactly on a shared endpoint survive the kernel's bounded
extent test. Vertex snap error drops from 5.3e-3 to <= 4.4e-16; tests
cover the 3-4-5 circumcircle at 16 approach directions, survey
coordinates, and the previously fixed all-Line case.
This commit is contained in:
Afonsop committed 2026-09-17 13:01:02 +01:00
1 parent 9e4c3837dd
commit 4985ab42cf
1 file changed
+521 -75
+521 -75
View File
@@ -2464,9 +2464,12 @@ impl WirePlane {
}
fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
let [geom] = wire.tangent_geoms.as_slice() else {
return None;
};
// A multi-geometry wire (a polyline carrying one Line/Arc geom per
// segment) still lives in one plane: take it from the first planar
// (non-Line) geom. Straight-only wires have no plane of their own and
// rely on the partner wire to supply the frame.
let mut planar = wire.tangent_geoms.iter().filter(|geom| !matches!(geom, TangentGeom::Line { .. }));
let geom = planar.next()?;
let (origin, axis_x, axis_y) = match geom {
TangentGeom::Circle { center, .. } => (DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64), DVec3::X, DVec3::Y),
TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } | TangentGeom::Arc { center, axis_x, axis_y, .. } => (
@@ -2488,7 +2491,47 @@ fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() })
}
fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve> {
/// The f64 endpoints behind a `TangentGeom::Line`. The geom itself stores
/// f32, which quantises survey-scale coordinates by centimetres; the wire's
/// tessellation points (a double-single pair) hold the same vertices in full
/// precision, so when a consecutive point pair matches the geom, use it.
fn line_geom_endpoints_f64(wire: &WireModel, p1: [f32; 3], p2: [f32; 3]) -> (DVec3, DVec3) {
if wire.points.len() == 2 {
return (wp_f64(wire, 0), wp_f64(wire, 1));
}
let f32_pair = (Vec3::from_array(p1).as_dvec3(), Vec3::from_array(p2).as_dvec3());
// The f64 pair matches the geom's own segment when it sits within the
// f32 cast's rounding error (≤ 0.5 ulp of the coordinate magnitude).
// Take the CLOSEST pair, not the first within tolerance: adjacent
// polyline vertices can sit closer together than the quantisation at
// survey scale, and a first-match scan would then grab a neighbour's
// segment.
let mag = p1.iter().chain(p2.iter()).fold(0.0f32, |m, c| m.max(c.abs()));
let tol = (mag * f32::EPSILON) as f64 + 1e-9;
let mut best: Option<(f64, DVec3, DVec3)> = None;
for index in 0..wire.points.len().saturating_sub(1) {
let a = wp_f64(wire, index);
let b = wp_f64(wire, index + 1);
// NaN separators split runs; either side being NaN never matches.
if !a.is_finite() || !b.is_finite() {
continue;
}
let miss = (a - f32_pair.0).length_squared() + (b - f32_pair.1).length_squared();
if miss <= 4.0 * tol * tol && best.map_or(true, |(m, _, _)| miss < m) {
best = Some((miss, a, b));
}
}
best.map(|(_, a, b)| (a, b)).unwrap_or(f32_pair)
}
/// Decompose a wire into its analytic curves inside `frame`.
///
/// Returns `None` unless EVERY geometry converts: a partially-converted wire
/// would silently drop the intersections of the skipped pieces, and the
/// tessellated sweep is the correct fallback for those pairs. A wire with no
/// tangent geoms is a bare polyline — only a single straight run (2 points)
/// has an analytic form.
fn curves_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Vec<Curve>> {
use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine};
if wire.tangent_geoms.is_empty() {
@@ -2496,99 +2539,150 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve
return None;
}
let (p0, p1) = (wp_f64(wire, 0), wp_f64(wire, 1));
return (frame.contains(p0, tol) && frame.contains(p1, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p0), end: frame.to_2d(p1) }));
return (frame.contains(p0, tol) && frame.contains(p1, tol))
.then(|| vec![Curve::Line(KLine { start: frame.to_2d(p0), end: frame.to_2d(p1) })]);
}
let [geom] = wire.tangent_geoms.as_slice() else {
return None;
};
let angle_in_frame = |world_point: DVec3, centre: DVec3| {
let (p2, c2) = (frame.to_2d(world_point), frame.to_2d(centre));
(p2[1] - c2[1]).atan2(p2[0] - c2[0])
};
match geom {
TangentGeom::Line { p1, p2 } => {
let (p1, p2) = if wire.points.len() == 2 {
(wp_f64(wire, 0), wp_f64(wire, 1))
} else {
(Vec3::from_array(*p1).as_dvec3(), Vec3::from_array(*p2).as_dvec3())
};
(frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) }))
}
TangentGeom::Circle { center, radius } => {
let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64);
(DVec3::Z.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 }))
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => {
let c = DVec3::new(center[0], center[1], center[2]);
let n = DVec3::new(axis_x[0], axis_x[1], axis_x[2]).cross(DVec3::new(axis_y[0], axis_y[1], axis_y[2])).normalize();
(n.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius }))
}
TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => {
let c = DVec3::new(center[0], center[1], center[2]);
let (ax, ay) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2]));
let n = ax.cross(ay).normalize();
if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
let mut curves = Vec::with_capacity(wire.tangent_geoms.len());
for geom in &wire.tangent_geoms {
let curve = match geom {
TangentGeom::Line { p1, p2 } => {
let (p1, p2) = line_geom_endpoints_f64(wire, *p1, *p2);
(frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| {
let (start, end) = (frame.to_2d(p1), frame.to_2d(p2));
// A crossing can sit exactly on a segment endpoint (the
// #1319 vertices are polyline vertices ON the circle).
// The frame reduction subtracts f64 world coordinates, so
// at survey scale the endpoint parameter lands an ulp or
// two outside the bounded line and the kernel drops the
// crossing. Extend the analytic segment by the world
// magnitude's own quantisation so those survive; the
// overhang (≤ ~1e-9 relative) is far below snap
// distinguishability.
let mag = p1.abs().max_element().max(p2.abs().max_element());
let pad = 1e-9 + mag * 4.0 * f64::EPSILON;
let dx = end[0] - start[0];
let dy = end[1] - start[1];
let len = dx.hypot(dy);
let (ux, uy) = if len > 1e-12 { (dx / len, dy / len) } else { (1.0, 0.0) };
Curve::Line(KLine {
start: [start[0] - ux * pad, start[1] - uy * pad],
end: [end[0] + ux * pad, end[1] + uy * pad],
})
})
}
let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle };
let sweep = arc.sweep();
if (sweep - std::f64::consts::TAU).abs() <= 1e-12 {
return Some(Curve::Circle(KCircle { centre: arc.centre, radius: *radius }));
TangentGeom::Circle { center, radius } => {
let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64);
(DVec3::Z.cross(frame.normal).length() <= tol && frame.contains(c, tol))
.then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 }))
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => {
let c = DVec3::new(center[0], center[1], center[2]);
let n = DVec3::new(axis_x[0], axis_x[1], axis_x[2]).cross(DVec3::new(axis_y[0], axis_y[1], axis_y[2])).normalize();
(n.cross(frame.normal).length() <= tol && frame.contains(c, tol))
.then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius }))
}
TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => {
let c = DVec3::new(center[0], center[1], center[2]);
let (ax, ay) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2]));
let n = ax.cross(ay).normalize();
if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
None
} else {
let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle };
let sweep = arc.sweep();
if (sweep - std::f64::consts::TAU).abs() <= 1e-12 {
Some(Curve::Circle(KCircle { centre: arc.centre, radius: *radius }))
} else {
let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle };
let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c);
Some(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc }))
}
}
}
let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle };
let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c);
Some(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc }))
}
TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => {
let c = DVec3::new(center[0], center[1], center[2]);
let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize();
if ell_normal.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => {
let c = DVec3::new(center[0], center[1], center[2]);
let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize();
if ell_normal.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
None
} else {
let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]);
let major_radius = major.length();
if major_radius <= 1e-12 {
None
} else {
let minor_radius = major_radius * *minor_axis_ratio;
let dir2d = frame.dir_to_2d(major / major_radius);
let len = (dir2d[0] * dir2d[0] + dir2d[1] * dir2d[1]).sqrt();
if len <= 1e-12 {
None
} else {
let major_axis_2d = [dir2d[0] / len, dir2d[1] / len];
let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 {
(-*end_param, -*start_param)
} else {
(*start_param, *end_param)
};
Some(Curve::Ellipse(KEllipseArc {
ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d },
start_parameter,
end_parameter,
}))
}
}
}
}
let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]);
let major_radius = major.length();
if major_radius <= 1e-12 {
return None;
}
let minor_radius = major_radius * *minor_axis_ratio;
let dir2d = frame.dir_to_2d(major / major_radius);
let len = (dir2d[0] * dir2d[0] + dir2d[1] * dir2d[1]).sqrt();
if len <= 1e-12 {
return None;
}
let major_axis_2d = [dir2d[0] / len, dir2d[1] / len];
let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 {
(-*end_param, -*start_param)
} else {
(*start_param, *end_param)
};
Some(Curve::Ellipse(KEllipseArc {
ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d },
start_parameter,
end_parameter,
}))
}
};
curves.push(curve?);
}
Some(curves)
}
/// Intersections of two wires from their analytic geometry, not their
/// tessellation (#1052, #1319).
///
/// Each wire may carry SEVERAL tangent geoms — a polyline holds one Line or
/// Arc per segment — so both sides are decomposed into their analytic pieces
/// and every piece pair is solved exactly (closed form for line/circle/arc
/// pairs, subdivision refined to tolerance for ellipse pairs). Returning
/// `Some` also tells the caller the pair is fully resolved, so the
/// tessellated segment sweep must skip it: a 48-segment circle chord meets a
/// vertex-holding segment r·(1−cos(π/48)) ≈ 0.0053 short of the true
/// crossing, which is exactly the error MEASURE reported in #1319.
fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option<Vec<DVec3>> {
let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b))?;
const PLANE_TOL: f64 = 1e-7;
let curve_a = curve_in_frame(wire_a, &frame, PLANE_TOL)?;
let curve_b = curve_in_frame(wire_b, &frame, PLANE_TOL)?;
// Two lines have nothing this path can improve on (the segment sweep is
// already exact for a straight pair); avoid the extra work.
if matches!(curve_a, Curve::Line(_)) && matches!(curve_b, Curve::Line(_)) {
let curves_a = curves_in_frame(wire_a, &frame, PLANE_TOL)?;
let curves_b = curves_in_frame(wire_b, &frame, PLANE_TOL)?;
// Straight-vs-straight pairs have nothing this path can improve on (the
// segment sweep is already exact for them); avoid the extra work. This
// covers two plain lines AND two straight-only polylines.
if curves_a.iter().all(Curve::is_straight) && curves_b.iter().all(Curve::is_straight) {
return None;
}
let tolerance = cadkernel::geom2d::Tolerance::new(1e-9_f64.max(PLANE_TOL));
let crossings = cadkernel::geom2d::intersect(&curve_a, &curve_b, tolerance);
let points: Vec<DVec3> = crossings.into_iter().map(|c| frame.to_3d(c.point)).collect();
let linear = tolerance.linear();
let mut points: Vec<DVec3> = Vec::new();
for curve_a in &curves_a {
for curve_b in &curves_b {
for crossing in cadkernel::geom2d::intersect(curve_a, curve_b, tolerance) {
let point = frame.to_3d(crossing.point);
// Adjacent pieces share vertices, so a crossing on the shared
// vertex is found by both; keep one copy.
if !points.iter().any(|p| p.distance_squared(point) <= linear * linear) {
points.push(point);
}
}
}
}
Some(points)
}
@@ -3544,6 +3638,59 @@ mod ext_tests {
assert!(points.iter().all(|point| (point.x - origin).abs() == 3.0 && point.y == origin));
}
/// #1319 at survey coordinates: a multi-segment polyline wire (one Line
/// geom per segment, f32 in the geom) must still intersect a circle
/// exactly — `line_geom_endpoints_f64` recovers the f64 endpoints from
/// the wire's double-single tessellation points instead of letting the
/// f32 geom endpoints quantise UTM-scale crossings by centimetres.
#[test]
fn polyline_circle_intersection_retains_low_coordinate_bits() {
let (ox, oy) = (512_345.678, 4_512_345.678);
let vertex = |x: f64, y: f64| [ox + x, oy + y, 0.0];
let segments = [
(vertex(0.0, 0.0), vertex(3.0, 0.0)),
(vertex(3.0, 0.0), vertex(0.0, 4.0)),
(vertex(0.0, 4.0), vertex(0.0, 0.0)),
];
// plinegen=false layout: one NaN-separated run per segment, split
// into double-single exactly like `tessellate` does.
let mut runs: Vec<[f64; 3]> = Vec::new();
let mut geoms = Vec::new();
for (index, (a, b)) in segments.iter().enumerate() {
if index > 0 {
runs.push([f64::NAN; 3]);
}
runs.push(*a);
runs.push(*b);
geoms.push(TangentGeom::Line {
p1: a.map(|v| v as f32),
p2: b.map(|v| v as f32),
});
}
let (points, points_low) = crate::scene::convert::tessellate::points_to_ds(runs);
let polyline = WireModel {
points, points_low,
tangent_geoms: geoms,
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: vertex(1.5, 2.0), axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 2.5,
}],
..Default::default()
};
let points = exact_curve_intersections(&polyline, &circle)
.expect("polyline segments and the circle are coplanar analytic curves");
assert_eq!(points.len(), 3, "one crossing per vertex, got {points:?}");
for (x, y) in [(0.0, 0.0), (3.0, 0.0), (0.0, 4.0)] {
let expected = DVec3::from(vertex(x, y));
assert!(
points.iter().any(|p| p.distance(expected) < 1e-6),
"vertex ({x}, {y}) not found exactly in {points:?}"
);
}
}
#[test]
fn test_unindexed_snap_endpoint_and_midpoint_accuracy() {
let mut snapper = Snapper::default();
@@ -3684,4 +3831,303 @@ mod ext_tests {
assert_eq!(res.world, DVec3::ZERO);
}
/// The scene wires of the #1319 repro: a 3-4-5 right triangle drawn as
/// three LINE entities plus their circumscribed circle — the circle
/// through (0,0), (3,0), (0,4), centre (1.5, 2), radius 2.5. The circle
/// wire mirrors `entities::circle::to_render_with_wpp`: a
/// `circle_segments` tessellation (48 segments at minimum zoom) carrying
/// one `TangentGeom::PlanarCircle`; each line wire mirrors
/// `entities::line::to_render` with one `TangentGeom::Line`.
fn issue_1319_wires() -> Vec<WireModel> {
let vertices = [
DVec3::new(0.0, 0.0, 0.0),
DVec3::new(3.0, 0.0, 0.0),
DVec3::new(0.0, 4.0, 0.0),
];
let line_wire = |a: DVec3, b: DVec3| {
let af = a.to_array().map(|v| v as f32);
let bf = b.to_array().map(|v| v as f32);
WireModel {
points: vec![af, bf],
key_vertices: vec![a.to_array(), b.to_array()],
tangent_geoms: vec![TangentGeom::Line { p1: af, p2: bf }],
aabb: [
af[0].min(bf[0]),
af[1].min(bf[1]),
af[0].max(bf[0]),
af[1].max(bf[1]),
],
..Default::default()
}
};
let mut wires = vec![
line_wire(vertices[0], vertices[1]),
line_wire(vertices[1], vertices[2]),
line_wire(vertices[2], vertices[0]),
];
let (center, radius) = (DVec3::new(1.5, 2.0, 0.0), 2.5_f64);
let n = crate::entities::circle::circle_segments(radius, None);
let points: Vec<[f32; 3]> = (0..=n)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / n as f64;
[
(center.x + radius * angle.cos()) as f32,
(center.y + radius * angle.sin()) as f32,
0.0,
]
})
.collect();
let mut circle = WireModel {
points,
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: center.to_array(),
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius,
}],
aabb: [
(center.x - radius) as f32,
(center.y - radius) as f32,
(center.x + radius) as f32,
(center.y + radius) as f32,
],
..Default::default()
};
circle.snap_pts = vec![
(center, SnapHint::Center),
(DVec3::new(center.x + radius, center.y, 0.0), SnapHint::Quadrant),
(DVec3::new(center.x, center.y + radius, 0.0), SnapHint::Quadrant),
(DVec3::new(center.x - radius, center.y, 0.0), SnapHint::Quadrant),
(DVec3::new(center.x, center.y - radius, 0.0), SnapHint::Quadrant),
];
wires.push(circle);
wires
}
/// #1319: snapping INTERSECTION at a triangle vertex that also lies on the
/// circumscribed circle must return the exact vertex, not a point on the
/// circle's tessellation chords (an error of r·(1−cos(π/48)) ≈ 0.0053 for
/// the 48-segment minimum tessellation), which then compounds through
/// MEASURE. Verified at every vertex, through both the indexed and the
/// unindexed wire sources the app snaps with.
#[test]
fn intersection_snap_at_circumcircle_vertex_is_exact() {
let view_rot = Mat4::IDENTITY;
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let vertices = [
DVec3::new(0.0, 0.0, 0.0),
DVec3::new(3.0, 0.0, 0.0),
DVec3::new(0.0, 4.0, 0.0),
];
fn check<W: crate::scene::pick::interaction_index::WireSource + ?Sized>(
wires: &W,
label: &str,
view_rot: Mat4,
bounds: Rectangle,
vertices: [DVec3; 3],
) {
for vertex in vertices {
let cursor_world = vertex + DVec3::new(0.01, 0.01, 0.0);
let eye = vertex + DVec3::new(0.0, 0.0, 500.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let res = snapper
.snap(
cursor_world, cursor_screen, wires,
view_rot, eye, bounds,
Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
)
.unwrap_or_else(|| panic!("{label}: no intersection snap near {vertex:?}"));
assert_eq!(res.snap_type, SnapType::Intersection);
let err = (res.world - vertex).length();
assert!(
err < 1e-6,
"{label}: intersection snap near {vertex:?} was off by {err} (expected the exact vertex)"
);
}
}
let wires = issue_1319_wires();
check(wires.as_slice(), "unindexed", view_rot, bounds, vertices);
let arc = std::sync::Arc::new(issue_1319_wires());
let index = crate::scene::pick::interaction_index::InteractionIndex::build(&arc);
for vertex in vertices {
let r = 0.05_f64;
let aabb = [
vertex.x - r,
vertex.y - r,
vertex.x + r,
vertex.y + r,
];
let candidates = index.query_xy(std::sync::Arc::clone(&arc), aabb);
check(&candidates, "indexed", view_rot, bounds, vertices);
}
}
/// #1319, end to end through the real production wire pipeline: the same
/// triangle + circumscribed circle committed as document entities, the
/// wires produced by the scene's own tessellator (`model_tile_wires_arc`,
/// which is what `hit_test_wires`/`entity_wires_arc` hand the snapper),
/// and the same `interaction_candidates_near` broad phase the viewport
/// uses before calling `Snapper::snap`. The INT snap at each vertex must
/// land on the exact vertex, not on the circle's tessellation chords.
#[test]
fn intersection_snap_through_scene_wires_is_exact() {
use crate::scene::view::camera::Camera;
use crate::scene::Scene;
let mut scene = Scene::new();
let line = |a: [f64; 3], b: [f64; 3]| {
acadrust::entities::Line::from_points(
acadrust::types::Vector3::new(a[0], a[1], a[2]),
acadrust::types::Vector3::new(b[0], b[1], b[2]),
)
};
scene.add_entity(acadrust::EntityType::Line(line([0.0, 0.0, 0.0], [3.0, 0.0, 0.0])));
scene.add_entity(acadrust::EntityType::Line(line([3.0, 0.0, 0.0], [0.0, 4.0, 0.0])));
scene.add_entity(acadrust::EntityType::Line(line([0.0, 4.0, 0.0], [0.0, 0.0, 0.0])));
let mut circle = acadrust::entities::Circle::default();
circle.center = acadrust::types::Vector3::new(1.5, 2.0, 0.0);
circle.radius = 2.5;
scene.add_entity(acadrust::EntityType::Circle(circle));
let camera = Camera::default();
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let view_rot = camera.view_proj_rte(bounds);
let eye = camera.eye();
let all_wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1000.0);
let vertices = [
DVec3::new(0.0, 0.0, 0.0),
DVec3::new(3.0, 0.0, 0.0),
DVec3::new(0.0, 4.0, 0.0),
];
// Sweep a ring of cursor offsets so the assertion does not depend on
// which side of the vertex the user approached from.
let mut worst = 0.0_f64;
for k in 0..16 {
let angle = k as f64 * std::f64::consts::TAU / 16.0;
let approach = DVec3::new(angle.cos(), angle.sin(), 0.0) * 0.02;
for vertex in vertices {
let cursor_world = vertex + approach;
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let snap_candidates =
scene.interaction_candidates_near(
std::sync::Arc::clone(&all_wires),
cursor_world,
view_rot,
eye,
bounds,
12.0,
);
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let res = snapper
.snap(
cursor_world, cursor_screen, &snap_candidates,
view_rot, eye, bounds,
Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
)
.unwrap_or_else(|| panic!("no intersection snap near {vertex:?}"));
assert_eq!(res.snap_type, SnapType::Intersection);
let err = (res.world - vertex).length();
worst = worst.max(err);
assert!(
err < 1e-6,
"scene-wire intersection snap near {vertex:?} (approach {approach:?}) was off by {err} (expected the exact vertex)"
);
}
}
}
/// #1319, the reported shape of it: the triangle drawn as ONE closed
/// polyline (how PLINE/POLYGON produce it) plus the circumscribed circle.
/// The polyline wire carries three `TangentGeom::Line` geoms, and the
/// vertex the user INTERSECTION-snaps to is polyline-segment ∩ circle.
/// The exact vertex must come back — the tessellated segment sweep puts
/// that crossing on a 48-segment chord, r·(1−cos(π/48)) ≈ 0.0053 inside
/// the true circle, so MEASURE reads ~2.9947 instead of 3.0000 while
/// ENDPOINT (key vertices) stays exact.
#[test]
fn intersection_snap_polyline_triangle_circumcircle_is_exact() {
use crate::scene::view::camera::Camera;
use crate::scene::Scene;
let mut scene = Scene::new();
let mut pl = acadrust::entities::LwPolyline::new();
pl.vertices = vec![
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(3.0, 0.0)),
acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 4.0)),
];
pl.is_closed = true;
scene.add_entity(acadrust::EntityType::LwPolyline(pl));
let mut circle = acadrust::entities::Circle::default();
circle.center = acadrust::types::Vector3::new(1.5, 2.0, 0.0);
circle.radius = 2.5;
scene.add_entity(acadrust::EntityType::Circle(circle));
let camera = Camera::default();
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let view_rot = camera.view_proj_rte(bounds);
let eye = camera.eye();
let all_wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1000.0);
let vertices = [
DVec3::new(0.0, 0.0, 0.0),
DVec3::new(3.0, 0.0, 0.0),
DVec3::new(0.0, 4.0, 0.0),
];
// The broad phase a small drawing gets (unindexed scan) and the one a
// large drawing gets (interaction index query) must agree.
let index = crate::scene::pick::interaction_index::InteractionIndex::build(&all_wires);
for k in 0..16 {
let angle = k as f64 * std::f64::consts::TAU / 16.0;
let approach = DVec3::new(angle.cos(), angle.sin(), 0.0) * 0.02;
for vertex in vertices {
let cursor_world = vertex + approach;
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let unindexed = scene.interaction_candidates_near(
std::sync::Arc::clone(&all_wires),
cursor_world,
view_rot,
eye,
bounds,
12.0,
);
let indexed = index.query_xy(
std::sync::Arc::clone(&all_wires),
[
cursor_world.x - 0.1,
cursor_world.y - 0.1,
cursor_world.x + 0.1,
cursor_world.y + 0.1,
],
);
for (label, snap_candidates) in [("unindexed", &unindexed as &dyn WireSource), ("indexed", &indexed)] {
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let res = snapper
.snap(
cursor_world, cursor_screen, snap_candidates,
view_rot, eye, bounds,
Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
)
.unwrap_or_else(|| panic!("no {label} intersection snap near {vertex:?}"));
assert_eq!(res.snap_type, SnapType::Intersection);
let err = (res.world - vertex).length();
assert!(
err < 1e-6,
"#1319: {label} polyline∩circle intersection snap near {vertex:?} (approach {approach:?}) was off by {err} — the tessellated chord crossing, not the exact vertex"
);
}
}
}
}
}