From 4e6a655683ebdfb73eba7ac3bd6444c68914413f Mon Sep 17 00:00:00 2001 From: jess Date: Wed, 13 May 2026 00:30:13 -0700 Subject: [PATCH] xtasks geom_math edit, lib for elec and curr +poly for mag --- .cargo/config.toml | 2 + .gitignore | 1 + Cargo.toml | 3 +- assets/belasolv.svg | 9 + assets/femm.svg | 5 + assets/idr_cviewtype.svg | 8 + assets/idr_femmplottype.svg | 7 + crates/femm-app/src/doc_canvas.rs | 62 ++- crates/femm-app/src/main.rs | 64 +++ crates/femm-doc-curr/src/edit.rs | 608 +++++++++++++++++++++++++ crates/femm-doc-curr/src/geom_math.rs | 322 ++++++++++++++ crates/femm-doc-curr/src/lib.rs | 2 + crates/femm-doc-elec/src/edit.rs | 612 ++++++++++++++++++++++++++ crates/femm-doc-elec/src/geom_math.rs | 322 ++++++++++++++ crates/femm-doc-elec/src/lib.rs | 2 + crates/femm-doc-heat/src/edit.rs | 612 ++++++++++++++++++++++++++ crates/femm-doc-heat/src/geom_math.rs | 322 ++++++++++++++ crates/femm-doc-heat/src/lib.rs | 2 + crates/femm-doc-mag/src/edit.rs | 14 +- crates/femm-doc-mag/src/lib.rs | 1 + crates/femm-doc-mag/src/poly.rs | 166 +++++++ scripts/macos/build.sh | 86 ++++ scripts/macos/debug.sh | 13 + scripts/macos/ffi.sh | 5 + scripts/macos/install.sh | 23 + xtask/Cargo.toml | 9 + xtask/src/main.rs | 119 +++++ 27 files changed, 3385 insertions(+), 16 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 assets/belasolv.svg create mode 100644 assets/femm.svg create mode 100644 assets/idr_cviewtype.svg create mode 100644 assets/idr_femmplottype.svg create mode 100644 crates/femm-doc-curr/src/edit.rs create mode 100644 crates/femm-doc-curr/src/geom_math.rs create mode 100644 crates/femm-doc-elec/src/edit.rs create mode 100644 crates/femm-doc-elec/src/geom_math.rs create mode 100644 crates/femm-doc-heat/src/edit.rs create mode 100644 crates/femm-doc-heat/src/geom_math.rs create mode 100644 crates/femm-doc-mag/src/poly.rs create mode 100755 scripts/macos/build.sh create mode 100755 scripts/macos/debug.sh create mode 100755 scripts/macos/ffi.sh create mode 100755 scripts/macos/install.sh create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e11d56a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --release --package xtask --" diff --git a/.gitignore b/.gitignore index d67dbc0..76a071b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ build/ffi/ *.o *.a target/ +assets/old/ Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 098cb69..c0e6725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" -members = ["crates/*"] +members = ["crates/*", "xtask"] +default-members = ["crates/femm-app"] [workspace.package] edition = "2024" diff --git a/assets/belasolv.svg b/assets/belasolv.svg new file mode 100644 index 0000000..5a8ad0c --- /dev/null +++ b/assets/belasolv.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/femm.svg b/assets/femm.svg new file mode 100644 index 0000000..3f24543 --- /dev/null +++ b/assets/femm.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/idr_cviewtype.svg b/assets/idr_cviewtype.svg new file mode 100644 index 0000000..bf23ee9 --- /dev/null +++ b/assets/idr_cviewtype.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/idr_femmplottype.svg b/assets/idr_femmplottype.svg new file mode 100644 index 0000000..c86a47f --- /dev/null +++ b/assets/idr_femmplottype.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index 7b8065f..420b7ec 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -18,6 +18,8 @@ const BG: Color = Color::WHITE; const GEOM: Color = Color::BLACK; const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85); const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30); +const SELECT_COLOR: Color = Color::from_rgb(0.95, 0.20, 0.20); +const SELECT_STROKE: f32 = 2.4; /// active editing mode on the canvas. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -36,6 +38,10 @@ pub enum CanvasMessage { Click { world: (f64, f64), tool: Tool }, /// two-point segment request from the canvas. SegmentBetween { from: (f64, f64), to: (f64, f64) }, + /// right-click on Select mode, toggling the closest entity to the given world point. + TogglePickAt { world: (f64, f64) }, + /// Delete key in Select mode, removing every selected entity from the doc. + DeleteSelected, } /// pan offset, zoom factor, and click-gesture bookkeeping for the canvas. @@ -84,6 +90,16 @@ impl<'a> canvas::Program for DocCanvas<'a> { return Some(Action::capture()); } } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { + if self.tool == Tool::Select { + if let Some(now) = cursor.position_in(bounds) { + let view = ViewTransform::fit(self.doc, bounds, state); + let world = view.inverse_map(now); + return Some(Action::publish(CanvasMessage::TogglePickAt { world }) + .and_capture()); + } + } + } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let press = state.press_origin.take(); state.drag_origin = None; @@ -162,11 +178,23 @@ impl<'a> canvas::Program for DocCanvas<'a> { return Some(Action::request_redraw()); } } - if matches!(key, iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape)) { + if matches!( + key, + iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), + ) { if state.pending_segment_start.take().is_some() { return Some(Action::request_redraw()); } } + if matches!( + key, + iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete) + | iced::keyboard::Key::Named(iced::keyboard::key::Named::Backspace), + ) { + if self.tool == Tool::Select { + return Some(Action::publish(CanvasMessage::DeleteSelected)); + } + } } _ => {} } @@ -192,8 +220,13 @@ impl<'a> canvas::Program for DocCanvas<'a> { { let a = view.map(p0.x, p0.y); let b = view.map(p1.x, p1.y); + let (color, width) = if s.selected { + (SELECT_COLOR, SELECT_STROKE) + } else { + (GEOM, STROKE_WIDTH) + }; frame.stroke(&Path::line(a, b), - Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM)); + Stroke::default().with_width(width).with_color(color)); } } @@ -201,6 +234,11 @@ impl<'a> canvas::Program for DocCanvas<'a> { if let (Some(p0), Some(p1)) = (self.doc.nodes.get(a.n0 as usize), self.doc.nodes.get(a.n1 as usize)) { + let (color, width) = if a.selected { + (SELECT_COLOR, SELECT_STROKE) + } else { + (GEOM, STROKE_WIDTH) + }; if let Some((center, radius, start_angle, end_angle)) = arc_geometry(p0.x, p0.y, p1.x, p1.y, a.arc_length, a.normal_direction) { @@ -214,19 +252,24 @@ impl<'a> canvas::Program for DocCanvas<'a> { end_angle, }); frame.stroke(&b.build(), - Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM)); + Stroke::default().with_width(width).with_color(color)); } else { let a_px = view.map(p0.x, p0.y); let b_px = view.map(p1.x, p1.y); frame.stroke(&Path::line(a_px, b_px), - Stroke::default().with_width(STROKE_WIDTH).with_color(GEOM)); + Stroke::default().with_width(width).with_color(color)); } } } for n in &self.doc.nodes { let p = view.map(n.x, n.y); - frame.fill(&Path::circle(p, NODE_RADIUS), GEOM); + let (color, r) = if n.selected { + (SELECT_COLOR, NODE_RADIUS + 2.0) + } else { + (GEOM, NODE_RADIUS) + }; + frame.fill(&Path::circle(p, r), color); } if let Some(start_world) = state.pending_segment_start { @@ -245,6 +288,11 @@ impl<'a> canvas::Program for DocCanvas<'a> { for label in &self.doc.block_labels { let p = view.map(label.x, label.y); + let (color, width) = if label.selected { + (SELECT_COLOR, SELECT_STROKE) + } else { + (LABEL_COLOR, STROKE_WIDTH) + }; let cross = Path::new(|b| { b.move_to(Point::new(p.x - LABEL_TICK_PX, p.y)); b.line_to(Point::new(p.x + LABEL_TICK_PX, p.y)); @@ -252,11 +300,11 @@ impl<'a> canvas::Program for DocCanvas<'a> { b.line_to(Point::new(p.x, p.y + LABEL_TICK_PX)); }); frame.stroke(&cross, - Stroke::default().with_width(STROKE_WIDTH).with_color(LABEL_COLOR)); + Stroke::default().with_width(width).with_color(color)); frame.fill_text(Text { content: label.block_type.clone(), position: Point::new(p.x + LABEL_TICK_PX + 4.0, p.y - 8.0), - color: LABEL_COLOR, + color, size: 12.0.into(), ..Text::default() }); diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index a5179c3..07cff7e 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -94,6 +94,22 @@ impl App { self.status = format!("rejected segment {n0} -> {n1}"); } } + Message::Canvas(CanvasMessage::TogglePickAt { world }) => { + toggle_closest(&mut self.doc, world.0, world.1); + self.status = String::from("toggled selection at right-click"); + } + Message::Canvas(CanvasMessage::DeleteSelected) => { + let n = self.doc.delete_selected_nodes(); + let s = self.doc.delete_selected_segments(); + let a = self.doc.delete_selected_arcs(); + let b = self.doc.delete_selected_block_labels(); + let total = n + s + a + b; + self.status = if total == 0 { + String::from("nothing selected") + } else { + format!("deleted: {n} nodes, {s} segments, {a} arcs, {b} labels") + }; + } } Task::none() } @@ -134,6 +150,54 @@ impl App { } } +/// flips the selected flag on whichever entity sits nearest to (x, y) in doc world coords. +fn toggle_closest(doc: &mut FemmDoc, x: f64, y: f64) { + use femm_doc_mag::geom_math::{ + shortest_distance_from_arc, shortest_distance_from_segment, + }; + + #[derive(Clone, Copy)] + enum Kind { Node, Segment, Arc, Label } + let mut best: Option<(Kind, usize, f64)> = None; + let mut consider = |kind: Kind, idx: usize, d: f64| { + match best { + None => best = Some((kind, idx, d)), + Some((_, _, bd)) if d < bd => best = Some((kind, idx, d)), + _ => {} + } + }; + + for (i, n) in doc.nodes.iter().enumerate() { + consider(Kind::Node, i, (n.x - x).hypot(n.y - y)); + } + for (i, s) in doc.segments.iter().enumerate() { + if let (Some(p0), Some(p1)) = + (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) + { + consider(Kind::Segment, i, shortest_distance_from_segment((x, y), (p0.x, p0.y), (p1.x, p1.y))); + } + } + for (i, a) in doc.arcs.iter().enumerate() { + if let (Some(p0), Some(p1)) = + (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) + { + consider(Kind::Arc, i, shortest_distance_from_arc((x, y), (p0.x, p0.y), (p1.x, p1.y), a.arc_length)); + } + } + for (i, b) in doc.block_labels.iter().enumerate() { + consider(Kind::Label, i, (b.x - x).hypot(b.y - y)); + } + + if let Some((kind, idx, _)) = best { + match kind { + Kind::Node => doc.nodes[idx].selected ^= true, + Kind::Segment => doc.segments[idx].selected ^= true, + Kind::Arc => doc.arcs[idx].selected ^= true, + Kind::Label => doc.block_labels[idx].selected ^= true, + } + } +} + fn tool_button(label: &str, this_tool: Tool, active: Tool) -> Element<'_, Message> { let btn = button(text(label).size(13)).on_press(Message::SelectTool(this_tool)); if this_tool == active { diff --git a/crates/femm-doc-curr/src/edit.rs b/crates/femm-doc-curr/src/edit.rs new file mode 100644 index 0000000..6d773d6 --- /dev/null +++ b/crates/femm-doc-curr/src/edit.rs @@ -0,0 +1,608 @@ +//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries. + +use crate::geom_math::{ + 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; +/// 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 { + if tol > 0.0 { + for (i, n) in self.nodes.iter().enumerate() { + let dx = n.x - x; + let dy = n.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.nodes.push(Node { + x, y, + boundary_marker: String::new(), + in_group: 0, + selected: false, + }); + self.nodes.len() - 1 + } + + /// adds a block label at (x, y), returning the index of an existing label within `tol` when present. + pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize { + if tol > 0.0 { + for (i, b) in self.block_labels.iter().enumerate() { + let dx = b.x - x; + let dy = b.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.block_labels.push(BlockLabel { + x, y, + max_area: 0.0, + block_type: String::from(""), + in_conductor: String::from(""), + in_group: 0, + is_external: false, + is_default: false, + selected: false, + }); + self.block_labels.len() - 1 + } + + /// 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 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 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: marker.to_string(), + hidden: false, + in_group: 0, + selected: false, + }); + + // 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 { + nodes_bbox_tolerance(&self.nodes) + } + + /// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits. + pub fn enforce_pslg(&mut self) { + let old_nodes = std::mem::take(&mut self.nodes); + let old_segments = std::mem::take(&mut self.segments); + let old_arcs = std::mem::take(&mut self.arcs); + let old_block_labels = std::mem::take(&mut self.block_labels); + + let tol = nodes_bbox_tolerance(&old_nodes); + + // dedupes by position with metadata preserved. + for n in &old_nodes { + let mut duplicate = false; + for existing in &self.nodes { + if (existing.x - n.x).hypot(existing.y - n.y) < tol { + duplicate = true; + break; + } + } + if !duplicate { + self.nodes.push(n.clone()); + } + } + + for s in old_segments { + let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y); + let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker); + } + + for a in old_arcs { + let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y); + let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a); + } + + self.block_labels = old_block_labels; + } + + /// 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, + boundary_marker: String::new(), + hidden: false, + in_group: 0, + normal_direction: true, + selected: false, + }; + 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. + pub fn delete_selected_nodes(&mut self) -> usize { + let keep: Vec = self.nodes.iter().map(|n| !n.selected).collect(); + let mut remap: Vec = Vec::with_capacity(keep.len()); + let mut next: i32 = 0; + for &k in &keep { + remap.push(if k { let r = next; next += 1; r } else { -1 }); + } + let removed = self.nodes.len() - next as usize; + + let mut new_nodes = Vec::with_capacity(next as usize); + for (i, n) in self.nodes.drain(..).enumerate() { + if keep[i] { new_nodes.push(n); } + } + self.nodes = new_nodes; + + self.segments.retain(|s| { + let a = s.n0 as usize; + let b = s.n1 as usize; + a < keep.len() && b < keep.len() && keep[a] && keep[b] + }); + for s in &mut self.segments { + s.n0 = remap[s.n0 as usize]; + s.n1 = remap[s.n1 as usize]; + } + + self.arcs.retain(|a| { + let i = a.n0 as usize; + let j = a.n1 as usize; + i < keep.len() && j < keep.len() && keep[i] && keep[j] + }); + for a in &mut self.arcs { + a.n0 = remap[a.n0 as usize]; + a.n1 = remap[a.n1 as usize]; + } + + removed + } + + /// removes selected segments and returns the count. + pub fn delete_selected_segments(&mut self) -> usize { + let before = self.segments.len(); + self.segments.retain(|s| !s.selected); + before - self.segments.len() + } + + /// removes selected arc segments and returns the count. + pub fn delete_selected_arcs(&mut self) -> usize { + let before = self.arcs.len(); + self.arcs.retain(|a| !a.selected); + before - self.arcs.len() + } + + /// removes selected block labels and returns the count. + pub fn delete_selected_block_labels(&mut self) -> usize { + let before = self.block_labels.len(); + self.block_labels.retain(|b| !b.selected); + before - self.block_labels.len() + } + + /// returns the index of the closest node to (x, y), or None when the node list is empty. + pub fn closest_node(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, n) in self.nodes.iter().enumerate() { + let d = (n.x - x).hypot(n.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the closest block label to (x, y), or None when none exist. + pub fn closest_block_label(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, b) in self.block_labels.iter().enumerate() { + let d = (b.x - x).hypot(b.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the segment whose nearest point is closest to (x, y). + pub fn closest_segment(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, s) in self.segments.iter().enumerate() { + let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue }; + let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// clears the selection flag on every geometric entity in the doc. + pub fn clear_selection(&mut self) { + for n in &mut self.nodes { n.selected = false; } + for s in &mut self.segments { s.selected = false; } + for a in &mut self.arcs { a.selected = false; } + for b in &mut self.block_labels { b.selected = false; } + } +} + +/// auto-tolerance derived from the bounding box of a node slice. +fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 { + if 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 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 +} + +/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by). +fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 { + let dx = bx - ax; + let dy = by - ay; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return (px - ax).hypot(py - ay); + } + let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0); + let cx = ax + t * dx; + 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() { + // 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); + 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 enforce_pslg_splits_first_segment_at_late_intersection() { + // 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, "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 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); + } + + #[test] + fn enforce_pslg_preserves_segment_boundary_marker() { + 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_with_marker(0, 1, "outer")); + + d.enforce_pslg(); + + assert_eq!(d.segments.len(), 1); + assert_eq!(d.segments[0].boundary_marker, "outer"); + } + + #[test] + fn enforce_pslg_preserves_arc_metadata() { + // arc carrying non-default marker, side length, group, and direction. + // enforce_pslg round-trips every field intact. + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + let template = ArcSegment { + n0: 0, + n1: 1, + arc_length: 90.0, + max_side_length: 5.0, + boundary_marker: String::from("outer"), + hidden: true, + in_group: 7, + normal_direction: false, + selected: false, + }; + assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template)); + + d.enforce_pslg(); + + assert_eq!(d.arcs.len(), 1); + let a = &d.arcs[0]; + assert_eq!(a.boundary_marker, "outer"); + assert!((a.max_side_length - 5.0).abs() < 1e-12); + assert!(a.hidden); + assert_eq!(a.in_group, 7); + assert!(!a.normal_direction); + } + + #[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 the arc once. + // expect a fresh 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 detects the on-arc node and emits 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() { + // two perpendicular segments through the origin. horizontal first, vertical second. + // the vertical add inserts an origin node and self-splits; the horizontal stays whole. + 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); + + // expected pieces: 0->1 (horizontal whole), 2->4 and 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); + } +} diff --git a/crates/femm-doc-curr/src/geom_math.rs b/crates/femm-doc-curr/src/geom_math.rs new file mode 100644 index 0000000..37eeb54 --- /dev/null +++ b/crates/femm-doc-curr/src/geom_math.rs @@ -0,0 +1,322 @@ +//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs. + +use num_complex::Complex64; + +/// closeness factor for common-endpoint and small-interval rejection. +const EPS_FRAC: f64 = 1.0e-8; + +/// recovers the center and radius of the circle through an arc's endpoints with the given sweep. +pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) { + let a0 = Complex64::new(p0.0, p0.1); + let a1 = Complex64::new(p1.0, p1.1); + let chord = (a1 - a0).norm(); + let t = (a1 - a0) / chord; + let tta = sweep_deg.to_radians(); + let r = chord / (2.0 * (tta / 2.0).sin()); + let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t; + (c.re, c.im, r.abs()) +} + +/// shortest distance from a point to a segment. +pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 { + let dx = b.0 - a.0; + let dy = b.1 - a.1; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt(); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0); + let cx = a.0 + t * dx; + let cy = a.1 + t * dy; + ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt() +} + +/// shortest distance from a point to a sweep-defined arc. +pub fn shortest_distance_from_arc( + p: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> f64 { + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let pc = Complex64::new(p.0 - cx, p.1 - cy); + let d = pc.norm(); + if d == 0.0 { + return r; + } + let t = pc / d; + let foot = pc - Complex64::new(r * t.re, r * t.im); + let l = foot.norm(); + let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy); + let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0; + let sweep_abs = sweep_deg.abs(); + if z > 0.0 && z < sweep_abs { + return l; + } + let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt(); + let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt(); + e0.min(e1) +} + +/// intersection of the open segments p0->p1 and q0->q1, or None when none exists. +pub fn line_line_intersection( + p0: (f64, f64), + p1: (f64, f64), + q0: (f64, f64), + q1: (f64, f64), +) -> Option<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let q0c = Complex64::new(q0.0, q0.1); + let q1c = Complex64::new(q1.0, q1.1); + + // shared endpoint -> no other intersection + if (p0c - q0c).norm() < f64::EPSILON + || (p0c - q1c).norm() < f64::EPSILON + || (p1c - q0c).norm() < f64::EPSILON + || (p1c - q1c).norm() < f64::EPSILON + { + return None; + } + + let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC; + + let denom = p1c - p0c; + if denom.norm() < f64::EPSILON { + return None; + } + let r0 = (q0c - p0c) / denom; + let r1 = (q1c - p0c) / denom; + + if r0.re <= 0.0 && r1.re <= 0.0 { return None; } + if r0.re >= 1.0 && r1.re >= 1.0 { return None; } + if r0.im <= 0.0 && r1.im <= 0.0 { return None; } + if r0.im >= 0.0 && r1.im >= 0.0 { return None; } + + let denom_im = r0.im - r1.im; + if denom_im.abs() < f64::EPSILON { return None; } + let z = r0.im / denom_im; + let x = ((1.0 - z) * r0 + z * r1).re; + if x < ee || x > 1.0 - ee { + return None; + } + + let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0; + let _ = hit; + let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z; + Some((result.re, result.im)) +} + +/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits. +pub fn line_arc_intersection( + p0: (f64, f64), + p1: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> Vec<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let a0c = Complex64::new(a0.0, a0.1); + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let c = Complex64::new(cx, cy); + + let d = (p1c - p0c).norm(); + if d < f64::EPSILON { + return Vec::new(); + } + let t = (p1c - p0c) / d; + let v = (c - p0c) / t; + + if v.im.abs() > r { + return Vec::new(); + } + let l = (r * r - v.im * v.im).sqrt(); + let tta = sweep_deg.to_radians().abs(); + + let mut out = Vec::with_capacity(2); + + if (l / r) < 1.0e-5 { + let hit = p0c + Complex64::new(v.re, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + return out; + } + + for sign in [1.0, -1.0] { + let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + } + out +} + +/// intersection points of two open arcs, returning 0, 1, or 2 hits. +pub fn arc_arc_intersection( + a0_p0: (f64, f64), + a0_p1: (f64, f64), + a0_sweep_deg: f64, + a1_p0: (f64, f64), + a1_p1: (f64, f64), + a1_sweep_deg: f64, +) -> Vec<(f64, f64)> { + let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg); + let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg); + let c0 = Complex64::new(c0x, c0y); + let c1 = Complex64::new(c1x, c1y); + let d = (c1 - c0).norm(); + + if d > r0 + r1 || d < 1.0e-8 { + return Vec::new(); + } + + let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d); + let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d); + let t = (c1 - c0) / d; + let tta0 = a0_sweep_deg.to_radians().abs(); + let tta1 = a1_sweep_deg.to_radians().abs(); + let a0c = Complex64::new(a0_p0.0, a0_p0.1); + let a1c = Complex64::new(a1_p0.0, a1_p0.1); + + let mut out = Vec::with_capacity(2); + + let first = c0 + Complex64::new(c * d / 2.0, l) * t; + let z0 = ((first - c0) / (a0c - c0)).arg(); + let z1 = ((first - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((first.re, first.im)); + } + + // tangent-touch case: only one intersection + if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 { + return out; + } + + let second = c0 + Complex64::new(c * d / 2.0, -l) * t; + let z0 = ((second - c0) / (a0c - c0)).arg(); + let z1 = ((second - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((second.re, second.im)); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol + } + + #[test] + fn line_line_crosses_at_origin() { + // (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin. + let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)); + assert!(hit.is_some()); + assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9)); + } + + #[test] + fn line_line_parallel_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn line_line_t_junction_at_endpoint_rejects() { + // the second segment's q0 sits ON the first segment's interior + // but the prospective endpoint check should reject (intersection at q0). + let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0)); + // shape: one endpoint sits on the other segment, no clean crossing. + // GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the + // prospective line — here the meeting point IS the prospective q0, so no split is wanted. + assert!(hit.is_none()); + } + + #[test] + fn line_line_shared_endpoint_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn circle_from_quarter_arc_unit() { + // a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin. + let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0); + assert!((cx - 0.0).abs() < 1e-9); + assert!((cy - 0.0).abs() < 1e-9); + assert!((r - 1.0).abs() < 1e-9); + } + + #[test] + fn line_through_quarter_arc_crosses_once() { + // diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point. + let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert_eq!(hits.len(), 1); + let h = hits[0]; + assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}"); + assert!((h.1 - 0.5).abs() < 1e-9); + assert!(h.0 > 0.0); + } + + #[test] + fn line_outside_arc_does_not_cross() { + // line clearly outside the arc swept region. + let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!(hits.is_empty()); + } + + #[test] + fn arc_arc_two_intersections() { + // two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs + // shaped to clip both crossings in their swept range. + // circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)). + // circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)). + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (2.0, 0.0), (0.0, 0.0), 180.0, + ); + assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}"); + let h = hits[0]; + assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}"); + assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}"); + } + + #[test] + fn arc_arc_disjoint() { + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (11.0, 0.0), (9.0, 0.0), 180.0, + ); + assert!(hits.is_empty()); + } + + #[test] + fn shortest_distance_from_segment_perpendicular() { + // point (0, 1) to segment (-1, 0)->(1, 0) is distance 1. + let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_segment_clamps_to_endpoint() { + // point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint). + let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_arc_radial() { + // point at origin distance to unit-circle quarter arc is 1. + let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!((d - 1.0).abs() < 1e-9); + } +} diff --git a/crates/femm-doc-curr/src/lib.rs b/crates/femm-doc-curr/src/lib.rs index cf6f41f..8f61f72 100644 --- a/crates/femm-doc-curr/src/lib.rs +++ b/crates/femm-doc-curr/src/lib.rs @@ -1,9 +1,11 @@ //! current-flow pre-processor document model: geometry, properties, parser, writer. pub mod geom; +pub mod geom_math; pub mod props; pub mod parser; pub mod writer; +pub mod edit; use num_complex::Complex64; diff --git a/crates/femm-doc-elec/src/edit.rs b/crates/femm-doc-elec/src/edit.rs new file mode 100644 index 0000000..7e87bbd --- /dev/null +++ b/crates/femm-doc-elec/src/edit.rs @@ -0,0 +1,612 @@ +//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries. + +use crate::geom_math::{ + 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; +/// 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 { + if tol > 0.0 { + for (i, n) in self.nodes.iter().enumerate() { + let dx = n.x - x; + let dy = n.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.nodes.push(Node { + x, y, + boundary_marker: String::new(), + in_conductor: String::new(), + in_group: 0, + selected: false, + }); + self.nodes.len() - 1 + } + + /// adds a block label at (x, y), returning the index of an existing label within `tol` when present. + pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize { + if tol > 0.0 { + for (i, b) in self.block_labels.iter().enumerate() { + let dx = b.x - x; + let dy = b.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.block_labels.push(BlockLabel { + x, y, + max_area: 0.0, + block_type: String::from(""), + in_conductor: String::from(""), + in_group: 0, + is_external: false, + is_default: false, + selected: false, + }); + self.block_labels.len() - 1 + } + + /// 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 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 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: marker.to_string(), + in_conductor: String::new(), + hidden: false, + in_group: 0, + selected: false, + }); + + // 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 { + nodes_bbox_tolerance(&self.nodes) + } + + /// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits. + pub fn enforce_pslg(&mut self) { + let old_nodes = std::mem::take(&mut self.nodes); + let old_segments = std::mem::take(&mut self.segments); + let old_arcs = std::mem::take(&mut self.arcs); + let old_block_labels = std::mem::take(&mut self.block_labels); + + let tol = nodes_bbox_tolerance(&old_nodes); + + // dedupes by position with metadata preserved. + for n in &old_nodes { + let mut duplicate = false; + for existing in &self.nodes { + if (existing.x - n.x).hypot(existing.y - n.y) < tol { + duplicate = true; + break; + } + } + if !duplicate { + self.nodes.push(n.clone()); + } + } + + for s in old_segments { + let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y); + let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker); + } + + for a in old_arcs { + let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y); + let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a); + } + + self.block_labels = old_block_labels; + } + + /// 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, + boundary_marker: String::new(), + in_conductor: String::new(), + hidden: false, + in_group: 0, + normal_direction: true, + selected: false, + }; + 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. + pub fn delete_selected_nodes(&mut self) -> usize { + let keep: Vec = self.nodes.iter().map(|n| !n.selected).collect(); + let mut remap: Vec = Vec::with_capacity(keep.len()); + let mut next: i32 = 0; + for &k in &keep { + remap.push(if k { let r = next; next += 1; r } else { -1 }); + } + let removed = self.nodes.len() - next as usize; + + let mut new_nodes = Vec::with_capacity(next as usize); + for (i, n) in self.nodes.drain(..).enumerate() { + if keep[i] { new_nodes.push(n); } + } + self.nodes = new_nodes; + + self.segments.retain(|s| { + let a = s.n0 as usize; + let b = s.n1 as usize; + a < keep.len() && b < keep.len() && keep[a] && keep[b] + }); + for s in &mut self.segments { + s.n0 = remap[s.n0 as usize]; + s.n1 = remap[s.n1 as usize]; + } + + self.arcs.retain(|a| { + let i = a.n0 as usize; + let j = a.n1 as usize; + i < keep.len() && j < keep.len() && keep[i] && keep[j] + }); + for a in &mut self.arcs { + a.n0 = remap[a.n0 as usize]; + a.n1 = remap[a.n1 as usize]; + } + + removed + } + + /// removes selected segments and returns the count. + pub fn delete_selected_segments(&mut self) -> usize { + let before = self.segments.len(); + self.segments.retain(|s| !s.selected); + before - self.segments.len() + } + + /// removes selected arc segments and returns the count. + pub fn delete_selected_arcs(&mut self) -> usize { + let before = self.arcs.len(); + self.arcs.retain(|a| !a.selected); + before - self.arcs.len() + } + + /// removes selected block labels and returns the count. + pub fn delete_selected_block_labels(&mut self) -> usize { + let before = self.block_labels.len(); + self.block_labels.retain(|b| !b.selected); + before - self.block_labels.len() + } + + /// returns the index of the closest node to (x, y), or None when the node list is empty. + pub fn closest_node(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, n) in self.nodes.iter().enumerate() { + let d = (n.x - x).hypot(n.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the closest block label to (x, y), or None when none exist. + pub fn closest_block_label(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, b) in self.block_labels.iter().enumerate() { + let d = (b.x - x).hypot(b.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the segment whose nearest point is closest to (x, y). + pub fn closest_segment(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, s) in self.segments.iter().enumerate() { + let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue }; + let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// clears the selection flag on every geometric entity in the doc. + pub fn clear_selection(&mut self) { + for n in &mut self.nodes { n.selected = false; } + for s in &mut self.segments { s.selected = false; } + for a in &mut self.arcs { a.selected = false; } + for b in &mut self.block_labels { b.selected = false; } + } +} + +/// auto-tolerance derived from the bounding box of a node slice. +fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 { + if 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 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 +} + +/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by). +fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 { + let dx = bx - ax; + let dy = by - ay; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return (px - ax).hypot(py - ay); + } + let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0); + let cx = ax + t * dx; + 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() { + // 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); + 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 enforce_pslg_splits_first_segment_at_late_intersection() { + // 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, "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 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); + } + + #[test] + fn enforce_pslg_preserves_segment_boundary_marker() { + 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_with_marker(0, 1, "outer")); + + d.enforce_pslg(); + + assert_eq!(d.segments.len(), 1); + assert_eq!(d.segments[0].boundary_marker, "outer"); + } + + #[test] + fn enforce_pslg_preserves_arc_metadata() { + // arc carrying non-default marker, side length, group, and direction. + // enforce_pslg round-trips every field intact. + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + let template = ArcSegment { + n0: 0, + n1: 1, + arc_length: 90.0, + max_side_length: 5.0, + boundary_marker: String::from("outer"), + in_conductor: String::new(), + hidden: true, + in_group: 7, + normal_direction: false, + selected: false, + }; + assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template)); + + d.enforce_pslg(); + + assert_eq!(d.arcs.len(), 1); + let a = &d.arcs[0]; + assert_eq!(a.boundary_marker, "outer"); + assert!((a.max_side_length - 5.0).abs() < 1e-12); + assert!(a.hidden); + assert_eq!(a.in_group, 7); + assert!(!a.normal_direction); + } + + #[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 the arc once. + // expect a fresh 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 detects the on-arc node and emits 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() { + // two perpendicular segments through the origin. horizontal first, vertical second. + // the vertical add inserts an origin node and self-splits; the horizontal stays whole. + 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); + + // expected pieces: 0->1 (horizontal whole), 2->4 and 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); + } +} diff --git a/crates/femm-doc-elec/src/geom_math.rs b/crates/femm-doc-elec/src/geom_math.rs new file mode 100644 index 0000000..37eeb54 --- /dev/null +++ b/crates/femm-doc-elec/src/geom_math.rs @@ -0,0 +1,322 @@ +//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs. + +use num_complex::Complex64; + +/// closeness factor for common-endpoint and small-interval rejection. +const EPS_FRAC: f64 = 1.0e-8; + +/// recovers the center and radius of the circle through an arc's endpoints with the given sweep. +pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) { + let a0 = Complex64::new(p0.0, p0.1); + let a1 = Complex64::new(p1.0, p1.1); + let chord = (a1 - a0).norm(); + let t = (a1 - a0) / chord; + let tta = sweep_deg.to_radians(); + let r = chord / (2.0 * (tta / 2.0).sin()); + let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t; + (c.re, c.im, r.abs()) +} + +/// shortest distance from a point to a segment. +pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 { + let dx = b.0 - a.0; + let dy = b.1 - a.1; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt(); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0); + let cx = a.0 + t * dx; + let cy = a.1 + t * dy; + ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt() +} + +/// shortest distance from a point to a sweep-defined arc. +pub fn shortest_distance_from_arc( + p: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> f64 { + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let pc = Complex64::new(p.0 - cx, p.1 - cy); + let d = pc.norm(); + if d == 0.0 { + return r; + } + let t = pc / d; + let foot = pc - Complex64::new(r * t.re, r * t.im); + let l = foot.norm(); + let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy); + let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0; + let sweep_abs = sweep_deg.abs(); + if z > 0.0 && z < sweep_abs { + return l; + } + let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt(); + let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt(); + e0.min(e1) +} + +/// intersection of the open segments p0->p1 and q0->q1, or None when none exists. +pub fn line_line_intersection( + p0: (f64, f64), + p1: (f64, f64), + q0: (f64, f64), + q1: (f64, f64), +) -> Option<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let q0c = Complex64::new(q0.0, q0.1); + let q1c = Complex64::new(q1.0, q1.1); + + // shared endpoint -> no other intersection + if (p0c - q0c).norm() < f64::EPSILON + || (p0c - q1c).norm() < f64::EPSILON + || (p1c - q0c).norm() < f64::EPSILON + || (p1c - q1c).norm() < f64::EPSILON + { + return None; + } + + let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC; + + let denom = p1c - p0c; + if denom.norm() < f64::EPSILON { + return None; + } + let r0 = (q0c - p0c) / denom; + let r1 = (q1c - p0c) / denom; + + if r0.re <= 0.0 && r1.re <= 0.0 { return None; } + if r0.re >= 1.0 && r1.re >= 1.0 { return None; } + if r0.im <= 0.0 && r1.im <= 0.0 { return None; } + if r0.im >= 0.0 && r1.im >= 0.0 { return None; } + + let denom_im = r0.im - r1.im; + if denom_im.abs() < f64::EPSILON { return None; } + let z = r0.im / denom_im; + let x = ((1.0 - z) * r0 + z * r1).re; + if x < ee || x > 1.0 - ee { + return None; + } + + let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0; + let _ = hit; + let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z; + Some((result.re, result.im)) +} + +/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits. +pub fn line_arc_intersection( + p0: (f64, f64), + p1: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> Vec<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let a0c = Complex64::new(a0.0, a0.1); + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let c = Complex64::new(cx, cy); + + let d = (p1c - p0c).norm(); + if d < f64::EPSILON { + return Vec::new(); + } + let t = (p1c - p0c) / d; + let v = (c - p0c) / t; + + if v.im.abs() > r { + return Vec::new(); + } + let l = (r * r - v.im * v.im).sqrt(); + let tta = sweep_deg.to_radians().abs(); + + let mut out = Vec::with_capacity(2); + + if (l / r) < 1.0e-5 { + let hit = p0c + Complex64::new(v.re, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + return out; + } + + for sign in [1.0, -1.0] { + let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + } + out +} + +/// intersection points of two open arcs, returning 0, 1, or 2 hits. +pub fn arc_arc_intersection( + a0_p0: (f64, f64), + a0_p1: (f64, f64), + a0_sweep_deg: f64, + a1_p0: (f64, f64), + a1_p1: (f64, f64), + a1_sweep_deg: f64, +) -> Vec<(f64, f64)> { + let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg); + let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg); + let c0 = Complex64::new(c0x, c0y); + let c1 = Complex64::new(c1x, c1y); + let d = (c1 - c0).norm(); + + if d > r0 + r1 || d < 1.0e-8 { + return Vec::new(); + } + + let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d); + let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d); + let t = (c1 - c0) / d; + let tta0 = a0_sweep_deg.to_radians().abs(); + let tta1 = a1_sweep_deg.to_radians().abs(); + let a0c = Complex64::new(a0_p0.0, a0_p0.1); + let a1c = Complex64::new(a1_p0.0, a1_p0.1); + + let mut out = Vec::with_capacity(2); + + let first = c0 + Complex64::new(c * d / 2.0, l) * t; + let z0 = ((first - c0) / (a0c - c0)).arg(); + let z1 = ((first - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((first.re, first.im)); + } + + // tangent-touch case: only one intersection + if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 { + return out; + } + + let second = c0 + Complex64::new(c * d / 2.0, -l) * t; + let z0 = ((second - c0) / (a0c - c0)).arg(); + let z1 = ((second - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((second.re, second.im)); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol + } + + #[test] + fn line_line_crosses_at_origin() { + // (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin. + let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)); + assert!(hit.is_some()); + assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9)); + } + + #[test] + fn line_line_parallel_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn line_line_t_junction_at_endpoint_rejects() { + // the second segment's q0 sits ON the first segment's interior + // but the prospective endpoint check should reject (intersection at q0). + let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0)); + // shape: one endpoint sits on the other segment, no clean crossing. + // GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the + // prospective line — here the meeting point IS the prospective q0, so no split is wanted. + assert!(hit.is_none()); + } + + #[test] + fn line_line_shared_endpoint_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn circle_from_quarter_arc_unit() { + // a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin. + let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0); + assert!((cx - 0.0).abs() < 1e-9); + assert!((cy - 0.0).abs() < 1e-9); + assert!((r - 1.0).abs() < 1e-9); + } + + #[test] + fn line_through_quarter_arc_crosses_once() { + // diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point. + let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert_eq!(hits.len(), 1); + let h = hits[0]; + assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}"); + assert!((h.1 - 0.5).abs() < 1e-9); + assert!(h.0 > 0.0); + } + + #[test] + fn line_outside_arc_does_not_cross() { + // line clearly outside the arc swept region. + let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!(hits.is_empty()); + } + + #[test] + fn arc_arc_two_intersections() { + // two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs + // shaped to clip both crossings in their swept range. + // circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)). + // circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)). + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (2.0, 0.0), (0.0, 0.0), 180.0, + ); + assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}"); + let h = hits[0]; + assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}"); + assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}"); + } + + #[test] + fn arc_arc_disjoint() { + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (11.0, 0.0), (9.0, 0.0), 180.0, + ); + assert!(hits.is_empty()); + } + + #[test] + fn shortest_distance_from_segment_perpendicular() { + // point (0, 1) to segment (-1, 0)->(1, 0) is distance 1. + let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_segment_clamps_to_endpoint() { + // point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint). + let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_arc_radial() { + // point at origin distance to unit-circle quarter arc is 1. + let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!((d - 1.0).abs() < 1e-9); + } +} diff --git a/crates/femm-doc-elec/src/lib.rs b/crates/femm-doc-elec/src/lib.rs index 95276e4..4e1b98c 100644 --- a/crates/femm-doc-elec/src/lib.rs +++ b/crates/femm-doc-elec/src/lib.rs @@ -1,9 +1,11 @@ //! electrostatic pre-processor document model: geometry, properties, parser, writer. pub mod geom; +pub mod geom_math; pub mod props; pub mod parser; pub mod writer; +pub mod edit; pub use geom::{ArcSegment, BlockLabel, Node, Segment}; pub use props::{BoundaryProp, ConductorProp, MaterialProp, PointProp}; diff --git a/crates/femm-doc-heat/src/edit.rs b/crates/femm-doc-heat/src/edit.rs new file mode 100644 index 0000000..7e87bbd --- /dev/null +++ b/crates/femm-doc-heat/src/edit.rs @@ -0,0 +1,612 @@ +//! geometry editing primitives on [`FemmDoc`]: add, delete, closest-point queries. + +use crate::geom_math::{ + 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; +/// 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 { + if tol > 0.0 { + for (i, n) in self.nodes.iter().enumerate() { + let dx = n.x - x; + let dy = n.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.nodes.push(Node { + x, y, + boundary_marker: String::new(), + in_conductor: String::new(), + in_group: 0, + selected: false, + }); + self.nodes.len() - 1 + } + + /// adds a block label at (x, y), returning the index of an existing label within `tol` when present. + pub fn add_block_label(&mut self, x: f64, y: f64, tol: f64) -> usize { + if tol > 0.0 { + for (i, b) in self.block_labels.iter().enumerate() { + let dx = b.x - x; + let dy = b.y - y; + if (dx * dx + dy * dy).sqrt() <= tol { + return i; + } + } + } + self.block_labels.push(BlockLabel { + x, y, + max_area: 0.0, + block_type: String::from(""), + in_conductor: String::from(""), + in_group: 0, + is_external: false, + is_default: false, + selected: false, + }); + self.block_labels.len() - 1 + } + + /// 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 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 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: marker.to_string(), + in_conductor: String::new(), + hidden: false, + in_group: 0, + selected: false, + }); + + // 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 { + nodes_bbox_tolerance(&self.nodes) + } + + /// rebuilds every list through the PSLG-aware add primitives, catching crossings missed by incremental edits. + pub fn enforce_pslg(&mut self) { + let old_nodes = std::mem::take(&mut self.nodes); + let old_segments = std::mem::take(&mut self.segments); + let old_arcs = std::mem::take(&mut self.arcs); + let old_block_labels = std::mem::take(&mut self.block_labels); + + let tol = nodes_bbox_tolerance(&old_nodes); + + // dedupes by position with metadata preserved. + for n in &old_nodes { + let mut duplicate = false; + for existing in &self.nodes { + if (existing.x - n.x).hypot(existing.y - n.y) < tol { + duplicate = true; + break; + } + } + if !duplicate { + self.nodes.push(n.clone()); + } + } + + for s in old_segments { + let p0 = (old_nodes[s.n0 as usize].x, old_nodes[s.n0 as usize].y); + let p1 = (old_nodes[s.n1 as usize].x, old_nodes[s.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_segment_with_marker(n0 as i32, n1 as i32, &s.boundary_marker); + } + + for a in old_arcs { + let p0 = (old_nodes[a.n0 as usize].x, old_nodes[a.n0 as usize].y); + let p1 = (old_nodes[a.n1 as usize].x, old_nodes[a.n1 as usize].y); + let (Some(n0), Some(n1)) = ( + self.closest_node(p0.0, p0.1), + self.closest_node(p1.0, p1.1), + ) else { continue }; + self.add_arc_segment_with_template(n0 as i32, n1 as i32, a.arc_length, &a); + } + + self.block_labels = old_block_labels; + } + + /// 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, + boundary_marker: String::new(), + in_conductor: String::new(), + hidden: false, + in_group: 0, + normal_direction: true, + selected: false, + }; + 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. + pub fn delete_selected_nodes(&mut self) -> usize { + let keep: Vec = self.nodes.iter().map(|n| !n.selected).collect(); + let mut remap: Vec = Vec::with_capacity(keep.len()); + let mut next: i32 = 0; + for &k in &keep { + remap.push(if k { let r = next; next += 1; r } else { -1 }); + } + let removed = self.nodes.len() - next as usize; + + let mut new_nodes = Vec::with_capacity(next as usize); + for (i, n) in self.nodes.drain(..).enumerate() { + if keep[i] { new_nodes.push(n); } + } + self.nodes = new_nodes; + + self.segments.retain(|s| { + let a = s.n0 as usize; + let b = s.n1 as usize; + a < keep.len() && b < keep.len() && keep[a] && keep[b] + }); + for s in &mut self.segments { + s.n0 = remap[s.n0 as usize]; + s.n1 = remap[s.n1 as usize]; + } + + self.arcs.retain(|a| { + let i = a.n0 as usize; + let j = a.n1 as usize; + i < keep.len() && j < keep.len() && keep[i] && keep[j] + }); + for a in &mut self.arcs { + a.n0 = remap[a.n0 as usize]; + a.n1 = remap[a.n1 as usize]; + } + + removed + } + + /// removes selected segments and returns the count. + pub fn delete_selected_segments(&mut self) -> usize { + let before = self.segments.len(); + self.segments.retain(|s| !s.selected); + before - self.segments.len() + } + + /// removes selected arc segments and returns the count. + pub fn delete_selected_arcs(&mut self) -> usize { + let before = self.arcs.len(); + self.arcs.retain(|a| !a.selected); + before - self.arcs.len() + } + + /// removes selected block labels and returns the count. + pub fn delete_selected_block_labels(&mut self) -> usize { + let before = self.block_labels.len(); + self.block_labels.retain(|b| !b.selected); + before - self.block_labels.len() + } + + /// returns the index of the closest node to (x, y), or None when the node list is empty. + pub fn closest_node(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, n) in self.nodes.iter().enumerate() { + let d = (n.x - x).hypot(n.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the closest block label to (x, y), or None when none exist. + pub fn closest_block_label(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, b) in self.block_labels.iter().enumerate() { + let d = (b.x - x).hypot(b.y - y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// returns the index of the segment whose nearest point is closest to (x, y). + pub fn closest_segment(&self, x: f64, y: f64) -> Option { + let mut best: Option<(usize, f64)> = None; + for (i, s) in self.segments.iter().enumerate() { + let (Some(p0), Some(p1)) = (self.nodes.get(s.n0 as usize), self.nodes.get(s.n1 as usize)) else { continue }; + let d = point_to_segment_distance(x, y, p0.x, p0.y, p1.x, p1.y); + match best { + None => best = Some((i, d)), + Some((_, bd)) if d < bd => best = Some((i, d)), + _ => {} + } + } + best.map(|(i, _)| i) + } + + /// clears the selection flag on every geometric entity in the doc. + pub fn clear_selection(&mut self) { + for n in &mut self.nodes { n.selected = false; } + for s in &mut self.segments { s.selected = false; } + for a in &mut self.arcs { a.selected = false; } + for b in &mut self.block_labels { b.selected = false; } + } +} + +/// auto-tolerance derived from the bounding box of a node slice. +fn nodes_bbox_tolerance(nodes: &[Node]) -> f64 { + if 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 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 +} + +/// Euclidean distance from (px, py) to the segment between (ax, ay) and (bx, by). +fn point_to_segment_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 { + let dx = bx - ax; + let dy = by - ay; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return (px - ax).hypot(py - ay); + } + let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0); + let cx = ax + t * dx; + 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() { + // 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); + 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 enforce_pslg_splits_first_segment_at_late_intersection() { + // 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, "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 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); + } + + #[test] + fn enforce_pslg_preserves_segment_boundary_marker() { + 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_with_marker(0, 1, "outer")); + + d.enforce_pslg(); + + assert_eq!(d.segments.len(), 1); + assert_eq!(d.segments[0].boundary_marker, "outer"); + } + + #[test] + fn enforce_pslg_preserves_arc_metadata() { + // arc carrying non-default marker, side length, group, and direction. + // enforce_pslg round-trips every field intact. + let mut d = FemmDoc::default(); + d.add_node(1.0, 0.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + let template = ArcSegment { + n0: 0, + n1: 1, + arc_length: 90.0, + max_side_length: 5.0, + boundary_marker: String::from("outer"), + in_conductor: String::new(), + hidden: true, + in_group: 7, + normal_direction: false, + selected: false, + }; + assert!(d.add_arc_segment_with_template(0, 1, 90.0, &template)); + + d.enforce_pslg(); + + assert_eq!(d.arcs.len(), 1); + let a = &d.arcs[0]; + assert_eq!(a.boundary_marker, "outer"); + assert!((a.max_side_length - 5.0).abs() < 1e-12); + assert!(a.hidden); + assert_eq!(a.in_group, 7); + assert!(!a.normal_direction); + } + + #[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 the arc once. + // expect a fresh 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 detects the on-arc node and emits 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() { + // two perpendicular segments through the origin. horizontal first, vertical second. + // the vertical add inserts an origin node and self-splits; the horizontal stays whole. + 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); + + // expected pieces: 0->1 (horizontal whole), 2->4 and 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); + } +} diff --git a/crates/femm-doc-heat/src/geom_math.rs b/crates/femm-doc-heat/src/geom_math.rs new file mode 100644 index 0000000..37eeb54 --- /dev/null +++ b/crates/femm-doc-heat/src/geom_math.rs @@ -0,0 +1,322 @@ +//! 2D intersection math and distance queries over (x, y) pairs and degree-sweep arcs. + +use num_complex::Complex64; + +/// closeness factor for common-endpoint and small-interval rejection. +const EPS_FRAC: f64 = 1.0e-8; + +/// recovers the center and radius of the circle through an arc's endpoints with the given sweep. +pub fn circle_from_arc(p0: (f64, f64), p1: (f64, f64), sweep_deg: f64) -> (f64, f64, f64) { + let a0 = Complex64::new(p0.0, p0.1); + let a1 = Complex64::new(p1.0, p1.1); + let chord = (a1 - a0).norm(); + let t = (a1 - a0) / chord; + let tta = sweep_deg.to_radians(); + let r = chord / (2.0 * (tta / 2.0).sin()); + let c = a0 + (Complex64::new(chord / 2.0, (r * r - chord * chord / 4.0).sqrt())) * t; + (c.re, c.im, r.abs()) +} + +/// shortest distance from a point to a segment. +pub fn shortest_distance_from_segment(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 { + let dx = b.0 - a.0; + let dy = b.1 - a.1; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { + return ((p.0 - a.0).powi(2) + (p.1 - a.1).powi(2)).sqrt(); + } + let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0); + let cx = a.0 + t * dx; + let cy = a.1 + t * dy; + ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt() +} + +/// shortest distance from a point to a sweep-defined arc. +pub fn shortest_distance_from_arc( + p: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> f64 { + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let pc = Complex64::new(p.0 - cx, p.1 - cy); + let d = pc.norm(); + if d == 0.0 { + return r; + } + let t = pc / d; + let foot = pc - Complex64::new(r * t.re, r * t.im); + let l = foot.norm(); + let a0_dir = Complex64::new(a0.0 - cx, a0.1 - cy); + let z = ((t / a0_dir).arg() * 180.0 / std::f64::consts::PI + 360.0) % 360.0; + let sweep_abs = sweep_deg.abs(); + if z > 0.0 && z < sweep_abs { + return l; + } + let e0 = ((p.0 - a0.0).powi(2) + (p.1 - a0.1).powi(2)).sqrt(); + let e1 = ((p.0 - a1.0).powi(2) + (p.1 - a1.1).powi(2)).sqrt(); + e0.min(e1) +} + +/// intersection of the open segments p0->p1 and q0->q1, or None when none exists. +pub fn line_line_intersection( + p0: (f64, f64), + p1: (f64, f64), + q0: (f64, f64), + q1: (f64, f64), +) -> Option<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let q0c = Complex64::new(q0.0, q0.1); + let q1c = Complex64::new(q1.0, q1.1); + + // shared endpoint -> no other intersection + if (p0c - q0c).norm() < f64::EPSILON + || (p0c - q1c).norm() < f64::EPSILON + || (p1c - q0c).norm() < f64::EPSILON + || (p1c - q1c).norm() < f64::EPSILON + { + return None; + } + + let ee = ((p1c - p0c).norm()).min((q1c - q0c).norm()) * EPS_FRAC; + + let denom = p1c - p0c; + if denom.norm() < f64::EPSILON { + return None; + } + let r0 = (q0c - p0c) / denom; + let r1 = (q1c - p0c) / denom; + + if r0.re <= 0.0 && r1.re <= 0.0 { return None; } + if r0.re >= 1.0 && r1.re >= 1.0 { return None; } + if r0.im <= 0.0 && r1.im <= 0.0 { return None; } + if r0.im >= 0.0 && r1.im >= 0.0 { return None; } + + let denom_im = r0.im - r1.im; + if denom_im.abs() < f64::EPSILON { return None; } + let z = r0.im / denom_im; + let x = ((1.0 - z) * r0 + z * r1).re; + if x < ee || x > 1.0 - ee { + return None; + } + + let hit = Complex64::new(p0.0, p0.1) * (1.0 - z) + Complex64::new(q0.0, q0.1) * 0.0; + let _ = hit; + let result = Complex64::new(q0.0, q0.1) * (1.0 - z) + Complex64::new(q1.0, q1.1) * z; + Some((result.re, result.im)) +} + +/// intersection points of an open line segment with an open arc, returning 0, 1, or 2 hits. +pub fn line_arc_intersection( + p0: (f64, f64), + p1: (f64, f64), + a0: (f64, f64), + a1: (f64, f64), + sweep_deg: f64, +) -> Vec<(f64, f64)> { + let p0c = Complex64::new(p0.0, p0.1); + let p1c = Complex64::new(p1.0, p1.1); + let a0c = Complex64::new(a0.0, a0.1); + let (cx, cy, r) = circle_from_arc(a0, a1, sweep_deg); + let c = Complex64::new(cx, cy); + + let d = (p1c - p0c).norm(); + if d < f64::EPSILON { + return Vec::new(); + } + let t = (p1c - p0c) / d; + let v = (c - p0c) / t; + + if v.im.abs() > r { + return Vec::new(); + } + let l = (r * r - v.im * v.im).sqrt(); + let tta = sweep_deg.to_radians().abs(); + + let mut out = Vec::with_capacity(2); + + if (l / r) < 1.0e-5 { + let hit = p0c + Complex64::new(v.re, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + return out; + } + + for sign in [1.0, -1.0] { + let hit = p0c + Complex64::new(v.re + sign * l, 0.0) * t; + let r_param = ((hit - p0c) / t).re; + let z = ((hit - c) / (a0c - c)).arg(); + if r_param > 0.0 && r_param < d && z > 0.0 && z < tta { + out.push((hit.re, hit.im)); + } + } + out +} + +/// intersection points of two open arcs, returning 0, 1, or 2 hits. +pub fn arc_arc_intersection( + a0_p0: (f64, f64), + a0_p1: (f64, f64), + a0_sweep_deg: f64, + a1_p0: (f64, f64), + a1_p1: (f64, f64), + a1_sweep_deg: f64, +) -> Vec<(f64, f64)> { + let (c0x, c0y, r0) = circle_from_arc(a0_p0, a0_p1, a0_sweep_deg); + let (c1x, c1y, r1) = circle_from_arc(a1_p0, a1_p1, a1_sweep_deg); + let c0 = Complex64::new(c0x, c0y); + let c1 = Complex64::new(c1x, c1y); + let d = (c1 - c0).norm(); + + if d > r0 + r1 || d < 1.0e-8 { + return Vec::new(); + } + + let l = ((r0 + r1 - d) * (d + r0 - r1) * (d - r0 + r1) * (d + r0 + r1)).sqrt() / (2.0 * d); + let c = 1.0 + (r0 / d) * (r0 / d) - (r1 / d) * (r1 / d); + let t = (c1 - c0) / d; + let tta0 = a0_sweep_deg.to_radians().abs(); + let tta1 = a1_sweep_deg.to_radians().abs(); + let a0c = Complex64::new(a0_p0.0, a0_p0.1); + let a1c = Complex64::new(a1_p0.0, a1_p0.1); + + let mut out = Vec::with_capacity(2); + + let first = c0 + Complex64::new(c * d / 2.0, l) * t; + let z0 = ((first - c0) / (a0c - c0)).arg(); + let z1 = ((first - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((first.re, first.im)); + } + + // tangent-touch case: only one intersection + if (d - r0 + r1).abs() / (r0 + r1) < 1.0e-5 { + return out; + } + + let second = c0 + Complex64::new(c * d / 2.0, -l) * t; + let z0 = ((second - c0) / (a0c - c0)).arg(); + let z1 = ((second - c1) / (a1c - c1)).arg(); + if z0 > 0.0 && z0 < tta0 && z1 > 0.0 && z1 < tta1 { + out.push((second.re, second.im)); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: (f64, f64), b: (f64, f64), tol: f64) -> bool { + ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < tol + } + + #[test] + fn line_line_crosses_at_origin() { + // (-1,-1)->(1,1) crosses (-1,1)->(1,-1) at origin. + let hit = line_line_intersection((-1.0, -1.0), (1.0, 1.0), (-1.0, 1.0), (1.0, -1.0)); + assert!(hit.is_some()); + assert!(close(hit.unwrap(), (0.0, 0.0), 1e-9)); + } + + #[test] + fn line_line_parallel_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn line_line_t_junction_at_endpoint_rejects() { + // the second segment's q0 sits ON the first segment's interior + // but the prospective endpoint check should reject (intersection at q0). + let hit = line_line_intersection((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (1.0, 1.0)); + // shape: one endpoint sits on the other segment, no clean crossing. + // GetIntersection returns FALSE when an intersection lies within ee of an endpoint of the + // prospective line — here the meeting point IS the prospective q0, so no split is wanted. + assert!(hit.is_none()); + } + + #[test] + fn line_line_shared_endpoint_no_intersection() { + let hit = line_line_intersection((0.0, 0.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0)); + assert!(hit.is_none()); + } + + #[test] + fn circle_from_quarter_arc_unit() { + // a quarter circle (90 deg sweep) from (1, 0) to (0, 1) sits on the unit circle centered at origin. + let (cx, cy, r) = circle_from_arc((1.0, 0.0), (0.0, 1.0), 90.0); + assert!((cx - 0.0).abs() < 1e-9); + assert!((cy - 0.0).abs() < 1e-9); + assert!((r - 1.0).abs() < 1e-9); + } + + #[test] + fn line_through_quarter_arc_crosses_once() { + // diagonal line from (-2, 0.5) to (2, 0.5) crosses the quarter circle (1,0)->(0,1) at one point. + let hits = line_arc_intersection((-2.0, 0.5), (2.0, 0.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert_eq!(hits.len(), 1); + let h = hits[0]; + assert!((h.0 * h.0 + h.1 * h.1 - 1.0).abs() < 1e-9, "point should lie on unit circle: {h:?}"); + assert!((h.1 - 0.5).abs() < 1e-9); + assert!(h.0 > 0.0); + } + + #[test] + fn line_outside_arc_does_not_cross() { + // line clearly outside the arc swept region. + let hits = line_arc_intersection((-2.0, 2.5), (2.0, 2.5), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!(hits.is_empty()); + } + + #[test] + fn arc_arc_two_intersections() { + // two unit circles offset by 1.0 along x, both swept 360 degrees... but we use partial arcs + // shaped to clip both crossings in their swept range. + // circle A: center (0,0), arcs covering full top half (180 deg from (1,0) to (-1,0)). + // circle B: center (1,0), arcs covering full top half (180 deg from (2,0) to (0,0)). + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (2.0, 0.0), (0.0, 0.0), 180.0, + ); + assert_eq!(hits.len(), 1, "two top-half arcs of overlapping unit circles meet at one upper crossing: {hits:?}"); + let h = hits[0]; + assert!((h.0 - 0.5).abs() < 1e-9, "x = 0.5 by symmetry, got {h:?}"); + assert!(h.1 > 0.0, "intersection should be above the x-axis: {h:?}"); + } + + #[test] + fn arc_arc_disjoint() { + let hits = arc_arc_intersection( + (1.0, 0.0), (-1.0, 0.0), 180.0, + (11.0, 0.0), (9.0, 0.0), 180.0, + ); + assert!(hits.is_empty()); + } + + #[test] + fn shortest_distance_from_segment_perpendicular() { + // point (0, 1) to segment (-1, 0)->(1, 0) is distance 1. + let d = shortest_distance_from_segment((0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_segment_clamps_to_endpoint() { + // point (-2, 0) to segment (-1, 0)->(1, 0) is distance 1 (clamps to left endpoint). + let d = shortest_distance_from_segment((-2.0, 0.0), (-1.0, 0.0), (1.0, 0.0)); + assert!((d - 1.0).abs() < 1e-9); + } + + #[test] + fn shortest_distance_from_arc_radial() { + // point at origin distance to unit-circle quarter arc is 1. + let d = shortest_distance_from_arc((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), 90.0); + assert!((d - 1.0).abs() < 1e-9); + } +} diff --git a/crates/femm-doc-heat/src/lib.rs b/crates/femm-doc-heat/src/lib.rs index 2fa5ab2..cfbead4 100644 --- a/crates/femm-doc-heat/src/lib.rs +++ b/crates/femm-doc-heat/src/lib.rs @@ -1,9 +1,11 @@ //! heat-flow pre-processor document model: geometry, properties, parser, writer. pub mod geom; +pub mod geom_math; pub mod props; pub mod parser; pub mod writer; +pub mod edit; use num_complex::Complex64; diff --git a/crates/femm-doc-mag/src/edit.rs b/crates/femm-doc-mag/src/edit.rs index 0fd5b3f..2b92fd1 100644 --- a/crates/femm-doc-mag/src/edit.rs +++ b/crates/femm-doc-mag/src/edit.rs @@ -553,8 +553,8 @@ mod tests { #[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. + // unit quarter arc from (1,0) to (0,1); horizontal line at y=0.5 crosses the arc once. + // expect a fresh 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); @@ -574,7 +574,7 @@ mod tests { #[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. + // adding the arc detects the on-arc node and emits 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); @@ -592,9 +592,8 @@ mod tests { #[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. + // two perpendicular segments through the origin. horizontal first, vertical second. + // the vertical add inserts an origin node and self-splits; the horizontal stays whole. let mut d = doc_with_corners(); assert!(d.add_segment(0, 1)); assert!(d.add_segment(2, 3)); @@ -604,8 +603,7 @@ mod tests { 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). + // expected pieces: 0->1 (horizontal whole), 2->4 and 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); diff --git a/crates/femm-doc-mag/src/lib.rs b/crates/femm-doc-mag/src/lib.rs index 9143cd3..7c931e2 100644 --- a/crates/femm-doc-mag/src/lib.rs +++ b/crates/femm-doc-mag/src/lib.rs @@ -6,6 +6,7 @@ pub mod props; pub mod parser; pub mod writer; pub mod edit; +pub mod poly; use num_complex::Complex64; diff --git a/crates/femm-doc-mag/src/poly.rs b/crates/femm-doc-mag/src/poly.rs new file mode 100644 index 0000000..925e86e --- /dev/null +++ b/crates/femm-doc-mag/src/poly.rs @@ -0,0 +1,166 @@ +//! .poly emitter consumed by the Triangle mesher. + +use crate::FemmDoc; +use std::fmt::Write; + +impl FemmDoc { + /// renders the doc geometry to the Triangle .poly text format, returning the file contents. + pub fn write_poly(&self) -> String { + let mut out = String::new(); + let bbox_diag = self.bbox_diagonal(); + let default_area = if bbox_diag > 0.0 { bbox_diag } else { -1.0 }; + + writeln!(out, "{}\t2\t0\t1", self.nodes.len()).unwrap(); + for (i, n) in self.nodes.iter().enumerate() { + let marker = point_marker_index(self, &n.boundary_marker); + writeln!(out, "{i}\t{:.17e}\t{:.17e}\t{marker}", n.x, n.y).unwrap(); + } + + writeln!(out, "{}\t1", self.segments.len()).unwrap(); + for (i, s) in self.segments.iter().enumerate() { + let marker = -boundary_marker_index(self, &s.boundary_marker); + writeln!(out, "{i}\t{}\t{}\t{marker}", s.n0, s.n1).unwrap(); + } + + let holes: Vec<&_> = self.block_labels.iter().filter(|b| b.block_type == "").collect(); + writeln!(out, "{}", holes.len()).unwrap(); + for (i, h) in holes.iter().enumerate() { + writeln!(out, "{i}\t{:.17e}\t{:.17e}", h.x, h.y).unwrap(); + } + + let regions: Vec<&_> = self.block_labels.iter().filter(|b| b.block_type != "").collect(); + writeln!(out, "{}", regions.len()).unwrap(); + for (i, r) in regions.iter().enumerate() { + let attr = (i as i32) + 1; + let max_area = if r.max_area > 0.0 && r.max_area < default_area { + r.max_area + } else { + default_area + }; + writeln!(out, "{i}\t{:.17e}\t{:.17e}\t{attr}\t{:.17e}", r.x, r.y, max_area).unwrap(); + } + + out + } + + /// writes the .poly text to disk at the given path. + pub fn save_poly(&self, path: impl AsRef) -> std::io::Result<()> { + std::fs::write(path, self.write_poly()) + } + + fn bbox_diagonal(&self) -> f64 { + if self.nodes.len() < 2 { return 0.0; } + 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() + } +} + +/// resolves a node's boundary-marker name to Triangle's marker integer (0 for unmarked). +fn point_marker_index(doc: &FemmDoc, name: &str) -> i32 { + if name.is_empty() { return 0; } + for (i, p) in doc.points.iter().enumerate() { + if p.name == name { return (i as i32) + 2; } + } + 0 +} + +/// resolves a segment's boundary-marker name to Triangle's marker integer, returned positive. +/// the caller negates for Triangle's segment-marker convention. +fn boundary_marker_index(doc: &FemmDoc, name: &str) -> i32 { + if name.is_empty() { return 0; } + for (i, b) in doc.boundaries.iter().enumerate() { + if b.name == name { return (i as i32) + 2; } + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BoundaryProp, PointProp, BlockLabel}; + + #[test] + fn write_poly_header_lines_count_correctly() { + // four-corner square with one block label inside; verify the section headers and counts. + let mut d = FemmDoc::default(); + d.points.push(PointProp { name: "A=0".into(), ..PointProp::default() }); + d.boundaries.push(BoundaryProp { name: "outer".into(), ..BoundaryProp::default() }); + d.add_node(0.0, 0.0, 0.0); + d.add_node(1.0, 0.0, 0.0); + d.add_node(1.0, 1.0, 0.0); + d.add_node(0.0, 1.0, 0.0); + d.nodes[0].boundary_marker = "A=0".into(); + d.add_segment_with_marker(0, 1, "outer"); + d.add_segment_with_marker(1, 2, "outer"); + d.add_segment_with_marker(2, 3, "outer"); + d.add_segment_with_marker(3, 0, "outer"); + d.block_labels.push(BlockLabel { + x: 0.5, y: 0.5, + max_area: 0.0, + block_type: "Air".into(), + ..BlockLabel::default() + }); + + let poly = d.write_poly(); + let lines: Vec<&str> = poly.lines().collect(); + + assert!(lines[0].starts_with("4\t2\t0\t1"), "node header: {}", lines[0]); + assert!(lines[5].starts_with("4\t1"), "segment header: {}", lines[5]); + assert_eq!(lines[10], "0", "no holes section: {}", lines[10]); + assert_eq!(lines[11], "1", "one region: {}", lines[11]); + } + + #[test] + fn write_poly_marks_nodes_and_segments_by_property_index() { + // first point property listed -> marker 2; first boundary property listed -> -2 on segment. + let mut d = FemmDoc::default(); + d.points.push(PointProp { name: "A=0".into(), ..PointProp::default() }); + d.boundaries.push(BoundaryProp { name: "outer".into(), ..BoundaryProp::default() }); + d.add_node(0.0, 0.0, 0.0); + d.add_node(1.0, 0.0, 0.0); + d.nodes[0].boundary_marker = "A=0".into(); + d.add_segment_with_marker(0, 1, "outer"); + + let poly = d.write_poly(); + let lines: Vec<&str> = poly.lines().collect(); + + assert!(lines[1].ends_with("\t2"), "node 0 marker = 2: {}", lines[1]); + assert!(lines[2].ends_with("\t0"), "node 1 marker = 0: {}", lines[2]); + assert!(lines[4].ends_with("\t-2"), "segment 0 marker = -2: {}", lines[4]); + } + + #[test] + fn write_poly_holes_partitioned_from_regions() { + // one "" label counts as a hole; everything else is a region. + let mut d = FemmDoc::default(); + d.add_node(0.0, 0.0, 0.0); + d.add_node(1.0, 0.0, 0.0); + d.block_labels.push(BlockLabel { + x: 0.5, y: 0.5, + block_type: "".into(), + ..BlockLabel::default() + }); + d.block_labels.push(BlockLabel { + x: 0.2, y: 0.2, + block_type: "Air".into(), + ..BlockLabel::default() + }); + + let poly = d.write_poly(); + let lines: Vec<&str> = poly.lines().collect(); + + // node header (0), 2 node rows (1,2), segment header "0\t1" (3), + // hole count (4), 1 hole row (5), region count (6), 1 region row (7). + assert_eq!(lines[4], "1", "one hole: {}", lines[4]); + assert_eq!(lines[6], "1", "one region: {}", lines[6]); + } +} diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh new file mode 100755 index 0000000..da5cde7 --- /dev/null +++ b/scripts/macos/build.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) - use cargo xtask build" >&2; exit 1 ;; +esac + +export MACOSX_DEPLOYMENT_TARGET=11.0 + +BUILD="$ROOT/build" +APP="$BUILD/bin/femm.app" +CONTENTS="$APP/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" +ICONSET="$BUILD/AppIcon.iconset" +ICNS="$RESOURCES/AppIcon.icns" +SVG="$ROOT/assets/femm.svg" + +echo "Building Rust workspace (release)..." +cargo build --release -p femm-app + +BIN="$ROOT/target/release/femm" +if [ ! -f "$BIN" ]; then + echo "ERROR: femm binary not found at $BIN" >&2 + exit 1 +fi + +rm -rf "$APP" +mkdir -p "$MACOS" "$RESOURCES" +cp "$BIN" "$MACOS/femm" + +if [ -f "$SVG" ]; then + if ! command -v rsvg-convert >/dev/null; then + echo "ERROR: rsvg-convert missing - brew install librsvg" >&2 + exit 1 + fi + if ! command -v iconutil >/dev/null; then + echo "ERROR: iconutil missing - install Xcode command line tools" >&2 + exit 1 + fi + rm -rf "$ICONSET" + mkdir -p "$ICONSET" + for size in 16 32 64 128 256 512 1024; do + rsvg-convert --width="$size" --height="$size" "$SVG" -o "$ICONSET/icon_${size}.png" + done + cp "$ICONSET/icon_16.png" "$ICONSET/icon_16x16.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_16x16@2x.png" + cp "$ICONSET/icon_32.png" "$ICONSET/icon_32x32.png" + cp "$ICONSET/icon_64.png" "$ICONSET/icon_32x32@2x.png" + cp "$ICONSET/icon_128.png" "$ICONSET/icon_128x128.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_128x128@2x.png" + cp "$ICONSET/icon_256.png" "$ICONSET/icon_256x256.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_256x256@2x.png" + cp "$ICONSET/icon_512.png" "$ICONSET/icon_512x512.png" + cp "$ICONSET/icon_1024.png" "$ICONSET/icon_512x512@2x.png" + iconutil -c icns "$ICONSET" -o "$ICNS" +else + echo "WARNING: $SVG not found - app bundle will lack an icon" +fi + +cat > "$CONTENTS/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion en + CFBundleExecutable femm + CFBundleIconFile AppIcon + CFBundleIdentifier org.else-if.femm + CFBundleInfoDictionaryVersion 6.0 + CFBundleName femm + CFBundleDisplayName FEMM + CFBundlePackageType APPL + CFBundleShortVersionString 0.0.1 + CFBundleVersion 0.0.1 + LSMinimumSystemVersion 11.0 + NSHighResolutionCapable + + +PLIST + +echo "Built: $APP" diff --git a/scripts/macos/debug.sh b/scripts/macos/debug.sh new file mode 100755 index 0000000..b7d5f26 --- /dev/null +++ b/scripts/macos/debug.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) - use cargo xtask debug" >&2; exit 1 ;; +esac + +export RUST_BACKTRACE=1 +exec cargo run -p femm-app -- "$@" diff --git a/scripts/macos/ffi.sh b/scripts/macos/ffi.sh new file mode 100755 index 0000000..12be94a --- /dev/null +++ b/scripts/macos/ffi.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +exec bash "$ROOT/scripts/macos/build_ffi.sh" "$@" diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 0000000..1c4e5e9 --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +case "$(uname -s)" in + Darwin) ;; + *) echo "wrong platform: $(uname -s) - use cargo xtask install" >&2; exit 1 ;; +esac + +DEST="/Applications/femm.app" + +bash "$ROOT/scripts/macos/build.sh" + +pkill -f "femm.app/Contents/MacOS/femm" 2>/dev/null || true +sleep 0.5 + +echo "Installing to $DEST..." +rm -rf "$DEST" +cp -R "$ROOT/build/bin/femm.app" "$DEST" + +echo "Installed: $DEST" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..f706b5b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "xtask" +path = "src/main.rs" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..3833458 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,119 @@ +use std::env; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; + +const KNOWN_PLATFORMS: &[&str] = &["macos", "windows", "linux"]; + +/// dispatches a cargo xtask sub-command to the matching platform script under scripts/. +fn main() -> ExitCode { + let args: Vec = env::args().skip(1).collect(); + let cmd = args.first().map(String::as_str).unwrap_or(""); + + if cmd.is_empty() || cmd == "help" || cmd == "--help" || cmd == "-h" { + print_help(); + return ExitCode::from(2); + } + + let extra_args: Vec<&String> = args.iter().skip(1).collect(); + let (action, platform) = parse(cmd); + + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("xtask manifest must have a parent") + .to_path_buf(); + + let (script, runner) = match platform.as_str() { + "windows" => ( + repo_root.join(format!("scripts/windows/{action}.ps1")), + vec![ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + ], + ), + "linux" | "macos" => ( + repo_root.join(format!("scripts/{platform}/{action}.sh")), + vec!["bash"], + ), + other => { + eprintln!("unknown platform: {other}"); + return ExitCode::from(2); + } + }; + + if !script.exists() { + eprintln!("script not found: {}", script.display()); + return ExitCode::from(1); + } + + let extra_display = if extra_args.is_empty() { + String::new() + } else { + format!( + " {}", + extra_args.iter().map(|s| s.as_str()).collect::>().join(" "), + ) + }; + eprintln!("-> {} {}{}", runner.join(" "), script.display(), extra_display); + + let mut command = Command::new(runner[0]); + for arg in &runner[1..] { + command.arg(arg); + } + command.arg(&script); + for a in &extra_args { + command.arg(a.as_str()); + } + command.current_dir(&repo_root); + + match command.status() { + Ok(status) if status.success() => ExitCode::SUCCESS, + Ok(status) => ExitCode::from(status.code().unwrap_or(1) as u8), + Err(e) => { + eprintln!("failed to run {}: {e}", script.display()); + ExitCode::from(1) + } + } +} + +/// splits an action-platform command into (action, platform), defaulting to the host OS. +fn parse(cmd: &str) -> (String, String) { + if let Some(idx) = cmd.rfind('-') { + let suffix = &cmd[idx + 1..]; + if KNOWN_PLATFORMS.contains(&suffix) { + return (cmd[..idx].to_string(), suffix.to_string()); + } + } + (cmd.to_string(), current_platform().to_string()) +} + +/// resolves the host OS to a script directory name, exiting if the OS lacks scripts. +fn current_platform() -> &'static str { + match env::consts::OS { + "linux" => "linux", + "macos" => "macos", + "windows" => "windows", + other => { + eprintln!("unsupported OS: {other}"); + std::process::exit(2); + } + } +} + +/// prints command list, platform suffix syntax, and packaging hints. +fn print_help() { + eprintln!("usage: cargo xtask "); + eprintln!(); + eprintln!("commands:"); + eprintln!(" build release build for the current platform"); + eprintln!(" install release build + install (macOS: /Applications)"); + eprintln!(" debug debug build + foreground launch"); + eprintln!(" ffi rebuild the C/C++ engine archives under build/ffi/"); + eprintln!(); + eprintln!("append -macos / -windows / -linux to force a platform."); + eprintln!(" e.g. cargo xtask build-macos"); + eprintln!(); + eprintln!("trailing args pass through to the script."); +}