From a2957d907a8460df134f672d97cfe4283c36cc27 Mon Sep 17 00:00:00 2001 From: jess Date: Fri, 15 May 2026 23:40:33 -0700 Subject: [PATCH] added more plotting modes and node angles. also added a very handy SVG pipeline. you can make fem documents in inkscape and whatnot now. added a few examples. use content-info to give your object a material (the title under accesability that way it serves a secondary purpose) class names: Segment Node Block you can claim a rectangle to be a segment, it will create an enclosed object out of segments for you. you should trim your objects to overlap just a smidge or you get some ghost nodes. --- assets/femm.svg | 148 +-- crates/femm-app/Cargo.toml | 1 + crates/femm-app/src/doc_canvas.rs | 76 +- crates/femm-app/src/export.rs | 25 +- crates/femm-app/src/main.rs | 492 ++++++- crates/femm-app/src/plot.rs | 2 +- crates/femm-app/src/probe.rs | 45 +- crates/femm-app/src/sim_meta.rs | 11 +- crates/femm-app/src/svg_io.rs | 1247 ++++++++++++++++++ crates/femm-doc-mag/src/edit.rs | 214 +++ examples/from-svg_disc_gtr.fem | 395 ++++++ examples/svg/3small_1large_toroids.svg | 29 + examples/svg/3small_1large_toroids_donut.svg | 29 + examples/svg/disc_gtr.svg | 49 + 14 files changed, 2598 insertions(+), 165 deletions(-) create mode 100644 crates/femm-app/src/svg_io.rs create mode 100644 examples/from-svg_disc_gtr.fem create mode 100644 examples/svg/3small_1large_toroids.svg create mode 100644 examples/svg/3small_1large_toroids_donut.svg create mode 100644 examples/svg/disc_gtr.svg 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 <title>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