From c3495eee92b8cba4add2e56fa95b181710a0ff09 Mon Sep 17 00:00:00 2001 From: jess Date: Tue, 12 May 2026 21:47:37 -0700 Subject: [PATCH] parser and pre-proc --- Cargo.toml | 4 +- crates/femm-app/Cargo.toml | 4 +- crates/femm-app/src/doc_canvas.rs | 2 +- crates/femm-app/src/main.rs | 2 +- crates/{femm-doc => femm-doc-mag}/Cargo.toml | 2 +- crates/{femm-doc => femm-doc-mag}/src/edit.rs | 168 ++++++++++++++++-- crates/{femm-doc => femm-doc-mag}/src/geom.rs | 0 .../src/geom_math.rs | 0 crates/{femm-doc => femm-doc-mag}/src/lib.rs | 0 .../{femm-doc => femm-doc-mag}/src/parser.rs | 0 .../{femm-doc => femm-doc-mag}/src/props.rs | 0 .../{femm-doc => femm-doc-mag}/src/writer.rs | 0 .../tests/roundtrip.rs | 2 +- 13 files changed, 162 insertions(+), 22 deletions(-) rename crates/{femm-doc => femm-doc-mag}/Cargo.toml (92%) rename crates/{femm-doc => femm-doc-mag}/src/edit.rs (70%) rename crates/{femm-doc => femm-doc-mag}/src/geom.rs (100%) rename crates/{femm-doc => femm-doc-mag}/src/geom_math.rs (100%) rename crates/{femm-doc => femm-doc-mag}/src/lib.rs (100%) rename crates/{femm-doc => femm-doc-mag}/src/parser.rs (100%) rename crates/{femm-doc => femm-doc-mag}/src/props.rs (100%) rename crates/{femm-doc => femm-doc-mag}/src/writer.rs (100%) rename crates/{femm-doc => femm-doc-mag}/tests/roundtrip.rs (99%) diff --git a/Cargo.toml b/Cargo.toml index 252bee4..098cb69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ rust-version = "1.85" publish = false [workspace.dependencies] -femm-sys = { path = "crates/femm-sys" } -femm-doc = { path = "crates/femm-doc" } +femm-sys = { path = "crates/femm-sys" } +femm-doc-mag = { path = "crates/femm-doc-mag" } [profile.release] opt-level = 3 diff --git a/crates/femm-app/Cargo.toml b/crates/femm-app/Cargo.toml index 688c3c8..9596652 100644 --- a/crates/femm-app/Cargo.toml +++ b/crates/femm-app/Cargo.toml @@ -11,7 +11,7 @@ name = "femm" path = "src/main.rs" [dependencies] -femm-sys = { workspace = true } -femm-doc = { workspace = true } +femm-sys = { workspace = true } +femm-doc-mag = { workspace = true } iced = { version = "0.14", features = ["canvas"] } rfd = "0.17" diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index 294bc89..d1fba08 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -1,6 +1,6 @@ //! draws a FemmDoc on an iced canvas: nodes, segments, arcs, block labels, with pan/zoom and click-to-add. -use femm_doc::FemmDoc; +use femm_doc_mag::FemmDoc; use iced::widget::canvas::{ self, Action, Canvas, Event, Frame, Geometry, Path, Stroke, Text, path::Builder, }; diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index 2352a1f..58c002b 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -3,7 +3,7 @@ mod doc_canvas; use doc_canvas::{CanvasMessage, Tool}; -use femm_doc::FemmDoc; +use femm_doc_mag::FemmDoc; use iced::widget::{button, column, container, row, text}; use iced::{Element, Length, Task}; diff --git a/crates/femm-doc/Cargo.toml b/crates/femm-doc-mag/Cargo.toml similarity index 92% rename from crates/femm-doc/Cargo.toml rename to crates/femm-doc-mag/Cargo.toml index 1e2225e..bd643c6 100644 --- a/crates/femm-doc/Cargo.toml +++ b/crates/femm-doc-mag/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "femm-doc" +name = "femm-doc-mag" version = "0.0.1" edition.workspace = true rust-version.workspace = true diff --git a/crates/femm-doc/src/edit.rs b/crates/femm-doc-mag/src/edit.rs similarity index 70% rename from crates/femm-doc/src/edit.rs rename to crates/femm-doc-mag/src/edit.rs index 29f05da..336bfec 100644 --- a/crates/femm-doc/src/edit.rs +++ b/crates/femm-doc-mag/src/edit.rs @@ -1,9 +1,11 @@ //! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries. use crate::geom_math::{ - line_arc_intersection, line_line_intersection, shortest_distance_from_segment, + arc_arc_intersection, circle_from_arc, line_arc_intersection, line_line_intersection, + shortest_distance_from_arc, shortest_distance_from_segment, }; use crate::{ArcSegment, BlockLabel, FemmDoc, Node, Segment}; +use num_complex::Complex64; /// fraction of the node-bbox diagonal used as auto-tolerance for intersection-node coalescing. const BBOX_TOLERANCE_FRAC: f64 = 1.0e-6; @@ -190,10 +192,9 @@ impl FemmDoc { self.block_labels = old_block_labels; } - /// adds an arc segment between two node indices, sweeping `arc_length_deg` degrees. - pub fn add_arc_segment(&mut self, n0: i32, n1: i32, arc_length_deg: f64) -> Option { - if n0 == n1 { return None; } - self.arcs.push(ArcSegment { + /// adds an arc between two node indices, splitting at every crossing and through any on-arc node. + pub fn add_arc_segment(&mut self, n0: i32, n1: i32, arc_length_deg: f64) -> bool { + let template = ArcSegment { n0, n1, arc_length: arc_length_deg, max_side_length: 10.0, @@ -202,8 +203,97 @@ impl FemmDoc { in_group: 0, normal_direction: true, selected: false, - }); - Some(self.arcs.len() - 1) + }; + self.add_arc_segment_with_template(n0, n1, arc_length_deg, &template) + } + + /// PSLG-aware variant propagating boundary marker and side-length metadata onto every arc piece. + pub fn add_arc_segment_with_template( + &mut self, + n0: i32, + n1: i32, + arc_length_deg: f64, + template: &ArcSegment, + ) -> bool { + if n0 == n1 { return false; } + let nn = self.nodes.len() as i32; + if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return false; } + + // same directed endpoints with similar sweep counts as duplicate. + for a in &self.arcs { + if a.n0 == n0 && a.n1 == n1 && (a.arc_length - arc_length_deg).abs() < 1.0e-2 { + 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); + for hit in line_arc_intersection(sp0, sp1, n0p, n1p, arc_length_deg) { + 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 arc_arc_intersection(n0p, n1p, arc_length_deg, 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); + } + + let new_arc = ArcSegment { + n0, n1, + arc_length: arc_length_deg, + ..template.clone() + }; + self.arcs.push(new_arc); + + // first non-endpoint node on the new arc's sweep range, if any. + let (cx, cy, radius) = circle_from_arc(n0p, n1p, arc_length_deg); + let sweep_rad = arc_length_deg.to_radians(); + let arc_length_world = radius * sweep_rad.abs(); + let dmin = arc_length_world * 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_arc(np, n0p, n1p, arc_length_deg); + if d < dmin { + split_at = Some(idx); + break; + } + } + + if let Some(mid) = split_at { + self.arcs.pop(); + let mid_pos = (self.nodes[mid as usize].x, self.nodes[mid as usize].y); + let c = Complex64::new(cx, cy); + let a0 = Complex64::new(n0p.0, n0p.1); + let a1 = Complex64::new(n1p.0, n1p.1); + let a2 = Complex64::new(mid_pos.0, mid_pos.1); + let sweep_to_mid = ((a2 - c) / (a0 - c)).arg().to_degrees(); + let sweep_from_mid = ((a1 - c) / (a2 - c)).arg().to_degrees(); + let a = self.add_arc_segment_with_template(n0, mid, sweep_to_mid, template); + let b = self.add_arc_segment_with_template(mid, n1, sweep_from_mid, template); + a || b + } else { + true + } } /// removes selected nodes and rewrites segment/arc endpoint indices to drop references. @@ -376,8 +466,8 @@ mod tests { #[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. + // three colinear nodes (-1,0), (1,0), (0,0). adding the outer-to-outer segment + // splits at the midpoint node, producing two pieces meeting there. let mut d = FemmDoc::default(); d.add_node(-1.0, 0.0, 0.0); d.add_node( 1.0, 0.0, 0.0); @@ -390,18 +480,17 @@ mod tests { #[test] fn enforce_pslg_splits_first_segment_at_late_intersection() { - // horizontal added first stays whole; vertical second, its split inserts node 4 at origin. - // enforce_pslg now re-adds both segments fresh; with node 4 already in place, both - // get split through it, ending at 4 segments touching the origin node. + // incremental edit splits only the second of two crossing segments. enforce_pslg + // back-splits the first segment through the intersection node. let mut d = doc_with_corners(); assert!(d.add_segment(0, 1)); assert!(d.add_segment(2, 3)); - assert_eq!(d.segments.len(), 3, "before enforce: one whole, one split"); + assert_eq!(d.segments.len(), 3, "pre-enforce: one whole, one split"); d.enforce_pslg(); assert_eq!(d.nodes.len(), 5, "no node added or merged by enforce"); - assert_eq!(d.segments.len(), 4, "both originals now split at the origin"); + assert_eq!(d.segments.len(), 4, "both segments split at the origin"); let touches_origin = d.segments.iter().filter(|s| s.n0 == 4 || s.n1 == 4).count(); assert_eq!(touches_origin, 4); } @@ -419,6 +508,57 @@ mod tests { assert_eq!(d.segments[0].boundary_marker, "outer"); } + #[test] + fn duplicate_arc_rejected() { + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + assert!(d.add_arc_segment(0, 1, 90.0)); + assert!(!d.add_arc_segment(0, 1, 90.0)); + // reversed endpoints define a distinct arc. + assert!(d.add_arc_segment(1, 0, 90.0)); + assert_eq!(d.arcs.len(), 2); + } + + #[test] + fn add_segment_crossing_unit_quarter_arc_splits_segment() { + // unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses it once. + // expect a new intersection node near (0.866, 0.5) and the segment split into two. + let mut d = FemmDoc::default(); + d.add_node( 1.0, 0.0, 0.0); + d.add_node( 0.0, 1.0, 0.0); + d.add_node(-2.0, 0.5, 0.0); + d.add_node( 2.0, 0.5, 0.0); + assert!(d.add_arc_segment(0, 1, 90.0)); + assert!(d.add_segment(2, 3)); + + assert_eq!(d.nodes.len(), 5); + let n4 = &d.nodes[4]; + assert!((n4.x - 0.75_f64.sqrt()).abs() < 1e-6); + assert!((n4.y - 0.5).abs() < 1e-9); + assert_eq!(d.segments.len(), 2); + assert_eq!(d.arcs.len(), 1); + } + + #[test] + fn add_arc_passing_through_existing_node_splits_into_two_arcs() { + // pre-existing node at (cos45, sin45) lies on the unit quarter arc from (1,0) to (0,1). + // adding the arc should detect the on-arc node and emit two sub-arcs of 45 degrees each. + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + let mid_x = std::f64::consts::FRAC_1_SQRT_2; + d.add_node(mid_x, mid_x, 0.0); + assert!(d.add_arc_segment(0, 1, 90.0)); + + assert_eq!(d.arcs.len(), 2); + let touches_mid = d.arcs.iter().filter(|a| a.n0 == 2 || a.n1 == 2).count(); + assert_eq!(touches_mid, 2); + // split sweeps sum to the parent 90 degrees, within floating slop. + let total: f64 = d.arcs.iter().map(|a| a.arc_length).sum(); + assert!((total - 90.0).abs() < 1e-6, "sum of split sweeps = {total}"); + } + #[test] fn crossing_second_segment_splits_at_intersection() { // horizontal (-1,0)->(1,0) added first stays whole. diff --git a/crates/femm-doc/src/geom.rs b/crates/femm-doc-mag/src/geom.rs similarity index 100% rename from crates/femm-doc/src/geom.rs rename to crates/femm-doc-mag/src/geom.rs diff --git a/crates/femm-doc/src/geom_math.rs b/crates/femm-doc-mag/src/geom_math.rs similarity index 100% rename from crates/femm-doc/src/geom_math.rs rename to crates/femm-doc-mag/src/geom_math.rs diff --git a/crates/femm-doc/src/lib.rs b/crates/femm-doc-mag/src/lib.rs similarity index 100% rename from crates/femm-doc/src/lib.rs rename to crates/femm-doc-mag/src/lib.rs diff --git a/crates/femm-doc/src/parser.rs b/crates/femm-doc-mag/src/parser.rs similarity index 100% rename from crates/femm-doc/src/parser.rs rename to crates/femm-doc-mag/src/parser.rs diff --git a/crates/femm-doc/src/props.rs b/crates/femm-doc-mag/src/props.rs similarity index 100% rename from crates/femm-doc/src/props.rs rename to crates/femm-doc-mag/src/props.rs diff --git a/crates/femm-doc/src/writer.rs b/crates/femm-doc-mag/src/writer.rs similarity index 100% rename from crates/femm-doc/src/writer.rs rename to crates/femm-doc-mag/src/writer.rs diff --git a/crates/femm-doc/tests/roundtrip.rs b/crates/femm-doc-mag/tests/roundtrip.rs similarity index 99% rename from crates/femm-doc/tests/roundtrip.rs rename to crates/femm-doc-mag/tests/roundtrip.rs index e2e29d1..4e3fdf1 100644 --- a/crates/femm-doc/tests/roundtrip.rs +++ b/crates/femm-doc-mag/tests/roundtrip.rs @@ -1,4 +1,4 @@ -use femm_doc::FemmDoc; +use femm_doc_mag::FemmDoc; const FIXTURE: &str = r#"[Format] = 4.0 [Frequency] = 60