added plots, fixed sessions for sims

This commit is contained in:
jess 2026-05-15 13:20:20 -07:00
parent 880a37ace7
commit d5e0bc2ef9
13 changed files with 3182 additions and 218 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ build/ffi/
*.o
*.a
target/
examples/vids/
assets/old/
assets/icons/
Cargo.lock

View File

@ -51,6 +51,7 @@ pub enum Tool {
AddNode,
AddBlockLabel,
AddSegment,
AddProbe,
}
/// composition mode for a pick action, derived from modifier keys at click/release time.
@ -124,9 +125,10 @@ pub fn view<'a>(
view_state: ViewState,
show_grid: bool,
zoom_window_active: bool,
probes: &'a [crate::probe::Probe],
) -> Element<'a, CanvasMessage> {
Canvas::new(DocCanvas {
doc, tool, mesh, solution, render_mode, view_state, show_grid, zoom_window_active,
doc, tool, mesh, solution, render_mode, view_state, show_grid, zoom_window_active, probes,
})
.width(Length::Fill)
.height(Length::Fill)
@ -142,6 +144,7 @@ struct DocCanvas<'a> {
view_state: ViewState,
show_grid: bool,
zoom_window_active: bool,
probes: &'a [crate::probe::Probe],
}
impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
@ -519,6 +522,21 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
});
}
for probe in self.probes {
let p = view.map(probe.x, probe.y);
let color = Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], probe.color[3] as f32 / 255.0);
frame.fill(&Path::circle(p, 6.0), color);
frame.stroke(&Path::circle(p, 6.0),
Stroke::default().with_width(1.5).with_color(Color::WHITE));
frame.fill_text(Text {
content: probe.label.clone(),
position: Point::new(p.x + 9.0, p.y - 9.0),
color,
size: 11.0.into(),
..Text::default()
});
}
if let (Some(start), true, Some(now)) =
(state.marquee_from, state.dragged, state.cursor_canvas)
{
@ -549,7 +567,7 @@ impl<'a> canvas::Program<CanvasMessage> for DocCanvas<'a> {
if cursor.position_in(bounds).is_some() {
return match self.tool {
Tool::Select => mouse::Interaction::Grab,
Tool::AddNode | Tool::AddBlockLabel | Tool::AddSegment => {
Tool::AddNode | Tool::AddBlockLabel | Tool::AddSegment | Tool::AddProbe => {
mouse::Interaction::Crosshair
}
};

View File

@ -29,6 +29,8 @@ pub struct ExportInput {
pub height: u32,
pub contour: bool,
pub crf: u32,
pub probes: Vec<crate::probe::Probe>,
pub probe_samples: Vec<HashMap<i64, (f64, f64)>>,
pub progress: Arc<Mutex<ExportProgress>>,
}
@ -86,6 +88,9 @@ pub fn export_webm(input: ExportInput) -> Result<ExportReport, String> {
"-pix_fmt", "yuv420p",
"-crf", crf_arg.as_str(),
"-b:v", "0",
"-r", fps_arg.as_str(),
"-fps_mode", "cfr",
"-vsync", "cfr",
"-deadline", "good",
"-cpu-used", "2",
"-row-mt", "1",
@ -135,7 +140,12 @@ pub fn export_webm(input: ExportInput) -> Result<ExportReport, String> {
let t = (f as f64) * input.dt;
kinematic::apply_tracks(&mut doc, &input.base_doc, &input.tracks, t);
let pixmap = render_frame(&doc, sol, input.width, input.height, input.contour, xmin, xmax, ymin, ymax);
let pixmap = render_frame(
&doc, sol, input.width, input.height, input.contour,
xmin, xmax, ymin, ymax,
&input.probes, &input.probe_samples,
f, input.buffer_size,
);
let frame_bytes = pixmap.data();
if frame_bytes.len() != expected_bytes_per_frame {
drop(stdin);
@ -276,10 +286,16 @@ fn render_frame(
height: u32,
contour: bool,
xmin: f64, xmax: f64, ymin: f64, ymax: f64,
probes: &[crate::probe::Probe],
probe_samples: &[HashMap<i64, (f64, f64)>],
current_frame: i64,
buffer_size: usize,
) -> Pixmap {
let mut pixmap = Pixmap::new(width, height).expect("allocate pixmap");
pixmap.fill(Color::from_rgba8(20, 20, 24, 255));
let view = View::fit(width, height, xmin, xmax, ymin, ymax);
let plot_strip_h = if probes.is_empty() { 0 } else { (height as f32 * 0.33) as u32 };
let canvas_h = height - plot_strip_h;
let view = View::fit(width, canvas_h, xmin, xmax, ymin, ymax);
let (lo, hi) = sol.b_magnitude_range();
let span = if hi > lo { hi - lo } else { 1.0 };
@ -366,9 +382,163 @@ fn render_frame(
}
}
for probe in probes {
let (px, py) = view.map(probe.x, probe.y);
paint.set_color(Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], 255));
let mut pb = PathBuilder::new();
pb.push_circle(px, py, 6.0);
if let Some(path) = pb.finish() {
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
paint.set_color(Color::from_rgba8(255, 255, 255, 255));
let ring = Stroke { width: 1.6, ..Default::default() };
let mut pb = PathBuilder::new();
pb.push_circle(px, py, 6.0);
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &ring, Transform::identity(), None);
}
}
if plot_strip_h > 0 && !probes.is_empty() {
draw_plot_strip(
&mut pixmap,
0, canvas_h, width, plot_strip_h,
probes, probe_samples,
current_frame, buffer_size,
);
}
pixmap
}
/// draws one plot pane per probe across a horizontal strip at (x0, y0, w, h), each pane color-matched to its probe and marked with a vertical bar at the current frame.
fn draw_plot_strip(
pixmap: &mut Pixmap,
x0: u32, y0: u32, w: u32, h: u32,
probes: &[crate::probe::Probe],
probe_samples: &[HashMap<i64, (f64, f64)>],
current_frame: i64,
buffer_size: usize,
) {
let n = probes.len().max(1);
let pane_w = (w as f32) / (n as f32);
let pane_x_top_pad = 4.0;
let pane_y_top_pad = 18.0;
let pane_bot_pad = 6.0;
let pane_lr_pad = 4.0;
let mut paint = Paint::default();
paint.anti_alias = true;
paint.set_color(Color::from_rgba8(12, 14, 20, 255));
let strip = tiny_skia::Rect::from_xywh(x0 as f32, y0 as f32, w as f32, h as f32);
if let Some(rect) = strip {
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
}
for (i, probe) in probes.iter().enumerate() {
let px0 = x0 as f32 + (i as f32) * pane_w + pane_lr_pad;
let py0 = y0 as f32 + pane_x_top_pad;
let pw = pane_w - 2.0 * pane_lr_pad;
let ph = (h as f32) - pane_x_top_pad - pane_bot_pad;
if pw <= 0.0 || ph <= 0.0 { continue; }
paint.set_color(Color::from_rgba8(18, 20, 26, 255));
if let Some(r) = tiny_skia::Rect::from_xywh(px0, py0, pw, ph) {
pixmap.fill_rect(r, &paint, Transform::identity(), None);
}
let plot_x = px0 + 4.0;
let plot_y = py0 + pane_y_top_pad;
let plot_w = pw - 8.0;
let plot_h = ph - pane_y_top_pad - 6.0;
if plot_w <= 0.0 || plot_h <= 0.0 { continue; }
let probe_color = Color::from_rgba8(probe.color[0], probe.color[1], probe.color[2], 255);
paint.set_color(probe_color);
let samples = probe_samples.get(i);
let mut points: Vec<(i64, f64)> = Vec::new();
if let Some(s) = samples {
for (&fi, &(bx, by)) in s {
points.push((fi, probe.mode.extract(bx, by)));
}
}
if points.is_empty() {
continue;
}
let mut ymin = f64::INFINITY;
let mut ymax = f64::NEG_INFINITY;
for &(_, v) in &points {
if v < ymin { ymin = v; }
if v > ymax { ymax = v; }
}
if !ymin.is_finite() || !ymax.is_finite() { ymin = -1.0; ymax = 1.0; }
if (ymax - ymin).abs() < 1e-12 { ymin -= 1.0; ymax += 1.0; }
points.sort_by_key(|p| p.0);
let buf = buffer_size.max(1) as f64;
let x_for = |fi: i64| -> f32 {
let t = (fi.rem_euclid(buf as i64) as f64) / buf;
plot_x + (t as f32) * plot_w
};
let y_for = |v: f64| -> f32 {
let t = (v - ymin) / (ymax - ymin);
plot_y + plot_h - (t as f32) * plot_h
};
let axis_stroke = Stroke { width: 1.0, ..Default::default() };
paint.set_color(Color::from_rgba8(60, 65, 75, 255));
let mut pb = PathBuilder::new();
pb.move_to(plot_x, plot_y + plot_h);
pb.line_to(plot_x + plot_w, plot_y + plot_h);
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &axis_stroke, Transform::identity(), None);
}
let mid_y = y_for((ymin + ymax) * 0.5);
paint.set_color(Color::from_rgba8(50, 55, 65, 255));
let mut pb = PathBuilder::new();
pb.move_to(plot_x, mid_y);
pb.line_to(plot_x + plot_w, mid_y);
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &axis_stroke, Transform::identity(), None);
}
paint.set_color(probe_color);
let line_stroke = Stroke { width: 1.4, ..Default::default() };
let mut pb = PathBuilder::new();
let mut started = false;
for (fi, v) in &points {
let x = x_for(*fi);
let y = y_for(*v);
if !started { pb.move_to(x, y); started = true; }
else { pb.line_to(x, y); }
}
if started {
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &line_stroke, Transform::identity(), None);
}
}
let cx = x_for(current_frame);
paint.set_color(Color::from_rgba8(255, 255, 255, 200));
let cur_stroke = Stroke { width: 1.2, ..Default::default() };
let mut pb = PathBuilder::new();
pb.move_to(cx, plot_y);
pb.line_to(cx, plot_y + plot_h);
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &cur_stroke, Transform::identity(), None);
}
let label_dot_r = 4.0;
paint.set_color(probe_color);
let mut pb = PathBuilder::new();
pb.push_circle(px0 + 8.0, py0 + 9.0, label_dot_r);
if let Some(path) = pb.finish() {
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
}
/// returns a polyline approximating an arc between two endpoints across a CCW degree sweep, capped at max_steps subdivisions.
fn arc_polyline(x0: f64, y0: f64, x1: f64, y1: f64, sweep_deg: f64, max_steps: usize) -> Vec<(f64, f64)> {
let theta = sweep_deg.to_radians();

View File

@ -86,7 +86,7 @@ impl Expression {
}
}
/// one continuous-time track: clamped chord, axis, member nodes, the closed-form displacement expression, and the pre-subdivision edge spine used to re-subdivide on demand.
/// one continuous-time track: clamped chord, axis, member nodes, the closed-form displacement expression, the pre-subdivision edge spine, and the per-track subdivision count regenerated against a stashed pristine doc.
#[derive(Debug, Clone)]
pub struct Track {
pub label: String,
@ -97,6 +97,7 @@ pub struct Track {
pub expression: Expression,
pub edge_endpoints: Vec<(usize, usize)>,
pub edge_subdivisions: Vec<Vec<usize>>,
pub subdivisions: usize,
}
/// snaps doc nodes to base and layers each track's displacement at simulated time t.
@ -184,148 +185,53 @@ pub fn track_from_selection(
expression,
edge_endpoints: Vec::new(),
edge_subdivisions: Vec::new(),
subdivisions: 0,
})
}
/// removes the chain of segments inside each edge and replaces it with a fresh chain at `count` subdivisions, appending new node indices to the doc. orphans the prior subdivision nodes to preserve every other track's index references.
pub fn rebuild_track_subdivisions(track: &mut Track, doc: &mut FemmDoc, count: usize) -> Result<(), String> {
if count < 2 {
return Err(String::from("subdivision count must be at least 2"));
}
if track.edge_endpoints.is_empty() {
let (eps, subs) = infer_edges_from_positions(track, doc);
if eps.is_empty() {
return Err(String::from("track has no recorded edges and inference from positions found none - delete and recreate the track"));
/// resets the live doc to a clone of the pristine doc, then re-subdivides every track's edges at the track's per-track subdivision count, refreshing each track's member_nodes and edge_subdivisions. orphan-free because no prior subdivision nodes survive the reset.
pub fn rebuild_all_tracks_from_pristine(
live_doc: &mut FemmDoc,
pristine: &FemmDoc,
tracks: &mut [Track],
) -> Result<(), String> {
*live_doc = pristine.clone();
for (ti, track) in tracks.iter_mut().enumerate() {
if track.edge_endpoints.is_empty() {
return Err(format!("track {} has no recorded edges; delete and recreate it", ti + 1));
}
track.edge_endpoints = eps;
track.edge_subdivisions = subs;
}
while track.edge_subdivisions.len() < track.edge_endpoints.len() {
track.edge_subdivisions.push(Vec::new());
}
let mut new_subdivisions: Vec<Vec<usize>> = Vec::with_capacity(track.edge_endpoints.len());
for (edge_idx, (a, b)) in track.edge_endpoints.iter().copied().enumerate() {
let old_subs = track.edge_subdivisions[edge_idx].clone();
let edge_nodes: std::collections::HashSet<usize> = std::iter::once(a)
.chain(std::iter::once(b))
.chain(old_subs.iter().copied())
.collect();
doc.segments.retain(|s| {
!(edge_nodes.contains(&(s.n0 as usize)) && edge_nodes.contains(&(s.n1 as usize)))
});
let pa = match doc.nodes.get(a) {
Some(n) => (n.x, n.y),
None => return Err(format!("edge endpoint {a} out of bounds")),
};
let pb = match doc.nodes.get(b) {
Some(n) => (n.x, n.y),
None => return Err(format!("edge endpoint {b} out of bounds")),
};
let mut new_subs: Vec<usize> = Vec::with_capacity(count.saturating_sub(1));
for i in 1..count {
let t = i as f64 / count as f64;
let x = pa.0 + (pb.0 - pa.0) * t;
let y = pa.1 + (pb.1 - pa.1) * t;
let idx = doc.nodes.len();
doc.nodes.push(femm_doc_mag::Node {
x, y,
boundary_marker: String::new(),
in_group: 0,
selected: false,
let count = if track.subdivisions >= 2 { track.subdivisions } else { 20 };
let mut new_edge_subs: Vec<Vec<usize>> = Vec::with_capacity(track.edge_endpoints.len());
for &(a, b) in &track.edge_endpoints {
let seg_idx = live_doc.segments.iter().position(|s| {
let n0 = s.n0 as usize;
let n1 = s.n1 as usize;
(n0 == a && n1 == b) || (n0 == b && n1 == a)
});
new_subs.push(idx);
}
let mut prev = a;
for &mid in &new_subs {
doc.segments.push(femm_doc_mag::Segment {
n0: prev as i32,
n1: mid as i32,
max_side_length: -1.0,
boundary_marker: String::new(),
hidden: false,
in_group: 0,
selected: false,
});
prev = mid;
}
doc.segments.push(femm_doc_mag::Segment {
n0: prev as i32,
n1: b as i32,
max_side_length: -1.0,
boundary_marker: String::new(),
hidden: false,
in_group: 0,
selected: false,
});
new_subdivisions.push(new_subs);
}
let mut corner_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
for &(a, b) in &track.edge_endpoints {
corner_set.insert(a);
corner_set.insert(b);
}
let mut new_members: Vec<usize> = Vec::new();
for &c in &corner_set {
if c != track.anchor_a && c != track.anchor_b { new_members.push(c); }
}
for subs in &new_subdivisions {
for &s in subs { new_members.push(s); }
}
track.member_nodes = new_members;
track.edge_subdivisions = new_subdivisions;
Ok(())
}
/// reconstructs a track's edges from its node positions: clusters anchors+members by the lateral coordinate (perpendicular to the displacement axis), and treats each cluster as one edge with extremes as endpoints and middles as subdivisions.
fn infer_edges_from_positions(track: &Track, doc: &FemmDoc) -> (Vec<(usize, usize)>, Vec<Vec<usize>>) {
let along_x = matches!(track.axis, Axis::PlusY | Axis::MinusY);
let pos = |i: usize| doc.nodes.get(i).map(|n| (n.x, n.y));
let perp = move |i: usize| pos(i).map(|(x, y)| if along_x { x } else { y });
let lat = move |i: usize| pos(i).map(|(x, y)| if along_x { y } else { x });
let mut all_indices: Vec<usize> = Vec::with_capacity(track.member_nodes.len() + 2);
all_indices.push(track.anchor_a);
all_indices.push(track.anchor_b);
all_indices.extend(&track.member_nodes);
let tol = 0.05_f64;
let mut clusters: Vec<(f64, Vec<usize>)> = Vec::new();
for &idx in &all_indices {
let v = match lat(idx) { Some(v) => v, None => continue };
let mut placed = false;
for cluster in &mut clusters {
if (v - cluster.0).abs() < tol {
cluster.1.push(idx);
placed = true;
break;
match seg_idx {
Some(i) => {
let subs = live_doc.subdivide_segment(i, count)
.into_iter().map(|n| n as usize).collect();
new_edge_subs.push(subs);
}
None => {
new_edge_subs.push(Vec::new());
}
}
}
if !placed { clusters.push((v, vec![idx])); }
}
let mut endpoints = Vec::new();
let mut subdivisions = Vec::new();
for (_, cluster) in clusters {
if cluster.len() < 2 { continue; }
let mut sorted: Vec<(usize, f64)> = cluster.iter()
.filter_map(|&i| perp(i).map(|p| (i, p)))
let mut corner_set: std::collections::HashSet<usize> = std::collections::HashSet::new();
for &(a, b) in &track.edge_endpoints {
corner_set.insert(a);
corner_set.insert(b);
}
let mut members: Vec<usize> = corner_set.into_iter()
.filter(|&c| c != track.anchor_a && c != track.anchor_b)
.collect();
sorted.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let a = sorted.first().unwrap().0;
let b = sorted.last().unwrap().0;
let subs: Vec<usize> = if sorted.len() >= 3 {
sorted[1..sorted.len()-1].iter().map(|(i, _)| *i).collect()
} else {
Vec::new()
};
endpoints.push((a, b));
subdivisions.push(subs);
for subs in &new_edge_subs {
for &s in subs { members.push(s); }
}
track.edge_subdivisions = new_edge_subs;
track.member_nodes = members;
}
(endpoints, subdivisions)
Ok(())
}

View File

@ -3,6 +3,8 @@
mod doc_canvas;
mod export;
mod kinematic;
mod plot;
mod probe;
mod session;
mod sim_meta;
mod spice;
@ -96,6 +98,9 @@ enum Message {
SimExportWebM,
SimExportComplete(Result<export::ExportReport, ErrorReport>),
SimExportPoll,
ProbeSetMode(usize, probe::ProbeMode),
ProbeRemove(usize),
ProbeExportWav(usize),
}
/// copyable diagnostic shown when a pipeline stage fails.
@ -130,6 +135,8 @@ struct Simulation {
fundamental_hz: f64,
fundamental_hz_text: String,
loop_playback: bool,
probes: Vec<probe::Probe>,
probe_samples: Vec<std::collections::HashMap<i64, (f64, f64)>>,
}
struct App {
@ -149,6 +156,7 @@ struct App {
zoom_window_active: bool,
canvas_size: Option<(f32, f32)>,
export_progress: Option<std::sync::Arc<std::sync::Mutex<export::ExportProgress>>>,
pristine_doc: Option<FemmDoc>,
}
impl App {
@ -171,6 +179,7 @@ impl App {
zoom_window_active: false,
canvas_size: None,
export_progress: None,
pristine_doc: None,
};
(app, Task::none())
}
@ -199,11 +208,80 @@ impl App {
}
}
/// reads a .femsess from path and replaces the doc, simulation, pristine snapshot, and source path on success.
fn load_session_from_path(&mut self, path: &Path) -> Task<Message> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("read session failed"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
let (base_doc, pristine_doc, meta) = match session::parse(&text) {
Ok(triple) => triple,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("parse session failed"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
let tracks = match sim_meta::resolve_tracks(&meta, &base_doc) {
Ok(t) => t,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("session tracks do not match embedded doc"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
self.doc = base_doc.clone();
self.mesh = None;
self.solution = None;
self.error = None;
self.pristine_doc = pristine_doc;
let mut sim = new_simulation(base_doc, String::new());
if let Some(secs) = spice::parse_spice(&meta.dt_text) { sim.dt = secs; }
sim.dt_text = meta.dt_text.clone();
if let Some(secs) = spice::parse_spice(&meta.interval_text) {
sim.wall_interval = Duration::from_secs_f64(secs);
}
sim.interval_text = meta.interval_text.clone();
sim.subdivisions = meta.subdivisions;
sim.subdivisions_text = meta.subdivisions.to_string();
sim.buffer_size = meta.buffer_size;
sim.buffer_size_text = meta.buffer_size.to_string();
sim.match_cycle = meta.match_cycle;
sim.loop_playback = meta.loop_playback;
sim.fundamental_hz = meta.fundamental_hz;
sim.fundamental_hz_text = format!("{}", meta.fundamental_hz);
sim.tracks = tracks;
if !sim.tracks.is_empty() {
sim.selected_track = Some(0);
sim.expression_text = sim.tracks[0].expression.source.clone();
}
maybe_recompute_buffer(&mut sim);
self.simulation = Some(sim);
self.source_path = meta.source_fem.as_ref().map(PathBuf::from);
self.source_label = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
self.status = format!("loaded session: {}", path.display());
Task::none()
}
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("FEMM magnetostatic or session", &["fem", "FEM", "femsess"])
.add_filter("All files", &["*"])
.pick_file();
@ -213,11 +291,19 @@ impl App {
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
let is_session = path.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("femsess"))
.unwrap_or(false);
if is_session {
return self.load_session_from_path(&path);
}
match FemmDoc::open(&path) {
Ok(d) => {
self.doc = d;
self.mesh = None;
self.solution = None;
self.pristine_doc = None;
self.source_label = label;
self.source_path = Some(path);
self.status = String::new();
@ -232,6 +318,7 @@ impl App {
self.doc = FemmDoc::default();
self.mesh = None;
self.solution = None;
self.pristine_doc = None;
self.source_label = String::from("untitled");
self.source_path = None;
self.error = None;
@ -435,6 +522,28 @@ impl App {
self.solution = None;
self.status = format!("block label {idx} at ({:.3}, {:.3})", world.0, world.1);
}
Tool::AddProbe => {
if self.simulation.is_none() {
self.simulation = Some(new_simulation(self.doc.clone(), String::new()));
}
let sim = self.simulation.as_mut().unwrap();
let idx = sim.probes.len();
let p = probe::Probe {
x: world.0, y: world.1,
label: format!("p{}", idx + 1),
color: probe::palette_color(idx),
mode: probe::ProbeMode::default(),
};
let mut samples = std::collections::HashMap::new();
for (&fi, sol) in &sim.lut {
if let Some(bxby) = sol.sample_b_at(p.x, p.y) {
samples.insert(fi, bxby);
}
}
sim.probes.push(p);
sim.probe_samples.push(samples);
self.status = format!("probe p{} placed at ({:.3}, {:.3})", idx + 1, world.0, world.1);
}
Tool::Select | Tool::AddSegment => {}
}
}
@ -495,6 +604,9 @@ impl App {
body: String::from("select at least one segment or arc (left-click or marquee). the track moves every node along the selection between its leftmost and rightmost endpoints."),
});
} else {
if self.pristine_doc.is_none() {
self.pristine_doc = Some(self.doc.clone());
}
let subdivisions = self.simulation.as_ref()
.map(|s| s.subdivisions)
.unwrap_or(SIM_DEFAULT_SUBDIVISIONS);
@ -537,6 +649,7 @@ impl App {
Ok(mut track) => {
track.edge_endpoints = edge_endpoints;
track.edge_subdivisions = edge_subdivisions;
track.subdivisions = subdivisions;
if self.simulation.is_none() {
self.simulation = Some(new_simulation(self.doc.clone(), expr_text));
}
@ -671,20 +784,27 @@ impl App {
self.status = format!("subdivisions for new tracks set to {n}");
return Task::none();
};
let mut track = sim.tracks[selected].clone();
let rebuild = kinematic::rebuild_track_subdivisions(&mut track, &mut self.doc, n);
sim.tracks[selected].subdivisions = n;
let Some(pristine) = self.pristine_doc.clone() else {
self.error = Some(ErrorReport {
title: String::from("no pristine doc stashed"),
body: String::from("this session was loaded without a pristine snapshot. delete and recreate all tracks (or load from a newer session) to enable subdivision rebuild."),
});
return Task::none();
};
let sim_mut = self.simulation.as_mut().unwrap();
let rebuild = kinematic::rebuild_all_tracks_from_pristine(&mut self.doc, &pristine, &mut sim_mut.tracks);
match rebuild {
Ok(()) => {
let sim = self.simulation.as_mut().unwrap();
sim.tracks[selected] = track;
sim.base_doc = self.doc.clone();
invalidate_lut(sim);
self.status = format!("rebuilt track {} with {n} subdivisions per edge", selected + 1);
let sim_mut = self.simulation.as_mut().unwrap();
sim_mut.base_doc = self.doc.clone();
invalidate_lut(sim_mut);
self.status = format!("rebuilt all tracks from pristine; track {} now at {n} subdivisions", selected + 1);
}
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("subdivision rebuild failed"),
body: format!("{e}\n\ndelete and recreate the track to enable rebuild for tracks created before this feature."),
body: e,
});
}
}
@ -789,6 +909,14 @@ impl App {
if frame_idx == sim.frame_idx && sim.current.is_none() {
sim.current = Some(sol.clone());
}
while sim.probe_samples.len() < sim.probes.len() {
sim.probe_samples.push(std::collections::HashMap::new());
}
for (i, p) in sim.probes.iter().enumerate() {
if let Some(bxby) = sol.sample_b_at(p.x, p.y) {
sim.probe_samples[i].insert(frame_idx, bxby);
}
}
sim.lut.insert(frame_idx, sol);
}
Err(report) => {
@ -975,7 +1103,7 @@ impl App {
.save_file();
if let Some(path) = picked {
let meta = build_sim_meta(sim, self.source_path.as_deref());
let text = session::serialize(&sim.base_doc, &meta);
let text = session::serialize(&sim.base_doc, self.pristine_doc.as_ref(), &meta);
match std::fs::write(&path, text) {
Ok(()) => self.status = format!("saved session: {}", path.display()),
Err(e) => self.error = Some(ErrorReport {
@ -990,64 +1118,7 @@ impl App {
.add_filter("femm42 session", &["femsess"])
.pick_file();
let Some(path) = picked else { return Task::none(); };
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("read session failed"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
let (base_doc, meta) = match session::parse(&text) {
Ok(pair) => pair,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("parse session failed"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
let tracks = match sim_meta::resolve_tracks(&meta, &base_doc) {
Ok(t) => t,
Err(e) => {
self.error = Some(ErrorReport {
title: String::from("session tracks do not match embedded doc"),
body: format!("{path:?}\n\n{e}"),
});
return Task::none();
}
};
self.doc = base_doc.clone();
self.mesh = None;
self.solution = None;
self.error = None;
let mut sim = new_simulation(base_doc, String::new());
if let Some(secs) = spice::parse_spice(&meta.dt_text) { sim.dt = secs; }
sim.dt_text = meta.dt_text.clone();
if let Some(secs) = spice::parse_spice(&meta.interval_text) {
sim.wall_interval = Duration::from_secs_f64(secs);
}
sim.interval_text = meta.interval_text.clone();
sim.subdivisions = meta.subdivisions;
sim.subdivisions_text = meta.subdivisions.to_string();
sim.buffer_size = meta.buffer_size;
sim.buffer_size_text = meta.buffer_size.to_string();
sim.match_cycle = meta.match_cycle;
sim.loop_playback = meta.loop_playback;
sim.fundamental_hz = meta.fundamental_hz;
sim.fundamental_hz_text = format!("{}", meta.fundamental_hz);
sim.tracks = tracks;
if !sim.tracks.is_empty() {
sim.selected_track = Some(0);
sim.expression_text = sim.tracks[0].expression.source.clone();
}
maybe_recompute_buffer(&mut sim);
self.simulation = Some(sim);
self.source_path = meta.source_fem.as_ref().map(PathBuf::from);
self.status = format!("loaded session: {}", path.display());
return self.load_session_from_path(&path);
}
Message::SimExportWebM => {
let Some(sim) = self.simulation.as_ref() else {
@ -1094,6 +1165,8 @@ impl App {
height: 1080,
contour,
crf: 18,
probes: sim.probes.clone(),
probe_samples: sim.probe_samples.clone(),
progress: std::sync::Arc::clone(&progress),
};
self.export_progress = Some(progress);
@ -1135,6 +1208,61 @@ impl App {
};
}
}
Message::ProbeSetMode(i, mode) => {
if let Some(sim) = self.simulation.as_mut() {
if let Some(p) = sim.probes.get_mut(i) {
p.mode = mode;
}
}
}
Message::ProbeRemove(i) => {
if let Some(sim) = self.simulation.as_mut() {
if i < sim.probes.len() {
sim.probes.remove(i);
if i < sim.probe_samples.len() {
sim.probe_samples.remove(i);
}
}
}
}
Message::ProbeExportWav(i) => {
let Some(sim) = self.simulation.as_ref() else { return Task::none(); };
let Some(p) = sim.probes.get(i) else { return Task::none(); };
let Some(samples) = sim.probe_samples.get(i) else { return Task::none(); };
if samples.is_empty() {
self.error = Some(ErrorReport {
title: String::from("no samples"),
body: String::from("fill the buffer (run the sim with loop enabled) before exporting a wav."),
});
return Task::none();
}
let default_name = format!("{}_{}.wav", p.label, match p.mode {
probe::ProbeMode::Magnitude => "magB",
probe::ProbeMode::Bx => "Bx",
probe::ProbeMode::By => "By",
probe::ProbeMode::Angle => "ang",
});
let picked = rfd::FileDialog::new()
.add_filter("WAV audio", &["wav"])
.set_file_name(&default_name)
.save_file();
let Some(path) = picked else { return Task::none(); };
let mut ordered: Vec<(i64, f64)> = samples.iter()
.map(|(&fi, &(bx, by))| (fi, p.mode.extract(bx, by)))
.collect();
ordered.sort_by_key(|s| s.0);
let values: Vec<f64> = ordered.into_iter().map(|(_, v)| v).collect();
let sample_rate = (1.0 / sim.dt).round().max(1.0) as u32;
match write_wav_mono_16bit_normalized(&path, &values, sample_rate) {
Ok(()) => self.status = format!(
"wrote {}: {} samples at {} Hz", path.display(), values.len(), sample_rate,
),
Err(e) => self.error = Some(ErrorReport {
title: String::from("wav export failed"),
body: format!("{path:?}\n\n{e}"),
}),
}
}
Message::SimToggleLoop(checked) => {
if let Some(sim) = self.simulation.as_mut() {
sim.loop_playback = checked;
@ -1174,10 +1302,10 @@ impl App {
.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)),
icon_button(ICON_FILE_NEW, "New problem", Some(Message::NewDoc)),
icon_button(ICON_FILE_OPEN, "Open .fem or .femsess", Some(Message::OpenFem)),
icon_button(ICON_FILE_SAVE, "Save", Some(Message::SaveDoc)),
icon_button(ICON_FILE_SAVE_AS, "Save As", Some(Message::SaveDocAs)),
].spacing(2);
let tool_group = row![
@ -1185,6 +1313,7 @@ impl App {
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),
text_button("Probe", Message::SelectTool(Tool::AddProbe)),
].spacing(2);
let edit_group = row![
@ -1247,9 +1376,12 @@ impl App {
.and_then(|s| s.current.as_ref())
.or(self.solution.as_ref());
let probes_slice: &[probe::Probe] = self.simulation.as_ref()
.map(|s| s.probes.as_slice())
.unwrap_or(&[]);
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,
self.view_state, self.show_grid, self.zoom_window_active, probes_slice,
).map(Message::Canvas);
let canvas_row = row![canvas, view_strip]
@ -1268,6 +1400,42 @@ impl App {
body = body.push(simulation_panel(sim));
}
body = body.push(canvas_row);
if let Some(sim) = &self.simulation {
if !sim.probes.is_empty() {
static EMPTY_MAP: std::sync::LazyLock<std::collections::HashMap<i64, (f64, f64)>>
= std::sync::LazyLock::new(std::collections::HashMap::new);
let mut pane_row = row![].spacing(8);
for (i, p) in sim.probes.iter().enumerate() {
let samples = sim.probe_samples.get(i).unwrap_or(&*EMPTY_MAP);
let mode_row = row![
button(text(p.label.clone()).size(11)).style(button::secondary),
button(text("|B|").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Magnitude))
.style(if p.mode == probe::ProbeMode::Magnitude { button::primary } else { button::secondary }),
button(text("Bx").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Bx))
.style(if p.mode == probe::ProbeMode::Bx { button::primary } else { button::secondary }),
button(text("By").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::By))
.style(if p.mode == probe::ProbeMode::By { button::primary } else { button::secondary }),
button(text("ang").size(10)).on_press(Message::ProbeSetMode(i, probe::ProbeMode::Angle))
.style(if p.mode == probe::ProbeMode::Angle { button::primary } else { button::secondary }),
button(text("wav").size(10)).on_press(Message::ProbeExportWav(i)).style(button::secondary),
button(text("x").size(10)).on_press(Message::ProbeRemove(i)).style(button::danger),
].spacing(2);
let plot_canvas: Element<'_, Message> =
plot::view::<Message>(p, samples, sim.frame_idx, sim.buffer_size.max(1));
let pane = column![mode_row, plot_canvas]
.spacing(2)
.width(Length::FillPortion(1))
.height(Length::Fill);
pane_row = pane_row.push(pane);
}
body = body.push(
container(pane_row)
.width(Length::Fill)
.height(Length::FillPortion(1))
.padding(4)
);
}
}
if !self.status.is_empty() {
body = body.push(text(&self.status).size(12));
}
@ -1319,6 +1487,8 @@ fn new_simulation(base_doc: FemmDoc, expression_text: String) -> Simulation {
fundamental_hz: SIM_DEFAULT_FUNDAMENTAL_HZ,
fundamental_hz_text: format!("{SIM_DEFAULT_FUNDAMENTAL_HZ}"),
loop_playback: false,
probes: Vec::new(),
probe_samples: Vec::new(),
}
}
@ -1339,6 +1509,7 @@ fn build_sim_meta(sim: &Simulation, source: Option<&Path>) -> sim_meta::SimMeta
axis: t.axis,
expression: t.expression.source.clone(),
edges,
subdivisions: t.subdivisions,
}
}).collect();
sim_meta::SimMeta {
@ -1370,6 +1541,46 @@ fn invalidate_lut(sim: &mut Simulation) {
sim.pending.clear();
sim.generation = sim.generation.wrapping_add(1);
sim.current = None;
for samples in sim.probe_samples.iter_mut() {
samples.clear();
}
}
/// writes mono 16-bit PCM WAV with the given sample rate, normalising the input so the peak magnitude maps to i16 full-scale.
fn write_wav_mono_16bit_normalized(path: &Path, values: &[f64], sample_rate: u32) -> std::io::Result<()> {
use std::io::Write;
let mut peak = 0.0_f64;
for &v in values {
let a = v.abs();
if a.is_finite() && a > peak { peak = a; }
}
let scale = if peak > 0.0 { (i16::MAX as f64 - 1.0) / peak } else { 0.0 };
let mut pcm: Vec<i16> = Vec::with_capacity(values.len());
for &v in values {
let s = if v.is_finite() { (v * scale).round() } else { 0.0 };
pcm.push(s.clamp(i16::MIN as f64, i16::MAX as f64) as i16);
}
let data_len = (pcm.len() * 2) as u32;
let chunk_size = 36 + data_len;
let byte_rate = sample_rate * 2;
let mut f = std::fs::File::create(path)?;
f.write_all(b"RIFF")?;
f.write_all(&chunk_size.to_le_bytes())?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
f.write_all(&16u32.to_le_bytes())?;
f.write_all(&1u16.to_le_bytes())?;
f.write_all(&1u16.to_le_bytes())?;
f.write_all(&sample_rate.to_le_bytes())?;
f.write_all(&byte_rate.to_le_bytes())?;
f.write_all(&2u16.to_le_bytes())?;
f.write_all(&16u16.to_le_bytes())?;
f.write_all(b"data")?;
f.write_all(&data_len.to_le_bytes())?;
for s in &pcm {
f.write_all(&s.to_le_bytes())?;
}
Ok(())
}
/// caps concurrent solve jobs at cores x 4, clamped to [8, 64].

161
crates/femm-app/src/plot.rs Normal file
View File

@ -0,0 +1,161 @@
//! per-probe waveform plot widget rendered as an iced canvas, color-matched to the probe marker on the main canvas.
use iced::widget::canvas::{self, Canvas, Frame, Geometry, Path, Stroke, Text};
use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse};
use crate::probe::Probe;
/// renders one probe's plot pane: a labeled waveform of the chosen signal across every frame currently in samples, with a vertical bar marking the current playback frame.
pub fn view<'a, Msg: 'a + Clone>(
probe: &'a Probe,
samples: &'a std::collections::HashMap<i64, (f64, f64)>,
current_frame: i64,
buffer_size: usize,
) -> Element<'a, Msg> {
Canvas::new(PlotProgram {
probe,
samples,
current_frame,
buffer_size,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
struct PlotProgram<'a> {
probe: &'a Probe,
samples: &'a std::collections::HashMap<i64, (f64, f64)>,
current_frame: i64,
buffer_size: usize,
}
impl<'a, Msg> canvas::Program<Msg> for PlotProgram<'a> {
type State = ();
fn draw(
&self,
_state: &Self::State,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry<Renderer>> {
let mut frame = Frame::new(renderer, bounds.size());
let bg = Color::from_rgba8(18, 20, 26, 1.0);
frame.fill_rectangle(Point::ORIGIN, bounds.size(), bg);
let pad_l = 8.0_f32;
let pad_r = 8.0_f32;
let pad_t = 18.0_f32;
let pad_b = 16.0_f32;
let plot_w = (bounds.width - pad_l - pad_r).max(1.0);
let plot_h = (bounds.height - pad_t - pad_b).max(1.0);
let probe_color = Color::from_rgba8(
self.probe.color[0], self.probe.color[1], self.probe.color[2], 1.0,
);
frame.fill_text(Text {
content: format!("{} - {}", self.probe.label, self.probe.mode.label()),
position: Point::new(pad_l, 2.0),
color: probe_color,
size: 11.0.into(),
..Text::default()
});
let buf = self.buffer_size.max(1);
let mut points: Vec<(i64, f64)> = Vec::with_capacity(self.samples.len());
for (&fi, &(bx, by)) in self.samples {
points.push((fi, self.probe.mode.extract(bx, by)));
}
if points.is_empty() {
frame.fill_text(Text {
content: String::from("no samples yet"),
position: Point::new(pad_l + 4.0, pad_t + 4.0),
color: Color::from_rgba8(140, 140, 150, 1.0),
size: 10.0.into(),
..Text::default()
});
return vec![frame.into_geometry()];
}
let mut ymin = f64::INFINITY;
let mut ymax = f64::NEG_INFINITY;
for &(_, v) in &points {
if v < ymin { ymin = v; }
if v > ymax { ymax = v; }
}
if !ymin.is_finite() || !ymax.is_finite() {
ymin = -1.0; ymax = 1.0;
}
if (ymax - ymin).abs() < 1e-12 {
ymin -= 1.0; ymax += 1.0;
}
points.sort_by_key(|p| p.0);
let x_for = |fi: i64| -> f32 {
let t = (fi.rem_euclid(buf as i64) as f64) / (buf as f64).max(1.0);
pad_l + (t as f32) * plot_w
};
let y_for = |v: f64| -> f32 {
let t = (v - ymin) / (ymax - ymin);
pad_t + plot_h - (t as f32) * plot_h
};
frame.stroke(
&Path::line(
Point::new(pad_l, pad_t + plot_h),
Point::new(pad_l + plot_w, pad_t + plot_h),
),
Stroke::default().with_width(0.5).with_color(Color::from_rgba8(80, 80, 90, 1.0)),
);
let mid_y = y_for((ymin + ymax) * 0.5);
frame.stroke(
&Path::line(
Point::new(pad_l, mid_y),
Point::new(pad_l + plot_w, mid_y),
),
Stroke::default().with_width(0.5).with_color(Color::from_rgba8(60, 60, 70, 1.0)),
);
let mut path = canvas::path::Builder::new();
let mut started = false;
for (fi, v) in &points {
let x = x_for(*fi);
let y = y_for(*v);
if !started { path.move_to(Point::new(x, y)); started = true; }
else { path.line_to(Point::new(x, y)); }
}
if started {
frame.stroke(&path.build(),
Stroke::default().with_width(1.4).with_color(probe_color));
}
let cx = x_for(self.current_frame);
frame.stroke(
&Path::line(
Point::new(cx, pad_t),
Point::new(cx, pad_t + plot_h),
),
Stroke::default().with_width(1.0).with_color(Color::from_rgba8(255, 255, 255, 0.55)),
);
frame.fill_text(Text {
content: format!("{:.3e}", ymax),
position: Point::new(pad_l, pad_t - 12.0),
color: Color::from_rgba8(150, 150, 160, 1.0),
size: 9.0.into(),
..Text::default()
});
frame.fill_text(Text {
content: format!("{:.3e}", ymin),
position: Point::new(pad_l, pad_t + plot_h + 2.0),
color: Color::from_rgba8(150, 150, 160, 1.0),
size: 9.0.into(),
..Text::default()
});
vec![frame.into_geometry()]
}
}

View File

@ -0,0 +1,55 @@
//! point-sample probes for the magnetic field, time-series plotted per frame in a panel below the canvas.
/// signal extracted from the (bx, by) sample at a probe location for waveform plotting.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProbeMode {
#[default]
Magnitude,
Bx,
By,
Angle,
}
impl ProbeMode {
pub fn label(self) -> &'static str {
match self {
ProbeMode::Magnitude => "|B|",
ProbeMode::Bx => "Bx",
ProbeMode::By => "By",
ProbeMode::Angle => "atan2(By,Bx)",
}
}
pub fn extract(self, bx: f64, by: f64) -> f64 {
match self {
ProbeMode::Magnitude => (bx * bx + by * by).sqrt(),
ProbeMode::Bx => bx,
ProbeMode::By => by,
ProbeMode::Angle => by.atan2(bx),
}
}
}
/// one user-placed probe with a world position, display color, label, and plot-mode selector.
#[derive(Debug, Clone)]
pub struct Probe {
pub x: f64,
pub y: f64,
pub label: String,
pub color: [u8; 4],
pub mode: ProbeMode,
}
/// returns a saturated palette color from a small cycling palette suitable for probe markers and matching plot lines.
pub fn palette_color(idx: usize) -> [u8; 4] {
const PALETTE: &[[u8; 4]] = &[
[231, 76, 60, 255],
[241, 196, 15, 255],
[ 46, 204, 113, 255],
[ 52, 152, 219, 255],
[155, 89, 182, 255],
[230, 126, 34, 255],
[ 26, 188, 156, 255],
[255, 105, 180, 255],
];
PALETTE[idx % PALETTE.len()]
}

View File

@ -4,13 +4,22 @@ use femm_doc_mag::FemmDoc;
use crate::sim_meta::{self, SimMeta};
const SESSION_HEADER: &str = "# femm42 session v1\n";
const PRISTINE_OPEN: &str = "[pristine-doc]\n";
const PRISTINE_CLOSE: &str = "[/pristine-doc]\n";
const DOC_OPEN: &str = "[base-doc]\n";
const DOC_CLOSE: &str = "[/base-doc]\n";
/// joins the base .fem text and the sim metadata into one delimited session blob.
pub fn serialize(base_doc: &FemmDoc, meta: &SimMeta) -> String {
/// joins the optional pristine .fem text, the live base .fem text, and the sim metadata into one delimited session blob.
pub fn serialize(base_doc: &FemmDoc, pristine_doc: Option<&FemmDoc>, meta: &SimMeta) -> String {
let mut out = String::new();
out.push_str(SESSION_HEADER);
if let Some(p) = pristine_doc {
out.push_str(PRISTINE_OPEN);
let pt = p.write();
out.push_str(&pt);
if !pt.ends_with('\n') { out.push('\n'); }
out.push_str(PRISTINE_CLOSE);
}
out.push_str(DOC_OPEN);
let doc_text = base_doc.write();
out.push_str(&doc_text);
@ -20,8 +29,19 @@ pub fn serialize(base_doc: &FemmDoc, meta: &SimMeta) -> String {
out
}
/// extracts the base FemmDoc and SimMeta from a session blob, failing if the markers or sub-payloads are malformed.
pub fn parse(text: &str) -> Result<(FemmDoc, SimMeta), String> {
/// extracts the optional pristine FemmDoc, the base FemmDoc, and SimMeta from a session blob.
pub fn parse(text: &str) -> Result<(FemmDoc, Option<FemmDoc>, SimMeta), String> {
let pristine = if let Some(p_start) = text.find(PRISTINE_OPEN) {
let body_start = p_start + PRISTINE_OPEN.len();
let after_body = text[body_start..]
.find(PRISTINE_CLOSE)
.ok_or_else(|| String::from("missing [/pristine-doc] marker"))?;
let p_text = &text[body_start..body_start + after_body];
Some(FemmDoc::parse(p_text).map_err(|e| format!("parse [pristine-doc] failed: {e}"))?)
} else {
None
};
let doc_start = text.find(DOC_OPEN).ok_or_else(|| String::from("missing [base-doc] section"))?;
let body_start = doc_start + DOC_OPEN.len();
let after_body = text[body_start..]
@ -33,5 +53,5 @@ pub fn parse(text: &str) -> Result<(FemmDoc, SimMeta), String> {
let doc = FemmDoc::parse(doc_text)
.map_err(|e| format!("parse [base-doc] failed: {e}"))?;
let meta = sim_meta::parse_meta(meta_text)?;
Ok((doc, meta))
Ok((doc, pristine, meta))
}

View File

@ -25,6 +25,7 @@ pub struct TrackMeta {
pub axis: Axis,
pub expression: String,
pub edges: Vec<(usize, usize, Vec<usize>)>,
pub subdivisions: usize,
}
/// renders a SimMeta into the on-disk text format.
@ -51,6 +52,7 @@ pub fn serialize_meta(meta: &SimMeta) -> String {
s.push_str(&format!("anchor_b = {}\n", track.anchor_b));
s.push_str(&format!("axis = {}\n", axis_to_str(track.axis)));
s.push_str(&format!("expression = {}\n", track.expression));
s.push_str(&format!("subdivisions = {}\n", track.subdivisions));
s.push_str("members =");
for n in &track.members { s.push_str(&format!(" {n}")); }
s.push_str("\n");
@ -95,6 +97,7 @@ pub fn parse_meta(text: &str) -> Result<SimMeta, String> {
axis: Axis::PlusY,
expression: String::from("0"),
edges: Vec::new(),
subdivisions: 0,
}); Section::Track }
other => return Err(format!("unknown section {other:?} at line {}", line_no + 1)),
};
@ -134,6 +137,9 @@ pub fn parse_meta(text: &str) -> Result<SimMeta, String> {
track.members.push(parse_usize(tok, line_no)?);
}
}
"subdivisions" => {
track.subdivisions = parse_usize(value, line_no)?;
}
"edge" => {
let mut iter = value.split_whitespace();
let a = iter.next().ok_or_else(|| format!("edge missing first endpoint at line {}", line_no + 1))?;
@ -203,6 +209,7 @@ pub fn resolve_tracks(meta: &SimMeta, doc: &FemmDoc) -> Result<Vec<Track>, Strin
expression: expr,
edge_endpoints,
edge_subdivisions,
subdivisions: if tm.subdivisions >= 2 { tm.subdivisions } else { 20 },
});
}
Ok(out)

View File

@ -175,6 +175,21 @@ impl MagSolution {
+ el.b2.re * el.b2.re + el.b2.im * el.b2.im).sqrt()
}
/// looks up the mesh triangle containing world point (x, y) and returns its (bx, by) real components, or None when the point lies outside every element.
pub fn sample_b_at(&self, x: f64, y: f64) -> Option<(f64, f64)> {
for el in &self.mesh_elements {
let (Some(a), Some(b), Some(c)) = (
self.mesh_nodes.get(el.n[0] as usize),
self.mesh_nodes.get(el.n[1] as usize),
self.mesh_nodes.get(el.n[2] as usize),
) else { continue };
if point_in_triangle((x, y), (a.x, a.y), (b.x, b.y), (c.x, c.y)) {
return Some((el.b1.re, el.b2.re));
}
}
None
}
/// (min, max) of |B| across every element, skipping NaN.
pub fn b_magnitude_range(&self) -> (f64, f64) {
let mut lo = f64::INFINITY;
@ -335,6 +350,19 @@ fn take_i32(s: &str) -> (i32, &str) {
(head.parse::<i32>().unwrap_or(0), tail)
}
/// returns true when point p lies inside (or on the boundary of) the triangle a-b-c, via the half-plane sign test.
fn point_in_triangle(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> bool {
let sign = |p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)| -> f64 {
(p1.0 - p3.0) * (p2.1 - p3.1) - (p2.0 - p3.0) * (p1.1 - p3.1)
};
let d1 = sign(p, a, b);
let d2 = sign(p, b, c);
let d3 = sign(p, c, a);
let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(neg && pos)
}
#[cfg(test)]
mod tests {
use super::*;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,233 @@
[Format] = 4.0
[Frequency] = 0
[Precision] = 1e-008
[MinAngle] = 25
[Depth] = 5
[LengthUnits] = millimeters
[ProblemType] = planar
[Coordinates] = cartesian
[Comment] = "quarter-scale fretboard: six plain steel strings (.009/.011/.016/.026/.036/.046) spanning 162 mm. each string is anchored on the left to its own isolated NdFeB pole-piece magnet (mag_dir 0, N face into the string) and on the right to a shared steel block that ties all six strings together. a bar magnet on the right face of the steel block (mag_dir 0) drives flux through the steel and out into the air return."
[PointProps] = 0
[BdryProps] = 1
<BeginBdry>
<BdryName> = "A=0"
<BdryType> = 0
<A_0> = 0
<A_1> = 0
<A_2> = 0
<Phi> = 0
<c0> = 0
<c0i> = 0
<c1> = 0
<c1i> = 0
<Mu_ssd> = 0
<Sigma_ssd> = 0
<EndBdry>
[BlockProps] = 3
<BeginBlock>
<BlockName> = "Air"
<Mu_x> = 1
<Mu_y> = 1
<H_c> = 0
<H_cAngle> = 0
<J_re> = 0
<J_im> = 0
<Sigma> = 0
<d_lam> = 0
<Phi_h> = 0
<Phi_hx> = 0
<Phi_hy> = 0
<LamType> = 0
<LamFill> = 1
<NStrands> = 0
<WireD> = 0
<BHPoints> = 0
<EndBlock>
<BeginBlock>
<BlockName> = "NdFeB"
<Mu_x> = 1.05
<Mu_y> = 1.05
<H_c> = 915000
<H_cAngle> = 0
<J_re> = 0
<J_im> = 0
<Sigma> = 0.667
<d_lam> = 0
<Phi_h> = 0
<Phi_hx> = 0
<Phi_hy> = 0
<LamType> = 0
<LamFill> = 1
<NStrands> = 0
<WireD> = 0
<BHPoints> = 0
<EndBlock>
<BeginBlock>
<BlockName> = "Steel"
<Mu_x> = 2500
<Mu_y> = 2500
<H_c> = 0
<H_cAngle> = 0
<J_re> = 0
<J_im> = 0
<Sigma> = 5.8
<d_lam> = 0
<Phi_h> = 0
<Phi_hx> = 0
<Phi_hy> = 0
<LamType> = 0
<LamFill> = 1
<NStrands> = 0
<WireD> = 0
<BHPoints> = 0
<EndBlock>
[CircuitProps] = 0
[NumPoints] = 58
-100 -100 0 0
300 -100 0 0
300 100 0 0
-100 100 0 0
-10 -30.25 0 0
0 -30.25 0 0
0 -26.8342 0 0
0 -25.6658 0 0
0 -22.25 0 0
-10 -22.25 0 0
-10 -19.75 0 0
0 -19.75 0 0
0 -16.2072 0 0
0 -15.2928 0 0
0 -11.75 0 0
-10 -11.75 0 0
-10 -9.25 0 0
0 -9.25 0 0
0 -5.5802 0 0
0 -4.9198 0 0
0 -1.25 0 0
-10 -1.25 0 0
-10 1.25 0 0
0 1.25 0 0
0 5.0468 0 0
0 5.4532 0 0
0 9.25 0 0
-10 9.25 0 0
-10 11.75 0 0
0 11.75 0 0
0 15.6103 0 0
0 15.8897 0 0
0 19.75 0 0
-10 19.75 0 0
-10 22.25 0 0
0 22.25 0 0
0 26.1357 0 0
0 26.3643 0 0
0 30.25 0 0
-10 30.25 0 0
162 -30 0 0
162 -26.8342 0 0
162 -25.6658 0 0
162 -16.2072 0 0
162 -15.2928 0 0
162 -5.5802 0 0
162 -4.9198 0 0
162 5.0468 0 0
162 5.4532 0 0
162 15.6103 0 0
162 15.8897 0 0
162 26.1357 0 0
162 26.3643 0 0
162 30 0 0
192 -30 0 0
192 30 0 0
222 -30 0 0
222 30 0 0
[NumSegments] = 71
0 1 -1 1 0 0
1 2 -1 1 0 0
2 3 -1 1 0 0
3 0 -1 1 0 0
4 9 -1 0 0 0
9 8 -1 0 0 0
8 7 -1 0 0 0
7 6 -1 0 0 0
6 5 -1 0 0 0
5 4 -1 0 0 0
10 15 -1 0 0 0
15 14 -1 0 0 0
14 13 -1 0 0 0
13 12 -1 0 0 0
12 11 -1 0 0 0
11 10 -1 0 0 0
16 21 -1 0 0 0
21 20 -1 0 0 0
20 19 -1 0 0 0
19 18 -1 0 0 0
18 17 -1 0 0 0
17 16 -1 0 0 0
22 27 -1 0 0 0
27 26 -1 0 0 0
26 25 -1 0 0 0
25 24 -1 0 0 0
24 23 -1 0 0 0
23 22 -1 0 0 0
28 33 -1 0 0 0
33 32 -1 0 0 0
32 31 -1 0 0 0
31 30 -1 0 0 0
30 29 -1 0 0 0
29 28 -1 0 0 0
34 39 -1 0 0 0
39 38 -1 0 0 0
38 37 -1 0 0 0
37 36 -1 0 0 0
36 35 -1 0 0 0
35 34 -1 0 0 0
7 42 -1 0 0 0
6 41 -1 0 0 0
13 44 -1 0 0 0
12 43 -1 0 0 0
19 46 -1 0 0 0
18 45 -1 0 0 0
25 48 -1 0 0 0
24 47 -1 0 0 0
31 50 -1 0 0 0
30 49 -1 0 0 0
37 52 -1 0 0 0
36 51 -1 0 0 0
40 41 -1 0 0 0
41 42 -1 0 0 0
42 43 -1 0 0 0
43 44 -1 0 0 0
44 45 -1 0 0 0
45 46 -1 0 0 0
46 47 -1 0 0 0
47 48 -1 0 0 0
48 49 -1 0 0 0
49 50 -1 0 0 0
50 51 -1 0 0 0
51 52 -1 0 0 0
52 53 -1 0 0 0
54 55 -1 0 0 0
53 55 -1 0 0 0
54 40 -1 0 0 0
56 57 -1 0 0 0
55 57 -1 0 0 0
56 54 -1 0 0 0
[NumArcSegments] = 0
[NumHoles] = 0
[NumBlockLabels] = 15
-50 0 1 -1 0 0 0 1 2
-5 -26.25 2 5 0 0 0 1 0
-5 -15.75 2 5 0 0 0 1 0
-5 -5.25 2 5 0 0 0 1 0
-5 5.25 2 5 0 0 0 1 0
-5 15.75 2 5 0 0 0 1 0
-5 26.25 2 5 0 0 0 1 0
81 -26.25 3 1 0 0 0 1 0
81 -15.75 3 1 0 0 0 1 0
81 -5.25 3 1 0 0 0 1 0
81 5.25 3 1 0 0 0 1 0
81 15.75 3 1 0 0 0 1 0
81 26.25 3 1 0 0 0 1 0
177 0 3 5 0 0 0 1 0
207 0 2 5 0 0 0 1 0

File diff suppressed because it is too large Load Diff