diff --git a/crates/femm-doc/src/edit.rs b/crates/femm-doc/src/edit.rs index 59174a8..a0c00dd 100644 --- a/crates/femm-doc/src/edit.rs +++ b/crates/femm-doc/src/edit.rs @@ -1,7 +1,15 @@ //! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries. +use crate::geom_math::{ + line_arc_intersection, line_line_intersection, shortest_distance_from_segment, +}; use crate::{ArcSegment, BlockLabel, FemmDoc, Node, Segment}; +/// fraction of the node-bbox diagonal used as auto-tolerance for intersection-node coalescing. +const BBOX_TOLERANCE_FRAC: f64 = 1.0e-6; +/// fraction of a segment's length within which an off-endpoint node triggers a recursive split. +const ON_LINE_FRAC: f64 = 1.0e-5; + impl FemmDoc { /// adds a node at (x, y), returning the index of an existing node within `tol` distance when present. pub fn add_node(&mut self, x: f64, y: f64, tol: f64) -> usize { @@ -50,23 +58,103 @@ impl FemmDoc { self.block_labels.len() - 1 } - /// adds a segment between two node indices, or returns the index of an existing duplicate. - pub fn add_segment(&mut self, n0: i32, n1: i32) -> Option { - if n0 == n1 { return None; } + /// adds a segment between two node indices, splitting at every crossing and through any on-line node. + pub fn add_segment(&mut self, n0: i32, n1: i32) -> bool { + self.add_segment_with_marker(n0, n1, "") + } + + /// PSLG-aware variant propagating a boundary-marker name onto every resulting segment piece. + pub fn add_segment_with_marker(&mut self, n0: i32, n1: i32, marker: &str) -> bool { + if n0 == n1 { return false; } + let nn = self.nodes.len() as i32; + if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; } + + // duplicate either-orientation. let (a, b) = if n0 < n1 { (n0, n1) } else { (n1, n0) }; - for (i, s) in self.segments.iter().enumerate() { + for s in &self.segments { let (sa, sb) = if s.n0 < s.n1 { (s.n0, s.n1) } else { (s.n1, s.n0) }; - if sa == a && sb == b { return Some(i); } + if sa == a && sb == b { return false; } } + + let n0p = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y); + let n1p = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y); + + // crossings with existing segments and arcs. + let mut new_points: Vec<(f64, f64)> = Vec::new(); + for s in &self.segments { + let sp0 = (self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y); + let sp1 = (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y); + if let Some(hit) = line_line_intersection(n0p, n1p, sp0, sp1) { + new_points.push(hit); + } + } + for arc in &self.arcs { + let ap0 = (self.nodes[arc.n0 as usize].x, self.nodes[arc.n0 as usize].y); + let ap1 = (self.nodes[arc.n1 as usize].x, self.nodes[arc.n1 as usize].y); + for hit in line_arc_intersection(n0p, n1p, ap0, ap1, arc.arc_length) { + new_points.push(hit); + } + } + + let tol = self.bbox_tolerance(); + for (x, y) in new_points { + self.add_node(x, y, tol); + } + self.segments.push(Segment { n0, n1, max_side_length: -1.0, - boundary_marker: String::new(), + boundary_marker: marker.to_string(), hidden: false, in_group: 0, selected: false, }); - Some(self.segments.len() - 1) + + // first non-endpoint node on the new segment's interior, if any. + let length = ((n1p.0 - n0p.0).powi(2) + (n1p.1 - n0p.1).powi(2)).sqrt(); + let dmin = length * ON_LINE_FRAC; + + let mut split_at: Option = None; + for (i, node) in self.nodes.iter().enumerate() { + let idx = i as i32; + if idx == n0 || idx == n1 { continue; } + let np = (node.x, node.y); + let de0 = ((np.0 - n0p.0).powi(2) + (np.1 - n0p.1).powi(2)).sqrt(); + let de1 = ((np.0 - n1p.0).powi(2) + (np.1 - n1p.1).powi(2)).sqrt(); + if de0 < dmin || de1 < dmin { continue; } + let d = shortest_distance_from_segment(np, n0p, n1p); + if d < dmin { + split_at = Some(idx); + break; + } + } + + if let Some(mid) = split_at { + self.segments.pop(); + let a = self.add_segment_with_marker(n0, mid, marker); + let b = self.add_segment_with_marker(mid, n1, marker); + a || b + } else { + true + } + } + + /// auto-tolerance for intersection-node coalescing, derived from the node bounding box. + fn bbox_tolerance(&self) -> f64 { + if self.nodes.len() < 2 { + return 1.0e-8; + } + let mut xmin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymin = f64::INFINITY; + let mut ymax = f64::NEG_INFINITY; + for n in &self.nodes { + xmin = xmin.min(n.x); xmax = xmax.max(n.x); + ymin = ymin.min(n.y); ymax = ymax.max(n.y); + } + let dx = xmax - xmin; + let dy = ymax - ymin; + (dx * dx + dy * dy).sqrt() * BBOX_TOLERANCE_FRAC } /// adds an arc segment between two node indices, sweeping `arc_length_deg` degrees. @@ -210,3 +298,63 @@ fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f6 let cy = ay + t * dy; (px - cx).hypot(py - cy) } + +#[cfg(test)] +mod tests { + use super::*; + + fn doc_with_corners() -> FemmDoc { + let mut d = FemmDoc::default(); + d.add_node(-1.0, 0.0, 0.0); + d.add_node( 1.0, 0.0, 0.0); + d.add_node( 0.0,-1.0, 0.0); + d.add_node( 0.0, 1.0, 0.0); + d + } + + #[test] + fn duplicate_segment_rejected() { + let mut d = FemmDoc::default(); + d.add_node(0.0, 0.0, 0.0); + d.add_node(1.0, 0.0, 0.0); + assert!(d.add_segment(0, 1)); + assert!(!d.add_segment(0, 1)); + assert!(!d.add_segment(1, 0)); + assert_eq!(d.segments.len(), 1); + } + + #[test] + fn segment_passing_through_existing_node_splits() { + // nodes at (-1, 0), (1, 0), and (0, 0); adding a segment between the outer two + // must split at the midpoint node, yielding two segments. + let mut d = FemmDoc::default(); + d.add_node(-1.0, 0.0, 0.0); + d.add_node( 1.0, 0.0, 0.0); + d.add_node( 0.0, 0.0, 0.0); + assert!(d.add_segment(0, 1)); + assert_eq!(d.segments.len(), 2); + let touches_mid = d.segments.iter().filter(|s| s.n0 == 2 || s.n1 == 2).count(); + assert_eq!(touches_mid, 2); + } + + #[test] + fn crossing_second_segment_splits_at_intersection() { + // horizontal (-1,0)->(1,0) added first stays whole. + // the vertical (0,-1)->(0,1) is added second; it intersects at origin, + // so a new node lands at (0,0) and the vertical splits into two pieces. + let mut d = doc_with_corners(); + assert!(d.add_segment(0, 1)); + assert!(d.add_segment(2, 3)); + + assert_eq!(d.nodes.len(), 5, "intersection node added"); + let new_idx = 4; + assert!((d.nodes[new_idx].x).abs() < 1e-9); + assert!((d.nodes[new_idx].y).abs() < 1e-9); + + // exactly one piece touches node 0 (-1,0) and node 4 (0,0): the original horizontal stays whole here. + // expected pieces: 0->1 (horizontal whole), 2->4, 4->3 (vertical halves). + assert_eq!(d.segments.len(), 3); + let touches_new = d.segments.iter().filter(|s| s.n0 == new_idx as i32 || s.n1 == new_idx as i32).count(); + assert_eq!(touches_new, 2); + } +}