1531 lines
63 KiB
Rust
1531 lines
63 KiB
Rust
//! iced shell entry point for the FEMM 4.2 port.
|
||
|
||
mod doc_canvas;
|
||
mod kinematic;
|
||
mod spice;
|
||
|
||
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, scrollable, svg, text, text_input, tooltip};
|
||
use iced::{Alignment, Background, Border, Color, Element, Length, Point, Rectangle, 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,
|
||
ZoomWindowToggle,
|
||
ZoomSelection,
|
||
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<kinematic::Track>,
|
||
t_now: f64,
|
||
dt: f64,
|
||
wall_interval: Duration,
|
||
running: bool,
|
||
computing: bool,
|
||
current: Option<MagSolution>,
|
||
last_solve_ms: u128,
|
||
selected_track: Option<usize>,
|
||
expression_text: String,
|
||
dt_text: String,
|
||
interval_text: String,
|
||
subdivisions: usize,
|
||
subdivisions_text: String,
|
||
}
|
||
|
||
struct App {
|
||
doc: FemmDoc,
|
||
mesh: Option<Mesh>,
|
||
solution: Option<MagSolution>,
|
||
source_label: String,
|
||
source_path: Option<PathBuf>,
|
||
status: String,
|
||
error: Option<ErrorReport>,
|
||
tool: Tool,
|
||
render_mode: RenderMode,
|
||
simulation: Option<Simulation>,
|
||
view_state: ViewState,
|
||
show_grid: bool,
|
||
snap_to_grid: bool,
|
||
zoom_window_active: bool,
|
||
canvas_size: Option<(f32, f32)>,
|
||
}
|
||
|
||
impl App {
|
||
fn new() -> (Self, Task<Message>) {
|
||
let doc = FemmDoc::parse(DEMO_FEM).unwrap_or_default();
|
||
let app = App {
|
||
doc,
|
||
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,
|
||
zoom_window_active: false,
|
||
canvas_size: None,
|
||
};
|
||
(app, Task::none())
|
||
}
|
||
|
||
fn title(&self) -> String {
|
||
format!("femm42 - {}", self.source_label)
|
||
}
|
||
|
||
/// applies a zoom multiplier around a focus offset measured from canvas centre, adjusting pan to keep that world point fixed.
|
||
fn apply_zoom_factor(&mut self, factor: f32, focus_rel: Point) {
|
||
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_rel.x * (1.0 - real) + real * self.view_state.pan.x;
|
||
self.view_state.pan.y = focus_rel.y * (1.0 - real) + real * self.view_state.pan.y;
|
||
self.view_state.zoom = next;
|
||
}
|
||
|
||
/// 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<Message> {
|
||
match msg {
|
||
Message::OpenFem => {
|
||
let picked = rfd::FileDialog::new()
|
||
.add_filter("FEMM magnetostatic", &["fem", "FEM"])
|
||
.add_filter("All files", &["*"])
|
||
.pick_file();
|
||
|
||
if let Some(path) = picked {
|
||
let label = path
|
||
.file_name()
|
||
.and_then(|n| n.to_str())
|
||
.unwrap_or("?")
|
||
.to_string();
|
||
match FemmDoc::open(&path) {
|
||
Ok(d) => {
|
||
self.doc = d;
|
||
self.mesh = None;
|
||
self.solution = None;
|
||
self.source_label = label;
|
||
self.source_path = Some(path);
|
||
self.status = String::new();
|
||
}
|
||
Err(e) => {
|
||
self.status = format!("failed to open {label}: {e}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 => self.apply_zoom_factor(1.25, Point::ORIGIN),
|
||
Message::ZoomOut => self.apply_zoom_factor(0.80, Point::ORIGIN),
|
||
Message::ZoomFit => {
|
||
self.view_state = ViewState::default();
|
||
self.zoom_window_active = false;
|
||
}
|
||
Message::ZoomWindowToggle => {
|
||
self.zoom_window_active = !self.zoom_window_active;
|
||
self.status = if self.zoom_window_active {
|
||
String::from("zoom-window mode: drag a rectangle on the canvas")
|
||
} else {
|
||
String::from("zoom-window cancelled")
|
||
};
|
||
}
|
||
Message::ZoomSelection => {
|
||
let Some((cw, ch)) = self.canvas_size else {
|
||
self.error = Some(ErrorReport {
|
||
title: String::from("canvas size unknown"),
|
||
body: String::from("move the cursor over the canvas once, then try Zoom Selection again."),
|
||
});
|
||
return Task::none();
|
||
};
|
||
match selection_bbox(&self.doc) {
|
||
Some((xmin, xmax, ymin, ymax)) => {
|
||
let rect = Rectangle { x: 0.0, y: 0.0, width: cw, height: ch };
|
||
if let Some((pan, zoom)) = doc_canvas::zoom_window_view(&self.doc, rect, xmin, xmax, ymin, ymax) {
|
||
self.view_state.pan = pan;
|
||
self.view_state.zoom = zoom;
|
||
self.status = format!("zoomed to selection bbox: x [{xmin:.3}, {xmax:.3}], y [{ymin:.3}, {ymax:.3}]");
|
||
}
|
||
}
|
||
None => {
|
||
self.error = Some(ErrorReport {
|
||
title: String::from("nothing selected"),
|
||
body: String::from("select at least one node, segment, arc, or label, then click Zoom Selection."),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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 => {
|
||
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.error = None;
|
||
self.mesh = Some(m);
|
||
}
|
||
Ok(Err(report)) => {
|
||
self.mesh = None;
|
||
self.solution = None;
|
||
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;
|
||
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: {}", 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(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");
|
||
match MagSolution::open(&ans_path) {
|
||
Ok(sol) => {
|
||
self.status = format!(
|
||
"solved: {} mesh nodes, {} elements",
|
||
sol.mesh_nodes.len(), sol.mesh_elements.len(),
|
||
);
|
||
self.error = None;
|
||
self.solution = Some(sol);
|
||
}
|
||
Err(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);
|
||
self.mesh = None;
|
||
self.solution = None;
|
||
self.status = format!("node {idx} at ({:.3}, {:.3})", world.0, world.1);
|
||
}
|
||
Tool::AddBlockLabel => {
|
||
let idx = self.doc.add_block_label(world.0, world.1, ADD_TOLERANCE);
|
||
self.mesh = None;
|
||
self.solution = None;
|
||
self.status = format!("block label {idx} at ({:.3}, {:.3})", world.0, world.1);
|
||
}
|
||
Tool::Select | Tool::AddSegment => {}
|
||
}
|
||
}
|
||
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) {
|
||
self.mesh = None;
|
||
self.status = format!(
|
||
"segment {n0} -> {n1} ({} total)",
|
||
self.doc.segments.len(),
|
||
);
|
||
} else {
|
||
self.status = format!("rejected segment {n0} -> {n1}");
|
||
}
|
||
}
|
||
Message::Canvas(CanvasMessage::PanBy { dx, dy }) => {
|
||
self.view_state.pan = self.view_state.pan + Vector::new(dx, dy);
|
||
}
|
||
Message::Canvas(CanvasMessage::ZoomAt { factor, focus_rel }) => {
|
||
self.apply_zoom_factor(factor, focus_rel);
|
||
}
|
||
Message::Canvas(CanvasMessage::ResetView) => {
|
||
self.view_state = ViewState::default();
|
||
self.zoom_window_active = false;
|
||
}
|
||
Message::Canvas(CanvasMessage::ViewportSize { width, height }) => {
|
||
self.canvas_size = Some((width, height));
|
||
}
|
||
Message::Canvas(CanvasMessage::SetView { pan, zoom }) => {
|
||
self.view_state.pan = pan;
|
||
self.view_state.zoom = zoom;
|
||
self.zoom_window_active = false;
|
||
}
|
||
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 sel_segs: Vec<usize> = self.doc.segments.iter().enumerate()
|
||
.filter_map(|(i, s)| if s.selected { Some(i) } else { None })
|
||
.collect();
|
||
if sel_segs.is_empty() {
|
||
self.error = Some(ErrorReport {
|
||
title: String::from("no segments selected"),
|
||
body: String::from("select at least one segment (left-click or marquee). the track moves every node along selected segments between their leftmost and rightmost endpoints."),
|
||
});
|
||
} 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));
|
||
let mut endpoint_nodes: Vec<usize> = Vec::new();
|
||
for &i in &sel_segs {
|
||
let seg = &self.doc.segments[i];
|
||
if !endpoint_nodes.contains(&(seg.n0 as usize)) { endpoint_nodes.push(seg.n0 as usize); }
|
||
if !endpoint_nodes.contains(&(seg.n1 as usize)) { endpoint_nodes.push(seg.n1 as usize); }
|
||
}
|
||
let mut all_members: Vec<usize> = endpoint_nodes.clone();
|
||
let mut to_subdivide = sel_segs;
|
||
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::<usize>() {
|
||
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 => {
|
||
let Some(sim) = self.simulation.as_mut() else {
|
||
self.error = Some(ErrorReport {
|
||
title: String::from("no simulation"),
|
||
body: String::from("create at least one track before running. select a segment and click \"Track from Selection\"."),
|
||
});
|
||
return Task::none();
|
||
};
|
||
if sim.tracks.is_empty() {
|
||
self.error = Some(ErrorReport {
|
||
title: String::from("no tracks defined"),
|
||
body: String::from("the simulation has no tracks to displace. select a segment, click \"Track from Selection\", then Run."),
|
||
});
|
||
return Task::none();
|
||
}
|
||
sim.running = !sim.running;
|
||
let 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))
|
||
};
|
||
self.status = status;
|
||
if sim.running {
|
||
return Task::done(Message::SimTick);
|
||
}
|
||
}
|
||
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();
|
||
let a = self.doc.delete_selected_arcs();
|
||
let b = self.doc.delete_selected_block_labels();
|
||
let total = n + s + a + b;
|
||
if total > 0 { self.mesh = None; }
|
||
self.status = if total == 0 {
|
||
String::from("nothing selected")
|
||
} else {
|
||
format!("deleted: {n} nodes, {s} segments, {a} arcs, {b} labels")
|
||
};
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
fn view(&self) -> Element<'_, Message> {
|
||
let stats = text(format!(
|
||
"{} nodes {} segments {} arcs {} labels",
|
||
self.doc.nodes.len(),
|
||
self.doc.segments.len(),
|
||
self.doc.arcs.len(),
|
||
self.doc.block_labels.len(),
|
||
))
|
||
.size(12);
|
||
|
||
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)
|
||
.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 zoom_window_tip = if self.zoom_window_active {
|
||
"Zoom to window: drag a rectangle (click again to cancel)"
|
||
} else {
|
||
"Zoom to window"
|
||
};
|
||
let view_strip = column![
|
||
strip_icon_button(ICON_ZOOM_IN, "Zoom in (scroll up)", Some(Message::ZoomIn)),
|
||
strip_icon_button(ICON_ZOOM_OUT, "Zoom out (scroll down)", Some(Message::ZoomOut)),
|
||
strip_icon_button(ICON_ZOOM_FIT, "Zoom to fit", Some(Message::ZoomFit)),
|
||
strip_icon_button(ICON_ZOOM_WINDOW, zoom_window_tip, Some(Message::ZoomWindowToggle)),
|
||
strip_icon_button(ICON_ZOOM_FIT, "Zoom to selection", Some(Message::ZoomSelection)),
|
||
horizontal_separator(),
|
||
strip_icon_button(ICON_GRID, grid_tip, Some(Message::ToggleGrid)),
|
||
strip_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(), active_solution, self.render_mode,
|
||
self.view_state, self.show_grid, self.zoom_window_active,
|
||
).map(Message::Canvas);
|
||
|
||
let canvas_row = row![canvas, view_strip].spacing(6);
|
||
|
||
let mut body = column![toolbar].spacing(8).padding(12);
|
||
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));
|
||
}
|
||
|
||
container(body)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
/// emits SimTick at the simulation's configured wall-clock interval.
|
||
fn subscription(&self) -> Subscription<Message> {
|
||
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.
|
||
fn active_stem() -> std::path::PathBuf {
|
||
let dir = std::env::temp_dir().join("femm42-mesh");
|
||
std::fs::create_dir_all(&dir).ok();
|
||
dir.join("active")
|
||
}
|
||
|
||
/// saves the doc to a temp .fem and matching .poly, invokes Triangle, and reads back the mesh.
|
||
fn run_mesh(doc: &FemmDoc) -> Result<Mesh, ErrorReport> {
|
||
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");
|
||
|
||
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"),
|
||
});
|
||
}
|
||
|
||
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 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}"),
|
||
])
|
||
.arg(stem_str)
|
||
.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| 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 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:?}"),
|
||
})?;
|
||
|
||
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(());
|
||
}
|
||
|
||
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),
|
||
),
|
||
})
|
||
}
|
||
|
||
/// maps a femm-mag-solve exit code onto a pipeline-stage label.
|
||
fn solve_stage_for_exit(code: Option<i32>) -> &'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<i32> {
|
||
use std::os::unix::process::ExitStatusExt;
|
||
status.signal()
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn unix_signal(_status: &std::process::ExitStatus) -> Option<i32> { 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<std::path::PathBuf> {
|
||
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<dyn std::any::Any + Send>) -> String {
|
||
if let Some(s) = payload.downcast_ref::<&'static str>() {
|
||
return (*s).to_string();
|
||
}
|
||
if let Some(s) = payload.downcast_ref::<String>() {
|
||
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<std::path::PathBuf> {
|
||
if let Ok(exe) = std::env::current_exe() {
|
||
if let Some(parent) = exe.parent() {
|
||
let bundled = parent.join("triangle");
|
||
if bundled.is_file() { return Some(bundled); }
|
||
let dev = parent.join("../../build/triangle/triangle");
|
||
if let Ok(canon) = dev.canonicalize() {
|
||
if canon.is_file() { return Some(canon); }
|
||
}
|
||
}
|
||
}
|
||
if let Ok(env_path) = std::env::var("FEMM_TRIANGLE") {
|
||
let p = std::path::PathBuf::from(env_path);
|
||
if p.is_file() { return Some(p); }
|
||
}
|
||
None
|
||
}
|
||
|
||
/// 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<bool> = doc.nodes.iter().map(|n| n.selected).collect();
|
||
let seg_snapshot: Vec<Segment> = doc.segments.iter().cloned().collect();
|
||
let arc_snapshot: Vec<ArcSegment> = doc.arcs.iter().cloned().collect();
|
||
let label_snapshot: Vec<BlockLabel> = 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<i32, i32> = 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<i32, i32>,
|
||
dx: f64,
|
||
dy: f64,
|
||
) -> Option<i32> {
|
||
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)
|
||
}
|
||
|
||
/// computes the (xmin, xmax, ymin, ymax) bounding box of every selected entity, or None when nothing is selected.
|
||
fn selection_bbox(doc: &FemmDoc) -> Option<(f64, f64, f64, f64)> {
|
||
let mut xmin = f64::INFINITY;
|
||
let mut xmax = f64::NEG_INFINITY;
|
||
let mut ymin = f64::INFINITY;
|
||
let mut ymax = f64::NEG_INFINITY;
|
||
let mut hit = false;
|
||
let mut grow = |x: f64, y: f64| {
|
||
if x < xmin { xmin = x; } if x > xmax { xmax = x; }
|
||
if y < ymin { ymin = y; } if y > ymax { ymax = y; }
|
||
};
|
||
for n in &doc.nodes {
|
||
if n.selected { grow(n.x, n.y); hit = true; }
|
||
}
|
||
for s in &doc.segments {
|
||
if s.selected {
|
||
if let (Some(a), Some(b)) = (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) {
|
||
grow(a.x, a.y); grow(b.x, b.y); hit = true;
|
||
}
|
||
}
|
||
}
|
||
for a in &doc.arcs {
|
||
if a.selected {
|
||
if let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) {
|
||
grow(p0.x, p0.y); grow(p1.x, p1.y); hit = true;
|
||
}
|
||
}
|
||
}
|
||
for l in &doc.block_labels {
|
||
if l.selected { grow(l.x, l.y); hit = true; }
|
||
}
|
||
if hit { Some((xmin, xmax, ymin, ymax)) } else { None }
|
||
}
|
||
|
||
#[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};
|
||
|
||
let mut best: Option<(Kind, usize, f64)> = None;
|
||
let mut consider = |kind: Kind, idx: usize, d: f64| {
|
||
match best {
|
||
None => best = Some((kind, idx, d)),
|
||
Some((_, _, bd)) if d < bd => best = Some((kind, idx, d)),
|
||
_ => {}
|
||
}
|
||
};
|
||
|
||
for (i, n) in doc.nodes.iter().enumerate() {
|
||
consider(Kind::Node, i, (n.x - x).hypot(n.y - y));
|
||
}
|
||
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, b) in doc.block_labels.iter().enumerate() {
|
||
consider(Kind::Label, i, (b.x - x).hypot(b.y - y));
|
||
}
|
||
}
|
||
|
||
if matches!(op, PickOp::Replace) { clear_selection(doc); }
|
||
|
||
let hit = best.and_then(|(k, i, d)| if d <= pick_radius_world { Some((k, i)) } else { None });
|
||
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 {
|
||
String::from("nothing at click")
|
||
}
|
||
}
|
||
|
||
/// 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<bool> = 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 button with its tooltip placed to the left, suitable for a vertical right-edge strip.
|
||
fn strip_icon_button<'a>(svg_bytes: &'static [u8], tip: &'a str, msg: Option<Message>) -> 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::Left).into()
|
||
}
|
||
|
||
/// 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<Message>) -> 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 {
|
||
btn.style(button::primary).into()
|
||
} else {
|
||
btn.style(button::secondary).into()
|
||
}
|
||
}
|
||
|
||
fn main() -> iced::Result {
|
||
iced::application(App::new, App::update, App::view)
|
||
.title(App::title)
|
||
.theme(|_app: &App| Theme::Dark)
|
||
.subscription(App::subscription)
|
||
.run()
|
||
}
|