From 5ed26c858db084c5f154f74cdf390b07118fa19f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:32:38 +0300 Subject: [PATCH] fix(snap): keep the key-vertex lookup linear and pin the vertex layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tangent_line_endpoints` (c5eec2a5) resolves a Line geom's f32 endpoints to the wire's f64 key vertices by scanning for a matching consecutive pair. Starting every scan at slot 0 makes a dense polyline quadratic, and the pairwise intersection loop runs it against every curved wire in the aperture on each cursor move. Carry a cursor across the wire's geoms: the pairs arrive in vertex order, so the next match sits at or just after the previous one and the scan is amortised linear. Add regression tests for the vertex layouts the by-value lookup exists for — a center mark (two vertices per segment plus the centre), a merged block entry (per-entity vertex runs), and the closing segment of a closed polyline at UTM scale — plus one that checks the cursor walks the wire once. Co-Authored-By: Claude Opus 5 --- src/snap.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/src/snap.rs b/src/snap.rs index eea0b02e7..9ae72e607 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -2797,10 +2797,20 @@ fn wire_plane(wire: &WireModel) -> Option { None } +/// Resolve a `TangentGeom::Line`'s f32 endpoints to the wire's f64 +/// `key_vertices`. The two lists are not index-aligned in general — a split +/// polyline keeps every vertex but only its straight geoms, a center mark +/// stores two vertices per segment, a block entry appends per entity — so the +/// vertices are found by value. `cursor` carries the slot of the previous +/// match: the geoms come in vertex order, so the next pair sits at or just +/// after it and the scan is amortised linear over the wire rather than +/// quadratic (a dense contour polyline is checked against every curved wire +/// in the aperture on each cursor move). fn tangent_line_endpoints( wire: &WireModel, p1: [f32; 3], p2: [f32; 3], + cursor: &mut usize, ) -> (DVec3, DVec3) { if wire.points.len() == 2 && wire.tangent_geoms.len() == 1 { return (wp_f64(wire, 0), wp_f64(wire, 1)); @@ -2813,9 +2823,11 @@ fn tangent_line_endpoints( }; let count = wire.key_vertices.len(); if count >= 2 { - for start in 0..count { + for step in 0..count { + let start = (*cursor + step) % count; let end = (start + 1) % count; if matches(wire.key_vertices[start], p1) && matches(wire.key_vertices[end], p2) { + *cursor = end; return ( DVec3::from_array(wire.key_vertices[start]), DVec3::from_array(wire.key_vertices[end]), @@ -2854,10 +2866,11 @@ fn curves_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option { - let (p1, p2) = tangent_line_endpoints(wire, *p1, *p2); + let (p1, p2) = tangent_line_endpoints(wire, *p1, *p2, &mut vertex_cursor); if !frame.contains(p1, tol) || !frame.contains(p2, tol) { return None; } @@ -4317,4 +4330,127 @@ mod ext_tests { assert!(line_pt.is_some(), "expected line crossing at (-5, 0, 0), got {pts:?}"); } + fn xy_circle(cx: f64, cy: f64, radius: f64) -> WireModel { + WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [cx, cy, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius, + }], + ..Default::default() + } + } + + fn line_geom(a: [f64; 3], b: [f64; 3]) -> TangentGeom { + TangentGeom::Line { + p1: [a[0] as f32, a[1] as f32, a[2] as f32], + p2: [b[0] as f32, b[1] as f32, b[2] as f32], + } + } + + /// A center mark (`entities/line.rs`) stores two `key_vertices` per + /// segment plus the centre, so index `i` of `tangent_geoms` does not name + /// vertex `i`; the endpoints must be found by value. + #[test] + fn exact_curve_intersections_handles_center_mark_vertex_layout() { + let segments: [([f64; 3], [f64; 3]); 4] = [ + ([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([0.0, -1.0, 0.0], [0.0, 1.0, 0.0]), + ([2.0, 0.0, 0.0], [7.0, 0.0, 0.0]), + ([0.0, 2.0, 0.0], [0.0, 7.0, 0.0]), + ]; + let mut mark = WireModel::default(); + for (a, b) in segments { + mark.key_vertices.push(a); + mark.key_vertices.push(b); + mark.tangent_geoms.push(line_geom(a, b)); + } + mark.key_vertices.push([0.0, 0.0, 0.0]); + + let mut pts = exact_curve_intersections(&mark, &xy_circle(0.0, 0.0, 5.0)) + .expect("the extension lines cross the circle"); + pts.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(pts.len(), 2, "only the two extension lines reach r=5: {pts:?}"); + assert!((pts[0] - DVec3::new(0.0, 5.0, 0.0)).length() < 1e-9, "{pts:?}"); + assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + } + + /// A block entry (`cache/block_cache.rs`) appends each entity's + /// `key_vertices` and `tangent_geoms` in turn; the seam between two + /// entities must not be read as a segment. + #[test] + fn exact_curve_intersections_handles_block_entry_vertex_layout() { + let (a0, a1) = ([-10.0, 0.0, 0.0], [10.0, 0.0, 0.0]); + let (b0, b1) = ([20.0, -10.0, 0.0], [20.0, 10.0, 0.0]); + let block = WireModel { + key_vertices: vec![a0, a1, b0, b1], + tangent_geoms: vec![ + line_geom(a0, a1), + line_geom(b0, b1), + TangentGeom::PlanarCircle { + center: [30.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 1.0, + }, + ], + ..Default::default() + }; + + let mut pts = exact_curve_intersections(&block, &xy_circle(0.0, 0.0, 5.0)) + .expect("the first line crosses the circle"); + pts.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(pts.len(), 2, "the seam a1->b0 is not a segment: {pts:?}"); + assert!((pts[0] - DVec3::new(-5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + } + + /// The closing segment of a closed polyline runs from the last vertex + /// back to the first; it must still resolve to f64 vertices rather than + /// the f32 geom endpoints, which drift by centimetres at UTM scale. + #[test] + fn exact_curve_intersections_closing_segment_keeps_f64_vertices() { + let (ox, oy) = (500_000.123456, 4_000_000.654321); + let v = [ + [ox + 10.0, oy, 0.0], + [ox + 10.0, oy + 10.0, 0.0], + [ox, oy + 10.0, 0.0], + [ox, oy, 0.0], + ]; + let square = WireModel { + key_vertices: v.to_vec(), + tangent_geoms: (0..4).map(|i| line_geom(v[i], v[(i + 1) % 4])).collect(), + ..Default::default() + }; + + // Centred on the closing edge x = ox, so both crossings sit on it. + let pts = exact_curve_intersections(&square, &xy_circle(ox, oy + 5.0, 1.0)) + .expect("the circle crosses the closing edge"); + assert_eq!(pts.len(), 2, "{pts:?}"); + for p in &pts { + assert!((p.x - ox).abs() < 1e-9, "closing edge fell back to f32 endpoints: {p:?}"); + } + } + + /// The by-value vertex lookup must stay linear over the wire: each geom's + /// pair is expected at the cursor left by the previous match. + #[test] + fn tangent_line_endpoints_cursor_walks_the_wire_once() { + let n = 64; + let verts: Vec<[f64; 3]> = (0..=n).map(|i| [i as f64, (i % 3) as f64, 0.0]).collect(); + let wire = WireModel { + key_vertices: verts.clone(), + tangent_geoms: (0..n).map(|i| line_geom(verts[i], verts[i + 1])).collect(), + ..Default::default() + }; + let mut cursor = 0usize; + for (i, geom) in wire.tangent_geoms.iter().enumerate() { + let TangentGeom::Line { p1, p2 } = geom else { unreachable!() }; + let (a, b) = tangent_line_endpoints(&wire, *p1, *p2, &mut cursor); + assert_eq!(a, DVec3::from_array(verts[i])); + assert_eq!(b, DVec3::from_array(verts[i + 1])); + assert_eq!(cursor, i + 1, "cursor must land on the segment's end vertex"); + } + } }