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