From 749a9d3e436fb3e586c57f453311157c3fe0ba31 Mon Sep 17 00:00:00 2001 From: jess Date: Wed, 13 May 2026 19:34:18 -0700 Subject: [PATCH] subprocess wrapper around the magnetostatic FFI solver - C++ errors are caught and collected and the process dies on its own thread. this allows errors in parsed .fem files to now provide useful errors back to the user. also stitched in the incomplete functions for the buttons that existed already but has no implementations. - new doc - save/as - zoom - grid - etc --- animations: new engine for defining the movement of a segment between two nodes or multiple pairs of two as a function over time and animated simulations are now capable showing the change in fields of moving objects. --- crates/femm-app/Cargo.toml | 4 +- crates/femm-app/src/doc_canvas.rs | 271 +++++-- crates/femm-app/src/kinematic.rs | 154 ++++ crates/femm-app/src/main.rs | 1256 ++++++++++++++++++++++++++--- crates/femm-app/src/spice.rs | 79 ++ crates/femm-doc-mag/src/edit.rs | 52 ++ crates/femm-mag-solve/Cargo.toml | 14 + crates/femm-mag-solve/src/main.rs | 61 ++ examples/Halbach.fem | 89 ++ examples/diametral_magn.fem | 141 ++++ examples/guitar_strings.fem | 170 ++++ examples/radial_magn.fem | 141 ++++ examples/uniform_field_axi.fem | 237 ++++++ scripts/macos/build.sh | 8 +- 14 files changed, 2501 insertions(+), 176 deletions(-) create mode 100644 crates/femm-app/src/kinematic.rs create mode 100644 crates/femm-app/src/spice.rs create mode 100644 crates/femm-mag-solve/Cargo.toml create mode 100644 crates/femm-mag-solve/src/main.rs create mode 100644 examples/Halbach.fem create mode 100644 examples/diametral_magn.fem create mode 100644 examples/guitar_strings.fem create mode 100644 examples/radial_magn.fem create mode 100644 examples/uniform_field_axi.fem diff --git a/crates/femm-app/Cargo.toml b/crates/femm-app/Cargo.toml index 9596652..24e02a3 100644 --- a/crates/femm-app/Cargo.toml +++ b/crates/femm-app/Cargo.toml @@ -11,7 +11,7 @@ name = "femm" path = "src/main.rs" [dependencies] -femm-sys = { workspace = true } femm-doc-mag = { workspace = true } -iced = { version = "0.14", features = ["canvas"] } +iced = { version = "0.14", features = ["canvas", "svg", "tokio"] } rfd = "0.17" +meval = "0.2" diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index c693c8c..0f45046 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -1,6 +1,6 @@ //! draws a FemmDoc on an iced canvas: nodes, segments, arcs, block labels, with pan/zoom and click-to-add. -use femm_doc_mag::FemmDoc; +use femm_doc_mag::{FemmDoc, LengthUnit}; use femm_doc_mag::ans::MagSolution; use femm_doc_mag::mesh::Mesh; use iced::widget::canvas::{ @@ -12,13 +12,14 @@ const PADDING_PX: f32 = 24.0; const NODE_RADIUS: f32 = 3.0; const STROKE_WIDTH: f32 = 1.2; const LABEL_TICK_PX: f32 = 6.0; -const ZOOM_STEP: f32 = 1.1; -const ZOOM_MIN: f32 = 0.05; -const ZOOM_MAX: f32 = 200.0; +const ZOOM_STEP: f32 = 1.1; +pub const ZOOM_MIN: f32 = 0.05; +pub const ZOOM_MAX: f32 = 200.0; const CLICK_DRAG_THRESHOLD_PX: f32 = 4.0; -const BG: Color = Color::WHITE; -const GEOM: Color = Color::BLACK; -const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85); +const PICK_RADIUS_PX: f32 = 10.0; +const MARQUEE_FILL: Color = Color::from_rgba(0.45, 0.65, 0.95, 0.12); +const MARQUEE_STROKE_COLOR: Color = Color::from_rgba(0.45, 0.65, 0.95, 0.9); +const LABEL_COLOR: Color = Color::from_rgb(0.45, 0.65, 0.95); const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30); const SELECT_COLOR: Color = Color::from_rgb(0.95, 0.20, 0.20); const SELECT_STROKE: f32 = 2.4; @@ -28,6 +29,9 @@ const FLUX_LINE_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.85); const FLUX_LINE_STROKE: f32 = 0.6; const BAND_COUNT: usize = 20; const FLUX_LINE_COUNT: usize = 19; +const GRID_COLOR: Color = Color::from_rgba(0.5, 0.5, 0.5, 0.25); +const GRID_STROKE: f32 = 0.5; +const GRID_MIN_PX_SPACING: f32 = 4.0; /// field-plot mode applied on top of the FE solution. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -49,29 +53,60 @@ pub enum Tool { AddSegment, } +/// composition mode for a pick action, derived from modifier keys at click/release time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PickOp { + Replace, + Add, + Toggle, +} + +/// restricts the entity-kind set considered by a pick. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PickRestrict { + Any, + NodesOnly, +} + /// messages emitted by the canvas back to the app. #[derive(Debug, Clone)] pub enum CanvasMessage { - /// click at a doc-world coordinate. + /// click at a doc-world coordinate under a non-Select tool. Click { world: (f64, f64), tool: Tool }, /// two-point segment request from the canvas. SegmentBetween { from: (f64, f64), to: (f64, f64) }, - /// right-click on Select mode, toggling the closest entity to the given world point. - TogglePickAt { world: (f64, f64) }, + /// left-click selection at a world point with modifier-derived op and restrict. + PickAt { world: (f64, f64), pick_radius_world: f64, op: PickOp, restrict: PickRestrict }, + /// left-drag marquee selection between two world points with modifier-derived op and restrict. + PickRect { p0: (f64, f64), p1: (f64, f64), op: PickOp, restrict: PickRestrict }, /// Delete key in Select mode, removing every selected entity from the doc. DeleteSelected, + /// drag delta in canvas pixels accumulating into app-side pan. + PanBy { dx: f32, dy: f32 }, + /// wheel-zoom factor focused on a canvas-pixel point. + ZoomAt { factor: f32, focus: Point }, + /// resets pan and zoom to the natural fit. + ResetView, } -/// pan offset, zoom factor, and click-gesture bookkeeping for the canvas. +/// pan offset and zoom factor owned by the app shell. #[derive(Debug, Default, Clone, Copy)] pub struct ViewState { - pan: Vector, - zoom: f32, - drag_origin: Option, - press_origin: Option, - dragged: bool, + pub pan: Vector, + pub zoom: f32, +} + +/// canvas-local click, drag, marquee, and modifier bookkeeping. +#[derive(Debug, Default, Clone, Copy)] +pub struct CanvasState { + pan_drag_from: Option, + marquee_from: Option, + press_origin: Option, + dragged: bool, pending_segment_start: Option<(f64, f64)>, - cursor_world: Option<(f64, f64)>, + cursor_world: Option<(f64, f64)>, + cursor_canvas: Option, + modifiers: iced::keyboard::Modifiers, } /// constructs the canvas widget for a doc reference, optional mesh overlay, and optional solution. @@ -81,8 +116,10 @@ pub fn view<'a>( mesh: Option<&'a Mesh>, solution: Option<&'a MagSolution>, render_mode: RenderMode, + view_state: ViewState, + show_grid: bool, ) -> Element<'a, CanvasMessage> { - Canvas::new(DocCanvas { doc, tool, mesh, solution, render_mode }) + Canvas::new(DocCanvas { doc, tool, mesh, solution, render_mode, view_state, show_grid }) .width(Length::Fill) .height(Length::Fill) .into() @@ -94,10 +131,12 @@ struct DocCanvas<'a> { mesh: Option<&'a Mesh>, solution: Option<&'a MagSolution>, render_mode: RenderMode, + view_state: ViewState, + show_grid: bool, } impl<'a> canvas::Program for DocCanvas<'a> { - type State = ViewState; + type State = CanvasState; fn update( &self, @@ -107,36 +146,63 @@ impl<'a> canvas::Program for DocCanvas<'a> { cursor: mouse::Cursor, ) -> Option> { match event { + Event::Keyboard(iced::keyboard::Event::ModifiersChanged(m)) => { + state.modifiers = *m; + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(p) = cursor.position_in(bounds) { + state.pan_drag_from = Some(p); + return Some(Action::capture()); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { + state.pan_drag_from = None; + return Some(Action::capture()); + } Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if let Some(p) = cursor.position_in(bounds) { state.press_origin = Some(p); state.dragged = false; if self.tool == Tool::Select { - state.drag_origin = Some(p); + state.marquee_from = Some(p); } return Some(Action::capture()); } } - Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { - if self.tool == Tool::Select { - if let Some(now) = cursor.position_in(bounds) { - let view = ViewTransform::fit(self.doc, bounds, state); - let world = view.inverse_map(now); - return Some(Action::publish(CanvasMessage::TogglePickAt { world }) - .and_capture()); - } - } - } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let press = state.press_origin.take(); - state.drag_origin = None; + let marquee_start = state.marquee_from.take(); let was_dragged = std::mem::take(&mut state.dragged); - if !was_dragged && self.tool != Tool::Select { - if let (Some(start), Some(now)) = (press, cursor.position_in(bounds)) { + let now_opt = cursor.position_in(bounds); + + if self.tool == Tool::Select { + if let (Some(start), Some(now)) = (marquee_start, now_opt) { + let view = ViewTransform::fit(self.doc, bounds, &self.view_state); + let op = pick_op_from(state.modifiers); + let restrict = pick_restrict_from(state.modifiers); + if was_dragged { + let p0 = view.inverse_map(start); + let p1 = view.inverse_map(now); + return Some(Action::publish(CanvasMessage::PickRect { + p0, p1, op, restrict, + }).and_capture()); + } else { + let world = view.inverse_map(now); + let pick_radius_world = (PICK_RADIUS_PX as f64) / view.scale.max(1e-9); + return Some(Action::publish(CanvasMessage::PickAt { + world, pick_radius_world, op, restrict, + }).and_capture()); + } + } + return Some(Action::capture()); + } + + if !was_dragged { + if let (Some(start), Some(now)) = (press, now_opt) { if (now.x - start.x).abs() < CLICK_DRAG_THRESHOLD_PX && (now.y - start.y).abs() < CLICK_DRAG_THRESHOLD_PX { - let view = ViewTransform::fit(self.doc, bounds, state); + let view = ViewTransform::fit(self.doc, bounds, &self.view_state); let world = view.inverse_map(now); if self.tool == Tool::AddSegment { if let Some(from) = state.pending_segment_start.take() { @@ -160,16 +226,26 @@ impl<'a> canvas::Program for DocCanvas<'a> { } Event::Mouse(mouse::Event::CursorMoved { .. }) => { if let Some(now) = cursor.position_in(bounds) { - let view = ViewTransform::fit(self.doc, bounds, state); + let view = ViewTransform::fit(self.doc, bounds, &self.view_state); state.cursor_world = Some(view.inverse_map(now)); + state.cursor_canvas = Some(now); } else { state.cursor_world = None; + state.cursor_canvas = None; } - if let (Some(prev), Some(now)) = (state.drag_origin, cursor.position_in(bounds)) { - state.pan = state.pan + Vector::new(now.x - prev.x, now.y - prev.y); - state.drag_origin = Some(now); - state.dragged = true; - return Some(Action::request_redraw().and_capture()); + if let (Some(prev), Some(now)) = (state.pan_drag_from, cursor.position_in(bounds)) { + let dx = now.x - prev.x; + let dy = now.y - prev.y; + state.pan_drag_from = Some(now); + return Some(Action::publish(CanvasMessage::PanBy { dx, dy }).and_capture()); + } + if let (Some(start), Some(now)) = (state.marquee_from, cursor.position_in(bounds)) { + if (now.x - start.x).abs() > CLICK_DRAG_THRESHOLD_PX + || (now.y - start.y).abs() > CLICK_DRAG_THRESHOLD_PX + { + state.dragged = true; + return Some(Action::request_redraw()); + } } if let (Some(start), Some(now)) = (state.press_origin, cursor.position_in(bounds)) { if (now.x - start.x).abs() > CLICK_DRAG_THRESHOLD_PX @@ -188,21 +264,15 @@ impl<'a> canvas::Program for DocCanvas<'a> { mouse::ScrollDelta::Lines { y, .. } => *y, mouse::ScrollDelta::Pixels { y, .. } => *y / 40.0, }; - if state.zoom == 0.0 { state.zoom = 1.0; } - let prev = state.zoom; - let next = (prev * ZOOM_STEP.powf(lines)).clamp(ZOOM_MIN, ZOOM_MAX); - let factor = next / prev; - state.pan.x = focus.x - factor * (focus.x - state.pan.x); - state.pan.y = focus.y - factor * (focus.y - state.pan.y); - state.zoom = next; - return Some(Action::request_redraw().and_capture()); + let factor = ZOOM_STEP.powf(lines); + return Some(Action::publish(CanvasMessage::ZoomAt { factor, focus }).and_capture()); } } Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. }) => { if let iced::keyboard::Key::Character(c) = key { if c.as_str().eq_ignore_ascii_case("r") { - *state = ViewState::default(); - return Some(Action::request_redraw()); + *state = CanvasState::default(); + return Some(Action::publish(CanvasMessage::ResetView)); } } if matches!( @@ -232,14 +302,21 @@ impl<'a> canvas::Program for DocCanvas<'a> { &self, state: &Self::State, renderer: &Renderer, - _theme: &Theme, + theme: &Theme, bounds: Rectangle, _cursor: mouse::Cursor, ) -> Vec> { + let palette = theme.palette(); + let bg = palette.background; + let geom = palette.text; let mut frame = Frame::new(renderer, bounds.size()); - frame.fill_rectangle(Point::ORIGIN, bounds.size(), BG); + frame.fill_rectangle(Point::ORIGIN, bounds.size(), bg); - let view = ViewTransform::fit(self.doc, bounds, state); + let view = ViewTransform::fit(self.doc, bounds, &self.view_state); + + if self.show_grid { + draw_grid(&mut frame, bounds, &view, self.doc.length_units); + } if let Some(sol) = self.solution { let (lo, hi) = sol.b_magnitude_range(); @@ -319,7 +396,7 @@ impl<'a> canvas::Program for DocCanvas<'a> { let (color, width) = if s.selected { (SELECT_COLOR, SELECT_STROKE) } else { - (GEOM, STROKE_WIDTH) + (geom, STROKE_WIDTH) }; frame.stroke(&Path::line(a, b), Stroke::default().with_width(width).with_color(color)); @@ -333,7 +410,7 @@ impl<'a> canvas::Program for DocCanvas<'a> { let (color, width) = if a.selected { (SELECT_COLOR, SELECT_STROKE) } else { - (GEOM, STROKE_WIDTH) + (geom, STROKE_WIDTH) }; if let Some((center, radius, start_angle, end_angle)) = arc_geometry(p0.x, p0.y, p1.x, p1.y, a.arc_length, a.normal_direction) @@ -363,7 +440,7 @@ impl<'a> canvas::Program for DocCanvas<'a> { let (color, r) = if n.selected { (SELECT_COLOR, NODE_RADIUS + 2.0) } else { - (GEOM, NODE_RADIUS) + (geom, NODE_RADIUS) }; frame.fill(&Path::circle(p, r), color); } @@ -406,6 +483,18 @@ impl<'a> canvas::Program for DocCanvas<'a> { }); } + if let (Some(start), true, Some(now)) = + (state.marquee_from, state.dragged, state.cursor_canvas) + { + let xmin = start.x.min(now.x); + let ymin = start.y.min(now.y); + let w = (start.x - now.x).abs(); + let h = (start.y - now.y).abs(); + let rect = Path::rectangle(Point::new(xmin, ymin), iced::Size::new(w, h)); + frame.fill(&rect, MARQUEE_FILL); + frame.stroke(&rect, Stroke::default().with_width(1.0).with_color(MARQUEE_STROKE_COLOR)); + } + vec![frame.into_geometry()] } @@ -415,9 +504,12 @@ impl<'a> canvas::Program for DocCanvas<'a> { bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { - if state.drag_origin.is_some() { + if state.pan_drag_from.is_some() { return mouse::Interaction::Grabbing; } + if state.marquee_from.is_some() && state.dragged { + return mouse::Interaction::Crosshair; + } if cursor.position_in(bounds).is_some() { return match self.tool { Tool::Select => mouse::Interaction::Grab, @@ -467,7 +559,7 @@ impl ViewTransform { Point::new(px, py) } - /// inverse of [`map`]: converts a canvas pixel back to a doc-world coordinate. + /// converts a canvas pixel back into a doc-world coordinate. fn inverse_map(&self, p: Point) -> (f64, f64) { let x = (p.x - self.offset.x) as f64 / self.scale; let y = self.y_max - (p.y - self.offset.y) as f64 / self.scale; @@ -498,6 +590,18 @@ fn doc_bounds(doc: &FemmDoc) -> (f64, f64, f64, f64) { } /// maps a normalized t in [0, 1] onto the jet colormap (blue, cyan, green, yellow, red). +/// derives the pick composition mode from a modifier snapshot. +fn pick_op_from(m: iced::keyboard::Modifiers) -> PickOp { + if m.shift() { PickOp::Add } + else if m.command() { PickOp::Toggle } + else { PickOp::Replace } +} + +/// derives the pick-restrict mode from a modifier snapshot. +fn pick_restrict_from(m: iced::keyboard::Modifiers) -> PickRestrict { + if m.alt() { PickRestrict::NodesOnly } else { PickRestrict::Any } +} + fn jet(t: f32) -> Color { let t = t.clamp(0.0, 1.0); let (r, g, b) = if t < 0.25 { @@ -516,8 +620,55 @@ fn jet(t: f32) -> Color { Color::from_rgb(r, g, b) } -/// derives center, radius, and angular extent of an arc segment from its endpoints -/// and degree sweep. returns None when endpoints coincide. +/// returns the world-space distance equal to one centimetre under the given length unit. +pub fn grid_step_for_unit(unit: LengthUnit) -> f64 { + match unit { + LengthUnit::Inches => 1.0 / 2.54, + LengthUnit::Millimeters => 10.0, + LengthUnit::Centimeters => 1.0, + LengthUnit::Meters => 0.01, + LengthUnit::Mils => 1000.0 / 2.54, + LengthUnit::Microns => 10_000.0, + } +} + +/// rounds a world-space coordinate to the nearest 1-cm grid step. +pub fn snap_world(x: f64, unit: LengthUnit) -> f64 { + let step = grid_step_for_unit(unit); + if step <= 0.0 { x } else { (x / step).round() * step } +} + +/// renders a faint 1-cm grid under the field plot. +fn draw_grid(frame: &mut Frame, bounds: Rectangle, view: &ViewTransform, unit: LengthUnit) { + let step = grid_step_for_unit(unit); + if step <= 0.0 { return; } + let px_per_step = (step * view.scale) as f32; + if px_per_step < GRID_MIN_PX_SPACING { return; } + + let (x0_world, y1_world) = view.inverse_map(Point::new(0.0, 0.0)); + let (x1_world, y0_world) = view.inverse_map(Point::new(bounds.width, bounds.height)); + let stroke = Stroke::default().with_width(GRID_STROKE).with_color(GRID_COLOR); + + let i_start = (x0_world / step).floor() as i32; + let i_end = (x1_world / step).ceil() as i32; + for i in i_start..=i_end { + let wx = i as f64 * step; + let top = view.map(wx, y1_world); + let bot = view.map(wx, y0_world); + frame.stroke(&Path::line(top, bot), stroke.clone()); + } + + let j_start = (y0_world / step).floor() as i32; + let j_end = (y1_world / step).ceil() as i32; + for j in j_start..=j_end { + let wy = j as f64 * step; + let left = view.map(x0_world, wy); + let right = view.map(x1_world, wy); + frame.stroke(&Path::line(left, right), stroke.clone()); + } +} + +/// derives center, radius, and angular extent of an arc segment from its endpoints and degree sweep, returning None on coincident endpoints. fn arc_geometry( x0: f64, y0: f64, x1: f64, y1: f64, diff --git a/crates/femm-app/src/kinematic.rs b/crates/femm-app/src/kinematic.rs new file mode 100644 index 0000000..c3ce8ab --- /dev/null +++ b/crates/femm-app/src/kinematic.rs @@ -0,0 +1,154 @@ +//! continuous-time displacement engine. a Track binds a clamped chord, an axis, a member-node set, and an f(s, t) expression evaluated each tick into a transverse displacement. + +use femm_doc_mag::FemmDoc; + +/// direction of transverse displacement applied to every tracked node. +#[derive(Debug, Clone, Copy)] +pub enum Axis { + PlusX, + MinusX, + PlusY, + MinusY, +} + +impl Axis { + /// returns the unit vector matching the axis variant. + pub fn unit(self) -> (f64, f64) { + match self { + Axis::PlusX => ( 1.0, 0.0), + Axis::MinusX => (-1.0, 0.0), + Axis::PlusY => ( 0.0, 1.0), + Axis::MinusY => ( 0.0, -1.0), + } + } +} + +/// parsed expression in chord parameter s and real time t, evaluable to a displacement. +pub struct Expression { + pub source: String, + expr: meval::Expr, +} + +impl std::fmt::Debug for Expression { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Expression({:?})", self.source) + } +} + +impl Clone for Expression { + fn clone(&self) -> Self { + Expression::parse(&self.source).unwrap_or_else(|_| Expression::zero()) + } +} + +impl Expression { + /// parses an arithmetic expression in chord parameter s and real time t in seconds. + pub fn parse(text: &str) -> Result { + let expr: meval::Expr = text.parse().map_err(|e: meval::Error| e.to_string())?; + Ok(Expression { source: text.to_string(), expr }) + } + + /// returns a constant zero expression. + pub fn zero() -> Self { + let expr: meval::Expr = "0".parse().expect("constant 0 parses"); + Expression { source: String::from("0"), expr } + } + + /// evaluates the expression with bindings for s and t. + pub fn eval(&self, s: f64, t: f64) -> f64 { + let mut ctx = meval::Context::new(); + ctx.var("s", s); + ctx.var("t", t); + self.expr.eval_with_context(ctx).unwrap_or(0.0) + } +} + +/// one continuous-time track: clamped chord, axis, member nodes, and the closed-form displacement expression. +#[derive(Debug, Clone)] +pub struct Track { + pub label: String, + pub anchor_a: usize, + pub anchor_b: usize, + pub member_nodes: Vec, + pub axis: Axis, + pub expression: Expression, +} + +/// projects a node onto the anchor_a-to-anchor_b chord, returning a parameter clamped to [0, 1]. +pub fn chord_parameter(base: &FemmDoc, anchor_a: usize, anchor_b: usize, node_idx: usize) -> f64 { + let (Some(a), Some(b), Some(n)) = ( + base.nodes.get(anchor_a), + base.nodes.get(anchor_b), + base.nodes.get(node_idx), + ) else { return 0.0 }; + let dx = b.x - a.x; + let dy = b.y - a.y; + let len2 = dx * dx + dy * dy; + if len2 < 1e-18 { return 0.0; } + let s = ((n.x - a.x) * dx + (n.y - a.y) * dy) / len2; + s.clamp(0.0, 1.0) +} + +/// resets every node position on doc from base, layering each track's evaluated displacement at simulated time t seconds. +pub fn apply_tracks(doc: &mut FemmDoc, base: &FemmDoc, tracks: &[Track], t: f64) { + for (i, n) in doc.nodes.iter_mut().enumerate() { + if let Some(b) = base.nodes.get(i) { + n.x = b.x; + n.y = b.y; + } + } + for track in tracks { + let (ux, uy) = track.axis.unit(); + for &node_idx in &track.member_nodes { + let s = chord_parameter(base, track.anchor_a, track.anchor_b, node_idx); + let delta = track.expression.eval(s, t); + if let Some(n) = doc.nodes.get_mut(node_idx) { + n.x += ux * delta; + n.y += uy * delta; + } + } + } +} + +/// builds one track from selected nodes, picking anchors as the two extremes along the chord axis and treating the rest as members. +pub fn track_from_selection( + doc: &FemmDoc, + selected: &[usize], + axis: Axis, + default_expression: &str, + label: String, +) -> Result { + if selected.len() < 3 { + return Err(String::from("track needs at least three selected nodes: two anchors plus one member")); + } + let chord_axis_x = matches!(axis, Axis::PlusY | Axis::MinusY); + let key = |i: usize| -> Option { + let n = doc.nodes.get(i)?; + Some(if chord_axis_x { n.x } else { n.y }) + }; + let mut min_idx = selected[0]; + let mut max_idx = selected[0]; + let mut min_v = key(min_idx).ok_or_else(|| String::from("invalid selected node index"))?; + let mut max_v = min_v; + for &i in selected { + let v = key(i).ok_or_else(|| String::from("invalid selected node index"))?; + if v < min_v { min_v = v; min_idx = i; } + if v > max_v { max_v = v; max_idx = i; } + } + if (max_v - min_v).abs() < 1e-12 { + return Err(String::from("selected nodes are collinear with the chosen axis - cannot pick anchors")); + } + let members: Vec = selected.iter().copied() + .filter(|&i| i != min_idx && i != max_idx) + .collect(); + let expression = Expression::parse(default_expression) + .map_err(|e| format!("default expression failed to parse: {e}"))?; + Ok(Track { + label, + anchor_a: min_idx, + anchor_b: max_idx, + member_nodes: members, + axis, + expression, + }) +} diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index d5d016b..ce67648 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -1,28 +1,106 @@ //! iced shell entry point for the FEMM 4.2 port. mod doc_canvas; +mod kinematic; +mod spice; -use doc_canvas::{CanvasMessage, RenderMode, Tool}; -use femm_doc_mag::FemmDoc; +use doc_canvas::{CanvasMessage, PickOp, PickRestrict, RenderMode, Tool, ViewState}; +use femm_doc_mag::{ArcSegment, BlockLabel, FemmDoc, Node, Segment}; use femm_doc_mag::ans::MagSolution; use femm_doc_mag::mesh::Mesh; -use iced::widget::{button, column, container, row, text}; -use iced::{Element, Length, Task}; -use std::ffi::CString; -use std::path::Path; +use iced::widget::{button, column, container, row, scrollable, svg, text, text_input, tooltip}; +use iced::{Alignment, Background, Border, Color, Element, Length, Point, Subscription, Task, Theme, Vector, clipboard, time}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const ICON_FILE_OPEN: &[u8] = include_bytes!("../../../assets/icons/file-open.svg"); +const ICON_FILE_NEW: &[u8] = include_bytes!("../../../assets/icons/file-new.svg"); +const ICON_FILE_SAVE: &[u8] = include_bytes!("../../../assets/icons/file-save.svg"); +const ICON_FILE_SAVE_AS: &[u8] = include_bytes!("../../../assets/icons/file-save-as.svg"); +const ICON_TOOL_SELECT: &[u8] = include_bytes!("../../../assets/icons/tool-select.svg"); +const ICON_TOOL_NODE: &[u8] = include_bytes!("../../../assets/icons/tool-add-node.svg"); +const ICON_TOOL_SEGMENT: &[u8] = include_bytes!("../../../assets/icons/tool-add-segment.svg"); +const ICON_TOOL_LABEL: &[u8] = include_bytes!("../../../assets/icons/tool-add-label.svg"); +const ICON_EDIT_DELETE: &[u8] = include_bytes!("../../../assets/icons/edit-delete.svg"); +const ICON_EDIT_COPY: &[u8] = include_bytes!("../../../assets/icons/edit-copy.svg"); +const ICON_ZOOM_IN: &[u8] = include_bytes!("../../../assets/icons/view-zoom-in.svg"); +const ICON_ZOOM_OUT: &[u8] = include_bytes!("../../../assets/icons/view-zoom-out.svg"); +const ICON_ZOOM_FIT: &[u8] = include_bytes!("../../../assets/icons/view-zoom-fit.svg"); +const ICON_ZOOM_WINDOW: &[u8] = include_bytes!("../../../assets/icons/view-zoom-window.svg"); +const ICON_GRID: &[u8] = include_bytes!("../../../assets/icons/view-grid.svg"); +const ICON_GRID_SNAP: &[u8] = include_bytes!("../../../assets/icons/view-grid-snap.svg"); +const ICON_PX: f32 = 28.0; const DEMO_FEM: &str = include_str!("../assets/brgmodel.fem"); const ADD_TOLERANCE: f64 = 0.5; const MIN_ANGLE_DEG: f64 = 30.0; +const SIM_DEFAULT_DT_S: f64 = 1.0e-4; +const SIM_DEFAULT_INTERVAL_S: f64 = 0.05; +const SIM_DEFAULT_SUBDIVISIONS: usize = 20; +const SIM_DEFAULT_EXPRESSION: &str = "0.5 * sin(pi*s) * cos(2*pi*82.41*t)"; + #[derive(Debug, Clone)] enum Message { OpenFem, + NewDoc, + SaveDoc, + SaveDocAs, + CopySelection, + ZoomIn, + ZoomOut, + ZoomFit, + ToggleGrid, + ToggleSnap, SelectTool(Tool), Canvas(CanvasMessage), RunMesh, RunAnalyze, SetRenderMode(RenderMode), + CopyError, + DismissError, + SimAddTrackFromSelection, + SimRemoveTrack(usize), + SimSelectTrack(usize), + SimEditExpressionText(String), + SimSubmitExpression, + SimSetDtText(String), + SimSubmitDt, + SimSetIntervalText(String), + SimSubmitInterval, + SimSetSubdivisionsText(String), + SimSubmitSubdivisions, + SimToggleRun, + SimTick, + SimResetTime, + SimClear, +} + +/// copyable diagnostic shown when a pipeline stage fails. +#[derive(Debug, Clone)] +struct ErrorReport { + title: String, + body: String, +} + +/// continuous-time simulation: captured base doc, per-track displacement expressions, SPICE-controlled step, live mag-solution at t_now. +struct Simulation { + base_doc: FemmDoc, + tracks: Vec, + t_now: f64, + dt: f64, + wall_interval: Duration, + running: bool, + computing: bool, + current: Option, + last_solve_ms: u128, + selected_track: Option, + expression_text: String, + dt_text: String, + interval_text: String, + subdivisions: usize, + subdivisions_text: String, } struct App { @@ -30,9 +108,15 @@ struct App { mesh: Option, solution: Option, source_label: String, + source_path: Option, status: String, + error: Option, tool: Tool, render_mode: RenderMode, + simulation: Option, + view_state: ViewState, + show_grid: bool, + snap_to_grid: bool, } impl App { @@ -43,9 +127,15 @@ impl App { mesh: None, solution: None, source_label: String::from("brgmodel.fem (embedded)"), + source_path: None, status: String::new(), + error: None, tool: Tool::Select, render_mode: RenderMode::default(), + simulation: None, + view_state: ViewState::default(), + show_grid: false, + snap_to_grid: false, }; (app, Task::none()) } @@ -54,6 +144,16 @@ impl App { format!("femm42 - {}", self.source_label) } + /// rounds a click world coordinate to the 1-cm grid step under snap-to-grid mode. + fn maybe_snap(&self, w: (f64, f64)) -> (f64, f64) { + if self.snap_to_grid { + let u = self.doc.length_units; + (doc_canvas::snap_world(w.0, u), doc_canvas::snap_world(w.1, u)) + } else { + w + } + } + fn update(&mut self, msg: Message) -> Task { match msg { Message::OpenFem => { @@ -74,6 +174,7 @@ impl App { self.mesh = None; self.solution = None; self.source_label = label; + self.source_path = Some(path); self.status = String::new(); } Err(e) => { @@ -82,35 +183,136 @@ impl App { } } } + Message::NewDoc => { + self.doc = FemmDoc::default(); + self.mesh = None; + self.solution = None; + self.source_label = String::from("untitled"); + self.source_path = None; + self.error = None; + self.status = String::from("new doc"); + } + Message::SaveDoc => { + if let Some(path) = self.source_path.clone() { + match self.doc.save(&path) { + Ok(()) => self.status = format!("saved: {}", path.display()), + Err(e) => self.error = Some(ErrorReport { + title: String::from("save .fem failed"), + body: format!("{path:?}\n\n{e}"), + }), + } + } else { + return Task::done(Message::SaveDocAs); + } + } + Message::SaveDocAs => { + let picked = rfd::FileDialog::new() + .add_filter("FEMM magnetostatic", &["fem"]) + .save_file(); + if let Some(path) = picked { + match self.doc.save(&path) { + Ok(()) => { + let label = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + self.status = format!("saved: {}", path.display()); + self.source_label = label; + self.source_path = Some(path); + } + Err(e) => self.error = Some(ErrorReport { + title: String::from("save .fem failed"), + body: format!("{path:?}\n\n{e}"), + }), + } + } + } + Message::CopySelection => { + let added = duplicate_selection(&mut self.doc); + if added == 0 { + self.status = String::from("nothing selected to copy"); + } else { + self.mesh = None; + self.solution = None; + self.status = format!("duplicated {added} entities, offset +10mm"); + } + } + Message::ZoomIn => { + let z = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; + self.view_state.zoom = (z * 1.25).clamp(doc_canvas::ZOOM_MIN, doc_canvas::ZOOM_MAX); + } + Message::ZoomOut => { + let z = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; + self.view_state.zoom = (z * 0.8).clamp(doc_canvas::ZOOM_MIN, doc_canvas::ZOOM_MAX); + } + Message::ZoomFit => { + self.view_state = ViewState::default(); + } + Message::ToggleGrid => { + self.show_grid = !self.show_grid; + self.status = format!("grid: {}", if self.show_grid { "on" } else { "off" }); + } + Message::ToggleSnap => { + self.snap_to_grid = !self.snap_to_grid; + self.status = format!("snap: {}", if self.snap_to_grid { "on" } else { "off" }); + } Message::SelectTool(t) => { self.tool = t; } Message::RunMesh => { - match run_mesh(&self.doc) { - Ok(m) => { + 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()); - self.mesh = Some(m); + self.error = None; + self.mesh = Some(m); } - Err(e) => { + Ok(Err(report)) => { self.mesh = None; self.solution = None; - self.status = format!("mesh failed: {e}"); + self.status = format!("mesh failed: {}", report.title); + self.error = Some(report); + } + Err(payload) => { + self.mesh = None; + self.solution = None; + self.status = String::from("mesh panicked"); + self.error = Some(ErrorReport { + title: String::from("mesh stage panicked"), + body: panic_payload_text(&payload), + }); } } } Message::RunAnalyze => { self.solution = None; - match run_mesh(&self.doc) { - Ok(m) => self.mesh = Some(m), - Err(e) => { + let mesh_outcome = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc))); + match mesh_outcome { + Ok(Ok(m)) => { + self.mesh = Some(m); + self.error = None; + } + Ok(Err(report)) => { self.mesh = None; - self.status = format!("mesh failed: {e}"); + self.status = format!("mesh failed: {}", report.title); + self.error = Some(report); + return Task::none(); + } + Err(payload) => { + self.mesh = None; + self.status = String::from("mesh panicked"); + self.error = Some(ErrorReport { + title: String::from("mesh stage panicked"), + body: panic_payload_text(&payload), + }); return Task::none(); } } let stem = active_stem(); - if let Err(e) = run_solve(&stem) { - self.status = format!("solve failed: {e}"); + if let Err(report) = run_solve(&stem) { + self.status = format!("solve failed: {}", report.title); + self.error = Some(report); return Task::none(); } let ans_path = stem.with_extension("ans"); @@ -120,14 +322,29 @@ impl App { "solved: {} mesh nodes, {} elements", sol.mesh_nodes.len(), sol.mesh_elements.len(), ); + self.error = None; self.solution = Some(sol); } Err(e) => { - self.status = format!("read .ans failed: {e}"); + self.status = String::from("read .ans failed"); + self.error = Some(ErrorReport { + title: String::from("read .ans failed"), + body: format!("{ans_path:?}\n\n{e}"), + }); } } } + Message::CopyError => { + if let Some(report) = &self.error { + let text = format!("[{}]\n\n{}", report.title, report.body); + return clipboard::write(text); + } + } + Message::DismissError => { + self.error = None; + } Message::Canvas(CanvasMessage::Click { world, tool }) => { + let world = self.maybe_snap(world); match tool { Tool::AddNode => { let idx = self.doc.add_node(world.0, world.1, ADD_TOLERANCE); @@ -145,6 +362,8 @@ impl App { } } Message::Canvas(CanvasMessage::SegmentBetween { from, to }) => { + let from = self.maybe_snap(from); + let to = self.maybe_snap(to); let n0 = self.doc.add_node(from.0, from.1, ADD_TOLERANCE) as i32; let n1 = self.doc.add_node(to.0, to.1, ADD_TOLERANCE) as i32; if self.doc.add_segment(n0, n1) { @@ -157,13 +376,287 @@ impl App { self.status = format!("rejected segment {n0} -> {n1}"); } } - Message::Canvas(CanvasMessage::TogglePickAt { world }) => { - toggle_closest(&mut self.doc, world.0, world.1); - self.status = String::from("toggled selection at right-click"); + Message::Canvas(CanvasMessage::PanBy { dx, dy }) => { + self.view_state.pan = self.view_state.pan + Vector::new(dx, dy); + } + Message::Canvas(CanvasMessage::ZoomAt { factor, focus }) => { + let prev = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; + let next = (prev * factor).clamp(doc_canvas::ZOOM_MIN, doc_canvas::ZOOM_MAX); + let real = next / prev; + self.view_state.pan.x = focus.x - real * (focus.x - self.view_state.pan.x); + self.view_state.pan.y = focus.y - real * (focus.y - self.view_state.pan.y); + self.view_state.zoom = next; + } + Message::Canvas(CanvasMessage::ResetView) => { + self.view_state = ViewState::default(); + } + Message::Canvas(CanvasMessage::PickAt { world, pick_radius_world, op, restrict }) => { + let summary = apply_pick_at(&mut self.doc, world.0, world.1, pick_radius_world, op, restrict); + self.status = summary; + } + Message::Canvas(CanvasMessage::PickRect { p0, p1, op, restrict }) => { + let summary = apply_pick_rect(&mut self.doc, p0, p1, op, restrict); + self.status = summary; } Message::SetRenderMode(mode) => { self.render_mode = mode; } + Message::SimAddTrackFromSelection => { + let selected: Vec = self.doc.nodes.iter().enumerate() + .filter_map(|(i, n)| if n.selected { Some(i) } else { None }) + .collect(); + if selected.is_empty() { + self.error = Some(ErrorReport { + title: String::from("no nodes selected"), + body: String::from("select at least 3 nodes: two anchors and one or more members the track will displace."), + }); + } else { + let subdivisions = self.simulation.as_ref() + .map(|s| s.subdivisions) + .unwrap_or(SIM_DEFAULT_SUBDIVISIONS); + let expr_text = self.simulation.as_ref() + .map(|s| s.expression_text.clone()) + .unwrap_or_else(|| String::from(SIM_DEFAULT_EXPRESSION)); + // subdivides every selected segment with both endpoints selected, producing interior nodes for chord displacement. + let seg_indices: Vec = self.doc.segments.iter().enumerate() + .filter_map(|(i, s)| { + if self.doc.nodes.get(s.n0 as usize).map(|n| n.selected).unwrap_or(false) + && self.doc.nodes.get(s.n1 as usize).map(|n| n.selected).unwrap_or(false) { + Some(i) + } else { None } + }) + .collect(); + let mut all_members: Vec = selected.clone(); + let mut to_subdivide = seg_indices; + to_subdivide.sort_by(|a, b| b.cmp(a)); + for idx in to_subdivide { + let new_nodes = self.doc.subdivide_segment(idx, subdivisions); + for n in new_nodes { all_members.push(n as usize); } + } + let label = format!("track {}", self.simulation.as_ref().map(|s| s.tracks.len() + 1).unwrap_or(1)); + match kinematic::track_from_selection(&self.doc, &all_members, kinematic::Axis::PlusY, &expr_text, label) { + Ok(track) => { + if self.simulation.is_none() { + self.simulation = Some(new_simulation(self.doc.clone(), expr_text)); + } + let sim = self.simulation.as_mut().unwrap(); + sim.base_doc = self.doc.clone(); + sim.tracks.push(track); + sim.selected_track = Some(sim.tracks.len() - 1); + self.status = format!("track added: {} members; {} tracks active", + sim.tracks.last().map(|t| t.member_nodes.len()).unwrap_or(0), + sim.tracks.len()); + } + Err(e) => { + self.error = Some(ErrorReport { + title: String::from("track creation failed"), + body: e, + }); + } + } + } + } + Message::SimRemoveTrack(i) => { + if let Some(sim) = self.simulation.as_mut() { + if i < sim.tracks.len() { + sim.tracks.remove(i); + sim.selected_track = if sim.tracks.is_empty() { + None + } else { + Some(i.min(sim.tracks.len() - 1)) + }; + } + } + } + Message::SimSelectTrack(i) => { + if let Some(sim) = self.simulation.as_mut() { + if let Some(t) = sim.tracks.get(i) { + sim.selected_track = Some(i); + sim.expression_text = t.expression.source.clone(); + } + } + } + Message::SimEditExpressionText(v) => { + if let Some(sim) = self.simulation.as_mut() { + sim.expression_text = v; + } + } + Message::SimSubmitExpression => { + if let Some(sim) = self.simulation.as_mut() { + if let Some(i) = sim.selected_track { + match kinematic::Expression::parse(&sim.expression_text) { + Ok(expr) => { + if let Some(t) = sim.tracks.get_mut(i) { + t.expression = expr; + self.status = format!("track {} expression updated", i + 1); + } + } + Err(e) => { + self.error = Some(ErrorReport { + title: String::from("expression parse failed"), + body: format!("{}\n\n{e}", sim.expression_text), + }); + } + } + } + } + } + Message::SimSetDtText(v) => { + if let Some(sim) = self.simulation.as_mut() { sim.dt_text = v; } + } + Message::SimSubmitDt => { + if let Some(sim) = self.simulation.as_mut() { + match spice::parse_spice(&sim.dt_text) { + Some(dt) if dt > 0.0 => { + sim.dt = dt; + sim.dt_text = spice::format_spice_time(dt); + self.status = format!("dt = {}", sim.dt_text); + } + _ => { + self.error = Some(ErrorReport { + title: String::from("dt parse failed"), + body: format!("could not read {:?} as SPICE time (try 100u, 1ms, 33n)", sim.dt_text), + }); + } + } + } + } + Message::SimSetIntervalText(v) => { + if let Some(sim) = self.simulation.as_mut() { sim.interval_text = v; } + } + 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); + self.status = format!("wall interval = {}", sim.interval_text); + } + _ => { + self.error = Some(ErrorReport { + title: String::from("interval parse failed"), + body: format!("could not read {:?} as SPICE time", sim.interval_text), + }); + } + } + } + } + Message::SimSetSubdivisionsText(v) => { + if let Some(sim) = self.simulation.as_mut() { sim.subdivisions_text = v; } + } + Message::SimSubmitSubdivisions => { + if let Some(sim) = self.simulation.as_mut() { + match sim.subdivisions_text.trim().parse::() { + Ok(n) if n >= 2 => { + sim.subdivisions = n; + self.status = format!("subdivisions per added segment = {n}"); + } + _ => { + self.error = Some(ErrorReport { + title: String::from("subdivision count invalid"), + body: format!("need an integer ≥ 2; got {:?}", sim.subdivisions_text), + }); + } + } + } + } + Message::SimToggleRun => { + if let Some(sim) = self.simulation.as_mut() { + sim.running = !sim.running; + self.status = if sim.running { + format!("running: t = {}", spice::format_spice_time(sim.t_now)) + } else { + format!("paused: t = {}", spice::format_spice_time(sim.t_now)) + }; + } + } + Message::SimTick => { + let should_step = match self.simulation.as_ref() { + Some(sim) => sim.running && !sim.computing && !sim.tracks.is_empty(), + None => false, + }; + if !should_step { return Task::none(); } + let (t_target, dt) = { + let sim = self.simulation.as_ref().unwrap(); + (sim.t_now + sim.dt, sim.dt) + }; + { + let sim = self.simulation.as_mut().unwrap(); + sim.computing = true; + kinematic::apply_tracks(&mut self.doc, &sim.base_doc, &sim.tracks, t_target); + } + let started = std::time::Instant::now(); + let mesh_result = catch_unwind(AssertUnwindSafe(|| run_mesh(&self.doc))); + match mesh_result { + Ok(Ok(m)) => { self.mesh = Some(m); } + Ok(Err(report)) => { + let sim = self.simulation.as_mut().unwrap(); + sim.computing = false; + sim.running = false; + self.error = Some(report); + self.status = format!("sim halted at t = {}", spice::format_spice_time(sim.t_now)); + return Task::none(); + } + Err(payload) => { + let sim = self.simulation.as_mut().unwrap(); + sim.computing = false; + sim.running = false; + self.error = Some(ErrorReport { + title: String::from("mesh stage panicked during sim tick"), + body: panic_payload_text(&payload), + }); + return Task::none(); + } + } + let stem = active_stem(); + if let Err(report) = run_solve(&stem) { + let sim = self.simulation.as_mut().unwrap(); + sim.computing = false; + sim.running = false; + self.error = Some(report); + return Task::none(); + } + let ans_path = stem.with_extension("ans"); + match MagSolution::open(&ans_path) { + Ok(sol) => { + let elapsed = started.elapsed().as_millis(); + let sim = self.simulation.as_mut().unwrap(); + sim.current = Some(sol); + sim.t_now = t_target; + sim.computing = false; + sim.last_solve_ms = elapsed; + self.status = format!( + "t = {} (dt = {}, last solve {} ms)", + spice::format_spice_time(sim.t_now), + spice::format_spice_time(dt), + elapsed, + ); + } + Err(e) => { + let sim = self.simulation.as_mut().unwrap(); + sim.computing = false; + sim.running = false; + self.error = Some(ErrorReport { + title: String::from("read .ans during sim tick failed"), + body: format!("{ans_path:?}\n\n{e}"), + }); + } + } + } + Message::SimResetTime => { + if let Some(sim) = self.simulation.as_mut() { + sim.t_now = 0.0; + self.doc = sim.base_doc.clone(); + self.status = String::from("t reset to 0"); + } + } + Message::SimClear => { + if let Some(sim) = self.simulation.as_ref() { + self.doc = sim.base_doc.clone(); + } + self.simulation = None; + self.status = String::from("simulation cleared"); + } Message::Canvas(CanvasMessage::DeleteSelected) => { let n = self.doc.delete_selected_nodes(); let s = self.doc.delete_selected_segments(); @@ -191,27 +684,88 @@ impl App { )) .size(12); - let toolbar = row![ - button("Open .fem...").on_press(Message::OpenFem), - tool_button("Select", Tool::Select, self.tool), - tool_button("Add Node", Tool::AddNode, self.tool), - tool_button("Add Segment", Tool::AddSegment, self.tool), - tool_button("Add Label", Tool::AddBlockLabel, self.tool), - button("Mesh").on_press(Message::RunMesh), - button("Analyze").on_press(Message::RunAnalyze), + let file_group = row![ + icon_button(ICON_FILE_NEW, "New problem", Some(Message::NewDoc)), + icon_button(ICON_FILE_OPEN, "Open .fem", Some(Message::OpenFem)), + icon_button(ICON_FILE_SAVE, "Save", Some(Message::SaveDoc)), + icon_button(ICON_FILE_SAVE_AS, "Save As", Some(Message::SaveDocAs)), + ].spacing(2); + + let tool_group = row![ + tool_icon_button(ICON_TOOL_SELECT, "Select", Tool::Select, 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), + ].spacing(2); + + let edit_group = row![ + icon_button(ICON_EDIT_COPY, "Copy selected (+10mm offset)", + Some(Message::CopySelection)), + icon_button(ICON_EDIT_DELETE, "Delete selected", + Some(Message::Canvas(CanvasMessage::DeleteSelected))), + ].spacing(2); + + let analysis_group = row![ + text_button("Mesh", Message::RunMesh), + text_button("Analyze", Message::RunAnalyze), + text_button("Track from Selection", Message::SimAddTrackFromSelection), + ].spacing(2); + + let plot_group = row![ render_mode_button("Density", RenderMode::Density, self.render_mode), render_mode_button("Contour", RenderMode::Contour, self.render_mode), + ].spacing(2); + + let toolbar = row![ + file_group, + separator(), + tool_group, + separator(), + edit_group, + separator(), + analysis_group, + separator(), + plot_group, + separator(), text(&self.source_label).size(13), stats, ] - .spacing(8); + .spacing(8) + .align_y(Alignment::Center); + + let grid_tip = if self.show_grid { "Toggle grid (on)" } else { "Toggle grid (off)" }; + let snap_tip = if self.snap_to_grid { "Toggle grid snap (on)" } else { "Toggle grid snap (off)" }; + let view_strip = column![ + icon_button(ICON_ZOOM_IN, "Zoom in", Some(Message::ZoomIn)), + icon_button(ICON_ZOOM_OUT, "Zoom out", Some(Message::ZoomOut)), + icon_button(ICON_ZOOM_FIT, "Zoom to fit", Some(Message::ZoomFit)), + icon_button(ICON_ZOOM_WINDOW, "Zoom to window (rubber-band not yet implemented)", None), + horizontal_separator(), + icon_button(ICON_GRID, grid_tip, Some(Message::ToggleGrid)), + icon_button(ICON_GRID_SNAP, snap_tip, Some(Message::ToggleSnap)), + ] + .spacing(4) + .align_x(Alignment::Center); + + let active_solution = self.simulation.as_ref() + .and_then(|s| s.current.as_ref()) + .or(self.solution.as_ref()); let canvas = doc_canvas::view( - &self.doc, self.tool, self.mesh.as_ref(), self.solution.as_ref(), self.render_mode, + &self.doc, self.tool, self.mesh.as_ref(), active_solution, self.render_mode, + self.view_state, self.show_grid, ).map(Message::Canvas); + let canvas_row = row![canvas, view_strip].spacing(6); + let mut body = column![toolbar].spacing(8).padding(12); - body = body.push(canvas); + if let Some(report) = &self.error { + body = body.push(error_panel(report)); + } + body = body.push(canvas_row); + if let Some(sim) = &self.simulation { + body = body.push(simulation_panel(sim)); + } if !self.status.is_empty() { body = body.push(text(&self.status).size(12)); } @@ -221,6 +775,155 @@ impl App { .height(Length::Fill) .into() } + + /// emits SimTick at the simulation's configured wall-clock interval. + fn subscription(&self) -> Subscription { + match &self.simulation { + Some(sim) if sim.running && !sim.computing && !sim.tracks.is_empty() => { + time::every(sim.wall_interval).map(|_| Message::SimTick) + } + _ => Subscription::none(), + } + } +} + +/// constructs an empty simulation seeded from the current doc and a default expression text. +fn new_simulation(base_doc: FemmDoc, expression_text: String) -> Simulation { + Simulation { + base_doc, + tracks: Vec::new(), + t_now: 0.0, + dt: SIM_DEFAULT_DT_S, + wall_interval: Duration::from_secs_f64(SIM_DEFAULT_INTERVAL_S), + running: false, + computing: false, + current: None, + last_solve_ms: 0, + selected_track: None, + expression_text, + dt_text: spice::format_spice_time(SIM_DEFAULT_DT_S), + interval_text: spice::format_spice_time(SIM_DEFAULT_INTERVAL_S), + subdivisions: SIM_DEFAULT_SUBDIVISIONS, + subdivisions_text: SIM_DEFAULT_SUBDIVISIONS.to_string(), + } +} + +/// renders the simulation control panel: track list, expression editor, dt/interval inputs, run/pause/reset/clear controls. +fn simulation_panel(sim: &Simulation) -> Element<'_, Message> { + let header = row![ + text(format!( + "t = {} dt = {} interval = {} tracks = {}", + spice::format_spice_time(sim.t_now), + sim.dt_text, + sim.interval_text, + sim.tracks.len(), + )).size(12).width(Length::Fill), + button(text(if sim.running { "Pause" } else { "Run" }).size(12)) + .on_press(Message::SimToggleRun).style(button::primary), + button(text("Reset t").size(12)) + .on_press(Message::SimResetTime).style(button::secondary), + button(text("Clear").size(12)) + .on_press(Message::SimClear).style(button::secondary), + ].spacing(8).align_y(Alignment::Center); + + let dt_input = text_input("dt (SPICE)", &sim.dt_text) + .on_input(Message::SimSetDtText) + .on_submit(Message::SimSubmitDt) + .size(12) + .width(Length::Fixed(140.0)); + let interval_input = text_input("wall interval", &sim.interval_text) + .on_input(Message::SimSetIntervalText) + .on_submit(Message::SimSubmitInterval) + .size(12) + .width(Length::Fixed(140.0)); + let subdiv_input = text_input("subdivisions", &sim.subdivisions_text) + .on_input(Message::SimSetSubdivisionsText) + .on_submit(Message::SimSubmitSubdivisions) + .size(12) + .width(Length::Fixed(120.0)); + + let controls = row![ + text("dt").size(12), dt_input, + text("every").size(12), interval_input, + text("subdiv").size(12), subdiv_input, + ].spacing(8).align_y(Alignment::Center); + + let mut tracks_col = column![text("tracks").size(12)].spacing(4); + for (i, t) in sim.tracks.iter().enumerate() { + let is_selected = sim.selected_track == Some(i); + let label_btn = button(text(format!( + "{} {}: anchors {}→{}, {} members", + if is_selected { "▸" } else { " " }, + t.label, t.anchor_a, t.anchor_b, t.member_nodes.len(), + )).size(11)) + .on_press(Message::SimSelectTrack(i)) + .style(if is_selected { button::primary } else { button::secondary }); + let remove_btn = button(text("×").size(11)) + .on_press(Message::SimRemoveTrack(i)) + .style(button::secondary); + tracks_col = tracks_col.push(row![label_btn, remove_btn].spacing(4)); + } + + let expr_input = text_input("f(s, t) = ...", &sim.expression_text) + .on_input(Message::SimEditExpressionText) + .on_submit(Message::SimSubmitExpression) + .size(12) + .width(Length::Fill); + let expr_row = row![ + text("f(s,t)").size(12), + expr_input, + button(text("Apply").size(12)) + .on_press(Message::SimSubmitExpression) + .style(button::secondary), + ].spacing(8).align_y(Alignment::Center); + + container(column![header, controls, expr_row, tracks_col].spacing(6).padding(8)) + .style(|t: &Theme| { + let p = t.extended_palette(); + container::Style { + background: Some(Background::Color(p.background.weak.color)), + border: Border { + color: p.primary.base.color, + width: 1.0, + radius: 4.0.into(), + }, + ..container::Style::default() + } + }) + .width(Length::Fill) + .into() +} + +/// renders a red-bordered error panel with copy and dismiss controls above the canvas. +fn error_panel(report: &ErrorReport) -> Element<'_, Message> { + let header = row![ + text(format!("error: {}", report.title)) + .size(13) + .color(Color::from_rgb(0.85, 0.15, 0.15)) + .width(Length::Fill), + button(text("Copy").size(12)).on_press(Message::CopyError), + button(text("Dismiss").size(12)).on_press(Message::DismissError), + ] + .spacing(6) + .align_y(iced::Alignment::Center); + + let body_text = text(report.body.clone()) + .size(12) + .font(iced::Font::MONOSPACE); + let body = scrollable(body_text).height(Length::Fixed(140.0)); + + container(column![header, body].spacing(6).padding(10)) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgb(1.0, 0.96, 0.94))), + border: Border { + color: Color::from_rgb(0.85, 0.15, 0.15), + width: 1.5, + radius: 4.0.into(), + }, + ..container::Style::default() + }) + .width(Length::Fill) + .into() } /// stem path used by all mesh and solve operations for the active doc. @@ -231,69 +934,177 @@ fn active_stem() -> std::path::PathBuf { } /// saves the doc to a temp .fem and matching .poly, invokes Triangle, and reads back the mesh. -fn run_mesh(doc: &FemmDoc) -> Result { +fn run_mesh(doc: &FemmDoc) -> Result { let stem = active_stem(); let fem_path = stem.with_extension("fem"); let poly_path = stem.with_extension("poly"); + let pbc_path = stem.with_extension("pbc"); - doc.save(&fem_path).map_err(|e| format!("save .fem: {e}"))?; - doc.save_poly(&poly_path).map_err(|e| format!("save .poly: {e}"))?; - let pbc_path = stem.with_extension("pbc"); - std::fs::write(&pbc_path, "0\n0\n").map_err(|e| format!("save .pbc: {e}"))?; + if doc.nodes.is_empty() { + return Err(ErrorReport { + title: String::from("empty geometry"), + body: String::from("the doc has zero nodes - nothing to mesh"), + }); + } + if doc.block_labels.is_empty() { + return Err(ErrorReport { + title: String::from("no block labels"), + body: String::from("the doc has no block labels - every closed region needs a material assignment before meshing"), + }); + } - let triangle = locate_triangle() - .ok_or_else(|| String::from("triangle binary not found at build/triangle/triangle - run scripts/macos/build_triangle.sh"))?; + doc.save(&fem_path).map_err(|e| ErrorReport { + title: String::from("write .fem failed"), + body: format!("{fem_path:?}\n\n{e}"), + })?; + doc.save_poly(&poly_path).map_err(|e| ErrorReport { + title: String::from("write .poly failed"), + body: format!("{poly_path:?}\n\n{e}"), + })?; + std::fs::write(&pbc_path, "0\n0\n").map_err(|e| ErrorReport { + title: String::from("write .pbc failed"), + body: format!("{pbc_path:?}\n\n{e}"), + })?; - let stem_str = stem.to_str().ok_or_else(|| String::from("non-utf8 path"))?; - let status = std::process::Command::new(&triangle) + let triangle = locate_triangle().ok_or_else(|| ErrorReport { + title: String::from("triangle binary missing"), + body: String::from("expected triangle next to femm.app/Contents/MacOS/triangle, or under build/triangle/triangle, or at $FEMM_TRIANGLE - run scripts/macos/build_triangle.sh"), + })?; + + let stem_str = stem.to_str().ok_or_else(|| ErrorReport { + title: String::from("non-utf8 mesh path"), + body: format!("{stem:?}"), + })?; + let min_angle = if doc.min_angle > 0.0 { doc.min_angle } else { MIN_ANGLE_DEG }; + let output = std::process::Command::new(&triangle) .args([ "-p", "-P", "-e", "-A", "-a", "-z", "-Q", "-I", - &format!("-q{MIN_ANGLE_DEG}"), + &format!("-q{min_angle}"), ]) .arg(stem_str) - .status() - .map_err(|e| format!("exec triangle: {e}"))?; - if !status.success() { - return Err(format!("triangle exited with {status}")); + .output() + .map_err(|e| ErrorReport { + title: String::from("exec triangle failed"), + body: format!("{triangle:?}\n\n{e}"), + })?; + if !output.status.success() { + return Err(ErrorReport { + title: format!("triangle exited with {}", output.status), + body: format!( + "triangle: {triangle:?}\nstem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ), + }); } - Mesh::load(&stem).map_err(|e| format!("read mesh: {e}")) + Mesh::load(&stem).map_err(|e| ErrorReport { + title: String::from("read mesh failed"), + body: format!("stem: {stem_str}\n\n{e}"), + }) } -/// runs the magnetostatic solver FFI pipeline against an on-disk .fem + .node + .ele rooted at `stem`. -fn run_solve(stem: &Path) -> Result<(), String> { - let stem_str = stem.to_str().ok_or_else(|| String::from("non-utf8 path"))?; - let cstem = CString::new(stem_str).map_err(|e| format!("path: {e}"))?; +/// runs the magnetostatic solver FFI pipeline against an on-disk .fem + .node + .ele rooted at the given stem. +fn run_solve(stem: &Path) -> Result<(), ErrorReport> { + let stem_str = stem.to_str().ok_or_else(|| ErrorReport { + title: String::from("non-utf8 solve path"), + body: format!("{stem:?}"), + })?; - unsafe { - let doc = femm_sys::femm_mag_doc_new(); - if doc.is_null() { - return Err(String::from("femm_mag_doc_new returned null")); - } - let load = femm_sys::femm_mag_doc_load_fem(doc, cstem.as_ptr()); - if load == 0 { - femm_sys::femm_mag_doc_free(doc); - return Err(String::from("load_fem returned 0")); - } - if femm_sys::femm_mag_doc_load_mesh(doc) == 0 { - femm_sys::femm_mag_doc_free(doc); - return Err(String::from("load_mesh returned 0")); - } - if femm_sys::femm_mag_doc_renumber(doc) == 0 { - femm_sys::femm_mag_doc_free(doc); - return Err(String::from("renumber returned 0")); - } - if femm_sys::femm_mag_doc_solve(doc) == 0 { - femm_sys::femm_mag_doc_free(doc); - return Err(String::from("solve returned 0")); - } - femm_sys::femm_mag_doc_free(doc); + let helper = locate_mag_solver().ok_or_else(|| ErrorReport { + title: String::from("femm-mag-solve binary missing"), + body: String::from("expected femm-mag-solve next to femm.app/Contents/MacOS/, or under target/release/, or at $FEMM_MAG_SOLVE - run `cargo xtask install`"), + })?; + + let output = std::process::Command::new(&helper) + .arg(stem_str) + .output() + .map_err(|e| ErrorReport { + title: String::from("spawn femm-mag-solve failed"), + body: format!("{helper:?}\n\n{e}"), + })?; + + if output.status.success() { + return Ok(()); } - Ok(()) + + let exit_label = match output.status.code() { + Some(c) => format!("exit code {c}"), + None => match unix_signal(&output.status) { + Some(sig) => format!("killed by signal {sig}"), + None => format!("{}", output.status), + }, + }; + let stage = solve_stage_for_exit(output.status.code()); + let title = format!("magnetostatic solve {exit_label} ({stage})"); + + Err(ErrorReport { + title, + body: format!( + "helper: {helper:?}\nstem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ), + }) } -/// resolves the Triangle binary path. -/// search order: sibling of the running exe (bundled .app), repo build dir under target/.., FEMM_TRIANGLE. +/// maps a femm-mag-solve exit code onto a pipeline-stage label. +fn solve_stage_for_exit(code: Option) -> &'static str { + match code { + Some(1) => "C++ engine called exit(1) - read the captured stderr for the actual cause", + Some(2) => "argv usage", + Some(3) => "stem path conversion", + Some(10) => "doc_new", + Some(11) => "load_fem", + Some(12) => "load_mesh", + Some(13) => "renumber", + Some(14) => "solve", + Some(_) => "unknown exit code", + None => "killed by signal - likely C++ assertion or segfault inside the solver", + } +} + +/// extracts the unix signal number from a child exit status, returning None on non-unix or normal exits. +#[cfg(unix)] +fn unix_signal(status: &std::process::ExitStatus) -> Option { + use std::os::unix::process::ExitStatusExt; + status.signal() +} + +#[cfg(not(unix))] +fn unix_signal(_status: &std::process::ExitStatus) -> Option { None } + +/// resolves the femm-mag-solve helper path from the bundled .app, the dev target dir, or $FEMM_MAG_SOLVE. +fn locate_mag_solver() -> Option { + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let bundled = parent.join("femm-mag-solve"); + if bundled.is_file() { return Some(bundled); } + let dev = parent.join("femm-mag-solve"); + if let Ok(canon) = dev.canonicalize() { + if canon.is_file() { return Some(canon); } + } + } + } + if let Ok(env_path) = std::env::var("FEMM_MAG_SOLVE") { + let p = std::path::PathBuf::from(env_path); + if p.is_file() { return Some(p); } + } + None +} + +/// converts a panic payload into a printable string. +fn panic_payload_text(payload: &Box) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + return (*s).to_string(); + } + if let Some(s) = payload.downcast_ref::() { + return s.clone(); + } + String::from("panic payload was not a string") +} + +/// resolves the Triangle binary path from the bundled .app, the dev build dir, or $FEMM_TRIANGLE. fn locate_triangle() -> Option { if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { @@ -312,14 +1123,144 @@ fn locate_triangle() -> Option { None } -/// flips the selected flag on whichever entity sits nearest to (x, y) in doc world coords. -fn toggle_closest(doc: &mut FemmDoc, x: f64, y: f64) { - use femm_doc_mag::geom_math::{ - shortest_distance_from_arc, shortest_distance_from_segment, - }; +/// duplicates every selected entity offset by +10mm in both axes and transfers selection to the copies. +fn duplicate_selection(doc: &mut FemmDoc) -> usize { + let (dx, dy) = offset_for_unit(doc.length_units); + + let nodes_snapshot: Vec<(f64, f64)> = doc.nodes.iter().map(|n| (n.x, n.y)).collect(); + let node_sel: Vec = doc.nodes.iter().map(|n| n.selected).collect(); + let seg_snapshot: Vec = doc.segments.iter().cloned().collect(); + let arc_snapshot: Vec = doc.arcs.iter().cloned().collect(); + let label_snapshot: Vec = doc.block_labels.iter().cloned().collect(); + + for n in doc.nodes.iter_mut() { n.selected = false; } + for s in doc.segments.iter_mut() { s.selected = false; } + for a in doc.arcs.iter_mut() { a.selected = false; } + for b in doc.block_labels.iter_mut() { b.selected = false; } + + let mut node_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut added = 0usize; + + for (i, src) in nodes_snapshot.iter().enumerate() { + if node_sel[i] { + let new_idx = doc.nodes.len() as i32; + doc.nodes.push(Node { + x: src.0 + dx, y: src.1 + dy, + boundary_marker: String::new(), + in_group: 0, + selected: true, + }); + node_map.insert(i as i32, new_idx); + added += 1; + } + } + + for s in &seg_snapshot { + if !s.selected { continue; } + let n0 = remap_or_clone_node(doc, s.n0, &nodes_snapshot, &mut node_map, dx, dy); + let n1 = remap_or_clone_node(doc, s.n1, &nodes_snapshot, &mut node_map, dx, dy); + let (Some(n0), Some(n1)) = (n0, n1) else { continue }; + doc.segments.push(Segment { + n0, n1, + max_side_length: s.max_side_length, + boundary_marker: s.boundary_marker.clone(), + hidden: s.hidden, + in_group: s.in_group, + selected: true, + }); + added += 1; + } + + for a in &arc_snapshot { + if !a.selected { continue; } + let n0 = remap_or_clone_node(doc, a.n0, &nodes_snapshot, &mut node_map, dx, dy); + let n1 = remap_or_clone_node(doc, a.n1, &nodes_snapshot, &mut node_map, dx, dy); + let (Some(n0), Some(n1)) = (n0, n1) else { continue }; + doc.arcs.push(ArcSegment { + n0, n1, + arc_length: a.arc_length, + max_side_length: a.max_side_length, + boundary_marker: a.boundary_marker.clone(), + hidden: a.hidden, + in_group: a.in_group, + normal_direction: a.normal_direction, + selected: true, + }); + added += 1; + } + + for b in &label_snapshot { + if !b.selected { continue; } + let mut clone = b.clone(); + clone.x += dx; + clone.y += dy; + clone.selected = true; + doc.block_labels.push(clone); + added += 1; + } + + added +} + +/// returns the duplicate-node index for an original index, creating a new offset copy on first request. +fn remap_or_clone_node( + doc: &mut FemmDoc, + original: i32, + nodes_snapshot: &[(f64, f64)], + node_map: &mut std::collections::HashMap, + dx: f64, + dy: f64, +) -> Option { + if let Some(&m) = node_map.get(&original) { return Some(m); } + let src = nodes_snapshot.get(original as usize)?; + let new_idx = doc.nodes.len() as i32; + doc.nodes.push(Node { + x: src.0 + dx, y: src.1 + dy, + boundary_marker: String::new(), + in_group: 0, + selected: true, + }); + node_map.insert(original, new_idx); + Some(new_idx) +} + +/// returns the world-space (dx, dy) corresponding to 10mm under the given length unit. +fn offset_for_unit(unit: femm_doc_mag::LengthUnit) -> (f64, f64) { + use femm_doc_mag::LengthUnit::*; + let v = match unit { + Inches => 10.0 / 25.4, + Millimeters => 10.0, + Centimeters => 1.0, + Meters => 0.01, + Mils => 10.0 / 0.0254, + Microns => 10_000.0, + }; + (v, v) +} + +#[derive(Clone, Copy)] +enum Kind { Node, Segment, Arc, Label } + +/// 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; } + for s in &mut doc.segments { s.selected = false; } + for a in &mut doc.arcs { a.selected = false; } + for b in &mut doc.block_labels { b.selected = false; } +} + +/// mutates one entity's selected flag according to the pick op. +fn apply_op(flag: &mut bool, op: PickOp) { + match op { + PickOp::Replace | PickOp::Add => *flag = true, + PickOp::Toggle => *flag ^= true, + } +} + +/// applies the pick op to the closest entity within pick_radius_world of (x, y), honouring the restrict mode. +fn apply_pick_at(doc: &mut FemmDoc, x: f64, y: f64, pick_radius_world: f64, op: PickOp, restrict: PickRestrict) -> String { + use femm_doc_mag::geom_math::{shortest_distance_from_arc, shortest_distance_from_segment}; - #[derive(Clone, Copy)] - enum Kind { Node, Segment, Arc, Label } let mut best: Option<(Kind, usize, f64)> = None; let mut consider = |kind: Kind, idx: usize, d: f64| { match best { @@ -332,44 +1273,131 @@ fn toggle_closest(doc: &mut FemmDoc, x: f64, y: f64) { for (i, n) in doc.nodes.iter().enumerate() { consider(Kind::Node, i, (n.x - x).hypot(n.y - y)); } - for (i, s) in doc.segments.iter().enumerate() { - if let (Some(p0), Some(p1)) = - (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) - { - consider(Kind::Segment, i, shortest_distance_from_segment((x, y), (p0.x, p0.y), (p1.x, p1.y))); + if matches!(restrict, PickRestrict::Any) { + for (i, s) in doc.segments.iter().enumerate() { + if let (Some(p0), Some(p1)) = (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) { + consider(Kind::Segment, i, shortest_distance_from_segment((x, y), (p0.x, p0.y), (p1.x, p1.y))); + } } - } - for (i, a) in doc.arcs.iter().enumerate() { - if let (Some(p0), Some(p1)) = - (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) - { - consider(Kind::Arc, i, shortest_distance_from_arc((x, y), (p0.x, p0.y), (p1.x, p1.y), a.arc_length)); + for (i, a) in doc.arcs.iter().enumerate() { + if let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) { + consider(Kind::Arc, i, shortest_distance_from_arc((x, y), (p0.x, p0.y), (p1.x, p1.y), a.arc_length)); + } + } + for (i, b) in doc.block_labels.iter().enumerate() { + consider(Kind::Label, i, (b.x - x).hypot(b.y - y)); } - } - for (i, b) in doc.block_labels.iter().enumerate() { - consider(Kind::Label, i, (b.x - x).hypot(b.y - y)); } - if let Some((kind, idx, _)) = best { - match kind { - Kind::Node => doc.nodes[idx].selected ^= true, - Kind::Segment => doc.segments[idx].selected ^= true, - Kind::Arc => doc.arcs[idx].selected ^= true, - Kind::Label => doc.block_labels[idx].selected ^= true, - } - } -} + if matches!(op, PickOp::Replace) { clear_selection(doc); } -fn tool_button(label: &str, this_tool: Tool, active: Tool) -> Element<'_, Message> { - let btn = button(text(label).size(13)).on_press(Message::SelectTool(this_tool)); - if this_tool == active { - btn.style(button::primary).into() + let hit = 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), + Kind::Segment => ("segment", &mut doc.segments[idx].selected), + Kind::Arc => ("arc", &mut doc.arcs[idx].selected), + Kind::Label => ("label", &mut doc.block_labels[idx].selected), + }; + apply_op(flag, op); + let verb = match op { PickOp::Replace => "selected", PickOp::Add => "added", PickOp::Toggle => "toggled" }; + format!("{verb} {label} {idx}") + } else if matches!(op, PickOp::Replace) { + String::from("selection cleared") } else { - btn.style(button::secondary).into() + String::from("nothing at click") } } -/// builds a primary/secondary-styled button that switches the post-processor render mode. +/// applies the pick op to every entity sitting inside the rectangle spanned by p0 and p1. +fn apply_pick_rect(doc: &mut FemmDoc, p0: (f64, f64), p1: (f64, f64), op: PickOp, restrict: PickRestrict) -> String { + let xmin = p0.0.min(p1.0); let xmax = p0.0.max(p1.0); + let ymin = p0.1.min(p1.1); let ymax = p0.1.max(p1.1); + let inside = |x: f64, y: f64| x >= xmin && x <= xmax && y >= ymin && y <= ymax; + + if matches!(op, PickOp::Replace) { clear_selection(doc); } + + let mut n_nodes = 0usize; + let mut n_segs = 0usize; + let mut n_arcs = 0usize; + let mut n_labs = 0usize; + + for n in doc.nodes.iter_mut() { + if inside(n.x, n.y) { apply_op(&mut n.selected, op); n_nodes += 1; } + } + if matches!(restrict, PickRestrict::Any) { + let node_inside: Vec = doc.nodes.iter().map(|n| inside(n.x, n.y)).collect(); + for s in doc.segments.iter_mut() { + let i0 = s.n0 as usize; let i1 = s.n1 as usize; + let ok = node_inside.get(i0).copied().unwrap_or(false) && node_inside.get(i1).copied().unwrap_or(false); + if ok { apply_op(&mut s.selected, op); n_segs += 1; } + } + for a in doc.arcs.iter_mut() { + let i0 = a.n0 as usize; let i1 = a.n1 as usize; + let ok = node_inside.get(i0).copied().unwrap_or(false) && node_inside.get(i1).copied().unwrap_or(false); + if ok { apply_op(&mut a.selected, op); n_arcs += 1; } + } + for b in doc.block_labels.iter_mut() { + if inside(b.x, b.y) { apply_op(&mut b.selected, op); n_labs += 1; } + } + } + let verb = match op { PickOp::Replace => "selected", PickOp::Add => "added", PickOp::Toggle => "toggled" }; + format!("{verb}: {n_nodes} nodes, {n_segs} segs, {n_arcs} arcs, {n_labs} labels") +} + +/// renders an SVG icon inside a square button with a hover tooltip. +fn icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, msg: Option) -> Element<'a, Message> { + let handle = svg::Handle::from_memory(svg_bytes); + let glyph = svg(handle) + .width(Length::Fixed(ICON_PX)) + .height(Length::Fixed(ICON_PX)); + let mut btn = button(glyph).padding(4).style(button::secondary); + if let Some(m) = msg { + btn = btn.on_press(m); + } + tooltip(btn, text(tip).size(11), tooltip::Position::Bottom).into() +} + +/// renders an SVG icon button with active-tool highlighting. +fn tool_icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, this_tool: Tool, active: Tool) -> Element<'a, Message> { + let handle = svg::Handle::from_memory(svg_bytes); + let glyph = svg(handle) + .width(Length::Fixed(ICON_PX)) + .height(Length::Fixed(ICON_PX)); + let style = if this_tool == active { button::primary } else { button::secondary }; + let btn = button(glyph) + .padding(4) + .style(style) + .on_press(Message::SelectTool(this_tool)); + tooltip(btn, text(tip).size(11), tooltip::Position::Bottom).into() +} + +/// renders a plain text button mapped to a single message. +fn text_button(label: &str, msg: Message) -> Element<'_, Message> { + button(text(label).size(13)).on_press(msg).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))) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.25))), + ..container::Style::default() + }) + .into() +} + +/// draws a thin horizontal rule between vertical-strip sections. +fn horizontal_separator<'a>() -> Element<'a, Message> { + container(iced::widget::Space::new().width(Length::Fixed(28.0)).height(Length::Fixed(1.0))) + .style(|_t: &Theme| container::Style { + background: Some(Background::Color(Color::from_rgba(1.0, 1.0, 1.0, 0.25))), + ..container::Style::default() + }) + .into() +} + +/// renders a render-mode toggle button with active-mode highlighting. fn render_mode_button(label: &str, this_mode: RenderMode, active: RenderMode) -> Element<'_, Message> { let btn = button(text(label).size(13)).on_press(Message::SetRenderMode(this_mode)); if this_mode == active { @@ -382,5 +1410,7 @@ fn render_mode_button(label: &str, this_mode: RenderMode, active: RenderMode) -> fn main() -> iced::Result { iced::application(App::new, App::update, App::view) .title(App::title) + .theme(|_app: &App| Theme::Dark) + .subscription(App::subscription) .run() } diff --git a/crates/femm-app/src/spice.rs b/crates/femm-app/src/spice.rs new file mode 100644 index 0000000..cf7d879 --- /dev/null +++ b/crates/femm-app/src/spice.rs @@ -0,0 +1,79 @@ +//! SPICE-notation time and quantity parser covering the f/p/n/u/m/k/meg/g/t suffixes and an optional trailing s. + +/// parses a SPICE-suffixed quantity into its base value, taking time as seconds when read as a duration. +pub fn parse_spice(text: &str) -> Option { + let s = text.trim().to_ascii_lowercase(); + if s.is_empty() { return None; } + let s = s.trim_end_matches('s'); + let split_at = s + .char_indices() + .find(|(_, c)| { + !(c.is_ascii_digit() || *c == '.' || *c == '+' || *c == '-' || *c == 'e') + }) + .map(|(i, _)| i) + .unwrap_or(s.len()); + let (num_part, suf) = s.split_at(split_at); + let num_part = num_part.trim_end_matches('e').trim_end_matches('E'); + let value: f64 = num_part.parse().ok()?; + let factor = suffix_factor(suf.trim())?; + Some(value * factor) +} + +/// maps a SPICE suffix to its decimal factor, keeping meg at 1e6 separate from m at 1e-3. +fn suffix_factor(suf: &str) -> Option { + Some(match suf { + "" => 1.0, + "f" => 1.0e-15, + "p" => 1.0e-12, + "n" => 1.0e-9, + "u" | "µ" => 1.0e-6, + "m" => 1.0e-3, + "k" => 1.0e3, + "meg" => 1.0e6, + "g" => 1.0e9, + "t" => 1.0e12, + _ => return None, + }) +} + +/// formats a base value back into SPICE notation with a trailing s, picking the smallest suffix yielding a magnitude of at least 1. +pub fn format_spice_time(seconds: f64) -> String { + if seconds == 0.0 { return String::from("0s"); } + let abs = seconds.abs(); + let (factor, suf) = if abs >= 1.0 { (1.0, "") } + else if abs >= 1.0e-3 { (1.0e-3, "m") } + else if abs >= 1.0e-6 { (1.0e-6, "u") } + else if abs >= 1.0e-9 { (1.0e-9, "n") } + else if abs >= 1.0e-12 { (1.0e-12, "p") } + else { (1.0e-15, "f") }; + let scaled = seconds / factor; + if (scaled.round() - scaled).abs() < 1e-9 { + format!("{}{}s", scaled.round() as i64, suf) + } else { + format!("{:.3}{}s", scaled, suf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spice_suffixes_round_trip_through_seconds() { + // standard SPICE suffixes resolve to their decimal factors, keeping m and meg apart. + assert!((parse_spice("100u").unwrap() - 1.0e-4).abs() < 1e-12); + assert!((parse_spice("1.5n").unwrap() - 1.5e-9).abs() < 1e-15); + assert!((parse_spice("2meg").unwrap() - 2.0e6).abs() < 1e-3); + assert!((parse_spice("33m").unwrap() - 33.0e-3).abs() < 1e-9); + assert!((parse_spice("1ms").unwrap() - 1.0e-3).abs() < 1e-9); + assert!((parse_spice("12.34").unwrap() - 12.34).abs() < 1e-9); + } + + #[test] + fn bad_input_rejected() { + // empty, alpha-only, and unknown suffixes all return None. + assert!(parse_spice("").is_none()); + assert!(parse_spice("xyz").is_none()); + assert!(parse_spice("1z").is_none()); + } +} diff --git a/crates/femm-doc-mag/src/edit.rs b/crates/femm-doc-mag/src/edit.rs index 2b92fd1..73632a3 100644 --- a/crates/femm-doc-mag/src/edit.rs +++ b/crates/femm-doc-mag/src/edit.rs @@ -65,6 +65,58 @@ impl FemmDoc { self.add_segment_with_marker(n0, n1, "") } + /// splits one segment into `n` equal pieces by inserting `n - 1` intermediate nodes, returning the new node indices in order from n0 toward n1. + pub fn subdivide_segment(&mut self, idx: usize, n: usize) -> Vec { + if n < 2 || idx >= self.segments.len() { return Vec::new(); } + let seg = self.segments[idx].clone(); + let n0 = seg.n0; let n1 = seg.n1; + let nn = self.nodes.len() as i32; + if n0 < 0 || n1 < 0 || n0 >= nn || n1 >= nn { return Vec::new(); } + let (x0, y0) = (self.nodes[n0 as usize].x, self.nodes[n0 as usize].y); + let (x1, y1) = (self.nodes[n1 as usize].x, self.nodes[n1 as usize].y); + let dx = (x1 - x0) / n as f64; + let dy = (y1 - y0) / n as f64; + + let mut new_nodes: Vec = Vec::with_capacity(n - 1); + for i in 1..n { + let nx = x0 + dx * i as f64; + let ny = y0 + dy * i as f64; + let idx_new = self.nodes.len() as i32; + self.nodes.push(Node { + x: nx, y: ny, + boundary_marker: String::new(), + in_group: seg.in_group, + selected: false, + }); + new_nodes.push(idx_new); + } + + self.segments.remove(idx); + let mut prev = n0; + for &mid in &new_nodes { + self.segments.push(Segment { + n0: prev, + n1: mid, + max_side_length: seg.max_side_length, + boundary_marker: seg.boundary_marker.clone(), + hidden: seg.hidden, + in_group: seg.in_group, + selected: false, + }); + prev = mid; + } + self.segments.push(Segment { + n0: prev, + n1: n1, + max_side_length: seg.max_side_length, + boundary_marker: seg.boundary_marker.clone(), + hidden: seg.hidden, + in_group: seg.in_group, + selected: false, + }); + new_nodes + } + /// PSLG-aware variant propagating a boundary-marker name onto every resulting segment piece. pub fn add_segment_with_marker(&mut self, n0: i32, n1: i32, marker: &str) -> bool { if n0 == n1 { return false; } diff --git a/crates/femm-mag-solve/Cargo.toml b/crates/femm-mag-solve/Cargo.toml new file mode 100644 index 0000000..0e3b47d --- /dev/null +++ b/crates/femm-mag-solve/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "femm-mag-solve" +version = "0.0.1" +edition.workspace = true +rust-version.workspace = true +publish.workspace = true +description = "subprocess wrapper around the magnetostatic FFI solver - isolates C++ aborts from the GUI" + +[[bin]] +name = "femm-mag-solve" +path = "src/main.rs" + +[dependencies] +femm-sys = { workspace = true } diff --git a/crates/femm-mag-solve/src/main.rs b/crates/femm-mag-solve/src/main.rs new file mode 100644 index 0000000..565cd25 --- /dev/null +++ b/crates/femm-mag-solve/src/main.rs @@ -0,0 +1,61 @@ +//! subprocess wrapper around the magnetostatic FFI pipeline, keyed by a stem path on argv. + +use std::ffi::CString; +use std::process::ExitCode; + +const EXIT_OK: u8 = 0; +const EXIT_USAGE: u8 = 2; +const EXIT_BAD_STEM: u8 = 3; +const EXIT_DOC_NULL: u8 = 10; +const EXIT_LOAD_FEM: u8 = 11; +const EXIT_LOAD_MESH: u8 = 12; +const EXIT_RENUMBER: u8 = 13; +const EXIT_SOLVE: u8 = 14; + +fn main() -> ExitCode { + let stem = match std::env::args().nth(1) { + Some(s) => s, + None => { + eprintln!("usage: femm-mag-solve "); + return ExitCode::from(EXIT_USAGE); + } + }; + let cstem = match CString::new(stem.as_str()) { + Ok(c) => c, + Err(e) => { + eprintln!("stem contains NUL: {e}"); + return ExitCode::from(EXIT_BAD_STEM); + } + }; + + unsafe { + let doc = femm_sys::femm_mag_doc_new(); + if doc.is_null() { + eprintln!("femm_mag_doc_new returned null"); + return ExitCode::from(EXIT_DOC_NULL); + } + if femm_sys::femm_mag_doc_load_fem(doc, cstem.as_ptr()) == 0 { + femm_sys::femm_mag_doc_free(doc); + eprintln!("femm_mag_doc_load_fem returned 0 - the .fem could not be parsed by the engine"); + return ExitCode::from(EXIT_LOAD_FEM); + } + if femm_sys::femm_mag_doc_load_mesh(doc) == 0 { + femm_sys::femm_mag_doc_free(doc); + eprintln!("femm_mag_doc_load_mesh returned 0 - .node/.ele/.pbc files missing or unreadable"); + return ExitCode::from(EXIT_LOAD_MESH); + } + if femm_sys::femm_mag_doc_renumber(doc) == 0 { + femm_sys::femm_mag_doc_free(doc); + eprintln!("femm_mag_doc_renumber returned 0 - mesh renumbering rejected the topology"); + return ExitCode::from(EXIT_RENUMBER); + } + if femm_sys::femm_mag_doc_solve(doc) == 0 { + femm_sys::femm_mag_doc_free(doc); + eprintln!("femm_mag_doc_solve returned 0 - linear solve failed (singular materials, missing block labels, or unbounded air region)"); + return ExitCode::from(EXIT_SOLVE); + } + femm_sys::femm_mag_doc_free(doc); + } + + ExitCode::from(EXIT_OK) +} diff --git a/examples/Halbach.fem b/examples/Halbach.fem new file mode 100644 index 0000000..bfd0680 --- /dev/null +++ b/examples/Halbach.fem @@ -0,0 +1,89 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 30 +[Depth] = 1 +[LengthUnits] = inches +[ProblemType] = planar +[Coordinates] = cartesian +[ACSolver] = 0 +[Comment] = "Add comments here." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 2 + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "NdFeB 40 MGOe" + = 1.0489999999999999 + = 1.0489999999999999 + = 979000 + = 0 + = 0 + = 0 + = 0.66700000000000004 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 8 +0 0 0 0 +0 1 0 0 +10 1 0 0 +10 0 0 0 +-2 -2 0 0 +-2 3 0 0 +12 3 0 0 +12 -2 0 0 +[NumSegments] = 8 +1 0 -1 0 0 0 +2 3 -1 0 0 0 +1 2 -1 0 0 0 +0 3 -1 0 0 0 +5 4 -1 1 0 0 +5 6 -1 1 0 0 +7 6 -1 1 0 0 +4 7 -1 1 0 0 +[NumArcSegments] = 0 +[NumHoles] = 0 +[NumBlockLabels] = 2 +5 -0.5 1 0.083872811355419435 0 0 0 1 0 +5 0.5 2 0.083872811355419435 0 0 0 1 0 "x*180" diff --git a/examples/diametral_magn.fem b/examples/diametral_magn.fem new file mode 100644 index 0000000..a0a48e5 --- /dev/null +++ b/examples/diametral_magn.fem @@ -0,0 +1,141 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 30 +[Depth] = 4 +[LengthUnits] = centimeters +[ProblemType] = planar +[Coordinates] = cartesian +[ACSolver] = 0 +[Comment] = "Add comments here." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Magnet" + = 1.05 + = 1.05 + = 909456.81766797299 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 5000 + = 5000 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 20 +2.6811555509164231 2.2497566339028872 0 0 +-2.6811555509164231 -2.2497566339028872 0 0 +2.987573328164014 2.5068716777775029 0 0 +-2.987573328164014 -2.5068716777775029 0 0 +4 0 0 0 +-4 0 0 0 +-2.6811555509164227 2.2497566339028876 0 0 +2.6811555509164227 -2.2497566339028876 0 0 +-2.9875733281640136 2.5068716777775029 0 0 +2.9875733281640136 -2.5068716777775029 0 0 +2.2497566339028876 2.6811555509164227 0 0 +2.5068716777775029 2.9875733281640136 0 0 +-2.5068716777775029 -2.9875733281640136 0 0 +-2.2497566339028876 -2.6811555509164227 0 0 +2.2497566339028872 -2.6811555509164231 0 0 +2.5068716777775024 -2.9875733281640136 0 0 +-2.5068716777775024 2.9875733281640136 0 0 +-2.2497566339028872 2.6811555509164231 0 0 +5 0 0 0 +-5 6.1232339957367663e-016 0 0 +[NumSegments] = 8 +0 2 -1 0 0 0 +7 9 -1 0 0 0 +3 1 -1 0 0 0 +8 6 -1 0 0 0 +10 11 -1 0 0 0 +12 13 -1 0 0 0 +14 15 -1 0 0 0 +16 17 -1 0 0 0 +[NumArcSegments] = 16 +0 10 10 1 0 0 0 +17 6 10.000000000000004 1 0 0 0 +6 1 80 1 0 0 0 +1 13 10 1 0 0 0 +14 7 10.000000000000004 1 0 0 0 +7 0 80 1 0 0 0 +11 16 80.000000000000043 1 0 0 0 +8 3 80 1 0 0 0 +12 15 80.000000000000043 1 0 0 0 +9 2 80 1 0 0 0 +4 5 180 1 0 0 0 +5 4 180 1 0 0 0 +10 17 80.000000000000043 1 0 0 0 +13 14 80 1 0 0 0 +18 19 180 1 1 0 0 +19 18 180 1 1 0 0 +[NumHoles] = 0 +[NumBlockLabels] = 7 +-0.26000000000000001 0.34000000000000002 3 -1 0 0 0 1 0 +3.7000000000000002 0 1 -1 0 0 0 1 0 +-3.7000000000000002 0 1 -1 0 180 0 1 0 +2.6699999999999999 2.6099999999999999 2 -1 0 0 0 1 0 +2.2655965784226036e-016 3.7000000000000002 1 -1 0 -90 0 1 0 +-2.2655965784226036e-016 -3.7000000000000002 1 -1 0 90 0 1 0 +0 4.4000000000000004 3 -1 0 0 0 1 0 diff --git a/examples/guitar_strings.fem b/examples/guitar_strings.fem new file mode 100644 index 0000000..89345d5 --- /dev/null +++ b/examples/guitar_strings.fem @@ -0,0 +1,170 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 25 +[Depth] = 5 +[LengthUnits] = millimeters +[ProblemType] = planar +[Coordinates] = cartesian +[Comment] = "guitar pickup geometry: 25.5-inch scale, 6 plain steel strings (0.010-0.046 inch, real gauges), NdFeB bar magnets above and below the string plane with opposite faces presented." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "NdFeB" + = 1.05 + = 1.05 + = 915000 + = 0 + = 0 + = 0 + = 0.667 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 2500 + = 2500 + = 0 + = 0 + = 0 + = 0 + = 5.8 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 36 +-50 -80 0 0 +700 -80 0 0 +700 80 0 0 +-50 80 0 0 +0 30 0 0 +647.7 30 0 0 +647.7 35 0 0 +0 35 0 0 +0 -35 0 0 +647.7 -35 0 0 +647.7 -30 0 0 +0 -30 0 0 +0 -26.377 0 0 +647.7 -26.377 0 0 +647.7 -26.123 0 0 +0 -26.123 0 0 +0 -15.915 0 0 +647.7 -15.915 0 0 +647.7 -15.585 0 0 +0 -15.585 0 0 +0 -5.466 0 0 +647.7 -5.466 0 0 +647.7 -5.034 0 0 +0 -5.034 0 0 +0 4.920 0 0 +647.7 4.920 0 0 +647.7 5.580 0 0 +0 5.580 0 0 +0 15.293 0 0 +647.7 15.293 0 0 +647.7 16.207 0 0 +0 16.207 0 0 +0 25.666 0 0 +647.7 25.666 0 0 +647.7 26.834 0 0 +0 26.834 0 0 +[NumSegments] = 36 +0 1 -1 1 0 0 +1 2 -1 1 0 0 +2 3 -1 1 0 0 +3 0 -1 1 0 0 +4 5 -1 0 0 0 +5 6 -1 0 0 0 +6 7 -1 0 0 0 +7 4 -1 0 0 0 +8 9 -1 0 0 0 +9 10 -1 0 0 0 +10 11 -1 0 0 0 +11 8 -1 0 0 0 +12 13 -1 0 0 0 +13 14 -1 0 0 0 +14 15 -1 0 0 0 +15 12 -1 0 0 0 +16 17 -1 0 0 0 +17 18 -1 0 0 0 +18 19 -1 0 0 0 +19 16 -1 0 0 0 +20 21 -1 0 0 0 +21 22 -1 0 0 0 +22 23 -1 0 0 0 +23 20 -1 0 0 0 +24 25 -1 0 0 0 +25 26 -1 0 0 0 +26 27 -1 0 0 0 +27 24 -1 0 0 0 +28 29 -1 0 0 0 +29 30 -1 0 0 0 +30 31 -1 0 0 0 +31 28 -1 0 0 0 +32 33 -1 0 0 0 +33 34 -1 0 0 0 +34 35 -1 0 0 0 +35 32 -1 0 0 0 +[NumArcSegments] = 0 +[NumHoles] = 0 +[NumBlockLabels] = 9 +325 70 1 -1 0 0 0 1 2 +325 32.5 2 5 0 270 0 1 0 +325 -32.5 2 5 0 270 0 1 0 +325 -26.25 3 1 0 0 0 1 0 +325 -15.75 3 1 0 0 0 1 0 +325 -5.25 3 1 0 0 0 1 0 +325 5.25 3 1 0 0 0 1 0 +325 15.75 3 1 0 0 0 1 0 +325 26.25 3 1 0 0 0 1 0 diff --git a/examples/radial_magn.fem b/examples/radial_magn.fem new file mode 100644 index 0000000..c4b445b --- /dev/null +++ b/examples/radial_magn.fem @@ -0,0 +1,141 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 30 +[Depth] = 4 +[LengthUnits] = centimeters +[ProblemType] = planar +[Coordinates] = cartesian +[ACSolver] = 0 +[Comment] = "Add comments here." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Magnet" + = 1.05 + = 1.05 + = 909456.81766797299 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 5000 + = 5000 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 20 +2.6811555509164231 2.2497566339028872 0 0 +-2.6811555509164231 -2.2497566339028872 0 0 +2.987573328164014 2.5068716777775029 0 0 +-2.987573328164014 -2.5068716777775029 0 0 +4 0 0 0 +-4 0 0 0 +-2.6811555509164227 2.2497566339028876 0 0 +2.6811555509164227 -2.2497566339028876 0 0 +-2.9875733281640136 2.5068716777775029 0 0 +2.9875733281640136 -2.5068716777775029 0 0 +2.2497566339028876 2.6811555509164227 0 0 +2.5068716777775029 2.9875733281640136 0 0 +-2.5068716777775029 -2.9875733281640136 0 0 +-2.2497566339028876 -2.6811555509164227 0 0 +2.2497566339028872 -2.6811555509164231 0 0 +2.5068716777775024 -2.9875733281640136 0 0 +-2.5068716777775024 2.9875733281640136 0 0 +-2.2497566339028872 2.6811555509164231 0 0 +5 0 0 0 +-5 6.1232339957367663e-016 0 0 +[NumSegments] = 8 +0 2 -1 0 0 0 +7 9 -1 0 0 0 +3 1 -1 0 0 0 +8 6 -1 0 0 0 +10 11 -1 0 0 0 +12 13 -1 0 0 0 +14 15 -1 0 0 0 +16 17 -1 0 0 0 +[NumArcSegments] = 16 +0 10 10 1 0 0 0 +17 6 10.000000000000004 1 0 0 0 +6 1 80 1 0 0 0 +1 13 10 1 0 0 0 +14 7 10.000000000000004 1 0 0 0 +7 0 80 1 0 0 0 +11 16 80.000000000000043 1 0 0 0 +8 3 80 1 0 0 0 +12 15 80.000000000000043 1 0 0 0 +9 2 80 1 0 0 0 +4 5 180 1 0 0 0 +5 4 180 1 0 0 0 +10 17 80.000000000000043 1 0 0 0 +13 14 80 1 0 0 0 +18 19 180 1 1 0 0 +19 18 180 1 1 0 0 +[NumHoles] = 0 +[NumBlockLabels] = 7 +-0.26000000000000001 0.34000000000000002 3 -1 0 0 0 1 0 +3.7000000000000002 0 1 -1 0 0 0 1 0 "theta" +-3.7000000000000002 0 1 -1 0 0 0 1 0 "theta" +2.6699999999999999 2.6099999999999999 2 -1 0 0 0 1 0 +2.2655965784226036e-016 3.7000000000000002 1 -1 0 0 0 1 0 "theta+180" +-2.2655965784226036e-016 -3.7000000000000002 1 -1 0 0 0 1 0 "theta+180" +0 4.4000000000000004 3 -1 0 0 0 1 0 diff --git a/examples/uniform_field_axi.fem b/examples/uniform_field_axi.fem new file mode 100644 index 0000000..6cc554f --- /dev/null +++ b/examples/uniform_field_axi.fem @@ -0,0 +1,237 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 30 +[DoSmartMesh] = 1 +[Depth] = 1 +[LengthUnits] = inches +[ProblemType] = axisymmetric +[Coordinates] = cartesian +[ACSolver] = 0 +[PrevType] = 0 +[PrevSoln] = "" +[Comment] = "Add comments here." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 8 + + = "u1" + = 2.40892426667406 + = 2.40892426667406 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u2" + = 0.149299291057435 + = 0.149299291057435 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u3" + = 13.817592213008799 + = 13.817592213008799 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u4" + = 0.058217224585269498 + = 0.058217224585269498 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u5" + = 45.655954531895603 + = 45.655954531895603 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u6" + = 0.033867295231772801 + = 0.033867295231772801 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "u7" + = 429.04642800993503 + = 429.04642800993503 + = 795774.71545947704 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 16 +0 2.7705849999999996 0 0 +0 8.7494149999999991 0 0 +0 3.0423499999999999 0 0 +0 8.4776500000000006 0 0 +0 3.0035264285714285 0 0 +0 8.5164735714285715 0 0 +0 2.9647028571428571 0 0 +0 8.5552971428571425 0 0 +0 2.9258792857142852 0 0 +0 8.5941207142857152 0 0 +0 2.8870557142857138 0 0 +0 8.6329442857142862 0 0 +0 2.8482321428571424 0 0 +0 8.6717678571428571 0 0 +0 2.8094085714285715 0 0 +0 8.7105914285714281 0 0 +[NumSegments] = 15 +0 14 -1 0 0 0 +2 3 -1 0 0 0 +3 5 -1 0 0 0 +4 2 -1 0 0 0 +5 7 -1 0 0 0 +6 4 -1 0 0 0 +7 9 -1 0 0 0 +8 6 -1 0 0 0 +9 11 -1 0 0 0 +10 8 -1 0 0 0 +11 13 -1 0 0 0 +12 10 -1 0 0 0 +13 15 -1 0 0 0 +14 12 -1 0 0 0 +15 1 -1 0 0 0 +[NumArcSegments] = 8 +2 3 180 1 0 0 0 1 +4 5 180 1 0 0 0 1 +6 7 180 1 0 0 0 1 +8 9 180 1 0 0 0 1 +10 11 180 1 0 0 0 1 +12 13 180 1 0 0 0 1 +14 15 180 1 0 0 0 1 +0 1 180 1 1 0 0 1 +[NumHoles] = 0 +[NumBlockLabels] = 8 +2.6844699109827519 6.2939742651530386 1 -1 0 90 0 1 0 +2.5645836660620689 6.8222853363234215 2 -1 0 90 0 1 0 +2.3403449415837985 7.3237684953287836 3 -1 0 90 0 1 0 +2.0177521810862022 7.7777521810862016 4 -1 0 90 0 1 0 +1.6069069365792328 8.1649061813516273 5 -1 0 90 0 1 0 +1.1217138866072494 8.4680568781494578 6 -1 0 90 0 1 0 +0.57941888346393566 8.6729354353217101 7 -1 0 90 0 1 0 +0.90000000000000002 5.7999999999999998 8 -1 0 0 0 1 0 diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh index 8314118..d5f1e77 100755 --- a/scripts/macos/build.sh +++ b/scripts/macos/build.sh @@ -30,17 +30,23 @@ if [ ! -f "$TRI" ]; then fi echo "Building Rust workspace (release)..." -cargo build --release -p femm-app +cargo build --release -p femm-app -p femm-mag-solve BIN="$ROOT/target/release/femm" +SOLVE="$ROOT/target/release/femm-mag-solve" if [ ! -f "$BIN" ]; then echo "ERROR: femm binary not found at $BIN" >&2 exit 1 fi +if [ ! -f "$SOLVE" ]; then + echo "ERROR: femm-mag-solve binary not found at $SOLVE" >&2 + exit 1 +fi rm -rf "$APP" mkdir -p "$MACOS" "$RESOURCES" cp "$BIN" "$MACOS/femm" +cp "$SOLVE" "$MACOS/femm-mag-solve" cp "$TRI" "$MACOS/triangle" if [ -f "$SVG" ]; then