diff --git a/assets/femm.svg b/assets/femm.svg
index d2f8281..7826677 100644
--- a/assets/femm.svg
+++ b/assets/femm.svg
@@ -1,125 +1,27 @@
-
-
\ No newline at end of file
diff --git a/crates/femm-app/Cargo.toml b/crates/femm-app/Cargo.toml
index f568250..4482732 100644
--- a/crates/femm-app/Cargo.toml
+++ b/crates/femm-app/Cargo.toml
@@ -18,3 +18,4 @@ meval = "0.2"
tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
libc = "0.2"
tiny-skia = "0.11"
+roxmltree = "0.20"
diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs
index abc445f..98352d1 100644
--- a/crates/femm-app/src/doc_canvas.rs
+++ b/crates/femm-app/src/doc_canvas.rs
@@ -48,6 +48,7 @@ pub enum RenderMode {
pub enum Tool {
#[default]
Select,
+ Edit,
AddNode,
AddBlockLabel,
AddSegment,
@@ -92,6 +93,10 @@ pub enum CanvasMessage {
ViewportSize { width: f32, height: f32 },
/// canvas-computed view replacement, used by zoom-window and other bounds-aware operations.
SetView { pan: Vector, zoom: f32 },
+ /// translates every selected node and label by a world-coordinate delta.
+ TranslateSelection { dx: f64, dy: f64 },
+ /// double-click at a world point, used to enter rename on the nearest block label.
+ DoubleClickAt { world: (f64, f64), pick_radius_world: f64 },
}
/// pan offset and zoom factor owned by the app shell.
@@ -113,6 +118,9 @@ pub struct CanvasState {
cursor_canvas: Option,
modifiers: iced::keyboard::Modifiers,
last_reported_size: Option<(f32, f32)>,
+ edit_drag_last_world: Option<(f64, f64)>,
+ last_click_at: Option,
+ last_click_world: Option<(f64, f64)>,
}
/// constructs the canvas widget for a doc reference, optional mesh overlay, and optional solution.
@@ -178,6 +186,34 @@ impl<'a> canvas::Program for DocCanvas<'a> {
if self.tool == Tool::Select || self.zoom_window_active {
state.marquee_from = Some(p);
}
+ if self.tool == Tool::Edit {
+ let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
+ let world = view.inverse_map(p);
+ let pick_radius_world = (PICK_RADIUS_PX as f64) / view.scale.max(1e-9);
+ let now = std::time::Instant::now();
+ let is_double = state.last_click_at
+ .map(|t| now.duration_since(t).as_millis() < 400)
+ .unwrap_or(false)
+ && state.last_click_world
+ .map(|w| (w.0 - world.0).hypot(w.1 - world.1) < pick_radius_world)
+ .unwrap_or(false);
+ if is_double {
+ state.last_click_at = None;
+ state.last_click_world = None;
+ state.edit_drag_last_world = None;
+ return Some(Action::publish(CanvasMessage::DoubleClickAt {
+ world, pick_radius_world,
+ }).and_capture());
+ }
+ state.last_click_at = Some(now);
+ state.last_click_world = Some(world);
+ state.edit_drag_last_world = Some(world);
+ return Some(Action::publish(CanvasMessage::PickAt {
+ world, pick_radius_world,
+ op: PickOp::Replace,
+ restrict: PickRestrict::Any,
+ }).and_capture());
+ }
return Some(Action::capture());
}
}
@@ -187,6 +223,15 @@ impl<'a> canvas::Program for DocCanvas<'a> {
let was_dragged = std::mem::take(&mut state.dragged);
let now_opt = cursor.position_in(bounds);
+ if self.tool == Tool::Edit {
+ state.edit_drag_last_world = None;
+ if was_dragged {
+ state.last_click_at = None;
+ state.last_click_world = None;
+ }
+ return Some(Action::capture());
+ }
+
if self.zoom_window_active {
if let (Some(start), Some(now)) = (marquee_start, now_opt) {
if was_dragged {
@@ -289,6 +334,18 @@ impl<'a> canvas::Program for DocCanvas<'a> {
state.dragged = true;
}
}
+ if self.tool == Tool::Edit && state.dragged {
+ if let (Some(prev), Some(now)) = (state.edit_drag_last_world, cursor.position_in(bounds)) {
+ let view = ViewTransform::fit(self.doc, bounds, &self.view_state);
+ let cur = view.inverse_map(now);
+ let dx = cur.0 - prev.0;
+ let dy = cur.1 - prev.1;
+ if dx != 0.0 || dy != 0.0 {
+ state.edit_drag_last_world = Some(cur);
+ return Some(Action::publish(CanvasMessage::TranslateSelection { dx, dy }).and_capture());
+ }
+ }
+ }
if state.pending_segment_start.is_some() {
return Some(Action::request_redraw());
}
@@ -525,6 +582,22 @@ impl<'a> canvas::Program for DocCanvas<'a> {
for probe in self.probes {
let p = view.map(probe.x, probe.y);
let color = Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], probe.color[3] as f32 / 255.0);
+ let theta = probe.angle_deg.to_radians() as f32;
+ let pri_len = 18.0_f32;
+ let per_len = 10.0_f32;
+ let pri_end = Point::new(
+ p.x + theta.cos() * pri_len,
+ p.y - theta.sin() * pri_len,
+ );
+ let per_end = Point::new(
+ p.x + (theta - std::f32::consts::FRAC_PI_2).cos() * per_len,
+ p.y - (theta - std::f32::consts::FRAC_PI_2).sin() * per_len,
+ );
+ frame.stroke(&Path::line(p, pri_end),
+ Stroke::default().with_width(2.0).with_color(color));
+ frame.stroke(&Path::line(p, per_end),
+ Stroke::default().with_width(1.0)
+ .with_color(Color { a: 0.55, ..color }));
frame.fill(&Path::circle(p, 6.0), color);
frame.stroke(&Path::circle(p, 6.0),
Stroke::default().with_width(1.5).with_color(Color::WHITE));
@@ -567,6 +640,7 @@ impl<'a> canvas::Program for DocCanvas<'a> {
if cursor.position_in(bounds).is_some() {
return match self.tool {
Tool::Select => mouse::Interaction::Grab,
+ Tool::Edit => mouse::Interaction::Pointer,
Tool::AddNode | Tool::AddBlockLabel | Tool::AddSegment | Tool::AddProbe => {
mouse::Interaction::Crosshair
}
@@ -776,6 +850,6 @@ fn arc_geometry(
let cy = mid_y + sign * perp_y * h;
let a0 = (-(y0 - cy)).atan2(x0 - cx);
- let a1 = (-(y1 - cy)).atan2(x1 - cx);
+ let a1 = a0 - theta;
Some(((cx, cy), radius, Radians(a0 as f32), Radians(a1 as f32)))
}
diff --git a/crates/femm-app/src/export.rs b/crates/femm-app/src/export.rs
index 8afd36a..91a8f63 100644
--- a/crates/femm-app/src/export.rs
+++ b/crates/femm-app/src/export.rs
@@ -384,6 +384,29 @@ fn render_frame(
for probe in probes {
let (px, py) = view.map(probe.x, probe.y);
+ let theta = probe.angle_deg.to_radians() as f32;
+ let pri_len = 22.0_f32;
+ let per_len = 12.0_f32;
+ let pri_ex = px + theta.cos() * pri_len;
+ let pri_ey = py - theta.sin() * pri_len;
+ let per_ex = px + (theta - std::f32::consts::FRAC_PI_2).cos() * per_len;
+ let per_ey = py - (theta - std::f32::consts::FRAC_PI_2).sin() * per_len;
+ paint.set_color(Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], 255));
+ let pri_stroke = Stroke { width: 2.2, ..Default::default() };
+ let mut pb = PathBuilder::new();
+ pb.move_to(px, py);
+ pb.line_to(pri_ex, pri_ey);
+ if let Some(path) = pb.finish() {
+ pixmap.stroke_path(&path, &paint, &pri_stroke, Transform::identity(), None);
+ }
+ paint.set_color(Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], 140));
+ let per_stroke = Stroke { width: 1.2, ..Default::default() };
+ let mut pb = PathBuilder::new();
+ pb.move_to(px, py);
+ pb.line_to(per_ex, per_ey);
+ if let Some(path) = pb.finish() {
+ pixmap.stroke_path(&path, &paint, &per_stroke, Transform::identity(), None);
+ }
paint.set_color(Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], 255));
let mut pb = PathBuilder::new();
pb.push_circle(px, py, 6.0);
@@ -460,7 +483,7 @@ fn draw_plot_strip(
let mut points: Vec<(i64, f64)> = Vec::new();
if let Some(s) = samples {
for (&fi, &(bx, by)) in s {
- points.push((fi, probe.mode.extract(bx, by)));
+ points.push((fi, probe.mode.extract(bx, by, probe.angle_deg.to_radians())));
}
}
if points.is_empty() {
diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs
index a1cfc3f..91d20da 100644
--- a/crates/femm-app/src/main.rs
+++ b/crates/femm-app/src/main.rs
@@ -8,6 +8,7 @@ mod probe;
mod session;
mod sim_meta;
mod spice;
+mod svg_io;
use doc_canvas::{CanvasMessage, PickOp, PickRestrict, RenderMode, Tool, ViewState};
use femm_doc_mag::{ArcSegment, BlockLabel, FemmDoc, Node, Segment};
@@ -41,6 +42,8 @@ const DEMO_FEM: &str = include_str!("../assets/brgmodel.fem");
const ADD_TOLERANCE: f64 = 0.5;
const MIN_ANGLE_DEG: f64 = 30.0;
+const LABEL_MATERIAL_INPUT_ID: &str = "label-material-input";
+
const SIM_DEFAULT_DT_S: f64 = 1.0e-4;
const SIM_DEFAULT_INTERVAL_S: f64 = 0.05;
const SIM_DEFAULT_SUBDIVISIONS: usize = 20;
@@ -101,6 +104,17 @@ enum Message {
ProbeSetMode(usize, probe::ProbeMode),
ProbeRemove(usize),
ProbeExportWav(usize),
+ ProbeRotateBy(usize, f64),
+ ProbeSetAngleText(usize, String),
+ ProbeSubmitAngle(usize),
+ ImportSvg,
+ ExportSvg,
+ LabelSetMaterial(usize, String),
+ LabelSetCircuit(usize, String),
+ LabelSetGroupText(usize, String),
+ LabelSetMaxAreaText(usize, String),
+ LabelSetMagDirText(usize, String),
+ LabelSetTurnsText(usize, String),
}
/// copyable diagnostic shown when a pipeline stage fails.
@@ -248,8 +262,8 @@ impl App {
let mut sim = new_simulation(base_doc, String::new());
if let Some(secs) = spice::parse_spice(&meta.dt_text) { sim.dt = secs; }
sim.dt_text = meta.dt_text.clone();
- if let Some(secs) = spice::parse_spice(&meta.interval_text) {
- sim.wall_interval = Duration::from_secs_f64(secs);
+ if let Some(d) = sim_meta::parse_interval(&meta.interval_text) {
+ sim.wall_interval = d;
}
sim.interval_text = meta.interval_text.clone();
sim.subdivisions = meta.subdivisions;
@@ -314,6 +328,51 @@ impl App {
}
}
}
+ Message::ImportSvg => {
+ let picked = rfd::FileDialog::new()
+ .add_filter("SVG", &["svg"])
+ .pick_file();
+ let Some(path) = picked else { return Task::none(); };
+ match svg_io::import_svg(&path) {
+ Ok(doc) => {
+ self.doc = doc;
+ self.mesh = None;
+ self.solution = None;
+ self.pristine_doc = None;
+ self.simulation = None;
+ self.source_label = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
+ self.source_path = Some(path.clone());
+ self.status = format!(
+ "imported {}: {} nodes, {} segs, {} arcs, {} labels",
+ path.display(),
+ self.doc.nodes.len(),
+ self.doc.segments.len(),
+ self.doc.arcs.len(),
+ self.doc.block_labels.len(),
+ );
+ }
+ Err(e) => {
+ self.error = Some(ErrorReport {
+ title: String::from("svg import failed"),
+ body: format!("{path:?}\n\n{e}"),
+ });
+ }
+ }
+ }
+ Message::ExportSvg => {
+ let picked = rfd::FileDialog::new()
+ .add_filter("SVG", &["svg"])
+ .set_file_name("doc.svg")
+ .save_file();
+ let Some(path) = picked else { return Task::none(); };
+ match svg_io::export_svg(&self.doc, &path) {
+ Ok(()) => self.status = format!("exported svg: {}", path.display()),
+ Err(e) => self.error = Some(ErrorReport {
+ title: String::from("svg export failed"),
+ body: format!("{path:?}\n\n{e}"),
+ }),
+ }
+ }
Message::NewDoc => {
self.doc = FemmDoc::default();
self.mesh = None;
@@ -428,9 +487,15 @@ impl App {
let outcome = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc)));
match outcome {
Ok(Ok(m)) => {
- self.status = format!("meshed: {} nodes, {} elements", m.nodes.len(), m.elements.len());
+ let (final_mesh, total_added) = autolabel_loop(&mut self.doc, m, 5);
+ self.status = if total_added > 0 {
+ format!("meshed: {} nodes, {} elements, +{} Air label(s) for orphan regions",
+ final_mesh.nodes.len(), final_mesh.elements.len(), total_added)
+ } else {
+ format!("meshed: {} nodes, {} elements", final_mesh.nodes.len(), final_mesh.elements.len())
+ };
self.error = None;
- self.mesh = Some(m);
+ self.mesh = Some(final_mesh);
}
Ok(Err(report)) => {
self.mesh = None;
@@ -454,7 +519,8 @@ impl App {
let mesh_outcome = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc)));
match mesh_outcome {
Ok(Ok(m)) => {
- self.mesh = Some(m);
+ let (final_mesh, _added) = autolabel_loop(&mut self.doc, m, 5);
+ self.mesh = Some(final_mesh);
self.error = None;
}
Ok(Err(report)) => {
@@ -533,6 +599,8 @@ impl App {
label: format!("p{}", idx + 1),
color: probe::palette_color(idx),
mode: probe::ProbeMode::default(),
+ angle_deg: 0.0,
+ angle_text: String::from("0"),
};
let mut samples = std::collections::HashMap::new();
for (&fi, sol) in &sim.lut {
@@ -544,7 +612,7 @@ impl App {
sim.probe_samples.push(samples);
self.status = format!("probe p{} placed at ({:.3}, {:.3})", idx + 1, world.0, world.1);
}
- Tool::Select | Tool::AddSegment => {}
+ Tool::Select | Tool::Edit | Tool::AddSegment => {}
}
}
Message::Canvas(CanvasMessage::SegmentBetween { from, to }) => {
@@ -588,6 +656,28 @@ impl App {
let summary = apply_pick_rect(&mut self.doc, p0, p1, op, restrict);
self.status = summary;
}
+ Message::Canvas(CanvasMessage::TranslateSelection { dx, dy }) => {
+ let moved = translate_selection(&mut self.doc, dx, dy);
+ if moved > 0 {
+ self.mesh = None;
+ self.solution = None;
+ }
+ }
+ Message::Canvas(CanvasMessage::DoubleClickAt { world, pick_radius_world }) => {
+ let mut best: Option<(usize, f64)> = None;
+ for (i, b) in self.doc.block_labels.iter().enumerate() {
+ let d = (b.x - world.0).hypot(b.y - world.1);
+ if d <= pick_radius_world && best.map(|(_, bd)| d < bd).unwrap_or(true) {
+ best = Some((i, d));
+ }
+ }
+ if let Some((idx, _)) = best {
+ clear_selection(&mut self.doc);
+ self.doc.block_labels[idx].selected = true;
+ self.status = format!("editing label {idx}");
+ return iced::widget::operation::focus(iced::widget::Id::new(LABEL_MATERIAL_INPUT_ID));
+ }
+ }
Message::SetRenderMode(mode) => {
self.render_mode = mode;
}
@@ -748,16 +838,16 @@ impl App {
}
Message::SimSubmitInterval => {
if let Some(sim) = self.simulation.as_mut() {
- match spice::parse_spice(&sim.interval_text) {
- Some(secs) if secs > 0.0 => {
- sim.wall_interval = Duration::from_secs_f64(secs);
- sim.interval_text = spice::format_spice_time(secs);
+ match sim_meta::parse_interval(&sim.interval_text) {
+ Some(d) => {
+ sim.wall_interval = d;
+ sim.interval_text = spice::format_spice_time(d.as_secs_f64());
self.status = format!("wall interval = {}", sim.interval_text);
}
- _ => {
+ None => {
self.error = Some(ErrorReport {
title: String::from("interval parse failed"),
- body: format!("could not read {:?} as SPICE time", sim.interval_text),
+ body: format!("could not read {:?} as positive SPICE time", sim.interval_text),
});
}
}
@@ -1069,8 +1159,8 @@ impl App {
let mut sim = new_simulation(self.doc.clone(), String::new());
if let Some(secs) = spice::parse_spice(&meta.dt_text) { sim.dt = secs; }
sim.dt_text = meta.dt_text.clone();
- if let Some(secs) = spice::parse_spice(&meta.interval_text) {
- sim.wall_interval = Duration::from_secs_f64(secs);
+ if let Some(d) = sim_meta::parse_interval(&meta.interval_text) {
+ sim.wall_interval = d;
}
sim.interval_text = meta.interval_text.clone();
sim.subdivisions = meta.subdivisions;
@@ -1215,6 +1305,36 @@ impl App {
}
}
}
+ Message::ProbeRotateBy(i, delta_deg) => {
+ if let Some(sim) = self.simulation.as_mut() {
+ if let Some(p) = sim.probes.get_mut(i) {
+ p.angle_deg = (p.angle_deg + delta_deg).rem_euclid(360.0);
+ p.angle_text = format!("{:.1}", p.angle_deg);
+ }
+ }
+ }
+ Message::ProbeSetAngleText(i, v) => {
+ if let Some(sim) = self.simulation.as_mut() {
+ if let Some(p) = sim.probes.get_mut(i) {
+ p.angle_text = v;
+ }
+ }
+ }
+ Message::ProbeSubmitAngle(i) => {
+ if let Some(sim) = self.simulation.as_mut() {
+ if let Some(p) = sim.probes.get_mut(i) {
+ match p.angle_text.trim().parse::() {
+ Ok(deg) => {
+ p.angle_deg = deg.rem_euclid(360.0);
+ p.angle_text = format!("{:.1}", p.angle_deg);
+ }
+ Err(_) => {
+ p.angle_text = format!("{:.1}", p.angle_deg);
+ }
+ }
+ }
+ }
+ }
Message::ProbeRemove(i) => {
if let Some(sim) = self.simulation.as_mut() {
if i < sim.probes.len() {
@@ -1237,10 +1357,13 @@ impl App {
return Task::none();
}
let default_name = format!("{}_{}.wav", p.label, match p.mode {
- probe::ProbeMode::Magnitude => "magB",
- probe::ProbeMode::Bx => "Bx",
- probe::ProbeMode::By => "By",
- probe::ProbeMode::Angle => "ang",
+ probe::ProbeMode::Magnitude => "magB",
+ probe::ProbeMode::Bx => "Bx",
+ probe::ProbeMode::By => "By",
+ probe::ProbeMode::Angle => "ang",
+ probe::ProbeMode::Primary => "Pri",
+ probe::ProbeMode::Perpendicular => "Per",
+ probe::ProbeMode::Differential => "Diff",
});
let picked = rfd::FileDialog::new()
.add_filter("WAV audio", &["wav"])
@@ -1248,7 +1371,7 @@ impl App {
.save_file();
let Some(path) = picked else { return Task::none(); };
let mut ordered: Vec<(i64, f64)> = samples.iter()
- .map(|(&fi, &(bx, by))| (fi, p.mode.extract(bx, by)))
+ .map(|(&fi, &(bx, by))| (fi, p.mode.extract(bx, by, p.angle_deg.to_radians())))
.collect();
ordered.sort_by_key(|s| s.0);
let values: Vec = ordered.into_iter().map(|(_, v)| v).collect();
@@ -1287,6 +1410,51 @@ impl App {
format!("deleted: {n} nodes, {s} segments, {a} arcs, {b} labels")
};
}
+ Message::LabelSetMaterial(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ b.block_type = v;
+ self.solution = None;
+ }
+ }
+ Message::LabelSetCircuit(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ b.in_circuit = v;
+ self.solution = None;
+ }
+ }
+ Message::LabelSetGroupText(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ if let Ok(g) = v.trim().parse::() {
+ b.in_group = g;
+ }
+ }
+ }
+ Message::LabelSetMaxAreaText(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ if let Ok(a) = v.trim().parse::() {
+ b.max_area = a.max(0.0);
+ self.mesh = None;
+ self.solution = None;
+ }
+ }
+ }
+ Message::LabelSetMagDirText(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ if let Ok(d) = v.trim().parse::() {
+ b.mag_dir = d;
+ b.mag_dir_fctn.clear();
+ self.solution = None;
+ }
+ }
+ }
+ Message::LabelSetTurnsText(i, v) => {
+ if let Some(b) = self.doc.block_labels.get_mut(i) {
+ if let Ok(t) = v.trim().parse::() {
+ b.turns = t;
+ self.solution = None;
+ }
+ }
+ }
}
Task::none()
}
@@ -1306,10 +1474,13 @@ impl App {
icon_button(ICON_FILE_OPEN, "Open .fem or .femsess", Some(Message::OpenFem)),
icon_button(ICON_FILE_SAVE, "Save", Some(Message::SaveDoc)),
icon_button(ICON_FILE_SAVE_AS, "Save As", Some(Message::SaveDocAs)),
+ text_button("Import SVG", Message::ImportSvg),
+ text_button("Export SVG", Message::ExportSvg),
].spacing(2);
let tool_group = row![
tool_icon_button(ICON_TOOL_SELECT, "Select", Tool::Select, self.tool),
+ edit_tool_button(self.tool),
tool_icon_button(ICON_TOOL_NODE, "Add Node", Tool::AddNode, self.tool),
tool_icon_button(ICON_TOOL_SEGMENT, "Add Segment", Tool::AddSegment, self.tool),
tool_icon_button(ICON_TOOL_LABEL, "Add Label", Tool::AddBlockLabel, self.tool),
@@ -1399,6 +1570,20 @@ impl App {
if let Some(sim) = &self.simulation {
body = body.push(simulation_panel(sim));
}
+ let selected_labels: Vec = self.doc.block_labels.iter()
+ .enumerate()
+ .filter(|(_, b)| b.selected)
+ .map(|(i, _)| i)
+ .collect();
+ if let Some(&idx) = selected_labels.first() {
+ if selected_labels.len() == 1 {
+ body = body.push(label_edit_panel(idx, &self.doc.block_labels[idx]));
+ } else {
+ body = body.push(
+ text(format!("{} labels selected — select one to edit", selected_labels.len())).size(11),
+ );
+ }
+ }
body = body.push(canvas_row);
if let Some(sim) = &self.simulation {
if !sim.probes.is_empty() {
@@ -1407,8 +1592,17 @@ impl App {
let mut pane_row = row![].spacing(8);
for (i, p) in sim.probes.iter().enumerate() {
let samples = sim.probe_samples.get(i).unwrap_or(&*EMPTY_MAP);
+ let angle_input = text_input("0", &p.angle_text)
+ .on_input(move |v| Message::ProbeSetAngleText(i, v))
+ .on_submit(Message::ProbeSubmitAngle(i))
+ .size(10)
+ .width(Length::Fixed(40.0));
let mode_row = row![
button(text(p.label.clone()).size(11)).style(button::secondary),
+ button(text("<").size(10)).on_press(Message::ProbeRotateBy(i, -15.0)).style(button::secondary),
+ angle_input,
+ text("deg").size(10),
+ button(text(">").size(10)).on_press(Message::ProbeRotateBy(i, 15.0)).style(button::secondary),
button(text("|B|").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Magnitude))
.style(if p.mode == probe::ProbeMode::Magnitude { button::primary } else { button::secondary }),
button(text("Bx").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Bx))
@@ -1417,6 +1611,12 @@ impl App {
.style(if p.mode == probe::ProbeMode::By { button::primary } else { button::secondary }),
button(text("ang").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Angle))
.style(if p.mode == probe::ProbeMode::Angle { button::primary } else { button::secondary }),
+ button(text("Pri").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Primary))
+ .style(if p.mode == probe::ProbeMode::Primary { button::primary } else { button::secondary }),
+ button(text("Per").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Perpendicular))
+ .style(if p.mode == probe::ProbeMode::Perpendicular { button::primary } else { button::secondary }),
+ button(text("Diff").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Differential))
+ .style(if p.mode == probe::ProbeMode::Differential { button::primary } else { button::secondary }),
button(text("wav").size(10)).on_press(Message::ProbeExportWav(i)).style(button::secondary),
button(text("x").size(10)).on_press(Message::ProbeRemove(i)).style(button::danger),
].spacing(2);
@@ -1845,6 +2045,93 @@ fn simulation_panel(sim: &Simulation) -> Element<'_, Message> {
}
/// renders a red-bordered error panel with copy and dismiss controls above the canvas.
+/// inline editor for a single selected block label: material, circuit, group, max area, mag direction, turns.
+fn label_edit_panel(idx: usize, b: &BlockLabel) -> Element<'_, Message> {
+ let header = text(format!("block label #{idx} at ({:.3}, {:.3})", b.x, b.y))
+ .size(12)
+ .color(Color::from_rgb(0.10, 0.15, 0.30));
+
+ let style = |_theme: &Theme, status: iced::widget::text_input::Status| {
+ let border = match status {
+ iced::widget::text_input::Status::Focused { .. } => Border {
+ color: Color::from_rgb(0.30, 0.45, 0.85),
+ width: 1.4,
+ radius: 3.0.into(),
+ },
+ _ => Border {
+ color: Color::from_rgb(0.55, 0.65, 0.85),
+ width: 1.0,
+ radius: 3.0.into(),
+ },
+ };
+ iced::widget::text_input::Style {
+ background: Background::Color(Color::WHITE),
+ border,
+ icon: Color::from_rgb(0.30, 0.35, 0.45),
+ placeholder: Color::from_rgb(0.60, 0.60, 0.65),
+ value: Color::from_rgb(0.05, 0.10, 0.15),
+ selection: Color::from_rgba(0.30, 0.45, 0.85, 0.35),
+ }
+ };
+ let lbl = |s: &'static str| text(s).size(11).color(Color::from_rgb(0.15, 0.20, 0.35));
+
+ let material = text_input("material", &b.block_type)
+ .id(iced::widget::Id::new(LABEL_MATERIAL_INPUT_ID))
+ .on_input(move |v| Message::LabelSetMaterial(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(160.0));
+ let circuit = text_input("", &b.in_circuit)
+ .on_input(move |v| Message::LabelSetCircuit(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(120.0));
+ let group = text_input("0", &b.in_group.to_string())
+ .on_input(move |v| Message::LabelSetGroupText(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(60.0));
+ let max_area = text_input("0", &format!("{}", b.max_area))
+ .on_input(move |v| Message::LabelSetMaxAreaText(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(80.0));
+ let mag_dir = text_input("0", &format!("{}", b.mag_dir))
+ .on_input(move |v| Message::LabelSetMagDirText(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(70.0));
+ let turns = text_input("1", &b.turns.to_string())
+ .on_input(move |v| Message::LabelSetTurnsText(idx, v))
+ .size(12)
+ .style(style)
+ .width(Length::Fixed(50.0));
+
+ let body_row = row![
+ lbl("Material:"), material,
+ lbl("Circuit:"), circuit,
+ lbl("Group:"), group,
+ lbl("MaxArea:"), max_area,
+ lbl("MagDir:"), mag_dir,
+ lbl("Turns:"), turns,
+ ]
+ .spacing(6)
+ .align_y(Alignment::Center);
+
+ container(column![header, body_row].spacing(4).padding(8))
+ .style(|_t: &Theme| container::Style {
+ background: Some(Background::Color(Color::from_rgb(0.96, 0.98, 1.0))),
+ border: Border {
+ color: Color::from_rgb(0.55, 0.65, 0.85),
+ width: 1.0,
+ radius: 4.0.into(),
+ },
+ ..container::Style::default()
+ })
+ .width(Length::Fill)
+ .into()
+}
+
fn error_panel(report: &ErrorReport) -> Element<'_, Message> {
let header = row![
text(format!("error: {}", report.title))
@@ -2225,6 +2512,149 @@ fn selection_bbox(doc: &FemmDoc) -> Option<(f64, f64, f64, f64)> {
#[derive(Clone, Copy)]
enum Kind { Node, Segment, Arc, Label }
+/// ensures the doc has a MaterialProp with the given name, appending a unit-mu Air-like definition when absent.
+fn ensure_material(doc: &mut FemmDoc, name: &str) {
+ if doc.materials.iter().any(|m| m.name == name) { return; }
+ doc.materials.push(femm_doc_mag::MaterialProp {
+ name: name.to_string(),
+ mu_x: 1.0, mu_y: 1.0,
+ h_c: 0.0, theta_m: 0.0,
+ j_src: Default::default(),
+ cduct: 0.0,
+ lam_d: 0.0,
+ theta_hn: 0.0, theta_hx: 0.0, theta_hy: 0.0,
+ lam_type: 0, lam_fill: 1.0,
+ n_strands: 0, wire_d: 0.0,
+ bh_curve: Vec::new(),
+ });
+}
+
+/// re-runs mesh + autolabel up to max_passes times, accumulating labels until no orphan regions remain or the mesher fails on a re-mesh.
+fn autolabel_loop(doc: &mut FemmDoc, initial: Mesh, max_passes: usize) -> (Mesh, usize) {
+ let mut mesh = initial;
+ let mut total = 0;
+ for _ in 0..max_passes {
+ let added = autolabel_orphan_regions(doc, &mesh);
+ if added == 0 { break; }
+ total += added;
+ match catch_unwind(AssertUnwindSafe(|| run_mesh(doc))) {
+ Ok(Ok(m)) => { mesh = m; }
+ _ => break,
+ }
+ }
+ (mesh, total)
+}
+
+/// inserts an `Air` block label at the centroid of the largest triangle in every connected component of mesh triangles whose region attribute is zero, returning the number of labels added.
+fn autolabel_orphan_regions(doc: &mut FemmDoc, mesh: &Mesh) -> usize {
+ let orphan: Vec = mesh.elements.iter().enumerate()
+ .filter_map(|(i, el)| if el.attribute == 0 { Some(i) } else { None })
+ .collect();
+ if orphan.is_empty() { return 0; }
+
+ let mut edge_to_tri: std::collections::HashMap<(u32, u32), Vec> = std::collections::HashMap::new();
+ for &i in &orphan {
+ let el = &mesh.elements[i];
+ for (a, b) in [(el.v0, el.v1), (el.v1, el.v2), (el.v2, el.v0)] {
+ let key = (a.min(b), a.max(b));
+ edge_to_tri.entry(key).or_default().push(i);
+ }
+ }
+
+ let mut parent: Vec = (0..mesh.elements.len()).collect();
+ fn find(p: &mut [usize], i: usize) -> usize {
+ let mut r = i;
+ while p[r] != r { r = p[r]; }
+ let mut cur = i;
+ while p[cur] != r {
+ let next = p[cur];
+ p[cur] = r;
+ cur = next;
+ }
+ r
+ }
+ for tris in edge_to_tri.values() {
+ for w in tris.windows(2) {
+ let ra = find(&mut parent, w[0]);
+ let rb = find(&mut parent, w[1]);
+ if ra != rb { parent[ra] = rb; }
+ }
+ }
+
+ let mut groups: std::collections::HashMap = std::collections::HashMap::new();
+ for &i in &orphan {
+ let el = &mesh.elements[i];
+ let n0 = &mesh.nodes[el.v0 as usize];
+ let n1 = &mesh.nodes[el.v1 as usize];
+ let n2 = &mesh.nodes[el.v2 as usize];
+ let cx = (n0.x + n1.x + n2.x) / 3.0;
+ let cy = (n0.y + n1.y + n2.y) / 3.0;
+ let area = ((n1.x - n0.x) * (n2.y - n0.y) - (n2.x - n0.x) * (n1.y - n0.y)).abs() * 0.5;
+ let root = find(&mut parent, i);
+ let e = groups.entry(root).or_insert((0.0, 0.0, 0.0));
+ if area > e.2 {
+ e.0 = cx;
+ e.1 = cy;
+ e.2 = area;
+ }
+ }
+
+ if !groups.is_empty() { ensure_material(doc, "Air"); }
+ let mut added = 0;
+ for (_, (cx, cy, area)) in groups {
+ if area <= 0.0 { continue; }
+ doc.block_labels.push(BlockLabel {
+ x: cx, y: cy,
+ max_area: 0.0, mag_dir: 0.0,
+ mag_dir_fctn: String::new(),
+ turns: 0,
+ block_type: String::from("Air"),
+ in_circuit: String::new(),
+ in_group: 0,
+ is_external: false,
+ is_default: false,
+ selected: false,
+ });
+ added += 1;
+ }
+ added
+}
+
+/// shifts every selected node, label, and endpoint-of-selected-segment-or-arc by (dx, dy) in world units, returning the number of distinct nodes plus labels moved.
+fn translate_selection(doc: &mut FemmDoc, dx: f64, dy: f64) -> usize {
+ let mut node_indices = std::collections::HashSet::::new();
+ for (i, n) in doc.nodes.iter().enumerate() {
+ if n.selected { node_indices.insert(i); }
+ }
+ for s in &doc.segments {
+ if s.selected {
+ node_indices.insert(s.n0 as usize);
+ node_indices.insert(s.n1 as usize);
+ }
+ }
+ for a in &doc.arcs {
+ if a.selected {
+ node_indices.insert(a.n0 as usize);
+ node_indices.insert(a.n1 as usize);
+ }
+ }
+ for &i in &node_indices {
+ if let Some(n) = doc.nodes.get_mut(i) {
+ n.x += dx;
+ n.y += dy;
+ }
+ }
+ let mut labels = 0;
+ for b in doc.block_labels.iter_mut() {
+ if b.selected {
+ b.x += dx;
+ b.y += dy;
+ labels += 1;
+ }
+ }
+ node_indices.len() + labels
+}
+
/// clears the selected flag on every entity in the doc.
fn clear_selection(doc: &mut FemmDoc) {
for n in &mut doc.nodes { n.selected = false; }
@@ -2275,7 +2705,18 @@ fn apply_pick_at(doc: &mut FemmDoc, x: f64, y: f64, pick_radius_world: f64, op:
if matches!(op, PickOp::Replace) { clear_selection(doc); }
- let hit = best.and_then(|(k, i, d)| if d <= pick_radius_world { Some((k, i)) } else { None });
+ let mut best_label: Option<(usize, f64)> = None;
+ for (i, b) in doc.block_labels.iter().enumerate() {
+ let d = (b.x - x).hypot(b.y - y);
+ if d <= pick_radius_world && best_label.map(|(_, bd)| d < bd).unwrap_or(true) {
+ best_label = Some((i, d));
+ }
+ }
+ let hit = if let Some((i, _)) = best_label {
+ Some((Kind::Label, i))
+ } else {
+ best.and_then(|(k, i, d)| if d <= pick_radius_world { Some((k, i)) } else { None })
+ };
if let Some((kind, idx)) = hit {
let (label, flag) = match kind {
Kind::Node => ("node", &mut doc.nodes[idx].selected),
@@ -2374,6 +2815,17 @@ fn text_button(label: &str, msg: Message) -> Element<'_, Message> {
button(text(label).size(13)).on_press(msg).into()
}
+/// Edit-tool toolbar button: click-drag to move selected entities, double-click a label to rename.
+fn edit_tool_button<'a>(active: Tool) -> Element<'a, Message> {
+ let active_flag = active == Tool::Edit;
+ let style = if active_flag { button::primary } else { button::secondary };
+ let btn = button(text("Edit").size(13))
+ .on_press(Message::SelectTool(Tool::Edit))
+ .style(style);
+ tooltip(btn, text("Edit: drag to move, double-click a label to rename").size(11),
+ tooltip::Position::Bottom).into()
+}
+
/// draws a thin vertical rule between toolbar groups.
fn separator<'a>() -> Element<'a, Message> {
container(iced::widget::Space::new().width(Length::Fixed(1.0)).height(Length::Fixed(28.0)))
diff --git a/crates/femm-app/src/plot.rs b/crates/femm-app/src/plot.rs
index 9e288eb..c552ebb 100644
--- a/crates/femm-app/src/plot.rs
+++ b/crates/femm-app/src/plot.rs
@@ -67,7 +67,7 @@ impl<'a, Msg> canvas::Program for PlotProgram<'a> {
let buf = self.buffer_size.max(1);
let mut points: Vec<(i64, f64)> = Vec::with_capacity(self.samples.len());
for (&fi, &(bx, by)) in self.samples {
- points.push((fi, self.probe.mode.extract(bx, by)));
+ points.push((fi, self.probe.mode.extract(bx, by, self.probe.angle_deg.to_radians())));
}
if points.is_empty() {
frame.fill_text(Text {
diff --git a/crates/femm-app/src/probe.rs b/crates/femm-app/src/probe.rs
index 7a609f9..5b43186 100644
--- a/crates/femm-app/src/probe.rs
+++ b/crates/femm-app/src/probe.rs
@@ -8,35 +8,50 @@ pub enum ProbeMode {
Bx,
By,
Angle,
+ Primary,
+ Perpendicular,
+ Differential,
}
impl ProbeMode {
pub fn label(self) -> &'static str {
match self {
- ProbeMode::Magnitude => "|B|",
- ProbeMode::Bx => "Bx",
- ProbeMode::By => "By",
- ProbeMode::Angle => "atan2(By,Bx)",
+ ProbeMode::Magnitude => "|B|",
+ ProbeMode::Bx => "Bx",
+ ProbeMode::By => "By",
+ ProbeMode::Angle => "atan2(By,Bx)",
+ ProbeMode::Primary => "Pri",
+ ProbeMode::Perpendicular => "Per (CW 90)",
+ ProbeMode::Differential => "Pri - Per",
}
}
- pub fn extract(self, bx: f64, by: f64) -> f64 {
+ pub fn extract(self, bx: f64, by: f64, angle_rad: f64) -> f64 {
+ let c = angle_rad.cos();
+ let s = angle_rad.sin();
+ let pri = bx * c + by * s;
+ let per = bx * s - by * c;
match self {
- ProbeMode::Magnitude => (bx * bx + by * by).sqrt(),
- ProbeMode::Bx => bx,
- ProbeMode::By => by,
- ProbeMode::Angle => by.atan2(bx),
+ ProbeMode::Magnitude => (bx * bx + by * by).sqrt(),
+ ProbeMode::Bx => bx,
+ ProbeMode::By => by,
+ ProbeMode::Angle => by.atan2(bx),
+ ProbeMode::Primary => pri,
+ ProbeMode::Perpendicular => per,
+ ProbeMode::Differential => pri - per,
}
}
}
-/// one user-placed probe with a world position, display color, label, and plot-mode selector.
+/// one user-placed probe with a world position, display color, label, plot-mode selector, and primary-axis orientation in degrees (CCW from +x).
#[derive(Debug, Clone)]
pub struct Probe {
- pub x: f64,
- pub y: f64,
- pub label: String,
- pub color: [u8; 4],
- pub mode: ProbeMode,
+ pub x: f64,
+ pub y: f64,
+ pub label: String,
+ pub color: [u8; 4],
+ pub mode: ProbeMode,
+ pub angle_deg: f64,
+ pub angle_text: String,
}
/// returns a saturated palette color from a small cycling palette suitable for probe markers and matching plot lines.
diff --git a/crates/femm-app/src/sim_meta.rs b/crates/femm-app/src/sim_meta.rs
index 04730ae..5ddfac4 100644
--- a/crates/femm-app/src/sim_meta.rs
+++ b/crates/femm-app/src/sim_meta.rs
@@ -5,6 +5,13 @@ use crate::kinematic::{Axis, Expression, Track};
use crate::spice;
use std::time::Duration;
+/// parses a SPICE-format duration text into a positive tokio Duration; None on parse failure or non-positive value.
+pub fn parse_interval(text: &str) -> Option {
+ spice::parse_spice(text)
+ .filter(|s| *s > 0.0)
+ .map(Duration::from_secs_f64)
+}
+
pub struct SimMeta {
pub source_fem: Option,
pub dt_text: String,
@@ -250,7 +257,3 @@ fn axis_from_str(s: &str) -> Option {
})
}
-/// parses a SPICE-format duration text into a tokio Duration, falling back to 50 ms on parse failure.
-pub fn parse_interval(text: &str) -> Duration {
- spice::parse_spice(text).map(Duration::from_secs_f64).unwrap_or(Duration::from_millis(50))
-}
diff --git a/crates/femm-app/src/svg_io.rs b/crates/femm-app/src/svg_io.rs
new file mode 100644
index 0000000..aac0bc4
--- /dev/null
+++ b/crates/femm-app/src/svg_io.rs
@@ -0,0 +1,1247 @@
+//! bidirectional SVG <-> FEM geometry conversion. accepts rect, circle, ellipse (with prompt), line, polyline, polygon, and path elements; material and boundary are tagged via data-material / data-boundary / data-mag-dir attributes (or id-naming convention as a fallback); the y-axis is flipped because SVG is +y-down and FEM is +y-up.
+
+use std::path::Path;
+use roxmltree::{Document, Node as XmlNode};
+
+use femm_doc_mag::{
+ ArcSegment, BlockLabel, BoundaryProp, FemmDoc, LengthUnit, MaterialProp, Node, Segment,
+};
+
+const NODE_MERGE_TOL: f64 = 1e-3;
+
+/// produces a fresh FemmDoc from the SVG at path, prompting (via rfd) to convert any non-circular ellipse into a circle on ambiguous radii.
+pub fn import_svg(path: &Path) -> Result {
+ let raw = std::fs::read_to_string(path).map_err(|e| format!("read svg: {e}"))?;
+ parse_svg_text(&raw, true)
+}
+
+/// parses an SVG text buffer into a FemmDoc; if prompt_for_ellipse is true and any non-circular ellipse is found, asks the user (rfd dialog) whether to convert.
+pub fn parse_svg_text(src: &str, prompt_for_ellipse: bool) -> Result {
+ let xml = Document::parse(src).map_err(|e| format!("svg parse: {e}"))?;
+ let mut builder = Builder::new();
+ let mut warnings = Warnings::default();
+ walk(xml.root_element(), Mat3::identity(), &mut builder, &mut warnings)?;
+
+ let convert_ellipses = if warnings.has_ellipses() && prompt_for_ellipse {
+ let r = rfd::MessageDialog::new()
+ .set_title("Non-circular ellipses detected")
+ .set_description(&format!(
+ "Found {} non-circular ellipse(s). Converting to circles (average radius) keeps the mesh clean and avoids the polygon-approximation penalty. Convert?",
+ warnings.ellipse_count
+ ))
+ .set_buttons(rfd::MessageButtons::YesNo)
+ .show();
+ matches!(r, rfd::MessageDialogResult::Yes)
+ } else {
+ true
+ };
+
+ if !warnings.bezier_paths.is_empty() {
+ rfd::MessageDialog::new()
+ .set_title("Bezier curves approximated")
+ .set_description(&format!(
+ "{} bezier curve(s) approximated with straight-line polylines. For smooth curves, replace with circles or arc paths.",
+ warnings.bezier_paths.len(),
+ ))
+ .set_buttons(rfd::MessageButtons::Ok)
+ .show();
+ }
+
+ if !warnings.no_material_shapes.is_empty() {
+ let ids = warnings.no_material_shapes.join(", ");
+ rfd::MessageDialog::new()
+ .set_title("Shapes missing material")
+ .set_description(&format!(
+ "{} shape(s) have a recognized class (Block/Segment/Toroid/Disc) but no material name. Add a child like Steel so the importer can label the region. Affected: {ids}",
+ warnings.no_material_shapes.len(),
+ ))
+ .set_buttons(rfd::MessageButtons::Ok)
+ .show();
+ }
+
+ let doc = builder.finalize(convert_ellipses);
+ Ok(doc)
+}
+
+/// emits an SVG containing every node (as ``), segment (as ``), arc (as ``), and block label (as ``); writes y-flipped so it renders right-side-up in any viewer.
+pub fn export_svg(doc: &FemmDoc, path: &Path) -> Result<(), String> {
+ let text = serialize_svg(doc);
+ std::fs::write(path, text).map_err(|e| format!("write svg: {e}"))
+}
+
+/// renders the doc into SVG text, ready for any external SVG viewer or editor.
+pub fn serialize_svg(doc: &FemmDoc) -> String {
+ let (mut xmin, mut xmax, mut ymin, mut ymax) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
+ for n in &doc.nodes {
+ if n.x < xmin { xmin = n.x; } if n.x > xmax { xmax = n.x; }
+ if n.y < ymin { ymin = n.y; } if n.y > ymax { ymax = n.y; }
+ }
+ for l in &doc.block_labels {
+ if l.x < xmin { xmin = l.x; } if l.x > xmax { xmax = l.x; }
+ if l.y < ymin { ymin = l.y; } if l.y > ymax { ymax = l.y; }
+ }
+ if !xmin.is_finite() { xmin = -1.0; xmax = 1.0; ymin = -1.0; ymax = 1.0; }
+ let pad = ((xmax - xmin) + (ymax - ymin)).max(1.0) * 0.05;
+ let vbx = xmin - pad;
+ let vby = -(ymax + pad);
+ let vbw = (xmax - xmin) + 2.0 * pad;
+ let vbh = (ymax - ymin) + 2.0 * pad;
+
+ let units = match doc.length_units {
+ LengthUnit::Inches => "inches",
+ LengthUnit::Millimeters => "millimeters",
+ LengthUnit::Centimeters => "centimeters",
+ LengthUnit::Meters => "meters",
+ LengthUnit::Mils => "mils",
+ LengthUnit::Microns => "micrometers",
+ };
+
+ let mut s = String::new();
+ s.push_str("\n");
+ s.push_str(&format!(
+ "\n",
+ vbx, vby, vbw, vbh, vbw, vbh, units,
+ ));
+
+ s.push_str(" \n");
+ for (i, n) in doc.nodes.iter().enumerate() {
+ s.push_str(&format!(
+ " \n",
+ n.x, -n.y,
+ ));
+ }
+ s.push_str(" \n");
+
+ s.push_str(" \n");
+ for (i, seg) in doc.segments.iter().enumerate() {
+ let (Some(a), Some(b)) = (doc.nodes.get(seg.n0 as usize), doc.nodes.get(seg.n1 as usize)) else { continue };
+ let cls = if seg.boundary_marker.is_empty() { String::from("Segment") }
+ else { format!("Segment boundary-{}", xml_escape(&seg.boundary_marker)) };
+ if seg.boundary_marker.is_empty() {
+ s.push_str(&format!(
+ " \n",
+ cls, a.x, -a.y, b.x, -b.y,
+ ));
+ } else {
+ s.push_str(&format!(
+ " \n {}\n \n",
+ cls, a.x, -a.y, b.x, -b.y, xml_escape(&seg.boundary_marker),
+ ));
+ }
+ }
+ s.push_str(" \n");
+
+ s.push_str(" \n");
+ for (i, a) in doc.arcs.iter().enumerate() {
+ let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) else { continue };
+ let theta = a.arc_length.to_radians();
+ if theta.abs() < 1e-9 { continue; }
+ let dx = p1.x - p0.x;
+ let dy = p1.y - p0.y;
+ let chord = (dx*dx + dy*dy).sqrt();
+ if chord < 1e-12 { continue; }
+ let radius = chord / (2.0 * (theta / 2.0).sin().abs());
+ let large = if a.arc_length.abs() > 180.0 { 1 } else { 0 };
+ let sweep = if a.arc_length > 0.0 { 0 } else { 1 };
+ let cls = if a.boundary_marker.is_empty() { String::from("Arc") }
+ else { format!("Arc boundary-{}", xml_escape(&a.boundary_marker)) };
+ if a.boundary_marker.is_empty() {
+ s.push_str(&format!(
+ " \n",
+ cls, p0.x, -p0.y, radius, radius, large, sweep, p1.x, -p1.y, a.arc_length,
+ ));
+ } else {
+ s.push_str(&format!(
+ " \n {}\n \n",
+ cls, p0.x, -p0.y, radius, radius, large, sweep, p1.x, -p1.y, a.arc_length, xml_escape(&a.boundary_marker),
+ ));
+ }
+ }
+ s.push_str(" \n");
+
+ s.push_str(" \n");
+ for (i, b) in doc.block_labels.iter().enumerate() {
+ s.push_str(&format!(
+ " \n",
+ b.mag_dir, b.max_area, -b.mag_dir, b.x, -b.y,
+ ));
+ s.push_str(&format!(
+ " {}\n",
+ xml_escape(&b.block_type),
+ ));
+ s.push_str(&format!(
+ " \n",
+ b.x, -b.y,
+ ));
+ s.push_str(&format!(
+ " {}\n",
+ b.x + 2.0, -b.y - 2.0, xml_escape(&b.block_type),
+ ));
+ s.push_str(" \n");
+ }
+ s.push_str(" \n");
+
+ s.push_str("\n");
+ s
+}
+
+fn xml_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+#[derive(Default)]
+struct Warnings {
+ ellipse_count: usize,
+ bezier_paths: Vec,
+ no_material_shapes: Vec,
+}
+
+impl Warnings {
+ fn has_ellipses(&self) -> bool { self.ellipse_count > 0 }
+}
+
+#[derive(Clone, Copy)]
+struct Mat3 { a: f64, b: f64, c: f64, d: f64, e: f64, f_: f64 }
+
+impl Mat3 {
+ fn identity() -> Self { Self { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f_: 0.0 } }
+ fn translate(self, tx: f64, ty: f64) -> Self {
+ self.mul(&Self { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: tx, f_: ty })
+ }
+ fn rotate_deg(self, deg: f64) -> Self {
+ let r = deg.to_radians();
+ let (cs, sn) = (r.cos(), r.sin());
+ self.mul(&Self { a: cs, b: sn, c: -sn, d: cs, e: 0.0, f_: 0.0 })
+ }
+ fn scale(self, sx: f64, sy: f64) -> Self {
+ self.mul(&Self { a: sx, b: 0.0, c: 0.0, d: sy, e: 0.0, f_: 0.0 })
+ }
+ fn mul(&self, o: &Self) -> Self {
+ Self {
+ a: self.a * o.a + self.c * o.b,
+ b: self.b * o.a + self.d * o.b,
+ c: self.a * o.c + self.c * o.d,
+ d: self.b * o.c + self.d * o.d,
+ e: self.a * o.e + self.c * o.f_ + self.e,
+ f_: self.b * o.e + self.d * o.f_ + self.f_,
+ }
+ }
+ fn apply(&self, x: f64, y: f64) -> (f64, f64) {
+ (self.a * x + self.c * y + self.e,
+ self.b * x + self.d * y + self.f_)
+ }
+ /// extracts the rotation angle in degrees from the linear part of the matrix; ignores any non-rotation skew.
+ fn rotation_deg(&self) -> f64 {
+ self.b.atan2(self.a).to_degrees()
+ }
+}
+
+struct Builder {
+ doc: FemmDoc,
+ region_blocks: Vec,
+ toroid_outer_rings: Vec<(f64, f64, f64)>,
+}
+
+struct PendingBlock {
+ x: f64,
+ y: f64,
+ material: String,
+ mag_dir: f64,
+ max_area: f64,
+ from_toroid: bool,
+}
+
+impl Builder {
+ fn new() -> Self {
+ let mut doc = FemmDoc::default();
+ doc.format = 4.0;
+ doc.frequency = 0.0;
+ doc.precision = 1e-8;
+ doc.min_angle = 25.0;
+ doc.depth = 5.0;
+ doc.length_units = LengthUnit::Millimeters;
+ doc.materials = vec![
+ material("Air", 1.0, 0.0, 0.0),
+ material("NdFeB", 1.05, 915_000.0, 0.667),
+ material("Steel", 2500.0, 0.0, 5.8),
+ ];
+ doc.boundaries = vec![
+ BoundaryProp { name: String::from("A=0"), ..Default::default() },
+ ];
+ Self { doc, region_blocks: Vec::new(), toroid_outer_rings: Vec::new() }
+ }
+
+ fn finalize(mut self, _convert_ellipses: bool) -> FemmDoc {
+ self.clip_segments_against_toroids();
+ let rings = self.toroid_outer_rings.clone();
+ self.region_blocks.retain(|pb| {
+ if pb.from_toroid { return true; }
+ for &(cx, cy, r) in &rings {
+ let dx = pb.x - cx;
+ let dy = pb.y - cy;
+ if dx * dx + dy * dy < r * r { return false; }
+ }
+ true
+ });
+ self.doc.split_at_intersections();
+ let mut kept: Vec = Vec::new();
+ for pb in self.region_blocks.drain(..) {
+ let dup = kept.iter().any(|k| {
+ let dx = k.x - pb.x;
+ let dy = k.y - pb.y;
+ dx*dx + dy*dy < NODE_MERGE_TOL * NODE_MERGE_TOL && k.material == pb.material
+ });
+ if !dup { kept.push(pb); }
+ }
+ for pb in kept {
+ self.doc.block_labels.push(BlockLabel {
+ x: pb.x,
+ y: pb.y,
+ max_area: pb.max_area,
+ mag_dir: pb.mag_dir,
+ mag_dir_fctn: String::new(),
+ turns: 0,
+ block_type: pb.material,
+ in_circuit: String::new(),
+ in_group: 0,
+ is_external: false,
+ is_default: false,
+ selected: false,
+ });
+ }
+ self.doc
+ }
+
+ /// returns the node index, reusing an existing one if (x, y) lies within NODE_MERGE_TOL of it.
+ fn add_node(&mut self, x: f64, y: f64) -> i32 {
+ for (i, n) in self.doc.nodes.iter().enumerate() {
+ let dx = n.x - x;
+ let dy = n.y - y;
+ if dx*dx + dy*dy < NODE_MERGE_TOL * NODE_MERGE_TOL {
+ return i as i32;
+ }
+ }
+ let idx = self.doc.nodes.len() as i32;
+ self.doc.nodes.push(Node { x, y, boundary_marker: String::new(), in_group: 0, selected: false });
+ idx
+ }
+
+ fn add_segment(&mut self, n0: i32, n1: i32, boundary: &str) {
+ if n0 == n1 { return; }
+ self.doc.segments.push(Segment {
+ n0, n1,
+ max_side_length: -1.0,
+ boundary_marker: boundary.to_string(),
+ hidden: false,
+ in_group: 0,
+ selected: false,
+ });
+ }
+
+ fn add_arc(&mut self, n0: i32, n1: i32, sweep_deg: f64, boundary: &str) {
+ if n0 == n1 { return; }
+ self.doc.arcs.push(ArcSegment {
+ n0, n1,
+ arc_length: sweep_deg,
+ max_side_length: 10.0,
+ boundary_marker: boundary.to_string(),
+ hidden: false,
+ in_group: 0,
+ normal_direction: true,
+ selected: false,
+ });
+ }
+
+ fn add_block(&mut self, x: f64, y: f64, material: &str, mag_dir: f64, max_area: f64) {
+ if material.is_empty() { return; }
+ self.region_blocks.push(PendingBlock {
+ x, y, material: material.to_string(), mag_dir, max_area, from_toroid: false,
+ });
+ }
+
+ fn add_toroid_block(&mut self, x: f64, y: f64, material: &str, mag_dir: f64, max_area: f64) {
+ if material.is_empty() { return; }
+ self.region_blocks.push(PendingBlock {
+ x, y, material: material.to_string(), mag_dir, max_area, from_toroid: true,
+ });
+ }
+
+ fn point_in_any_toroid(&self, x: f64, y: f64) -> bool {
+ for &(cx, cy, r) in &self.toroid_outer_rings {
+ let dx = x - cx;
+ let dy = y - cy;
+ if dx * dx + dy * dy < r * r {
+ return true;
+ }
+ }
+ false
+ }
+
+ /// for every segment that crosses or sits inside any toroid outer ring, splits it at the ring intersections and drops the pieces whose midpoint lies inside any toroid disk.
+ fn clip_segments_against_toroids(&mut self) {
+ if self.toroid_outer_rings.is_empty() { return; }
+ let old_segments = std::mem::take(&mut self.doc.segments);
+ for s in old_segments {
+ let p0 = (self.doc.nodes[s.n0 as usize].x, self.doc.nodes[s.n0 as usize].y);
+ let p1 = (self.doc.nodes[s.n1 as usize].x, self.doc.nodes[s.n1 as usize].y);
+ let dx = p1.0 - p0.0;
+ let dy = p1.1 - p0.1;
+ let len2 = dx * dx + dy * dy;
+ if len2 < 1.0e-18 {
+ self.doc.segments.push(s);
+ continue;
+ }
+ let mut breaks: Vec = Vec::new();
+ for &(cx, cy, r) in &self.toroid_outer_rings {
+ let ex = p0.0 - cx;
+ let ey = p0.1 - cy;
+ let a = len2;
+ let b = 2.0 * (ex * dx + ey * dy);
+ let c = ex * ex + ey * ey - r * r;
+ let disc = b * b - 4.0 * a * c;
+ if disc <= 0.0 { continue; }
+ let sd = disc.sqrt();
+ for t in [(-b - sd) / (2.0 * a), (-b + sd) / (2.0 * a)] {
+ if t > 1.0e-6 && t < 1.0 - 1.0e-6 { breaks.push(t); }
+ }
+ }
+ breaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+ breaks.dedup_by(|a, b| (*a - *b).abs() < 1.0e-9);
+
+ let mut ts = vec![0.0_f64];
+ ts.extend(breaks.iter().copied());
+ ts.push(1.0_f64);
+
+ let mut prev_node = s.n0;
+ let mut prev_t = 0.0_f64;
+ for i in 1..ts.len() {
+ let t = ts[i];
+ let mid_t = (prev_t + t) * 0.5;
+ let mx = p0.0 + mid_t * dx;
+ let my = p0.1 + mid_t * dy;
+ let drop = self.point_in_any_toroid(mx, my);
+
+ let end_node = if i == ts.len() - 1 {
+ s.n1
+ } else {
+ let x = p0.0 + t * dx;
+ let y = p0.1 + t * dy;
+ self.add_node(x, y) as i32
+ };
+
+ if !drop && prev_node != end_node {
+ self.doc.segments.push(Segment {
+ n0: prev_node, n1: end_node,
+ max_side_length: s.max_side_length,
+ boundary_marker: s.boundary_marker.clone(),
+ hidden: s.hidden,
+ in_group: s.in_group,
+ selected: false,
+ });
+ }
+ prev_node = end_node;
+ prev_t = t;
+ }
+ }
+ }
+}
+
+fn material(name: &str, mu: f64, hc: f64, sigma: f64) -> MaterialProp {
+ MaterialProp {
+ name: name.to_string(),
+ mu_x: mu, mu_y: mu,
+ h_c: hc,
+ theta_m: 0.0,
+ j_src: Default::default(),
+ cduct: sigma,
+ lam_d: 0.0,
+ theta_hn: 0.0, theta_hx: 0.0, theta_hy: 0.0,
+ lam_type: 0, lam_fill: 1.0,
+ n_strands: 0, wire_d: 0.0,
+ bh_curve: Vec::new(),
+ }
+}
+
+fn walk(node: XmlNode, transform: Mat3, builder: &mut Builder, warnings: &mut Warnings) -> Result<(), String> {
+ if !node.is_element() { return Ok(()); }
+ let local = attr_transform(node);
+ let xf = transform.mul(&local);
+ match node.tag_name().name() {
+ "g" => {
+ if node.attribute("id") == Some("nodes") {
+ return Ok(());
+ }
+ if classes_of(node).iter().any(|c| *c == "BlockLabel") {
+ parse_block_label_group(node, xf, builder);
+ return Ok(());
+ }
+ for child in node.children() { walk(child, xf, builder, warnings)?; }
+ return Ok(());
+ }
+ "svg" | "defs" => {
+ for child in node.children() { walk(child, xf, builder, warnings)?; }
+ return Ok(());
+ }
+ "rect" => parse_rect(node, xf, builder),
+ "circle" => parse_circle(node, xf, builder),
+ "ellipse" => parse_ellipse(node, xf, builder, warnings),
+ "line" => parse_line(node, xf, builder),
+ "polyline" => parse_polyline(node, xf, builder, false),
+ "polygon" => parse_polyline(node, xf, builder, true),
+ "path" => parse_path(node, xf, builder, warnings)?,
+ _ => {}
+ }
+ Ok(())
+}
+
+fn attr_transform(node: XmlNode) -> Mat3 {
+ let Some(spec) = node.attribute("transform") else { return Mat3::identity(); };
+ let mut m = Mat3::identity();
+ let mut rest = spec;
+ while let Some(open) = rest.find('(') {
+ let head = rest[..open].trim();
+ let after_open = &rest[open+1..];
+ let Some(close) = after_open.find(')') else { break; };
+ let args: Vec = after_open[..close]
+ .split(|c: char| c == ',' || c.is_whitespace())
+ .filter(|s| !s.is_empty())
+ .filter_map(|s| s.parse::().ok())
+ .collect();
+ rest = &after_open[close+1..];
+ match head {
+ "translate" => {
+ let tx = args.first().copied().unwrap_or(0.0);
+ let ty = args.get(1).copied().unwrap_or(0.0);
+ m = m.translate(tx, ty);
+ }
+ "rotate" => {
+ let deg = args.first().copied().unwrap_or(0.0);
+ if args.len() >= 3 {
+ let cx = args[1];
+ let cy = args[2];
+ m = m.translate(cx, cy).rotate_deg(deg).translate(-cx, -cy);
+ } else {
+ m = m.rotate_deg(deg);
+ }
+ }
+ "scale" => {
+ let sx = args.first().copied().unwrap_or(1.0);
+ let sy = args.get(1).copied().unwrap_or(sx);
+ m = m.scale(sx, sy);
+ }
+ "matrix" => if args.len() == 6 {
+ let o = Mat3 { a: args[0], b: args[1], c: args[2], d: args[3], e: args[4], f_: args[5] };
+ m = m.mul(&o);
+ }
+ _ => {}
+ }
+ }
+ m
+}
+
+fn fem_xy(xf: &Mat3, x: f64, y: f64) -> (f64, f64) {
+ let (px, py) = xf.apply(x, y);
+ (px, -py)
+}
+
+fn data<'a, 'i: 'a>(node: XmlNode<'a, 'i>, key: &str) -> Option<&'a str> {
+ node.attribute(key)
+}
+
+const KNOWN_MATERIALS: &[&str] = &["Air", "NdFeB", "Steel"];
+
+fn classes_of<'a, 'i: 'a>(node: XmlNode<'a, 'i>) -> Vec<&'a str> {
+ match node.attribute("class") {
+ Some(s) => s.split_whitespace().collect(),
+ None => Vec::new(),
+ }
+}
+
+fn title_of<'a, 'i: 'a>(node: XmlNode<'a, 'i>) -> Option {
+ for child in node.children() {
+ if child.is_element() && child.tag_name().name() == "title" {
+ if let Some(t) = child.text() {
+ let trimmed = t.trim();
+ if !trimmed.is_empty() {
+ return Some(trimmed.to_string());
+ }
+ }
+ }
+ }
+ None
+}
+
+fn material_for(node: XmlNode) -> Option {
+ if let Some(t) = title_of(node) {
+ return Some(t);
+ }
+ for cls in classes_of(node) {
+ for prefix in ["material-", "material:", "block-", "block:"] {
+ if let Some(rest) = cls.strip_prefix(prefix) {
+ return Some(rest.to_string());
+ }
+ }
+ if KNOWN_MATERIALS.iter().any(|m| *m == cls) {
+ return Some(cls.to_string());
+ }
+ }
+ if let Some(label) = node.attribute(("http://www.inkscape.org/namespaces/inkscape", "label")) {
+ for prefix in ["material-", "material:", "block-", "block:"] {
+ if let Some(rest) = label.strip_prefix(prefix) {
+ return Some(rest.to_string());
+ }
+ }
+ if KNOWN_MATERIALS.iter().any(|m| *m == label) { return Some(label.to_string()); }
+ }
+ if let Some(m) = data(node, "data-material") { return Some(m.to_string()); }
+ if let Some(id) = node.attribute("id") {
+ for prefix in ["block-", "material-", "block:", "material:"] {
+ if let Some(rest) = id.strip_prefix(prefix) {
+ return Some(rest.to_string());
+ }
+ }
+ }
+ None
+}
+
+fn shape_class<'a, 'i: 'a>(node: XmlNode<'a, 'i>) -> Option<&'a str> {
+ for cls in classes_of(node) {
+ match cls {
+ "Toroid" | "Segment" | "Disc" | "Block" => return Some(cls),
+ _ => {}
+ }
+ }
+ None
+}
+
+fn boundary_for(node: XmlNode) -> String {
+ for cls in classes_of(node) {
+ for prefix in ["boundary-", "boundary:", "bdry-", "bdry:"] {
+ if let Some(rest) = cls.strip_prefix(prefix) {
+ return rest.to_string();
+ }
+ }
+ }
+ if let Some(label) = node.attribute(("http://www.inkscape.org/namespaces/inkscape", "label")) {
+ for prefix in ["boundary-", "boundary:", "bdry-", "bdry:"] {
+ if let Some(rest) = label.strip_prefix(prefix) {
+ return rest.to_string();
+ }
+ }
+ }
+ if let Some(b) = data(node, "data-boundary") { return b.to_string(); }
+ if let Some(id) = node.attribute("id") {
+ for prefix in ["boundary-", "bdry-", "boundary:", "bdry:"] {
+ if let Some(rest) = id.strip_prefix(prefix) {
+ return rest.to_string();
+ }
+ }
+ }
+ String::new()
+}
+
+fn mag_dir_for(node: XmlNode, xf: &Mat3) -> f64 {
+ if let Some(s) = data(node, "data-mag-dir") {
+ if let Ok(v) = s.parse::() { return v; }
+ }
+ for cls in classes_of(node) {
+ if let Some(rest) = cls.strip_prefix("mag-dir-") {
+ if let Ok(v) = rest.parse::() { return v; }
+ }
+ }
+ -xf.rotation_deg()
+}
+
+fn max_area_for(node: XmlNode) -> f64 {
+ data(node, "data-max-area")
+ .and_then(|s: &str| s.parse::().ok())
+ .unwrap_or(0.0)
+}
+
+/// extracts a single block label from a `` group: title -> material, inner circle's cx/cy -> position, data-mag-dir / transform rotate -> orientation.
+fn parse_block_label_group(node: XmlNode, xf: Mat3, b: &mut Builder) {
+ let Some(material) = material_for(node) else { return };
+ let mut pos: Option<(f64, f64)> = None;
+ for child in node.children() {
+ if child.is_element() && child.tag_name().name() == "circle" {
+ let cx = child.attribute("cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let cy = child.attribute("cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ pos = Some((cx, cy));
+ break;
+ }
+ }
+ let Some((cx, cy)) = pos else { return };
+ let (fx, fy) = fem_xy(&xf, cx, cy);
+ b.add_block(fx, fy, &material, mag_dir_for(node, &xf), max_area_for(node));
+}
+
+fn parse_rect(node: XmlNode, xf: Mat3, b: &mut Builder) {
+ let x = node.attribute("x").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let y = node.attribute("y").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let w: f64 = node.attribute("width").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let h: f64 = node.attribute("height").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ if w <= 0.0 || h <= 0.0 { return; }
+ let corners = [
+ (x, y),
+ (x + w, y),
+ (x + w, y + h),
+ (x, y + h),
+ ];
+ let mut idxs = [0_i32; 4];
+ for (i, (cx, cy)) in corners.into_iter().enumerate() {
+ let (fx, fy) = fem_xy(&xf, cx, cy);
+ idxs[i] = b.add_node(fx, fy);
+ }
+ let bdry = boundary_for(node);
+ b.add_segment(idxs[0], idxs[1], &bdry);
+ b.add_segment(idxs[1], idxs[2], &bdry);
+ b.add_segment(idxs[2], idxs[3], &bdry);
+ b.add_segment(idxs[3], idxs[0], &bdry);
+ if let Some(mat) = material_for(node) {
+ let mag_dir = mag_dir_for(node, &xf);
+ let max_area = max_area_for(node);
+ let (long_w, short_h, long_axis_x) = if w >= h { (w, h, true) } else { (h, w, false) };
+ let count = if long_w >= short_h * 4.0 { 5 } else { 1 };
+ let short_center = if long_axis_x { y + h * 0.5 } else { x + w * 0.5 };
+ for k in 0..count {
+ let t = if count == 1 { 0.5 } else { 0.1 + 0.2 * k as f64 };
+ let long_pos = if long_axis_x { x + w * t } else { y + h * t };
+ let (px, py) = if long_axis_x { (long_pos, short_center) } else { (short_center, long_pos) };
+ let (fx, fy) = fem_xy(&xf, px, py);
+ b.add_block(fx, fy, &mat, mag_dir, max_area);
+ }
+ }
+}
+
+fn parse_circle(node: XmlNode, xf: Mat3, b: &mut Builder) {
+ let cx = node.attribute("cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let cy = node.attribute("cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let r: f64 = node.attribute("r").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ if r <= 0.0 { return; }
+ emit_circle(cx, cy, r, &xf, node, b);
+}
+
+fn emit_circle(cx: f64, cy: f64, r: f64, xf: &Mat3, node: XmlNode, b: &mut Builder) {
+ let cardinals = [
+ (cx + r, cy),
+ (cx, cy + r),
+ (cx - r, cy),
+ (cx, cy - r),
+ ];
+ let mut idxs = [0_i32; 4];
+ for (i, (px, py)) in cardinals.into_iter().enumerate() {
+ let (fx, fy) = fem_xy(xf, px, py);
+ idxs[i] = b.add_node(fx, fy);
+ }
+ let bdry = boundary_for(node);
+ b.add_arc(idxs[0], idxs[3], 90.0, &bdry);
+ b.add_arc(idxs[3], idxs[2], 90.0, &bdry);
+ b.add_arc(idxs[2], idxs[1], 90.0, &bdry);
+ b.add_arc(idxs[1], idxs[0], 90.0, &bdry);
+ if let Some(mat) = material_for(node) {
+ let (fx, fy) = fem_xy(xf, cx, cy);
+ b.add_block(fx, fy, &mat, mag_dir_for(node, xf), max_area_for(node));
+ }
+}
+
+fn parse_ellipse(node: XmlNode, xf: Mat3, b: &mut Builder, warnings: &mut Warnings) {
+ let cx = node.attribute("cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let cy = node.attribute("cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let rx: f64 = node.attribute("rx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let ry: f64 = node.attribute("ry").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ if rx <= 0.0 || ry <= 0.0 { return; }
+ if (rx - ry).abs() < 1e-9 {
+ emit_circle(cx, cy, rx, &xf, node, b);
+ return;
+ }
+ warnings.ellipse_count += 1;
+ let r = 0.5 * (rx + ry);
+ emit_circle(cx, cy, r, &xf, node, b);
+}
+
+fn parse_line(node: XmlNode, xf: Mat3, b: &mut Builder) {
+ let x1 = node.attribute("x1").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let y1 = node.attribute("y1").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let x2 = node.attribute("x2").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let y2 = node.attribute("y2").and_then(|s| s.parse().ok()).unwrap_or(0.0);
+ let (fx1, fy1) = fem_xy(&xf, x1, y1);
+ let (fx2, fy2) = fem_xy(&xf, x2, y2);
+ let n0 = b.add_node(fx1, fy1);
+ let n1 = b.add_node(fx2, fy2);
+ let bdry = boundary_for(node);
+ b.add_segment(n0, n1, &bdry);
+}
+
+fn parse_polyline(node: XmlNode, xf: Mat3, b: &mut Builder, closed: bool) {
+ let Some(pts) = node.attribute("points") else { return; };
+ let nums: Vec = pts
+ .split(|c: char| c == ',' || c.is_whitespace())
+ .filter(|s| !s.is_empty())
+ .filter_map(|s| s.parse::().ok())
+ .collect();
+ if nums.len() < 4 { return; }
+ let mut idxs = Vec::with_capacity(nums.len() / 2);
+ for chunk in nums.chunks(2) {
+ if chunk.len() < 2 { break; }
+ let (fx, fy) = fem_xy(&xf, chunk[0], chunk[1]);
+ idxs.push(b.add_node(fx, fy));
+ }
+ let bdry = boundary_for(node);
+ for w in idxs.windows(2) { b.add_segment(w[0], w[1], &bdry); }
+ if closed && idxs.len() >= 3 {
+ b.add_segment(*idxs.last().unwrap(), idxs[0], &bdry);
+ }
+}
+
+fn parse_path(node: XmlNode, xf: Mat3, b: &mut Builder, warnings: &mut Warnings) -> Result<(), String> {
+ let Some(d) = node.attribute("d") else { return Ok(()); };
+ if matches!(shape_class(node), Some("Toroid")) {
+ return parse_toroid_path(node, d, xf, b);
+ }
+ let bdry = boundary_for(node);
+ let tokens = tokenize_path(d);
+ let anchors = path_anchor_points(d);
+ let mut i = 0;
+ let mut cur: Option<(f64, f64)> = None;
+ let mut start: Option<(f64, f64)> = None;
+ let mut bezier_warn = false;
+ while i < tokens.len() {
+ let cmd = match &tokens[i] {
+ PathToken::Cmd(c) => *c,
+ PathToken::Num(_) => { i += 1; continue; }
+ };
+ i += 1;
+ let abs = cmd.is_ascii_uppercase();
+ let c = cmd.to_ascii_lowercase();
+ loop {
+ match c {
+ 'm' => {
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ let p = if abs { (x, y) } else { match cur { Some((cx, cy)) => (cx + x, cy + y), None => (x, y) } };
+ cur = Some(p);
+ start = Some(p);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'l' => {
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ let p = if abs { (x, y) } else { match cur { Some((cx, cy)) => (cx + x, cy + y), None => (x, y) } };
+ if let Some(p0) = cur {
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, p.0, p.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_segment(n0, n1, &bdry);
+ }
+ cur = Some(p);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'h' => {
+ let (x, ok) = take_num(&tokens, &mut i);
+ if !ok { break; }
+ let p = if abs { (x, cur.map(|p| p.1).unwrap_or(0.0)) }
+ else { (cur.map(|p| p.0).unwrap_or(0.0) + x, cur.map(|p| p.1).unwrap_or(0.0)) };
+ if let Some(p0) = cur {
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, p.0, p.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_segment(n0, n1, &bdry);
+ }
+ cur = Some(p);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'v' => {
+ let (y, ok) = take_num(&tokens, &mut i);
+ if !ok { break; }
+ let p = if abs { (cur.map(|p| p.0).unwrap_or(0.0), y) }
+ else { (cur.map(|p| p.0).unwrap_or(0.0), cur.map(|p| p.1).unwrap_or(0.0) + y) };
+ if let Some(p0) = cur {
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, p.0, p.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_segment(n0, n1, &bdry);
+ }
+ cur = Some(p);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'a' => {
+ let (rx, ok1) = take_num(&tokens, &mut i);
+ let (ry, ok2) = take_num(&tokens, &mut i);
+ let (_rot, ok3) = take_num(&tokens, &mut i);
+ let (large, ok4) = take_num(&tokens, &mut i);
+ let (sweep, ok5) = take_num(&tokens, &mut i);
+ let (x, ok6) = take_num(&tokens, &mut i);
+ let (y, ok7) = take_num(&tokens, &mut i);
+ if !(ok1 && ok2 && ok3 && ok4 && ok5 && ok6 && ok7) { break; }
+ let end = if abs { (x, y) } else { match cur { Some((cx, cy)) => (cx + x, cy + y), None => (x, y) } };
+ if (rx - ry).abs() > 1e-6 {
+ warnings.ellipse_count += 1;
+ }
+ if let Some(p0) = cur {
+ let r = 0.5 * (rx + ry);
+ let dx = end.0 - p0.0;
+ let dy = end.1 - p0.1;
+ let chord = (dx*dx + dy*dy).sqrt();
+ if chord > 0.0 && r > 0.0 {
+ let half = (chord / (2.0 * r)).clamp(-1.0, 1.0).asin();
+ let mut sweep_deg = 2.0 * half.to_degrees();
+ if large.abs() > 0.5 { sweep_deg = 360.0 - sweep_deg; }
+ let fem_sweep = if sweep.abs() > 0.5 { -sweep_deg } else { sweep_deg };
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, end.0, end.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_arc(n0, n1, fem_sweep, &bdry);
+ }
+ }
+ cur = Some(end);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'z' => {
+ if let (Some(p0), Some(s)) = (cur, start) {
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, s.0, s.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_segment(n0, n1, &bdry);
+ }
+ cur = start;
+ break;
+ }
+ 'c' | 's' | 'q' | 't' => {
+ bezier_warn = true;
+ let count = match c { 'c' => 6, 's' | 'q' => 4, 't' => 2, _ => 0 };
+ let mut end = cur.unwrap_or((0.0, 0.0));
+ for k in 0..count {
+ let (v, ok) = take_num(&tokens, &mut i);
+ if !ok { break; }
+ if k == count - 2 { end.0 = if abs { v } else { end.0 + v }; }
+ if k == count - 1 { end.1 = if abs { v } else { end.1 + v }; }
+ }
+ if let Some(p0) = cur {
+ let (fx0, fy0) = fem_xy(&xf, p0.0, p0.1);
+ let (fx1, fy1) = fem_xy(&xf, end.0, end.1);
+ let n0 = b.add_node(fx0, fy0);
+ let n1 = b.add_node(fx1, fy1);
+ b.add_segment(n0, n1, &bdry);
+ }
+ cur = Some(end);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ _ => break,
+ }
+ }
+ }
+ if bezier_warn {
+ warnings.bezier_paths.push(node.attribute("id").unwrap_or("?").to_string());
+ }
+ if material_for(node).is_none() && shape_class(node).is_some() {
+ warnings.no_material_shapes.push(node.attribute("id").unwrap_or("").to_string());
+ }
+ if let Some(mat) = material_for(node) {
+ let mag_dir = mag_dir_for(node, &xf);
+ let max_area = max_area_for(node);
+ if anchors.is_empty() {
+ if let Some(p) = cur {
+ let (fx, fy) = fem_xy(&xf, p.0, p.1);
+ b.add_block(fx, fy, &mat, mag_dir, max_area);
+ }
+ } else {
+ let (cx, cy, xmin, xmax, ymin, ymax) = {
+ let mut sx = 0.0;
+ let mut sy = 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 &(ax, ay) in &anchors {
+ sx += ax;
+ sy += ay;
+ if ax < xmin { xmin = ax; }
+ if ax > xmax { xmax = ax; }
+ if ay < ymin { ymin = ay; }
+ if ay > ymax { ymax = ay; }
+ }
+ let n = anchors.len() as f64;
+ (sx / n, sy / n, xmin, xmax, ymin, ymax)
+ };
+ let w = xmax - xmin;
+ let h = ymax - ymin;
+ let (long_w, short_h, long_axis_x) = if w >= h { (w, h, true) } else { (h, w, false) };
+ let count = if short_h > 0.0 && long_w >= short_h * 4.0 { 5 } else { 1 };
+ if count == 1 {
+ let (fx, fy) = fem_xy(&xf, cx, cy);
+ b.add_block(fx, fy, &mat, mag_dir, max_area);
+ } else {
+ let short_mid = if long_axis_x { (ymin + ymax) * 0.5 } else { (xmin + xmax) * 0.5 };
+ for k in 0..count {
+ let t = 0.1 + 0.2 * k as f64;
+ let long_pos = if long_axis_x { xmin + w * t } else { ymin + h * t };
+ let (px, py) = if long_axis_x { (long_pos, short_mid) } else { (short_mid, long_pos) };
+ let (fx, fy) = fem_xy(&xf, px, py);
+ b.add_block(fx, fy, &mat, mag_dir, max_area);
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+/// interprets a `class="Toroid"` path as one or two concentric circles, sniffing anchor points to recover each circle's center and radius, then emits cardinal-node arc segments and a block label in the annular body.
+fn parse_toroid_path(node: XmlNode, d: &str, xf: Mat3, b: &mut Builder) -> Result<(), String> {
+ let subpaths = path_subpath_anchors(d);
+ let mut circles: Vec<(f64, f64, f64)> = Vec::new();
+ for sp in subpaths {
+ if sp.is_empty() { continue; }
+ let (mut xmin, mut xmax, mut ymin, mut ymax) =
+ (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
+ for &(x, y) in &sp {
+ if x < xmin { xmin = x; } if x > xmax { xmax = x; }
+ if y < ymin { ymin = y; } if y > ymax { ymax = y; }
+ }
+ let cx = (xmin + xmax) * 0.5;
+ let cy = (ymin + ymax) * 0.5;
+ let r = ((xmax - xmin) + (ymax - ymin)) * 0.25;
+ if r > 1e-6 { circles.push((cx, cy, r)); }
+ }
+ if circles.is_empty() { return Ok(()); }
+ let bdry = boundary_for(node);
+ for &(cx, cy, r) in &circles {
+ let cardinals = [
+ (cx + r, cy),
+ (cx, cy + r),
+ (cx - r, cy),
+ (cx, cy - r),
+ ];
+ let mut idxs = [0_i32; 4];
+ for (i, (px, py)) in cardinals.into_iter().enumerate() {
+ let (fx, fy) = fem_xy(&xf, px, py);
+ idxs[i] = b.add_node(fx, fy);
+ }
+ b.add_arc(idxs[0], idxs[3], 90.0, &bdry);
+ b.add_arc(idxs[3], idxs[2], 90.0, &bdry);
+ b.add_arc(idxs[2], idxs[1], 90.0, &bdry);
+ b.add_arc(idxs[1], idxs[0], 90.0, &bdry);
+ }
+ circles.sort_by(|a, c| a.2.partial_cmp(&c.2).unwrap_or(std::cmp::Ordering::Equal));
+ let (cx, cy, r_outer) = *circles.last().unwrap();
+ let r_inner = if circles.len() >= 2 { circles[0].2 } else { 0.0 };
+ let (cx_fem, cy_fem) = fem_xy(&xf, cx, cy);
+ b.toroid_outer_rings.push((cx_fem, cy_fem, r_outer));
+ let diag = std::f64::consts::FRAC_1_SQRT_2;
+ if let Some(mat) = material_for(node) {
+ let r_mid = (r_outer + r_inner) * 0.5 * diag;
+ let mag_dir = mag_dir_for(node, &xf);
+ let max_area = max_area_for(node);
+ for (dx, dy) in [(r_mid, r_mid), (-r_mid, r_mid), (-r_mid, -r_mid), (r_mid, -r_mid)] {
+ let (fx, fy) = fem_xy(&xf, cx + dx, cy + dy);
+ b.add_toroid_block(fx, fy, &mat, mag_dir, max_area);
+ }
+ }
+ if circles.len() >= 2 {
+ let r_hole = r_inner * 0.5 * diag;
+ for (dx, dy) in [(r_hole, r_hole), (-r_hole, r_hole), (-r_hole, -r_hole), (r_hole, -r_hole)] {
+ let (fx, fy) = fem_xy(&xf, cx + dx, cy + dy);
+ b.add_toroid_block(fx, fy, "Air", 0.0, 0.0);
+ }
+ }
+ Ok(())
+}
+
+/// collects anchor points grouped by subpath; every `M` command starts a new subpath group, so a path with two move-then-close rings yields two groups. control points of beziers are excluded.
+fn path_subpath_anchors(d: &str) -> Vec> {
+ let flat = path_anchor_points(d);
+ let tokens = tokenize_path(d);
+ let mut groups: Vec> = Vec::new();
+ let mut anchor_idx = 0_usize;
+ let mut started = false;
+ let mut i = 0;
+ while i < tokens.len() {
+ let cmd = match tokens[i] {
+ PathToken::Cmd(c) => c,
+ PathToken::Num(_) => { i += 1; continue; }
+ };
+ i += 1;
+ let c = cmd.to_ascii_lowercase();
+ loop {
+ match c {
+ 'm' => {
+ let _ = take_num(&tokens, &mut i);
+ let _ = take_num(&tokens, &mut i);
+ if !started || groups.last().map(|g| !g.is_empty()).unwrap_or(false) {
+ groups.push(Vec::new());
+ started = true;
+ }
+ if anchor_idx < flat.len() {
+ groups.last_mut().unwrap().push(flat[anchor_idx]);
+ anchor_idx += 1;
+ }
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'l' | 'h' | 'v' | 'c' | 's' | 'q' | 't' | 'a' => {
+ match c {
+ 'h' | 'v' => { let _ = take_num(&tokens, &mut i); }
+ 'l' | 't' => { let _ = take_num(&tokens, &mut i); let _ = take_num(&tokens, &mut i); }
+ 's' | 'q' => { for _ in 0..4 { let _ = take_num(&tokens, &mut i); } }
+ 'c' => { for _ in 0..6 { let _ = take_num(&tokens, &mut i); } }
+ 'a' => { for _ in 0..7 { let _ = take_num(&tokens, &mut i); } }
+ _ => {}
+ }
+ if anchor_idx < flat.len() {
+ if groups.is_empty() { groups.push(Vec::new()); started = true; }
+ groups.last_mut().unwrap().push(flat[anchor_idx]);
+ anchor_idx += 1;
+ }
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'z' => { break; }
+ _ => break,
+ }
+ }
+ }
+ groups.into_iter().filter(|g| !g.is_empty()).collect()
+}
+
+/// collects every (x, y) anchor point referenced in the path data (move targets, line targets, curve endpoints, arc endpoints) in document order. control points of beziers are excluded so a circle approximated by 4 cubic beziers yields exactly 4 cardinal anchors.
+fn path_anchor_points(d: &str) -> Vec<(f64, f64)> {
+ let tokens = tokenize_path(d);
+ let mut out = Vec::new();
+ let mut i = 0;
+ let mut cur = (0.0_f64, 0.0_f64);
+ let mut start = cur;
+ while i < tokens.len() {
+ let cmd = match tokens[i] {
+ PathToken::Cmd(c) => c,
+ PathToken::Num(_) => { i += 1; continue; }
+ };
+ i += 1;
+ let abs = cmd.is_ascii_uppercase();
+ let c = cmd.to_ascii_lowercase();
+ loop {
+ match c {
+ 'm' => {
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ start = cur;
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'l' => {
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'h' => {
+ let (x, ok) = take_num(&tokens, &mut i);
+ if !ok { break; }
+ cur = if abs { (x, cur.1) } else { (cur.0 + x, cur.1) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'v' => {
+ let (y, ok) = take_num(&tokens, &mut i);
+ if !ok { break; }
+ cur = if abs { (cur.0, y) } else { (cur.0, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'c' => {
+ for _ in 0..2 { let _ = take_num(&tokens, &mut i); let _ = take_num(&tokens, &mut i); }
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 's' | 'q' => {
+ let _ = take_num(&tokens, &mut i); let _ = take_num(&tokens, &mut i);
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 't' => {
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'a' => {
+ for _ in 0..5 { let _ = take_num(&tokens, &mut i); }
+ let (x, ok_x) = take_num(&tokens, &mut i);
+ let (y, ok_y) = take_num(&tokens, &mut i);
+ if !(ok_x && ok_y) { break; }
+ cur = if abs { (x, y) } else { (cur.0 + x, cur.1 + y) };
+ out.push(cur);
+ if !matches!(tokens.get(i), Some(PathToken::Num(_))) { break; }
+ }
+ 'z' => {
+ cur = start;
+ break;
+ }
+ _ => break,
+ }
+ }
+ }
+ out
+}
+
+#[derive(Debug, Clone, Copy)]
+enum PathToken { Cmd(char), Num(f64) }
+
+fn tokenize_path(d: &str) -> Vec {
+ let mut out = Vec::new();
+ let mut buf = String::new();
+ let flush = |out: &mut Vec, buf: &mut String| {
+ if !buf.is_empty() {
+ if let Ok(n) = buf.parse::() {
+ out.push(PathToken::Num(n));
+ }
+ buf.clear();
+ }
+ };
+ for c in d.chars() {
+ if c.is_ascii_alphabetic() {
+ flush(&mut out, &mut buf);
+ out.push(PathToken::Cmd(c));
+ } else if c == ',' || c.is_whitespace() {
+ flush(&mut out, &mut buf);
+ } else if c == '-' && !buf.is_empty() && !buf.ends_with('e') && !buf.ends_with('E') {
+ flush(&mut out, &mut buf);
+ buf.push(c);
+ } else {
+ buf.push(c);
+ }
+ }
+ flush(&mut out, &mut buf);
+ out
+}
+
+fn take_num(tokens: &[PathToken], i: &mut usize) -> (f64, bool) {
+ while let Some(t) = tokens.get(*i) {
+ match t {
+ PathToken::Num(v) => { *i += 1; return (*v, true); }
+ PathToken::Cmd(_) => return (0.0, false),
+ }
+ }
+ (0.0, false)
+}
diff --git a/crates/femm-doc-mag/src/edit.rs b/crates/femm-doc-mag/src/edit.rs
index 73632a3..d95a846 100644
--- a/crates/femm-doc-mag/src/edit.rs
+++ b/crates/femm-doc-mag/src/edit.rs
@@ -198,6 +198,220 @@ impl FemmDoc {
nodes_bbox_tolerance(&self.nodes)
}
+ /// walks every pair of segments and arcs, computes their geometric intersections, inserts a node at each crossing, and splits the crossed primitives so they terminate at the new nodes. also splits any segment or arc whose interior passes within `tol` of an existing node (T-junction). preserves boundary markers and per-piece metadata.
+ pub fn split_at_intersections(&mut self) {
+ let tol = self.bbox_tolerance().max(1.0e-9);
+ let n_segs = self.segments.len();
+ let n_arcs = self.arcs.len();
+
+ let seg_endpoints: Vec<((f64, f64), (f64, f64))> = (0..n_segs).map(|i| {
+ let s = &self.segments[i];
+ ((self.nodes[s.n0 as usize].x, self.nodes[s.n0 as usize].y),
+ (self.nodes[s.n1 as usize].x, self.nodes[s.n1 as usize].y))
+ }).collect();
+ let arc_endpoints: Vec<((f64, f64), (f64, f64), f64)> = (0..n_arcs).map(|i| {
+ let a = &self.arcs[i];
+ ((self.nodes[a.n0 as usize].x, self.nodes[a.n0 as usize].y),
+ (self.nodes[a.n1 as usize].x, self.nodes[a.n1 as usize].y),
+ a.arc_length)
+ }).collect();
+
+ let mut seg_breaks: Vec> = vec![Vec::new(); n_segs];
+ let mut arc_breaks: Vec> = vec![Vec::new(); n_arcs];
+
+ for i in 0..n_segs {
+ for j in (i + 1)..n_segs {
+ if let Some(hit) = line_line_intersection(
+ seg_endpoints[i].0, seg_endpoints[i].1,
+ seg_endpoints[j].0, seg_endpoints[j].1,
+ ) {
+ seg_breaks[i].push(hit);
+ seg_breaks[j].push(hit);
+ }
+ }
+ }
+ for i in 0..n_segs {
+ for j in 0..n_arcs {
+ for hit in line_arc_intersection(
+ seg_endpoints[i].0, seg_endpoints[i].1,
+ arc_endpoints[j].0, arc_endpoints[j].1, arc_endpoints[j].2,
+ ) {
+ seg_breaks[i].push(hit);
+ arc_breaks[j].push(hit);
+ }
+ }
+ }
+ for i in 0..n_arcs {
+ for j in (i + 1)..n_arcs {
+ for hit in arc_arc_intersection(
+ arc_endpoints[i].0, arc_endpoints[i].1, arc_endpoints[i].2,
+ arc_endpoints[j].0, arc_endpoints[j].1, arc_endpoints[j].2,
+ ) {
+ arc_breaks[i].push(hit);
+ arc_breaks[j].push(hit);
+ }
+ }
+ }
+
+ let on_line_tol = tol * 100.0;
+ for i in 0..n_segs {
+ let (p0, p1) = seg_endpoints[i];
+ for n in &self.nodes {
+ let d = shortest_distance_from_segment((n.x, n.y), p0, p1);
+ let d0 = (n.x - p0.0).hypot(n.y - p0.1);
+ let d1 = (n.x - p1.0).hypot(n.y - p1.1);
+ if d < on_line_tol && d0 > on_line_tol && d1 > on_line_tol {
+ seg_breaks[i].push((n.x, n.y));
+ }
+ }
+ }
+ for i in 0..n_arcs {
+ let (p0, p1, sweep) = arc_endpoints[i];
+ for n in &self.nodes {
+ let d = shortest_distance_from_arc((n.x, n.y), p0, p1, sweep);
+ let d0 = (n.x - p0.0).hypot(n.y - p0.1);
+ let d1 = (n.x - p1.0).hypot(n.y - p1.1);
+ if d < on_line_tol && d0 > on_line_tol && d1 > on_line_tol {
+ arc_breaks[i].push((n.x, n.y));
+ }
+ }
+ }
+
+ for breaks in seg_breaks.iter().chain(arc_breaks.iter()) {
+ for &(x, y) in breaks {
+ self.add_node(x, y, tol);
+ }
+ }
+
+ let old_segments = std::mem::take(&mut self.segments);
+ for (i, s) in old_segments.into_iter().enumerate() {
+ let (p0, p1) = seg_endpoints[i];
+ let dx = p1.0 - p0.0;
+ let dy = p1.1 - p0.1;
+ let len2 = dx * dx + dy * dy;
+ if len2 < tol * tol {
+ self.segments.push(s);
+ continue;
+ }
+ let mut ts: Vec = seg_breaks[i].iter()
+ .map(|&(bx, by)| ((bx - p0.0) * dx + (by - p0.1) * dy) / len2)
+ .filter(|&t| t > 1.0e-6 && t < 1.0 - 1.0e-6)
+ .collect();
+ ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+ ts.dedup_by(|a, b| (*a - *b).abs() < 1.0e-9);
+
+ let mut prev_node = s.n0;
+ for &t in &ts {
+ let bx = p0.0 + t * dx;
+ let by = p0.1 + t * dy;
+ let Some(node_idx) = self.closest_node(bx, by) else { continue };
+ let node_idx = node_idx as i32;
+ if node_idx == prev_node || node_idx == s.n1 { continue; }
+ self.segments.push(Segment {
+ n0: prev_node, n1: node_idx,
+ max_side_length: s.max_side_length,
+ boundary_marker: s.boundary_marker.clone(),
+ hidden: s.hidden,
+ in_group: s.in_group,
+ selected: false,
+ });
+ prev_node = node_idx;
+ }
+ self.segments.push(Segment {
+ n0: prev_node, n1: s.n1,
+ max_side_length: s.max_side_length,
+ boundary_marker: s.boundary_marker.clone(),
+ hidden: s.hidden,
+ in_group: s.in_group,
+ selected: false,
+ });
+ }
+
+ let old_arcs = std::mem::take(&mut self.arcs);
+ for (i, a) in old_arcs.into_iter().enumerate() {
+ let (p0, p1, _) = arc_endpoints[i];
+ if a.arc_length.abs() < 1.0e-6 {
+ self.arcs.push(a);
+ continue;
+ }
+ let (cx, cy, r) = circle_from_arc(p0, p1, a.arc_length);
+ if r < 1.0e-9 {
+ self.arcs.push(a);
+ continue;
+ }
+ let a0_rad = (p0.1 - cy).atan2(p0.0 - cx);
+ let sweep_rad = a.arc_length.to_radians();
+ let sign = if sweep_rad >= 0.0 { 1.0 } else { -1.0 };
+ let sweep_abs = sweep_rad.abs();
+
+ let two_pi = 2.0 * std::f64::consts::PI;
+ let mut ts: Vec = arc_breaks[i].iter()
+ .filter_map(|&(bx, by)| {
+ let ang = (by - cy).atan2(bx - cx);
+ let mut delta = (ang - a0_rad) * sign;
+ while delta < 0.0 { delta += two_pi; }
+ while delta >= two_pi { delta -= two_pi; }
+ let t = delta / sweep_abs;
+ if t > 1.0e-6 && t < 1.0 - 1.0e-6 { Some(t) } else { None }
+ })
+ .collect();
+ ts.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
+ ts.dedup_by(|x, y| (*x - *y).abs() < 1.0e-9);
+
+ let mut prev_node = a.n0;
+ let mut prev_t = 0.0;
+ for &t in &ts {
+ let ang = a0_rad + sign * t * sweep_abs;
+ let bx = cx + r * ang.cos();
+ let by = cy + r * ang.sin();
+ let Some(node_idx) = self.closest_node(bx, by) else { continue };
+ let node_idx = node_idx as i32;
+ if node_idx == prev_node || node_idx == a.n1 { continue; }
+ let piece_sweep = a.arc_length * (t - prev_t);
+ self.arcs.push(ArcSegment {
+ n0: prev_node, n1: node_idx,
+ arc_length: piece_sweep,
+ ..a.clone()
+ });
+ prev_node = node_idx;
+ prev_t = t;
+ }
+ let last_sweep = a.arc_length * (1.0 - prev_t);
+ self.arcs.push(ArcSegment {
+ n0: prev_node, n1: a.n1,
+ arc_length: last_sweep,
+ ..a
+ });
+ }
+
+ self.dedup_segments_and_arcs();
+ }
+
+ /// removes exact-duplicate segments (same unordered endpoints) and exact-duplicate arcs (same directed endpoints and near-equal sweep).
+ fn dedup_segments_and_arcs(&mut self) {
+ let mut kept_segs: Vec = Vec::with_capacity(self.segments.len());
+ for s in std::mem::take(&mut self.segments) {
+ let (a, b) = if s.n0 < s.n1 { (s.n0, s.n1) } else { (s.n1, s.n0) };
+ if a == b { continue; }
+ let dup = kept_segs.iter().any(|k| {
+ let (ka, kb) = if k.n0 < k.n1 { (k.n0, k.n1) } else { (k.n1, k.n0) };
+ ka == a && kb == b
+ });
+ if !dup { kept_segs.push(s); }
+ }
+ self.segments = kept_segs;
+
+ let mut kept_arcs: Vec = Vec::with_capacity(self.arcs.len());
+ for a in std::mem::take(&mut self.arcs) {
+ if a.n0 == a.n1 || a.arc_length.abs() < 1.0e-6 { continue; }
+ let dup = kept_arcs.iter().any(|k| {
+ k.n0 == a.n0 && k.n1 == a.n1 && (k.arc_length - a.arc_length).abs() < 1.0e-2
+ });
+ if !dup { kept_arcs.push(a); }
+ }
+ self.arcs = kept_arcs;
+ }
+
/// 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);
diff --git a/examples/from-svg_disc_gtr.fem b/examples/from-svg_disc_gtr.fem
new file mode 100644
index 0000000..9f7f642
--- /dev/null
+++ b/examples/from-svg_disc_gtr.fem
@@ -0,0 +1,395 @@
+[Format] = 4.00000000000000000e0
+[Frequency] = 0.00000000000000000e0
+[Precision] = 1.00000000000000002e-8
+[MinAngle] = 2.50000000000000000e1
+[DoSmartMesh] = 0
+[Depth] = 5.00000000000000000e0
+[LengthUnits] = millimeters
+[ProblemType] = planar
+[Coordinates] = cartesian
+[ACSolver] = 0
+[PrevType] = 0
+[PrevSoln] = ""
+[Comment] = ""
+[PointProps] = 0
+[BdryProps] = 1
+
+ = "A=0"
+ = 0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+
+[BlockProps] = 3
+
+ = "Air"
+ = 1.00000000000000000e0
+ = 1.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0
+ = 1.00000000000000000e0
+ = 0
+ = 0.00000000000000000e0
+ = 0
+
+
+ = "NdFeB"
+ = 1.05000000000000004e0
+ = 1.05000000000000004e0
+ = 9.15000000000000000e5
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 6.67000000000000037e-1
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0
+ = 1.00000000000000000e0
+ = 0
+ = 0.00000000000000000e0
+ = 0
+
+
+ = "Steel"
+ = 2.50000000000000000e3
+ = 2.50000000000000000e3
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 5.79999999999999982e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0.00000000000000000e0
+ = 0
+ = 1.00000000000000000e0
+ = 0
+ = 0.00000000000000000e0
+ = 0
+
+[CircuitProps] = 0
+[NumPoints] = 108
+8.24005999999999972e2 -5.52793000000000006e2 0 0
+4.71225999999999999e2 -9.05572999999999979e2 0 0
+1.18446000000000026e2 -5.52793000000000006e2 0 0
+4.71225999999999999e2 -2.00013000000000034e2 0 0
+6.46474000000000046e2 -5.52792500000000018e2 0 0
+4.71225500000000011e2 -7.28041000000000054e2 0 0
+2.95976999999999975e2 -5.52792500000000018e2 0 0
+4.71225500000000011e2 -3.77543999999999983e2 0 0
+-2.80048700000000008e3 -7.22574000000000069e2 0 0
+-2.85275000000000000e3 -7.74837000000000103e2 0 0
+-2.90501299999999992e3 -7.22574000000000069e2 0 0
+-2.85275000000000000e3 -6.70311000000000035e2 0 0
+-2.83670149999999967e3 -7.22574000000000069e2 0 0
+-2.85269999999999982e3 -7.38572500000000105e2 0 0
+-2.86869849999999997e3 -7.22574000000000069e2 0 0
+-2.85269999999999982e3 -7.06575500000000034e2 0 0
+-2.80048700000000008e3 -4.08918000000000006e2 0 0
+-2.85275000000000000e3 -4.61181000000000040e2 0 0
+-2.90501299999999992e3 -4.08918000000000006e2 0 0
+-2.85275000000000000e3 -3.56654999999999973e2 0 0
+-2.83680150000000003e3 -4.08918000000000006e2 0 0
+-2.85280000000000018e3 -4.24916500000000042e2 0 0
+-2.86879850000000033e3 -4.08918000000000006e2 0 0
+-2.85280000000000018e3 -3.92919499999999971e2 0 0
+-2.80041199999999981e3 -8.27125999999999976e2 0 0
+-2.85269999999999982e3 -8.79413999999999987e2 0 0
+-2.90498799999999983e3 -8.27125999999999976e2 0 0
+-2.85269999999999982e3 -7.74837999999999965e2 0 0
+-2.83670149999999967e3 -8.27125999999999976e2 0 0
+-2.85269999999999982e3 -8.43124500000000012e2 0 0
+-2.86869849999999997e3 -8.27125999999999976e2 0 0
+-2.85269999999999982e3 -8.11127499999999941e2 0 0
+-2.80048700000000008e3 -3.04365999999999985e2 0 0
+-2.85275000000000000e3 -3.56628999999999962e2 0 0
+-2.90501299999999992e3 -3.04365999999999985e2 0 0
+-2.85275000000000000e3 -2.52102999999999980e2 0 0
+-2.83670149999999967e3 -3.04365999999999985e2 0 0
+-2.85269999999999982e3 -3.20364499999999964e2 0 0
+-2.86869849999999997e3 -3.04365999999999985e2 0 0
+-2.85269999999999982e3 -2.88367500000000007e2 0 0
+-2.80048700000000008e3 -5.13470000000000027e2 0 0
+-2.85275000000000000e3 -5.65733000000000061e2 0 0
+-2.90501299999999992e3 -5.13470000000000027e2 0 0
+-2.85275000000000000e3 -4.61207000000000050e2 0 0
+-2.83680150000000003e3 -5.13470000000000027e2 0 0
+-2.85280000000000018e3 -5.29468500000000063e2 0 0
+-2.86879850000000033e3 -5.13470000000000027e2 0 0
+-2.85280000000000018e3 -4.97471500000000049e2 0 0
+-2.80041199999999981e3 -6.18021999999999935e2 0 0
+-2.85269999999999982e3 -6.70309999999999945e2 0 0
+-2.90498799999999983e3 -6.18021999999999935e2 0 0
+-2.85269999999999982e3 -5.65733999999999924e2 0 0
+-2.83670149999999967e3 -6.18021999999999935e2 0 0
+-2.85269999999999982e3 -6.34020499999999970e2 0 0
+-2.86869849999999997e3 -6.18021999999999935e2 0 0
+-2.85269999999999982e3 -6.02023499999999899e2 0 0
+-2.80230000000000018e3 -7.26054999999999950e2 0 0
+1.64704000000000008e2 -7.26054999999999950e2 0 0
+1.58929000000000002e2 -7.15385999999999967e2 0 0
+-2.80229899999999998e3 -7.15385999999999967e2 0 0
+-2.80509999999999991e3 -4.02382000000000005e2 0 0
+1.52897999999999996e2 -4.02382000000000005e2 0 0
+1.50802999999999997e2 -4.06911999999999978e2 0 0
+-2.80509999999999991e3 -4.06911999999999978e2 0 0
+-2.80080000000000018e3 -8.19880999999999972e2 0 0
+2.41389999999999986e2 -8.19880999999999972e2 0 0
+2.59242000000000019e2 -8.34370000000000005e2 0 0
+-2.80080000000000018e3 -8.34370000000000005e2 0 0
+-2.80230099999999993e3 -2.97177999999999997e2 0 0
+2.28776999999999987e2 -2.97177999999999997e2 0 0
+2.25158999999999992e2 -3.00680999999999983e2 0 0
+-2.80230000000000018e3 -3.00680999999999983e2 0 0
+-2.80509999999999991e3 -5.06934000000000026e2 0 0
+1.22325000000000003e2 -5.06934000000000026e2 0 0
+1.21597999999999999e2 -5.12947999999999979e2 0 0
+-2.80509999999999991e3 -5.12947999999999979e2 0 0
+-2.80080000000000018e3 -6.10777000000000044e2 0 0
+1.24111000000000004e2 -6.10777000000000044e2 0 0
+1.25593999999999994e2 -6.19129999999999995e2 0 0
+-2.80080000000000018e3 -6.19129999999999995e2 0 0
+-2.97580000000000018e3 1.03290000000000009e3 0 0
+8.75769999999999982e2 1.03290000000000009e3 0 0
+8.75769999999999982e2 -1.93863999999999987e3 0 0
+-2.97580000000000018e3 -1.93863999999999987e3 0 0
+-2.80060305562163785e3 -7.26054999999999950e2 0 0
+1.63924832159717880e2 -7.26054999999999950e2 0 0
+1.58148941193386122e2 -7.15385999999999967e2 0 0
+-2.80098366127493409e3 -7.15385999999999967e2 0 0
+-2.80089730601984138e3 -4.02382000000000005e2 0 0
+1.52117461287854894e2 -4.02382000000000005e2 0 0
+1.50021295428289278e2 -4.06911999999999978e2 0 0
+-2.80052551213271863e3 -4.06911999999999978e2 0 0
+2.40753162867295032e2 -8.19880999999999972e2 0 0
+2.58698068962689092e2 -8.34370000000000005e2 0 0
+-2.80080000000000018e3 -8.33484061339750951e2 0 0
+-2.80080000000000018e3 -8.20767938660249001e2 0 0
+-2.80098366127491727e3 -2.97177999999999997e2 0 0
+2.28090852036980777e2 -2.97177999999999997e2 0 0
+2.24460386803995789e2 -3.00680999999999983e2 0 0
+-2.80061707428122872e3 -3.00680999999999983e2 0 0
+-2.80089730601984138e3 -5.06934000000000026e2 0 0
+1.21439378587746887e2 -5.06934000000000026e2 0 0
+1.20703383932220476e2 -5.12947999999999979e2 0 0
+-2.80048960691881257e3 -5.12947999999999979e2 0 0
+1.23243839330807077e2 -6.10777000000000044e2 0 0
+1.24739159223903599e2 -6.19129999999999995e2 0 0
+-2.80042374076121632e3 -6.19129999999999995e2 0 0
+-2.80080000000000018e3 -6.11663938660248959e2 0 0
+[NumSegments] = 19
+84 85 -1 0 0 0
+86 87 -1 0 0 0
+88 89 -1 0 0 0
+90 91 -1 0 0 0
+64 92 -1 0 0 0
+93 67 -1 0 0 0
+67 94 -1 0 0 0
+95 64 -1 0 0 0
+96 97 -1 0 0 0
+98 99 -1 0 0 0
+100 101 -1 0 0 0
+102 103 -1 0 0 0
+76 104 -1 0 0 0
+105 106 -1 0 0 0
+107 76 -1 0 0 0
+80 81 -1 0 0 0
+81 82 -1 0 0 0
+82 83 -1 0 0 0
+83 80 -1 0 0 0
+[NumArcSegments] = 86
+0 3 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+3 69 4.34857757270957705e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+69 97 8.08539229291915795e-2 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+97 98 8.19364700061769868e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+98 89 2.03772786851518397e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+89 90 8.10683064316110036e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+90 101 1.69568447092203130e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+101 102 9.84046273757427370e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+102 2 6.48515291746757683e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+2 104 9.46023528666818692e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+104 105 1.37822874154360875e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+105 86 1.66061297943698634e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+86 85 1.97050248335090927e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+85 92 1.97936746204819087e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+92 65 7.83988493135179265e-2 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+65 93 3.66815324705578405e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+93 66 7.05763358568378685e-2 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+66 1 3.69741006413593851e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+1 0 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+4 7 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+7 6 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+6 5 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+5 4 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+8 87 7.90524150172130735e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+87 11 8.20947584982786935e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+11 10 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+10 9 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+9 84 8.61809622364696821e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+84 8 3.81903776353032187e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+12 15 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+15 14 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+14 13 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+13 12 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+16 91 2.19971248551486598e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+91 88 4.98449632060359704e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+88 19 8.28157911938815374e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+19 18 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+18 17 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+17 16 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+20 23 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+23 22 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+22 21 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+21 20 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+24 95 6.98427608648830134e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+95 64 9.62595439387266216e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+64 27 8.20531284741244349e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+27 26 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+26 25 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+25 67 8.20542113402890294e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+67 94 9.61512573222417233e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+94 24 6.98427608648855092e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+28 31 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+31 30 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+30 29 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+29 28 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+32 99 4.04320996664804166e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+99 96 3.86203153507314179e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+96 35 8.20947584982788214e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+35 34 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+34 33 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+33 32 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+36 39 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+39 38 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+38 37 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+37 36 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+40 103 5.72276643139511543e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+103 100 6.61193216297895070e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+100 43 8.28157911938815374e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+43 42 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+42 41 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+41 40 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+44 47 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+47 46 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+46 45 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+45 44 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+48 107 6.98427608648830134e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+107 76 9.62595439387141427e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+76 51 8.20531284741245486e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+51 50 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+50 49 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+49 106 8.87769926454948006e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+106 48 1.22300735450519338e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+52 55 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+55 54 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+54 53 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+53 52 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1
+[NumHoles] = 0
+[NumBlockLabels] = 87
+6.57912266504880449e2 -7.39479266504880457e2 2 -1 0 -0.00000000000000000e0 0 0 0
+2.84539733495119549e2 -7.39479266504880457e2 2 -1 0 -0.00000000000000000e0 0 0 0
+2.84539733495119549e2 -3.66106733495119556e2 2 -1 0 -0.00000000000000000e0 0 0 0
+6.57912266504880449e2 -3.66106733495119556e2 2 -1 0 -0.00000000000000000e0 0 0 0
+5.33185701371385335e2 -6.14752701371385342e2 1 -1 0 0.00000000000000000e0 0 0 0
+4.09266298628614663e2 -6.14752701371385342e2 1 -1 0 0.00000000000000000e0 0 0 0
+4.09266298628614663e2 -4.90833298628614671e2 1 -1 0 0.00000000000000000e0 0 0 0
+5.33185701371385335e2 -4.90833298628614671e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -7.46708084771982840e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -7.46708084771982840e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -6.98439915228017298e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -6.98439915228017298e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -7.28230323919406601e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -7.28230323919406601e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -7.16917676080593537e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -7.16917676080593537e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -4.33052084771982777e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -4.33052084771982777e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -3.84783915228017236e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -3.84783915228017236e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -4.14574323919406481e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -4.14574323919406481e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -4.03261676080593531e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -4.03261676080593531e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82855707639325237e3 -8.51268923606747535e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87684292360674726e3 -8.51268923606747535e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87684292360674726e3 -8.02983076393252418e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82855707639325237e3 -8.02983076393252418e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84704367608059329e3 -8.32782323919406508e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85835632391940635e3 -8.32782323919406508e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85835632391940635e3 -8.21469676080593445e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84704367608059329e3 -8.21469676080593445e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -3.28500084771982756e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -3.28500084771982756e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -2.80231915228017215e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -2.80231915228017215e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -3.10022323919406460e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -3.10022323919406460e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -2.98709676080593511e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -2.98709676080593511e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -5.37604084771982798e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -5.37604084771982798e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87688408477198254e3 -4.89335915228017257e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82861591522801746e3 -4.89335915228017257e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -5.19126323919406559e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -5.19126323919406559e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85840632391940653e3 -5.07813676080593552e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84709367608059347e3 -5.07813676080593552e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.82855707639325237e3 -6.42164923606747493e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87684292360674726e3 -6.42164923606747493e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.87684292360674726e3 -5.93879076393252376e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.82855707639325237e3 -5.93879076393252376e2 2 -1 0 -0.00000000000000000e0 0 0 0
+-2.84704367608059329e3 -6.23678323919406466e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85835632391940635e3 -6.23678323919406466e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.85835632391940635e3 -6.12365676080593403e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.84704367608059329e3 -6.12365676080593403e2 1 -1 0 0.00000000000000000e0 0 0 0
+-2.50559960000000001e3 -7.20720499999999902e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.91219879999999989e3 -7.20720499999999902e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.31879800000000000e3 -7.20720499999999902e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-7.25397199999999884e2 -7.20720499999999902e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.31996399999999994e2 -7.20720499999999902e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-2.50930019999999968e3 -4.04646999999999991e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.91770059999999967e3 -4.04646999999999991e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.32610099999999989e3 -4.04646999999999991e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-7.34501399999999649e2 -4.04646999999999991e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.42901799999999639e2 -4.04646999999999991e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-2.49479580000000033e3 -8.27125499999999988e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.88278739999999993e3 -8.27125499999999988e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.27077900000000000e3 -8.27125499999999988e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-6.58770599999999831e2 -8.27125499999999988e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-4.67621999999996660e1 -8.27125499999999988e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-2.49919319999999971e3 -2.98929499999999962e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.89297759999999971e3 -2.98929499999999962e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.28676199999999994e3 -2.98929499999999962e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-6.80546399999999721e2 -2.98929499999999962e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-7.43307999999997264e1 -2.98929499999999962e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-2.51235750000000007e3 -5.09941000000000031e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.92687249999999995e3 -5.09941000000000031e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.34138750000000005e3 -5.09941000000000031e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-7.55902499999999691e2 -5.09941000000000031e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.70417500000000018e2 -5.09941000000000031e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-2.50816060000000016e3 -6.14953500000000076e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.92288180000000011e3 -6.14953500000000076e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.33760300000000007e3 -6.14953500000000076e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-7.52324200000000019e2 -6.14953500000000076e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.67045399999999972e2 -6.14953500000000076e2 3 -1 0 -0.00000000000000000e0 0 0 0
+-1.05001500000000010e3 -4.52869999999999891e2 1 -1 0 -0.00000000000000000e0 0 0 0
diff --git a/examples/svg/3small_1large_toroids.svg b/examples/svg/3small_1large_toroids.svg
new file mode 100644
index 0000000..08ef85d
--- /dev/null
+++ b/examples/svg/3small_1large_toroids.svg
@@ -0,0 +1,29 @@
+
+
+
+
+ Air
+
+
+ Steel
+
+
+ NdFeB
+
+
+ Steel
+
+
+ Steel
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+
\ No newline at end of file
diff --git a/examples/svg/3small_1large_toroids_donut.svg b/examples/svg/3small_1large_toroids_donut.svg
new file mode 100644
index 0000000..8f6b878
--- /dev/null
+++ b/examples/svg/3small_1large_toroids_donut.svg
@@ -0,0 +1,29 @@
+
+
+
+
+ Air
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ Steel
+
+
+ Steel
+
+
+ Steel
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+
\ No newline at end of file
diff --git a/examples/svg/disc_gtr.svg b/examples/svg/disc_gtr.svg
new file mode 100644
index 0000000..9abd25f
--- /dev/null
+++ b/examples/svg/disc_gtr.svg
@@ -0,0 +1,49 @@
+
+
+
+
+ NdFeB
+
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+ NdFeB
+
+
+
+ Steel
+
+
+ Steel
+
+
+ Steel
+
+
+ Steel
+
+
+ Steel
+
+
+ Steel
+
+
+ Air
+
+
+
\ No newline at end of file