FEMM/crates/femm-app/src/main.rs

1531 lines
63 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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()
}