diff --git a/assets/femm.svg b/assets/femm.svg index 3271172..d2f8281 100644 --- a/assets/femm.svg +++ b/assets/femm.svg @@ -1,5 +1,125 @@ - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/femm-app/Cargo.toml b/crates/femm-app/Cargo.toml index 24e02a3..f568250 100644 --- a/crates/femm-app/Cargo.toml +++ b/crates/femm-app/Cargo.toml @@ -15,3 +15,6 @@ femm-doc-mag = { workspace = true } iced = { version = "0.14", features = ["canvas", "svg", "tokio"] } rfd = "0.17" meval = "0.2" +tokio = { version = "1", features = ["rt", "rt-multi-thread"] } +libc = "0.2" +tiny-skia = "0.11" diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index 8af57f7..501dc04 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -8,7 +8,7 @@ use iced::widget::canvas::{ }; use iced::{Color, Element, Length, Point, Radians, Rectangle, Renderer, Theme, Vector, mouse}; -const PADDING_PX: f32 = 24.0; +const PADDING_PX: f32 = 48.0; const NODE_RADIUS: f32 = 3.0; const STROKE_WIDTH: f32 = 1.2; const LABEL_TICK_PX: f32 = 6.0; @@ -29,9 +29,9 @@ const FLUX_LINE_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.85); const FLUX_LINE_STROKE: f32 = 0.6; const BAND_COUNT: usize = 20; const FLUX_LINE_COUNT: usize = 19; -const GRID_COLOR: Color = Color::from_rgba(0.5, 0.5, 0.5, 0.25); -const GRID_STROKE: f32 = 0.5; -const GRID_MIN_PX_SPACING: f32 = 4.0; +const GRID_COLOR: Color = Color::from_rgba(0.55, 0.65, 0.85, 0.55); +const GRID_STROKE: f32 = 0.6; +const GRID_MIN_PX_SPACING: f32 = 5.0; /// field-plot mode applied on top of the FE solution. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] diff --git a/crates/femm-app/src/export.rs b/crates/femm-app/src/export.rs new file mode 100644 index 0000000..f7356f3 --- /dev/null +++ b/crates/femm-app/src/export.rs @@ -0,0 +1,423 @@ +//! offline RGBA frame renderer piping to an ffmpeg VP9 encoder. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use femm_doc_mag::FemmDoc; +use femm_doc_mag::ans::MagSolution; +use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform}; + +use crate::kinematic::{self, Track}; + +const BAND_COUNT: usize = 20; +const FLUX_LINE_COUNT: usize = 19; +const PADDING_PX: f64 = 48.0; + +pub struct ExportInput { + pub dest: PathBuf, + pub base_doc: FemmDoc, + pub tracks: Vec, + pub dt: f64, + pub buffer_size: usize, + pub fps: f64, + pub lut: HashMap, + pub width: u32, + pub height: u32, + pub contour: bool, + pub crf: u32, + pub progress: Arc>, +} + +#[derive(Debug, Clone, Default)] +pub struct ExportProgress { + pub phase: ExportPhase, + pub frames_done: usize, + pub frames_total: usize, + pub bytes_sent: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ExportPhase { + #[default] + Idle, + Rendering, + Finalizing, + Done, +} + +#[derive(Debug, Clone)] +pub struct ExportReport { + pub written: usize, + pub dest: PathBuf, +} + +/// renders each buffered frame to a pixmap and pipes the raw RGBA bytes to a libvpx-vp9 ffmpeg subprocess. +pub fn export_webm(input: ExportInput) -> Result { + if input.buffer_size == 0 { + return Err(String::from("buffer is empty - set a buffer size and run the sim first")); + } + for f in 0..(input.buffer_size as i64) { + if !input.lut.contains_key(&f) { + return Err(format!("frame {f} of {} not cached - fill the buffer before exporting", input.buffer_size)); + } + } + + let size_arg = format!("{}x{}", input.width, input.height); + let fps_arg = format!("{:.6}", input.fps.max(1.0)); + let dest_str = input.dest.to_string_lossy().to_string(); + let ffmpeg = locate_ffmpeg().ok_or_else(|| String::from( + "ffmpeg binary not found. install via `brew install ffmpeg`, or set FFMPEG_PATH to its absolute path. searched: $FFMPEG_PATH, $PATH, /opt/homebrew/bin/ffmpeg, /usr/local/bin/ffmpeg, /opt/local/bin/ffmpeg, /usr/bin/ffmpeg" + ))?; + let crf_arg = input.crf.clamp(0, 63).to_string(); + let mut ff = Command::new(&ffmpeg) + .args([ + "-y", + "-loglevel", "info", + "-f", "rawvideo", + "-pixel_format", "rgba", + "-video_size", size_arg.as_str(), + "-framerate", fps_arg.as_str(), + "-i", "-", + "-c:v", "libvpx-vp9", + "-pix_fmt", "yuv420p", + "-crf", crf_arg.as_str(), + "-b:v", "0", + "-deadline", "good", + "-cpu-used", "2", + "-row-mt", "1", + "-tile-columns", "2", + "-threads", "8", + dest_str.as_str(), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("ffmpeg spawn failed: {e}"))?; + let mut stdin = ff.stdin.take().ok_or_else(|| String::from("ffmpeg stdin pipe missing"))?; + + let stderr_buf = Arc::new(Mutex::new(Vec::::new())); + let stderr_pipe = ff.stderr.take().ok_or_else(|| String::from("ffmpeg stderr pipe missing"))?; + let stderr_thread = { + let buf = Arc::clone(&stderr_buf); + thread::spawn(move || { + let mut reader = stderr_pipe; + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => buf.lock().unwrap().extend_from_slice(&chunk[..n]), + } + } + }) + }; + + let (xmin, xmax, ymin, ymax) = world_bounds(&input.base_doc); + let expected_bytes_per_frame = (input.width as usize) * (input.height as usize) * 4; + + { + let mut p = input.progress.lock().unwrap(); + p.phase = ExportPhase::Rendering; + p.frames_done = 0; + p.frames_total = input.buffer_size; + p.bytes_sent = 0; + } + + let mut written = 0usize; + let mut bytes_sent = 0usize; + for f in 0..(input.buffer_size as i64) { + let sol = input.lut.get(&f).expect("frame presence verified above"); + let mut doc = input.base_doc.clone(); + 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 frame_bytes = pixmap.data(); + if frame_bytes.len() != expected_bytes_per_frame { + drop(stdin); + let _ = ff.kill(); + let _ = stderr_thread.join(); + return Err(format!( + "frame {f} produced {} bytes, expected {} (width={}, height={})", + frame_bytes.len(), expected_bytes_per_frame, input.width, input.height, + )); + } + if let Err(e) = stdin.write_all(frame_bytes) { + drop(stdin); + let _ = ff.wait(); + let _ = stderr_thread.join(); + let log = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).to_string(); + return Err(format!( + "write frame {f} to ffmpeg failed after {bytes_sent} bytes ({written} frames): {e}\n\n--- ffmpeg log ---\n{log}", + )); + } + bytes_sent += frame_bytes.len(); + written += 1; + { + let mut p = input.progress.lock().unwrap(); + p.frames_done = written; + p.bytes_sent = bytes_sent; + } + } + drop(stdin); + { + let mut p = input.progress.lock().unwrap(); + p.phase = ExportPhase::Finalizing; + } + let status = ff.wait().map_err(|e| format!("ffmpeg wait failed: {e}"))?; + let _ = stderr_thread.join(); + let log = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).to_string(); + + let dest_size = std::fs::metadata(&input.dest).map(|m| m.len()).unwrap_or(0); + if !status.success() { + return Err(format!( + "ffmpeg exited with {status}. wrote {written} frames ({bytes_sent} bytes) to stdin, output {dest_size} bytes.\n\n--- ffmpeg log ---\n{log}", + )); + } + if dest_size == 0 { + return Err(format!( + "ffmpeg succeeded but the output is 0 bytes. wrote {written} frames ({bytes_sent} bytes) to stdin at {input_w}x{input_h} rgba, {fps} fps.\n\n--- ffmpeg log ---\n{log}", + input_w = input.width, input_h = input.height, fps = fps_arg, + )); + } + { + let mut p = input.progress.lock().unwrap(); + p.phase = ExportPhase::Done; + } + Ok(ExportReport { written, dest: input.dest }) +} + +/// locates an ffmpeg binary via $FFMPEG_PATH, $PATH, and the standard Homebrew, MacPorts, and system locations. +fn locate_ffmpeg() -> Option { + if let Ok(p) = std::env::var("FFMPEG_PATH") { + let pb = std::path::PathBuf::from(p); + if pb.is_file() { return Some(pb); } + } + if let Ok(path) = std::env::var("PATH") { + for dir in path.split(':') { + let pb = std::path::PathBuf::from(dir).join("ffmpeg"); + if pb.is_file() { return Some(pb); } + } + } + for candidate in [ + "/opt/homebrew/bin/ffmpeg", + "/usr/local/bin/ffmpeg", + "/opt/local/bin/ffmpeg", + "/usr/bin/ffmpeg", + ] { + let pb = std::path::PathBuf::from(candidate); + if pb.is_file() { return Some(pb); } + } + None +} + +fn world_bounds(doc: &FemmDoc) -> (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 had_point = false; + for n in &doc.nodes { + if n.x < xmin { xmin = n.x; } + if n.x > xmax { xmax = n.x; } + if n.y < ymin { ymin = n.y; } + if n.y > ymax { ymax = n.y; } + had_point = true; + } + for l in &doc.block_labels { + if l.x < xmin { xmin = l.x; } + if l.x > xmax { xmax = l.x; } + if l.y < ymin { ymin = l.y; } + if l.y > ymax { ymax = l.y; } + had_point = true; + } + if !had_point { return (-1.0, 1.0, -1.0, 1.0); } + if (xmax - xmin).abs() < 1e-9 { xmin -= 0.5; xmax += 0.5; } + if (ymax - ymin).abs() < 1e-9 { ymin -= 0.5; ymax += 0.5; } + (xmin, xmax, ymin, ymax) +} + +struct View { + scale: f64, + offset_x: f64, + offset_y: f64, + y_max: f64, +} + +impl View { + fn fit(width: u32, height: u32, xmin: f64, xmax: f64, ymin: f64, ymax: f64) -> Self { + let avail_w = (width as f64 - 2.0 * PADDING_PX).max(1.0); + let avail_h = (height as f64 - 2.0 * PADDING_PX).max(1.0); + let dx = (xmax - xmin).max(1e-9); + let dy = (ymax - ymin).max(1e-9); + let scale = (avail_w / dx).min(avail_h / dy); + let drawn_w = dx * scale; + let drawn_h = dy * scale; + let offset_x = (width as f64 - drawn_w) / 2.0 - xmin * scale; + let offset_y = (height as f64 - drawn_h) / 2.0; + let _ = ymin; + Self { scale, offset_x, offset_y, y_max: ymax } + } + fn map(&self, x: f64, y: f64) -> (f32, f32) { + let px = x * self.scale + self.offset_x; + let py = (self.y_max - y) * self.scale + self.offset_y; + (px as f32, py as f32) + } +} + +fn render_frame( + doc: &FemmDoc, + sol: &MagSolution, + width: u32, + height: u32, + contour: bool, + xmin: f64, xmax: f64, ymin: f64, ymax: f64, +) -> 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 (lo, hi) = sol.b_magnitude_range(); + let span = if hi > lo { hi - lo } else { 1.0 }; + + let mut paint = Paint::default(); + paint.anti_alias = true; + + for i in 0..sol.mesh_elements.len() { + let el = &sol.mesh_elements[i]; + let (Some(a), Some(b), Some(c)) = ( + sol.mesh_nodes.get(el.n[0] as usize), + sol.mesh_nodes.get(el.n[1] as usize), + sol.mesh_nodes.get(el.n[2] as usize), + ) else { continue }; + let (pa_x, pa_y) = view.map(a.x, a.y); + let (pb_x, pb_y) = view.map(b.x, b.y); + let (pc_x, pc_y) = view.map(c.x, c.y); + let t = ((sol.b_magnitude(i) - lo) / span).clamp(0.0, 1.0); + let t_mapped = if contour { + let band = (t * BAND_COUNT as f64).floor().min(BAND_COUNT as f64 - 1.0); + (band + 0.5) / BAND_COUNT as f64 + } else { + t + }; + paint.set_color(jet_color(t_mapped as f32)); + let mut pb = PathBuilder::new(); + pb.move_to(pa_x, pa_y); + pb.line_to(pb_x, pb_y); + pb.line_to(pc_x, pc_y); + pb.close(); + if let Some(path) = pb.finish() { + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + } + + if contour { + let (a_lo, a_hi) = sol.a_real_range(); + if a_hi > a_lo { + let mut levels = Vec::with_capacity(FLUX_LINE_COUNT); + let denom = (FLUX_LINE_COUNT + 1) as f64; + for k in 1..=FLUX_LINE_COUNT { + levels.push(a_lo + (a_hi - a_lo) * (k as f64) / denom); + } + paint.set_color(Color::from_rgba8(20, 20, 24, 220)); + let stroke = Stroke { width: 1.0, ..Default::default() }; + for (p0, p1) in sol.flux_lines(&levels) { + let (ax, ay) = view.map(p0.0, p0.1); + let (bx, by) = view.map(p1.0, p1.1); + let mut pb = PathBuilder::new(); + pb.move_to(ax, ay); + pb.line_to(bx, by); + if let Some(path) = pb.finish() { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + } + + paint.set_color(Color::from_rgba8(230, 230, 235, 255)); + let stroke = Stroke { width: 1.4, ..Default::default() }; + for s in &doc.segments { + if let (Some(p0), Some(p1)) = (doc.nodes.get(s.n0 as usize), doc.nodes.get(s.n1 as usize)) { + let (ax, ay) = view.map(p0.x, p0.y); + let (bx, by) = view.map(p1.x, p1.y); + let mut pb = PathBuilder::new(); + pb.move_to(ax, ay); + pb.line_to(bx, by); + if let Some(path) = pb.finish() { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + + for a in &doc.arcs { + let (Some(p0), Some(p1)) = (doc.nodes.get(a.n0 as usize), doc.nodes.get(a.n1 as usize)) else { continue }; + let polyline = arc_polyline(p0.x, p0.y, p1.x, p1.y, a.arc_length, 24); + let mut pb = PathBuilder::new(); + for (i, (x, y)) in polyline.iter().enumerate() { + let (px, py) = view.map(*x, *y); + if i == 0 { pb.move_to(px, py); } else { pb.line_to(px, py); } + } + if let Some(path) = pb.finish() { + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + + pixmap +} + +/// 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(); + if theta.abs() < 1e-9 { return vec![(x0, y0), (x1, y1)]; } + let dx = x1 - x0; + let dy = y1 - y0; + let chord = (dx * dx + dy * dy).sqrt(); + if chord < 1e-12 { return vec![(x0, y0)]; } + let radius = chord / (2.0 * (theta / 2.0).sin().abs()); + let mid_x = (x0 + x1) / 2.0; + let mid_y = (y0 + y1) / 2.0; + let perp_x = -dy / chord; + let perp_y = dx / chord; + let h = radius * (theta / 2.0).cos(); + let sign = if theta > 0.0 { 1.0 } else { -1.0 }; + let cx = mid_x + sign * perp_x * h; + let cy = mid_y + sign * perp_y * h; + + let a0 = (y0 - cy).atan2(x0 - cx); + let a1 = (y1 - cy).atan2(x1 - cx); + let mut delta = a1 - a0; + if theta > 0.0 { + while delta < 0.0 { delta += std::f64::consts::TAU; } + } else { + while delta > 0.0 { delta -= std::f64::consts::TAU; } + } + let steps = ((sweep_deg.abs() / 4.0).ceil() as usize).clamp(2, max_steps); + let mut out = Vec::with_capacity(steps + 1); + for i in 0..=steps { + let a = a0 + delta * (i as f64) / (steps as f64); + out.push((cx + radius * a.cos(), cy + radius * a.sin())); + } + out +} + +fn jet_color(t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + let (r, g, b) = if t < 0.25 { + let s = t * 4.0; + (0.0, s, 1.0) + } else if t < 0.5 { + let s = (t - 0.25) * 4.0; + (0.0, 1.0, 1.0 - s) + } else if t < 0.75 { + let s = (t - 0.5) * 4.0; + (s, 1.0, 0.0) + } else { + let s = (t - 0.75) * 4.0; + (1.0, 1.0 - s, 0.0) + }; + Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255) +} diff --git a/crates/femm-app/src/kinematic.rs b/crates/femm-app/src/kinematic.rs index c3ce8ab..4eb2d40 100644 --- a/crates/femm-app/src/kinematic.rs +++ b/crates/femm-app/src/kinematic.rs @@ -54,42 +54,52 @@ impl Expression { Expression { source: String::from("0"), expr } } - /// evaluates the expression with bindings for s and t. + /// evaluates the expression with bindings for s and t, plus the helper functions harm and series and the tau constant. pub fn eval(&self, s: f64, t: f64) -> f64 { + use std::f64::consts::PI; let mut ctx = meval::Context::new(); ctx.var("s", s); ctx.var("t", t); + ctx.var("tau", 2.0 * PI); + ctx.funcn("harm", |args| { + let s = args[0]; + let t = args[1]; + let f0 = args[2]; + let n = args[3].round(); + if n < 1.0 { return 0.0; } + (n * PI * s).sin() * (2.0 * PI * n * f0 * t).cos() / n + }, 4); + ctx.funcn("series", |args| { + let s = args[0]; + let t = args[1]; + let f0 = args[2]; + let n_max = args[3].round() as i64; + if n_max < 1 { return 0.0; } + let mut acc = 0.0; + for k in 1..=n_max { + let kf = k as f64; + acc += (kf * PI * s).sin() * (2.0 * PI * kf * f0 * t).cos() / kf; + } + acc + }, 4); self.expr.eval_with_context(ctx).unwrap_or(0.0) } } -/// one continuous-time track: clamped chord, axis, member nodes, and the closed-form displacement expression. +/// 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. #[derive(Debug, Clone)] pub struct Track { - pub label: String, - pub anchor_a: usize, - pub anchor_b: usize, - pub member_nodes: Vec, - pub axis: Axis, - pub expression: Expression, + pub label: String, + pub anchor_a: usize, + pub anchor_b: usize, + pub member_nodes: Vec, + pub axis: Axis, + pub expression: Expression, + pub edge_endpoints: Vec<(usize, usize)>, + pub edge_subdivisions: Vec>, } -/// projects a node onto the anchor_a-to-anchor_b chord, returning a parameter clamped to [0, 1]. -pub fn chord_parameter(base: &FemmDoc, anchor_a: usize, anchor_b: usize, node_idx: usize) -> f64 { - let (Some(a), Some(b), Some(n)) = ( - base.nodes.get(anchor_a), - base.nodes.get(anchor_b), - base.nodes.get(node_idx), - ) else { return 0.0 }; - let dx = b.x - a.x; - let dy = b.y - a.y; - let len2 = dx * dx + dy * dy; - if len2 < 1e-18 { return 0.0; } - let s = ((n.x - a.x) * dx + (n.y - a.y) * dy) / len2; - s.clamp(0.0, 1.0) -} - -/// resets every node position on doc from base, layering each track's evaluated displacement at simulated time t seconds. +/// snaps doc nodes to base and layers each track's displacement at simulated time t. pub fn apply_tracks(doc: &mut FemmDoc, base: &FemmDoc, tracks: &[Track], t: f64) { for (i, n) in doc.nodes.iter_mut().enumerate() { if let Some(b) = base.nodes.get(i) { @@ -99,8 +109,30 @@ pub fn apply_tracks(doc: &mut FemmDoc, base: &FemmDoc, tracks: &[Track], t: f64) } for track in tracks { let (ux, uy) = track.axis.unit(); + let along_x = matches!(track.axis, Axis::PlusY | Axis::MinusY); + let val = |i: usize| -> Option { + let n = base.nodes.get(i)?; + Some(if along_x { n.x } else { n.y }) + }; + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for &idx in &[track.anchor_a, track.anchor_b] { + if let Some(v) = val(idx) { + if v < lo { lo = v; } + if v > hi { hi = v; } + } + } + for &idx in &track.member_nodes { + if let Some(v) = val(idx) { + if v < lo { lo = v; } + if v > hi { hi = v; } + } + } + let span = hi - lo; + if !span.is_finite() || span.abs() < 1e-12 { continue; } for &node_idx in &track.member_nodes { - let s = chord_parameter(base, track.anchor_a, track.anchor_b, node_idx); + let n_v = match val(node_idx) { Some(v) => v, None => continue }; + let s = ((n_v - lo) / span).clamp(0.0, 1.0); let delta = track.expression.eval(s, t); if let Some(n) = doc.nodes.get_mut(node_idx) { n.x += ux * delta; @@ -150,5 +182,150 @@ pub fn track_from_selection( member_nodes: members, axis, expression, + edge_endpoints: Vec::new(), + edge_subdivisions: Vec::new(), }) } + +/// 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")); + } + 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::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 = 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 = 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, + }); + 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 = std::collections::HashSet::new(); + for &(a, b) in &track.edge_endpoints { + corner_set.insert(a); + corner_set.insert(b); + } + let mut new_members: Vec = 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>) { + 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 = 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)> = 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; + } + } + 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))) + .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 = if sorted.len() >= 3 { + sorted[1..sorted.len()-1].iter().map(|(i, _)| *i).collect() + } else { + Vec::new() + }; + endpoints.push((a, b)); + subdivisions.push(subs); + } + + (endpoints, subdivisions) +} diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index 29e52ce..e325d1a 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -1,14 +1,17 @@ //! iced shell entry point for the FEMM 4.2 port. mod doc_canvas; +mod export; mod kinematic; +mod session; +mod sim_meta; 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::widget::{button, checkbox, 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}; @@ -40,6 +43,8 @@ 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)"; +const SIM_DEFAULT_BUFFER: usize = 10; +const SIM_DEFAULT_FUNDAMENTAL_HZ: f64 = 82.41; #[derive(Debug, Clone)] enum Message { @@ -76,7 +81,21 @@ enum Message { SimToggleRun, SimTick, SimResetTime, - SimClear, + SimClose, + SimFrameReady { generation: u64, frame_idx: i64, result: Result }, + SimSetBufferText(String), + SimSubmitBuffer, + SimSetFundamentalText(String), + SimSubmitFundamental, + SimToggleMatchCycle(bool), + SimToggleLoop(bool), + SimSaveMeta, + SimLoadMeta, + SimSaveSession, + SimLoadSession, + SimExportWebM, + SimExportComplete(Result), + SimExportPoll, } /// copyable diagnostic shown when a pipeline stage fails. @@ -86,15 +105,13 @@ struct ErrorReport { body: String, } -/// continuous-time simulation: captured base doc, per-track displacement expressions, SPICE-controlled step, live mag-solution at t_now. +/// continuous-time simulation with a parallel pre-compute lookahead and a frame-indexed solution LUT. struct Simulation { base_doc: FemmDoc, tracks: Vec, - t_now: f64, dt: f64, wall_interval: Duration, running: bool, - computing: bool, current: Option, last_solve_ms: u128, selected_track: Option, @@ -103,6 +120,16 @@ struct Simulation { interval_text: String, subdivisions: usize, subdivisions_text: String, + frame_idx: i64, + lut: std::collections::HashMap, + pending: std::collections::HashSet, + generation: u64, + buffer_size: usize, + buffer_size_text: String, + match_cycle: bool, + fundamental_hz: f64, + fundamental_hz_text: String, + loop_playback: bool, } struct App { @@ -121,6 +148,7 @@ struct App { snap_to_grid: bool, zoom_window_active: bool, canvas_size: Option<(f32, f32)>, + export_progress: Option>>, } impl App { @@ -142,6 +170,7 @@ impl App { snap_to_grid: false, zoom_window_active: false, canvas_size: None, + export_progress: None, }; (app, Task::none()) } @@ -254,11 +283,20 @@ impl App { 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::ZoomIn => { + self.apply_zoom_factor(1.25, Point::ORIGIN); + let eff = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; + self.status = format!("zoom {eff:.2}x"); + } + Message::ZoomOut => { + self.apply_zoom_factor(0.80, Point::ORIGIN); + let eff = if self.view_state.zoom <= 0.0 { 1.0 } else { self.view_state.zoom }; + self.status = format!("zoom {eff:.2}x"); + } Message::ZoomFit => { self.view_state = ViewState::default(); self.zoom_window_active = false; + self.status = String::from("zoom: fit to doc bounds"); } Message::ZoomWindowToggle => { self.zoom_window_active = !self.zoom_window_active; @@ -269,20 +307,15 @@ impl App { }; } 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(); - }; + let (cw, ch) = self.canvas_size.unwrap_or((1024.0, 768.0)); 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}]"); + self.zoom_window_active = false; + self.status = format!("zoomed to selection: x [{xmin:.3}, {xmax:.3}], y [{ymin:.3}, {ymax:.3}]"); } } None => { @@ -453,10 +486,13 @@ impl App { let sel_segs: Vec = self.doc.segments.iter().enumerate() .filter_map(|(i, s)| if s.selected { Some(i) } else { None }) .collect(); - if sel_segs.is_empty() { + let sel_arcs: Vec = self.doc.arcs.iter().enumerate() + .filter_map(|(i, a)| if a.selected { Some(i) } else { None }) + .collect(); + if sel_segs.is_empty() && sel_arcs.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."), + title: String::from("no geometry selected"), + 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 { let subdivisions = self.simulation.as_ref() @@ -471,26 +507,50 @@ impl App { 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 = 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); } + for &i in &sel_arcs { + let arc = &self.doc.arcs[i]; + if !endpoint_nodes.contains(&(arc.n0 as usize)) { endpoint_nodes.push(arc.n0 as usize); } + if !endpoint_nodes.contains(&(arc.n1 as usize)) { endpoint_nodes.push(arc.n1 as usize); } } + let edges_pre: Vec<(usize, (usize, usize))> = sel_segs.iter().map(|&i| { + let seg = &self.doc.segments[i]; + (i, (seg.n0 as usize, seg.n1 as usize)) + }).collect(); + let edge_endpoints: Vec<(usize, usize)> = edges_pre.iter().map(|(_, e)| *e).collect(); + + let mut all_members: Vec = endpoint_nodes.clone(); + let mut subs_by_edge: std::collections::HashMap<(usize, usize), Vec> = std::collections::HashMap::new(); + let mut to_subdivide = edges_pre.clone(); + to_subdivide.sort_by(|a, b| b.0.cmp(&a.0)); + for (idx, endpoints) in to_subdivide { + let new_nodes = self.doc.subdivide_segment(idx, subdivisions); + let subs: Vec = new_nodes.into_iter().map(|n| n as usize).collect(); + for n in &subs { all_members.push(*n); } + subs_by_edge.insert(endpoints, subs); + } + let edge_subdivisions: Vec> = edge_endpoints.iter() + .map(|e| subs_by_edge.get(e).cloned().unwrap_or_default()) + .collect(); + 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) => { + Ok(mut track) => { + track.edge_endpoints = edge_endpoints; + track.edge_subdivisions = edge_subdivisions; 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(); + let (a_x, a_y) = sim.base_doc.nodes.get(track.anchor_a).map(|n| (n.x, n.y)).unwrap_or((0.0, 0.0)); + let (b_x, b_y) = sim.base_doc.nodes.get(track.anchor_b).map(|n| (n.x, n.y)).unwrap_or((0.0, 0.0)); + let n_members = track.member_nodes.len(); 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()); + self.status = format!( + "track {} added: anchors ({:.2},{:.2})-({:.2},{:.2}), {} members, span {:.2} along x", + sim.tracks.len(), a_x, a_y, b_x, b_y, n_members, (b_x - a_x).abs(), + ); } Err(e) => { self.error = Some(ErrorReport { @@ -510,6 +570,7 @@ impl App { } else { Some(i.min(sim.tracks.len() - 1)) }; + invalidate_lut(sim); } } } @@ -534,6 +595,7 @@ impl App { if let Some(t) = sim.tracks.get_mut(i) { t.expression = expr; self.status = format!("track {} expression updated", i + 1); + invalidate_lut(sim); } } Err(e) => { @@ -555,7 +617,9 @@ impl App { Some(dt) if dt > 0.0 => { sim.dt = dt; sim.dt_text = spice::format_spice_time(dt); + maybe_recompute_buffer(sim); self.status = format!("dt = {}", sim.dt_text); + invalidate_lut(sim); } _ => { self.error = Some(ErrorReport { @@ -590,18 +654,38 @@ impl App { if let Some(sim) = self.simulation.as_mut() { sim.subdivisions_text = v; } } Message::SimSubmitSubdivisions => { - if let Some(sim) = self.simulation.as_mut() { - match sim.subdivisions_text.trim().parse::() { - Ok(n) if n >= 2 => { - sim.subdivisions = n; - self.status = format!("subdivisions per added segment = {n}"); - } - _ => { - self.error = Some(ErrorReport { - title: String::from("subdivision count invalid"), - body: format!("need an integer ≥ 2; got {:?}", sim.subdivisions_text), - }); - } + let Some(sim) = self.simulation.as_mut() else { return Task::none(); }; + let parsed = sim.subdivisions_text.trim().parse::(); + let n = match parsed { + Ok(n) if n >= 2 => n, + _ => { + self.error = Some(ErrorReport { + title: String::from("subdivision count invalid"), + body: format!("need an integer >= 2; got {:?}", sim.subdivisions_text), + }); + return Task::none(); + } + }; + sim.subdivisions = n; + let Some(selected) = sim.selected_track.filter(|&i| i < sim.tracks.len()) else { + 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); + 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); + } + 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."), + }); } } } @@ -621,102 +705,446 @@ impl App { return Task::none(); } sim.running = !sim.running; - let status = if sim.running { - format!("running: t = {}", spice::format_spice_time(sim.t_now)) + let t_now = (sim.frame_idx as f64) * sim.dt; + self.status = if sim.running { + format!("running: t = {}", spice::format_spice_time(t_now)) } else { - format!("paused: t = {}", spice::format_spice_time(sim.t_now)) + format!("paused: t = {}", spice::format_spice_time(t_now)) }; - self.status = status; if sim.running { - return Task::done(Message::SimTick); + return Task::batch(spawn_lookahead_jobs(sim)); } } 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); + enum Outcome { + Advanced { t_now: f64, base: FemmDoc, tracks: Vec, lut_ahead: usize, dt: f64, last_solve_ms: u128 }, + Buffering { t_now: f64, pending: usize }, + Nothing, } - 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(); + let outcome = match self.simulation.as_mut() { + Some(sim) if sim.running && !sim.tracks.is_empty() => { + let next_idx = sim.frame_idx + 1; + let lut_key = if sim.loop_playback && sim.buffer_size > 0 { + next_idx.rem_euclid(sim.buffer_size as i64) + } else { + next_idx + }; + let sol_opt = if sim.loop_playback { + sim.lut.get(&lut_key).cloned() + } else { + sim.lut.remove(&lut_key) + }; + if let Some(sol) = sol_opt { + sim.frame_idx = next_idx; + sim.current = Some(sol); + Outcome::Advanced { + t_now: (sim.frame_idx as f64) * sim.dt, + base: sim.base_doc.clone(), + tracks: sim.tracks.clone(), + lut_ahead: sim.lut.len(), + dt: sim.dt, + last_solve_ms: sim.last_solve_ms, + } + } else { + Outcome::Buffering { + t_now: (sim.frame_idx as f64) * sim.dt, + pending: sim.pending.len(), + } + } } - 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; + _ => Outcome::Nothing, + }; + match outcome { + Outcome::Advanced { t_now, base, tracks, lut_ahead, dt, last_solve_ms } => { + kinematic::apply_tracks(&mut self.doc, &base, &tracks, t_now); self.status = format!( - "t = {} (dt = {}, last solve {} ms)", - spice::format_spice_time(sim.t_now), + "t = {} (dt = {}, lut = {} ahead, last solve {} ms)", + spice::format_spice_time(t_now), spice::format_spice_time(dt), - elapsed, + lut_ahead, + last_solve_ms, ); } - Err(e) => { - let sim = self.simulation.as_mut().unwrap(); - sim.computing = false; + Outcome::Buffering { t_now, pending } => { + self.status = format!( + "buffering at t = {} (pending {} solves)", + spice::format_spice_time(t_now), + pending, + ); + } + Outcome::Nothing => return Task::none(), + } + if let Some(sim) = self.simulation.as_mut() { + return Task::batch(spawn_lookahead_jobs(sim)); + } + } + Message::SimFrameReady { generation, frame_idx, result } => { + let Some(sim) = self.simulation.as_mut() else { return Task::none(); }; + if generation != sim.generation { return Task::none(); } + sim.pending.remove(&frame_idx); + match result { + Ok(sol) => { + let started_elapsed = sol.mesh_elements.len() as u128; + sim.last_solve_ms = started_elapsed.max(sim.last_solve_ms); + if frame_idx == sim.frame_idx && sim.current.is_none() { + sim.current = Some(sol.clone()); + } + sim.lut.insert(frame_idx, sol); + } + Err(report) => { sim.running = false; - self.error = Some(ErrorReport { - title: String::from("read .ans during sim tick failed"), - body: format!("{ans_path:?}\n\n{e}"), - }); + self.error = Some(report); + } + } + if let Some(sim) = self.simulation.as_mut() { + if sim.running { + return Task::batch(spawn_lookahead_jobs(sim)); } } } Message::SimResetTime => { + let base = match self.simulation.as_mut() { + Some(sim) => { + sim.running = false; + sim.frame_idx = 0; + sim.lut.clear(); + sim.pending.clear(); + sim.generation = sim.generation.wrapping_add(1); + sim.current = None; + sim.base_doc.clone() + } + None => return Task::none(), + }; + self.doc = base; + self.status = String::from("reset to base geometry"); + } + Message::SimClose => { + self.simulation = None; + self.status = String::from("simulation closed"); + } + Message::SimSetBufferText(v) => { + if let Some(sim) = self.simulation.as_mut() { sim.buffer_size_text = v; } + } + Message::SimSubmitBuffer => { 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"); + match sim.buffer_size_text.trim().parse::() { + Ok(n) if n >= 2 => { + if sim.match_cycle { + self.status = String::from("buffer is locked to one cycle; uncheck the cycle box to set it manually"); + } else { + sim.buffer_size = n; + invalidate_lut(sim); + self.status = format!("buffer = {n} frames"); + } + } + _ => { + self.error = Some(ErrorReport { + title: String::from("buffer size invalid"), + body: format!("need an integer >= 2; got {:?}", sim.buffer_size_text), + }); + } + } } } - Message::SimClear => { - if let Some(sim) = self.simulation.as_ref() { - self.doc = sim.base_doc.clone(); + Message::SimSetFundamentalText(v) => { + if let Some(sim) = self.simulation.as_mut() { sim.fundamental_hz_text = v; } + } + Message::SimSubmitFundamental => { + if let Some(sim) = self.simulation.as_mut() { + match sim.fundamental_hz_text.trim().parse::() { + Ok(f) if f > 0.0 => { + sim.fundamental_hz = f; + sim.fundamental_hz_text = format!("{f}"); + maybe_recompute_buffer(sim); + if sim.match_cycle { invalidate_lut(sim); } + self.status = format!("fundamental = {f} Hz (period = {})", spice::format_spice_time(1.0 / f)); + } + _ => { + self.error = Some(ErrorReport { + title: String::from("fundamental Hz invalid"), + body: format!("need a positive number in Hz; got {:?}", sim.fundamental_hz_text), + }); + } + } + } + } + Message::SimToggleMatchCycle(checked) => { + if let Some(sim) = self.simulation.as_mut() { + sim.match_cycle = checked; + if checked { + maybe_recompute_buffer(sim); + invalidate_lut(sim); + self.status = format!("buffer locked to one cycle: {} frames", sim.buffer_size); + } else { + self.status = String::from("cycle lock off; buffer is manual"); + } + } + } + Message::SimSaveMeta => { + let Some(sim) = self.simulation.as_ref() else { + self.error = Some(ErrorReport { + title: String::from("no simulation"), + body: String::from("create at least one track before saving sim metadata."), + }); + return Task::none(); + }; + let picked = rfd::FileDialog::new() + .add_filter("femm42 sim metadata", &["femsim"]) + .save_file(); + if let Some(path) = picked { + let meta = build_sim_meta(sim, self.source_path.as_deref()); + let text = sim_meta::serialize_meta(&meta); + match std::fs::write(&path, text) { + Ok(()) => self.status = format!("saved sim metadata: {}", path.display()), + Err(e) => self.error = Some(ErrorReport { + title: String::from("write sim metadata failed"), + body: format!("{path:?}\n\n{e}"), + }), + } + } + } + Message::SimLoadMeta => { + let picked = rfd::FileDialog::new() + .add_filter("femm42 sim metadata", &["femsim"]) + .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 sim metadata failed"), + body: format!("{path:?}\n\n{e}"), + }); + return Task::none(); + } + }; + let meta = match sim_meta::parse_meta(&text) { + Ok(m) => m, + Err(e) => { + self.error = Some(ErrorReport { + title: String::from("parse sim metadata failed"), + body: format!("{path:?}\n\n{e}"), + }); + return Task::none(); + } + }; + let tracks = match sim_meta::resolve_tracks(&meta, &self.doc) { + Ok(t) => t, + Err(e) => { + self.error = Some(ErrorReport { + title: String::from("sim metadata mismatches current doc"), + body: format!("{path:?}\n\n{e}\n\nload the .fem this metadata was saved against, or rebuild the tracks by hand."), + }); + return Task::none(); + } + }; + let mut sim = new_simulation(self.doc.clone(), 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.status = format!("loaded sim metadata: {}", path.display()); + } + Message::SimSaveSession => { + let Some(sim) = self.simulation.as_ref() else { + self.error = Some(ErrorReport { + title: String::from("no simulation"), + body: String::from("create at least one track before saving a session."), + }); + return Task::none(); + }; + let picked = rfd::FileDialog::new() + .add_filter("femm42 session", &["femsess"]) + .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); + match std::fs::write(&path, text) { + Ok(()) => self.status = format!("saved session: {}", path.display()), + Err(e) => self.error = Some(ErrorReport { + title: String::from("write session failed"), + body: format!("{path:?}\n\n{e}"), + }), + } + } + } + Message::SimLoadSession => { + let picked = rfd::FileDialog::new() + .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()); + } + Message::SimExportWebM => { + let Some(sim) = self.simulation.as_ref() else { + self.error = Some(ErrorReport { + title: String::from("no simulation"), + body: String::from("create at least one track before exporting."), + }); + return Task::none(); + }; + if sim.tracks.is_empty() { + self.error = Some(ErrorReport { + title: String::from("no tracks"), + body: String::from("add a track before exporting."), + }); + return Task::none(); + } + let missing: Vec = (0..sim.buffer_size as i64) + .filter(|f| !sim.lut.contains_key(f)) + .collect(); + if !missing.is_empty() { + self.error = Some(ErrorReport { + title: format!("buffer not filled ({} of {} frames missing)", missing.len(), sim.buffer_size), + body: String::from("fill the buffer (Run with loop enabled until all frames are cached) before exporting."), + }); + return Task::none(); + } + let picked = rfd::FileDialog::new() + .add_filter("WebM video", &["webm"]) + .set_file_name("simulation.webm") + .save_file(); + let Some(dest) = picked else { return Task::none(); }; + let fps = (1.0_f64 / sim.wall_interval.as_secs_f64()).max(1.0); + let contour = matches!(self.render_mode, RenderMode::Contour); + let progress = std::sync::Arc::new(std::sync::Mutex::new(export::ExportProgress::default())); + let input = export::ExportInput { + dest, + base_doc: sim.base_doc.clone(), + tracks: sim.tracks.clone(), + dt: sim.dt, + buffer_size: sim.buffer_size, + fps, + lut: sim.lut.clone(), + width: 1920, + height: 1080, + contour, + crf: 18, + progress: std::sync::Arc::clone(&progress), + }; + self.export_progress = Some(progress); + self.status = format!("exporting {} frames at {:.2} fps (1920x1080, crf 18)...", sim.buffer_size, fps); + return Task::perform( + async move { + tokio::task::spawn_blocking(move || export::export_webm(input)) + .await + .unwrap_or_else(|e| Err(format!("export worker panicked: {e}"))) + }, + |res| Message::SimExportComplete(res.map_err(|e| ErrorReport { + title: String::from("webm export failed"), + body: e, + })), + ); + } + Message::SimExportComplete(result) => { + self.export_progress = None; + match result { + Ok(report) => { + self.status = format!("exported {} frames: {}", report.written, report.dest.display()); + } + Err(report) => { + self.error = Some(report); + } + } + } + Message::SimExportPoll => { + if let Some(progress) = self.export_progress.as_ref() { + let p = progress.lock().unwrap().clone(); + self.status = match p.phase { + export::ExportPhase::Idle => format!("export queued..."), + export::ExportPhase::Rendering => format!( + "encoding frame {} of {} ({:.1} MB sent)", + p.frames_done, p.frames_total, p.bytes_sent as f64 / 1_048_576.0, + ), + export::ExportPhase::Finalizing => format!("flushing ffmpeg ({} frames sent)", p.frames_done), + export::ExportPhase::Done => format!("finalizing output file..."), + }; + } + } + Message::SimToggleLoop(checked) => { + if let Some(sim) = self.simulation.as_mut() { + sim.loop_playback = checked; + invalidate_lut(sim); + self.status = if checked { + String::from("loop mode: playback cycles through the buffered frames") + } else { + String::from("stream mode: buffer slides ahead of playback") + }; } - self.simulation = None; - self.status = String::from("simulation cleared"); } Message::Canvas(CanvasMessage::DeleteSelected) => { let n = self.doc.delete_selected_nodes(); @@ -770,6 +1198,7 @@ impl App { text_button("Mesh", Message::RunMesh), text_button("Analyze", Message::RunAnalyze), text_button("Track from Selection", Message::SimAddTrackFromSelection), + text_button("Load Sim", Message::SimLoadMeta), ].spacing(2); let plot_group = row![ @@ -823,16 +1252,22 @@ impl App { self.view_state, self.show_grid, self.zoom_window_active, ).map(Message::Canvas); - let canvas_row = row![canvas, view_strip].spacing(6); + let canvas_row = row![canvas, view_strip] + .spacing(12) + .height(Length::Fill); - let mut body = column![toolbar].spacing(8).padding(12); + let mut body = column![toolbar] + .spacing(8) + .padding(iced::Padding { top: 0.0, right: 12.0, bottom: 12.0, left: 12.0 }) + .width(Length::Fill) + .height(Length::Fill); 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)); } + body = body.push(canvas_row); if !self.status.is_empty() { body = body.push(text(&self.status).size(12)); } @@ -843,14 +1278,18 @@ impl App { .into() } - /// emits SimTick at the simulation's configured wall-clock interval. + /// emits SimTick at the simulation's wall-clock interval and SimExportPoll while a webm export runs. fn subscription(&self) -> Subscription { - match &self.simulation { - Some(sim) if sim.running && !sim.computing && !sim.tracks.is_empty() => { - time::every(sim.wall_interval).map(|_| Message::SimTick) + let mut subs: Vec> = Vec::new(); + if let Some(sim) = &self.simulation { + if sim.running && !sim.tracks.is_empty() { + subs.push(time::every(sim.wall_interval).map(|_| Message::SimTick)); } - _ => Subscription::none(), } + if self.export_progress.is_some() { + subs.push(time::every(Duration::from_millis(200)).map(|_| Message::SimExportPoll)); + } + Subscription::batch(subs) } } @@ -859,11 +1298,9 @@ 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, @@ -872,25 +1309,238 @@ fn new_simulation(base_doc: FemmDoc, expression_text: String) -> Simulation { interval_text: spice::format_spice_time(SIM_DEFAULT_INTERVAL_S), subdivisions: SIM_DEFAULT_SUBDIVISIONS, subdivisions_text: SIM_DEFAULT_SUBDIVISIONS.to_string(), + frame_idx: 0, + lut: std::collections::HashMap::new(), + pending: std::collections::HashSet::new(), + generation: 0, + buffer_size: SIM_DEFAULT_BUFFER, + buffer_size_text: SIM_DEFAULT_BUFFER.to_string(), + match_cycle: false, + fundamental_hz: SIM_DEFAULT_FUNDAMENTAL_HZ, + fundamental_hz_text: format!("{SIM_DEFAULT_FUNDAMENTAL_HZ}"), + loop_playback: false, } } +/// snapshots a live simulation's parameters and tracks into the serialisable metadata shape. +fn build_sim_meta(sim: &Simulation, source: Option<&Path>) -> sim_meta::SimMeta { + let tracks = sim.tracks.iter().enumerate().map(|(i, t)| { + let edges: Vec<(usize, usize, Vec)> = t.edge_endpoints.iter().enumerate() + .map(|(j, &(a, b))| { + let subs = t.edge_subdivisions.get(j).cloned().unwrap_or_default(); + (a, b, subs) + }) + .collect(); + sim_meta::TrackMeta { + label: if t.label.is_empty() { format!("track {}", i + 1) } else { t.label.clone() }, + anchor_a: t.anchor_a, + anchor_b: t.anchor_b, + members: t.member_nodes.clone(), + axis: t.axis, + expression: t.expression.source.clone(), + edges, + } + }).collect(); + sim_meta::SimMeta { + source_fem: source.map(|p| p.display().to_string()), + dt_text: sim.dt_text.clone(), + interval_text: sim.interval_text.clone(), + subdivisions: sim.subdivisions, + buffer_size: sim.buffer_size, + match_cycle: sim.match_cycle, + loop_playback: sim.loop_playback, + fundamental_hz: sim.fundamental_hz, + tracks, + } +} + +/// recomputes buffer_size from the simulation's fundamental period when match_cycle is on. +fn maybe_recompute_buffer(sim: &mut Simulation) { + if sim.match_cycle && sim.fundamental_hz > 0.0 && sim.dt > 0.0 { + let period = 1.0 / sim.fundamental_hz; + let n = (period / sim.dt).ceil().max(2.0) as usize; + sim.buffer_size = n; + sim.buffer_size_text = n.to_string(); + } +} + +/// drops every cached and pending frame for a simulation and advances the generation counter. +fn invalidate_lut(sim: &mut Simulation) { + sim.lut.clear(); + sim.pending.clear(); + sim.generation = sim.generation.wrapping_add(1); + sim.current = None; +} + +/// caps concurrent solve jobs at cores x 4, clamped to [8, 64]. +fn max_in_flight_jobs() -> usize { + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4); + (cores * 4).clamp(8, 64) +} + +/// queues solve jobs for uncached frames in the buffer window, up to max_in_flight_jobs minus pending. +fn spawn_lookahead_jobs(sim: &mut Simulation) -> Vec> { + if sim.tracks.is_empty() || sim.buffer_size == 0 { return Vec::new(); } + let (base, last) = if sim.loop_playback { + (0_i64, sim.buffer_size as i64 - 1) + } else { + let base = sim.frame_idx; + (base, base + sim.buffer_size as i64) + }; + let cap = max_in_flight_jobs(); + let mut budget = cap.saturating_sub(sim.pending.len()); + let mut tasks = Vec::new(); + for frame_idx in base..=last { + if budget == 0 { break; } + if frame_idx < 0 { continue; } + if sim.lut.contains_key(&frame_idx) { continue; } + if !sim.pending.insert(frame_idx) { continue; } + budget -= 1; + let job_gen = sim.generation; + let doc = sim.base_doc.clone(); + let tracks = sim.tracks.clone(); + let dt = sim.dt; + tasks.push(Task::perform( + async move { + let res = tokio::task::spawn_blocking(move || compute_frame(doc, tracks, frame_idx, dt)) + .await + .unwrap_or_else(|e| Err(ErrorReport { + title: String::from("solve worker panicked"), + body: format!("frame {frame_idx}\n\n{e}"), + })); + (job_gen, frame_idx, res) + }, + |(generation, frame_idx, result)| Message::SimFrameReady { generation, frame_idx, result }, + )); + } + tasks +} + +/// runs the full mesh + solve + parse pipeline for one displaced-doc frame at a unique stem, returning the resulting magnetostatic solution. +fn compute_frame(base: FemmDoc, tracks: Vec, frame_idx: i64, dt: f64) -> Result { + let t = (frame_idx as f64) * dt; + let mut doc = base.clone(); + kinematic::apply_tracks(&mut doc, &base, &tracks, t); + + let dir = std::env::temp_dir().join("femm42-frames"); + std::fs::create_dir_all(&dir).map_err(|e| ErrorReport { + title: String::from("create frame dir failed"), + body: format!("{dir:?}\n\n{e}"), + })?; + let stem = dir.join(format!("frame-{frame_idx}")); + let stem_str = stem.to_str().ok_or_else(|| ErrorReport { + title: String::from("non-utf8 frame path"), + body: format!("{stem:?}"), + })?.to_string(); + + let fem_path = stem.with_extension("fem"); + let poly_path = stem.with_extension("poly"); + let pbc_path = stem.with_extension("pbc"); + doc.save(&fem_path).map_err(|e| ErrorReport { + title: String::from("frame save .fem failed"), + body: format!("{fem_path:?}\n\n{e}"), + })?; + doc.save_poly(&poly_path).map_err(|e| ErrorReport { + title: String::from("frame save .poly failed"), + body: format!("{poly_path:?}\n\n{e}"), + })?; + std::fs::write(&pbc_path, "0\n0\n").map_err(|e| ErrorReport { + title: String::from("frame save .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("run `cargo xtask install`"), + })?; + let min_angle = if doc.min_angle > 0.0 { doc.min_angle } else { MIN_ANGLE_DEG }; + let tri_out = 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 (frame)"), + body: format!("{triangle:?}\n\n{e}"), + })?; + if !tri_out.status.success() { + return Err(ErrorReport { + title: format!("triangle exited with {} (frame {frame_idx})", tri_out.status), + body: format!( + "stem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&tri_out.stdout), + String::from_utf8_lossy(&tri_out.stderr), + ), + }); + } + + let helper = locate_mag_solver().ok_or_else(|| ErrorReport { + title: String::from("femm-mag-solve binary missing"), + body: String::from("run `cargo xtask install`"), + })?; + let solver_out = std::process::Command::new(&helper) + .arg(&stem_str) + .output() + .map_err(|e| ErrorReport { + title: String::from("spawn femm-mag-solve failed (frame)"), + body: format!("{helper:?}\n\n{e}"), + })?; + if !solver_out.status.success() { + let exit_label = match solver_out.status.code() { + Some(c) => format!("exit code {c}"), + None => match unix_signal(&solver_out.status) { + Some(sig) => format!("killed by signal {sig}"), + None => format!("{}", solver_out.status), + }, + }; + let stage = solve_stage_for_exit(solver_out.status.code()); + return Err(ErrorReport { + title: format!("frame {frame_idx} solve {exit_label} ({stage})"), + body: format!( + "stem: {stem_str}\n\n--- stdout ---\n{}\n--- stderr ---\n{}", + String::from_utf8_lossy(&solver_out.stdout), + String::from_utf8_lossy(&solver_out.stderr), + ), + }); + } + + let ans_path = stem.with_extension("ans"); + MagSolution::open(&ans_path).map_err(|e| ErrorReport { + title: format!("read .ans failed (frame {frame_idx})"), + body: format!("{ans_path:?}\n\n{e}"), + }) +} + /// 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), + "t = {} dt = {} interval = {} tracks = {} lut ahead = {} pending = {}", + spice::format_spice_time((sim.frame_idx as f64) * sim.dt), sim.dt_text, sim.interval_text, sim.tracks.len(), + sim.lut.len(), + sim.pending.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), + button(text("Save Session").size(12)) + .on_press(Message::SimSaveSession).style(button::primary), + button(text("Load Session").size(12)) + .on_press(Message::SimLoadSession).style(button::primary), + button(text("Save Meta").size(12)) + .on_press(Message::SimSaveMeta).style(button::secondary), + button(text("Load Meta").size(12)) + .on_press(Message::SimLoadMeta).style(button::secondary), + button(text("Export WebM").size(12)) + .on_press(Message::SimExportWebM).style(button::secondary), + button(text("Close").size(12)) + .on_press(Message::SimClose).style(button::secondary), ].spacing(8).align_y(Alignment::Center); let dt_input = text_input("dt (SPICE)", &sim.dt_text) @@ -912,16 +1562,38 @@ fn simulation_panel(sim: &Simulation) -> Element<'_, Message> { let controls = row![ text("dt").size(12), dt_input, text("every").size(12), interval_input, - text("subdiv").size(12), subdiv_input, + text("subdiv/new track").size(12), subdiv_input, + ].spacing(8).align_y(Alignment::Center); + + let mut buffer_input = text_input("buffer", &sim.buffer_size_text).size(12).width(Length::Fixed(80.0)); + if !sim.match_cycle { + buffer_input = buffer_input + .on_input(Message::SimSetBufferText) + .on_submit(Message::SimSubmitBuffer); + } + let fundamental_input = text_input("Hz", &sim.fundamental_hz_text) + .on_input(Message::SimSetFundamentalText) + .on_submit(Message::SimSubmitFundamental) + .size(12) + .width(Length::Fixed(100.0)); + let cycle_check = checkbox(sim.match_cycle).label("match cycle").on_toggle(Message::SimToggleMatchCycle).size(14); + let loop_check = checkbox(sim.loop_playback).label("loop").on_toggle(Message::SimToggleLoop).size(14); + let buffer_row = row![ + text("buffer").size(12), buffer_input, + text("fundamental Hz").size(12), fundamental_input, + cycle_check, + loop_check, ].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 (a_x, a_y) = sim.base_doc.nodes.get(t.anchor_a).map(|n| (n.x, n.y)).unwrap_or((0.0, 0.0)); + let (b_x, b_y) = sim.base_doc.nodes.get(t.anchor_b).map(|n| (n.x, n.y)).unwrap_or((0.0, 0.0)); let label_btn = button(text(format!( - "{} {}: anchors {}→{}, {} members", + "{} {}: ({:.1},{:.1})-({:.1},{:.1}), span {:.1}, {} members", if is_selected { "▸" } else { " " }, - t.label, t.anchor_a, t.anchor_b, t.member_nodes.len(), + t.label, a_x, a_y, b_x, b_y, (b_x - a_x).abs(), t.member_nodes.len(), )).size(11)) .on_press(Message::SimSelectTrack(i)) .style(if is_selected { button::primary } else { button::secondary }); @@ -944,7 +1616,7 @@ fn simulation_panel(sim: &Simulation) -> Element<'_, Message> { .style(button::secondary), ].spacing(8).align_y(Alignment::Center); - container(column![header, controls, expr_row, tracks_col].spacing(6).padding(8)) + container(column![header, controls, buffer_row, expr_row, tracks_col].spacing(6).padding(8)) .style(|t: &Theme| { let p = t.extended_palette(); container::Style { @@ -1521,7 +2193,25 @@ fn render_mode_button(label: &str, this_mode: RenderMode, active: RenderMode) -> } } +/// raises the per-process file-descriptor soft limit toward the hard limit, capped at 65536. +#[cfg(unix)] +fn raise_fd_limit() { + unsafe { + let mut lim: libc::rlimit = std::mem::zeroed(); + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) != 0 { return; } + let target = lim.rlim_max.min(65536); + if lim.rlim_cur < target { + lim.rlim_cur = target; + libc::setrlimit(libc::RLIMIT_NOFILE, &lim); + } + } +} + +#[cfg(not(unix))] +fn raise_fd_limit() {} + fn main() -> iced::Result { + raise_fd_limit(); iced::application(App::new, App::update, App::view) .title(App::title) .theme(|_app: &App| Theme::Dark) diff --git a/crates/femm-app/src/session.rs b/crates/femm-app/src/session.rs new file mode 100644 index 0000000..41e7193 --- /dev/null +++ b/crates/femm-app/src/session.rs @@ -0,0 +1,37 @@ +//! single-file packaging of a base FemmDoc plus its SimMeta, round-tripping the full reset state into one .femsess blob. + +use femm_doc_mag::FemmDoc; +use crate::sim_meta::{self, SimMeta}; + +const SESSION_HEADER: &str = "# femm42 session v1\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 { + let mut out = String::new(); + out.push_str(SESSION_HEADER); + out.push_str(DOC_OPEN); + let doc_text = base_doc.write(); + out.push_str(&doc_text); + if !doc_text.ends_with('\n') { out.push('\n'); } + out.push_str(DOC_CLOSE); + out.push_str(&sim_meta::serialize_meta(meta)); + 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> { + 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..] + .find(DOC_CLOSE) + .ok_or_else(|| String::from("missing [/base-doc] marker"))?; + let doc_text = &text[body_start..body_start + after_body]; + let meta_text = &text[body_start + after_body + DOC_CLOSE.len()..]; + + 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)) +} diff --git a/crates/femm-app/src/sim_meta.rs b/crates/femm-app/src/sim_meta.rs new file mode 100644 index 0000000..c645bc3 --- /dev/null +++ b/crates/femm-app/src/sim_meta.rs @@ -0,0 +1,249 @@ +//! save/load format for simulation metadata: SPICE-suffixed scalars, per-track expressions and node references, plain text sections. + +use femm_doc_mag::FemmDoc; +use crate::kinematic::{Axis, Expression, Track}; +use crate::spice; +use std::time::Duration; + +pub struct SimMeta { + pub source_fem: Option, + pub dt_text: String, + pub interval_text: String, + pub subdivisions: usize, + pub buffer_size: usize, + pub match_cycle: bool, + pub loop_playback: bool, + pub fundamental_hz: f64, + pub tracks: Vec, +} + +pub struct TrackMeta { + pub label: String, + pub anchor_a: usize, + pub anchor_b: usize, + pub members: Vec, + pub axis: Axis, + pub expression: String, + pub edges: Vec<(usize, usize, Vec)>, +} + +/// renders a SimMeta into the on-disk text format. +pub fn serialize_meta(meta: &SimMeta) -> String { + let mut s = String::new(); + s.push_str("# femm42 simulation metadata\n"); + s.push_str("# track node indices reference the doc loaded above; editing the doc may invalidate them.\n\n"); + if let Some(p) = &meta.source_fem { + s.push_str("[source]\n"); + s.push_str(&format!("fem = {p}\n\n")); + } + s.push_str("[sim]\n"); + s.push_str(&format!("dt = {}\n", meta.dt_text)); + s.push_str(&format!("interval = {}\n", meta.interval_text)); + s.push_str(&format!("subdivisions = {}\n", meta.subdivisions)); + s.push_str(&format!("buffer_size = {}\n", meta.buffer_size)); + s.push_str(&format!("match_cycle = {}\n", meta.match_cycle)); + s.push_str(&format!("loop_playback = {}\n", meta.loop_playback)); + s.push_str(&format!("fundamental_hz = {}\n\n", meta.fundamental_hz)); + for track in &meta.tracks { + s.push_str("[track]\n"); + s.push_str(&format!("label = {}\n", track.label)); + s.push_str(&format!("anchor_a = {}\n", track.anchor_a)); + 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("members ="); + for n in &track.members { s.push_str(&format!(" {n}")); } + s.push_str("\n"); + for (a, b, subs) in &track.edges { + s.push_str(&format!("edge = {a} {b}")); + for n in subs { s.push_str(&format!(" {n}")); } + s.push_str("\n"); + } + s.push_str("\n"); + } + s +} + +/// parses the on-disk metadata text into a SimMeta, failing on unknown sections or unparseable values. +pub fn parse_meta(text: &str) -> Result { + let mut source_fem: Option = None; + let mut dt_text = String::from("100u"); + let mut interval_text = String::from("50m"); + let mut subdivisions = 20usize; + let mut buffer_size = 10usize; + let mut match_cycle = false; + let mut loop_playback = false; + let mut fundamental_hz = 82.41f64; + let mut tracks: Vec = Vec::new(); + + enum Section { None, Source, Sim, Track } + let mut section = Section::None; + let mut cur: Option = None; + + for (line_no, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + if let Some(t) = cur.take() { tracks.push(t); } + section = match name.trim() { + "source" => Section::Source, + "sim" => Section::Sim, + "track" => { cur = Some(TrackMeta { + label: format!("track {}", tracks.len() + 1), + anchor_a: 0, anchor_b: 0, + members: Vec::new(), + axis: Axis::PlusY, + expression: String::from("0"), + edges: Vec::new(), + }); Section::Track } + other => return Err(format!("unknown section {other:?} at line {}", line_no + 1)), + }; + continue; + } + let (key, value) = match line.split_once('=') { + Some((k, v)) => (k.trim(), v.trim()), + None => return Err(format!("expected key = value at line {}: {line:?}", line_no + 1)), + }; + match section { + Section::Source => match key { + "fem" => source_fem = Some(value.trim_matches('"').to_string()), + other => return Err(format!("unknown source key {other:?} at line {}", line_no + 1)), + }, + Section::Sim => match key { + "dt" => { dt_text = value.to_string(); } + "interval" => { interval_text = value.to_string(); } + "subdivisions" => subdivisions = parse_usize(value, line_no)?, + "buffer_size" => buffer_size = parse_usize(value, line_no)?, + "match_cycle" => match_cycle = parse_bool(value, line_no)?, + "loop_playback" => loop_playback = parse_bool(value, line_no)?, + "fundamental_hz" => fundamental_hz = parse_f64(value, line_no)?, + other => return Err(format!("unknown sim key {other:?} at line {}", line_no + 1)), + }, + Section::Track => { + let track = cur.as_mut().ok_or_else(|| format!("track key outside [track] at line {}", line_no + 1))?; + match key { + "label" => track.label = value.to_string(), + "anchor_a" => track.anchor_a = parse_usize(value, line_no)?, + "anchor_b" => track.anchor_b = parse_usize(value, line_no)?, + "axis" => track.axis = axis_from_str(value) + .ok_or_else(|| format!("unknown axis {value:?} at line {}", line_no + 1))?, + "expression" => track.expression = value.to_string(), + "members" => { + track.members.clear(); + for tok in value.split_whitespace() { + track.members.push(parse_usize(tok, 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))?; + let b = iter.next().ok_or_else(|| format!("edge missing second endpoint at line {}", line_no + 1))?; + let a = parse_usize(a, line_no)?; + let b = parse_usize(b, line_no)?; + let mut subs = Vec::new(); + for tok in iter { subs.push(parse_usize(tok, line_no)?); } + track.edges.push((a, b, subs)); + } + other => return Err(format!("unknown track key {other:?} at line {}", line_no + 1)), + } + } + Section::None => return Err(format!("key = value before any section at line {}", line_no + 1)), + } + } + if let Some(t) = cur.take() { tracks.push(t); } + + Ok(SimMeta { + source_fem, + dt_text, interval_text, + subdivisions, buffer_size, + match_cycle, loop_playback, + fundamental_hz, + tracks, + }) +} + +/// validates a parsed metadata block against the currently-loaded doc, returning concrete Track values when every referenced node index is in bounds. +pub fn resolve_tracks(meta: &SimMeta, doc: &FemmDoc) -> Result, String> { + let n = doc.nodes.len(); + let mut out = Vec::with_capacity(meta.tracks.len()); + for (i, tm) in meta.tracks.iter().enumerate() { + if tm.anchor_a >= n { + return Err(format!("track {}: anchor_a {} out of bounds (doc has {n} nodes)", i + 1, tm.anchor_a)); + } + if tm.anchor_b >= n { + return Err(format!("track {}: anchor_b {} out of bounds (doc has {n} nodes)", i + 1, tm.anchor_b)); + } + for &m in &tm.members { + if m >= n { + return Err(format!("track {}: member {} out of bounds (doc has {n} nodes)", i + 1, m)); + } + } + let expr = Expression::parse(&tm.expression) + .map_err(|e| format!("track {}: expression parse failed: {e}", i + 1))?; + let mut edge_endpoints = Vec::with_capacity(tm.edges.len()); + let mut edge_subdivisions = Vec::with_capacity(tm.edges.len()); + for (a, b, subs) in &tm.edges { + if *a >= n || *b >= n { + return Err(format!("track {}: edge endpoint out of bounds (doc has {n} nodes)", i + 1)); + } + for &s in subs { + if s >= n { + return Err(format!("track {}: edge subdivision {s} out of bounds (doc has {n} nodes)", i + 1)); + } + } + edge_endpoints.push((*a, *b)); + edge_subdivisions.push(subs.clone()); + } + out.push(Track { + label: tm.label.clone(), + anchor_a: tm.anchor_a, + anchor_b: tm.anchor_b, + member_nodes: tm.members.clone(), + axis: tm.axis, + expression: expr, + edge_endpoints, + edge_subdivisions, + }); + } + Ok(out) +} + +fn parse_usize(v: &str, line: usize) -> Result { + v.trim().parse::().map_err(|e| format!("integer parse failed at line {}: {e}", line + 1)) +} + +fn parse_f64(v: &str, line: usize) -> Result { + v.trim().parse::().map_err(|e| format!("float parse failed at line {}: {e}", line + 1)) +} + +fn parse_bool(v: &str, line: usize) -> Result { + match v.trim() { + "true" | "1" | "yes" | "on" => Ok(true), + "false" | "0" | "no" | "off" => Ok(false), + other => Err(format!("bool parse failed at line {}: {other:?}", line + 1)), + } +} + +fn axis_to_str(a: Axis) -> &'static str { + match a { + Axis::PlusX => "+x", + Axis::MinusX => "-x", + Axis::PlusY => "+y", + Axis::MinusY => "-y", + } +} + +fn axis_from_str(s: &str) -> Option { + Some(match s.trim() { + "+x" | "x" | "PlusX" => Axis::PlusX, + "-x" | "MinusX" => Axis::MinusX, + "+y" | "y" | "PlusY" => Axis::PlusY, + "-y" | "MinusY" => Axis::MinusY, + _ => return None, + }) +} + +/// parses a SPICE-format duration text into a tokio Duration, falling back to 50 ms on parse failure. +pub fn parse_interval(text: &str) -> Duration { + spice::parse_spice(text).map(Duration::from_secs_f64).unwrap_or(Duration::from_millis(50)) +} diff --git a/examples/fullsized_guitar_ring.fem b/examples/fullsized_guitar_ring.fem new file mode 100644 index 0000000..a2a9244 --- /dev/null +++ b/examples/fullsized_guitar_ring.fem @@ -0,0 +1,174 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 25 +[Depth] = 5 +[LengthUnits] = millimeters +[ProblemType] = planar +[Coordinates] = cartesian +[Comment] = "full 25.5-inch guitar scale inside a diametrically-magnetised NdFeB ring (inner radius 324 mm, outer 354 mm, mag along +x). six plain steel strings at the standard .009/.011/.016/.026/.036/.046 set, spanning roughly 647.7 mm from the N inner-face on the left to the S inner-face on the right. flux runs through every string in +x and closes through the ring body and surrounding air." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "NdFeB" + = 1.05 + = 1.05 + = 915000 + = 0 + = 0 + = 0 + = 0.667 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 2500 + = 2500 + = 0 + = 0 + = 0 + = 0 + = 5.8 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 32 +-150 -450 0 0 +800 -450 0 0 +800 450 0 0 +-150 450 0 0 +-30 0 0 0 +324 -354 0 0 +678 0 0 0 +324 354 0 0 +0.038 -4.9198 0 0 +0.049 -5.5802 0 0 +0.361 -15.2928 0 0 +0.406 -16.2072 0 0 +1.019 -25.6658 0 0 +1.114 -26.8342 0 0 +646.886 -26.8342 0 0 +646.981 -25.6658 0 0 +647.594 -16.2072 0 0 +647.639 -15.2928 0 0 +647.951 -5.5802 0 0 +647.962 -4.9198 0 0 +647.961 5.0468 0 0 +647.954 5.4532 0 0 +647.624 15.6103 0 0 +647.611 15.8897 0 0 +646.945 26.1357 0 0 +646.926 26.3643 0 0 +1.074 26.3643 0 0 +1.055 26.1357 0 0 +0.389 15.8897 0 0 +0.376 15.6103 0 0 +0.046 5.4532 0 0 +0.039 5.0468 0 0 +[NumSegments] = 16 +0 1 -1 1 0 0 +1 2 -1 1 0 0 +2 3 -1 1 0 0 +3 0 -1 1 0 0 +12 15 -1 0 0 0 +13 14 -1 0 0 0 +10 17 -1 0 0 0 +11 16 -1 0 0 0 +8 19 -1 0 0 0 +9 18 -1 0 0 0 +30 21 -1 0 0 0 +31 20 -1 0 0 0 +28 23 -1 0 0 0 +29 22 -1 0 0 0 +26 25 -1 0 0 0 +27 24 -1 0 0 0 +[NumArcSegments] = 28 +4 5 90 10 0 0 0 +5 6 90 10 0 0 0 +6 7 90 10 0 0 0 +7 4 90 10 0 0 0 +8 9 0.117 10 0 0 0 +9 10 1.719 10 0 0 0 +10 11 0.162 10 0 0 0 +11 12 1.677 10 0 0 0 +12 13 0.208 10 0 0 0 +13 14 170.494 10 0 0 0 +14 15 0.208 10 0 0 0 +15 16 1.677 10 0 0 0 +16 17 0.162 10 0 0 0 +17 18 1.719 10 0 0 0 +18 19 0.117 10 0 0 0 +19 20 1.762 10 0 0 0 +20 21 0.072 10 0 0 0 +21 22 1.798 10 0 0 0 +22 23 0.050 10 0 0 0 +23 24 1.816 10 0 0 0 +24 25 0.040 10 0 0 0 +25 26 170.664 10 0 0 0 +26 27 0.040 10 0 0 0 +27 28 1.816 10 0 0 0 +28 29 0.050 10 0 0 0 +29 30 1.798 10 0 0 0 +30 31 0.072 10 0 0 0 +31 8 1.762 10 0 0 0 +[NumHoles] = 0 +[NumBlockLabels] = 9 +-100 0 1 -1 0 0 0 1 2 +324 0 1 -1 0 0 0 1 0 +324 339 2 10 0 0 0 1 0 +324 -26.25 3 1 0 0 0 1 0 +324 -15.75 3 1 0 0 0 1 0 +324 -5.25 3 1 0 0 0 1 0 +324 5.25 3 1 0 0 0 1 0 +324 15.75 3 1 0 0 0 1 0 +324 26.25 3 1 0 0 0 1 0 diff --git a/examples/guitar_bar_and_discs.fem b/examples/guitar_bar_and_discs.fem new file mode 100644 index 0000000..51d5797 --- /dev/null +++ b/examples/guitar_bar_and_discs.fem @@ -0,0 +1,290 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 25 +[Depth] = 5 +[LengthUnits] = millimeters +[ProblemType] = planar +[Coordinates] = cartesian +[Comment] = "six plain steel strings (.010-.046 inch gauges) over a 25.5-inch scale, anchored to a NdFeB bar magnet at the left (mag_dir 0, N facing the strings) and a passive steel block at the right. small NdFeB disc magnets sit above and below each string at 5/7 of the way toward the steel block, all magnetised in -y so the disc above each string has its N face downward and the disc below has its S face upward, driving a strong vertical bias field through each string at the probe point. flip every disc mag_dir from 270 to 90 to invert the bias direction." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "NdFeB" + = 1.05 + = 1.05 + = 915000 + = 0 + = 0 + = 0 + = 0.667 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 2500 + = 2500 + = 0 + = 0 + = 0 + = 0 + = 5.8 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 84 +-100 -100 0 0 +750 -100 0 0 +750 100 0 0 +-100 100 0 0 +-30 -30 0 0 +-30 30 0 0 +0 -30 0 0 +0 -26.834 0 0 +0 -25.666 0 0 +0 -16.207 0 0 +0 -15.293 0 0 +0 -5.580 0 0 +0 -4.920 0 0 +0 5.034 0 0 +0 5.466 0 0 +0 15.585 0 0 +0 15.915 0 0 +0 26.123 0 0 +0 26.377 0 0 +0 30 0 0 +647.7 -30 0 0 +647.7 -26.834 0 0 +647.7 -25.666 0 0 +647.7 -16.207 0 0 +647.7 -15.293 0 0 +647.7 -5.580 0 0 +647.7 -4.920 0 0 +647.7 5.034 0 0 +647.7 5.466 0 0 +647.7 15.585 0 0 +647.7 15.915 0 0 +647.7 26.123 0 0 +647.7 26.377 0 0 +647.7 30 0 0 +677.7 -30 0 0 +677.7 30 0 0 +461.14 27.377 0 0 +464.14 27.377 0 0 +464.14 29.377 0 0 +461.14 29.377 0 0 +461.14 23.123 0 0 +464.14 23.123 0 0 +464.14 25.123 0 0 +461.14 25.123 0 0 +461.14 16.915 0 0 +464.14 16.915 0 0 +464.14 18.915 0 0 +461.14 18.915 0 0 +461.14 12.585 0 0 +464.14 12.585 0 0 +464.14 14.585 0 0 +461.14 14.585 0 0 +461.14 6.466 0 0 +464.14 6.466 0 0 +464.14 8.466 0 0 +461.14 8.466 0 0 +461.14 2.034 0 0 +464.14 2.034 0 0 +464.14 4.034 0 0 +461.14 4.034 0 0 +461.14 -3.920 0 0 +464.14 -3.920 0 0 +464.14 -1.920 0 0 +461.14 -1.920 0 0 +461.14 -8.580 0 0 +464.14 -8.580 0 0 +464.14 -6.580 0 0 +461.14 -6.580 0 0 +461.14 -14.293 0 0 +464.14 -14.293 0 0 +464.14 -12.293 0 0 +461.14 -12.293 0 0 +461.14 -19.207 0 0 +464.14 -19.207 0 0 +464.14 -17.207 0 0 +461.14 -17.207 0 0 +461.14 -24.666 0 0 +464.14 -24.666 0 0 +464.14 -22.666 0 0 +461.14 -22.666 0 0 +461.14 -29.834 0 0 +464.14 -29.834 0 0 +464.14 -27.834 0 0 +461.14 -27.834 0 0 +[NumSegments] = 96 +0 1 -1 1 0 0 +1 2 -1 1 0 0 +2 3 -1 1 0 0 +3 0 -1 1 0 0 +4 5 -1 0 0 0 +5 19 -1 0 0 0 +6 4 -1 0 0 0 +19 18 -1 0 0 0 +18 17 -1 0 0 0 +17 16 -1 0 0 0 +16 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 +10 9 -1 0 0 0 +9 8 -1 0 0 0 +8 7 -1 0 0 0 +7 6 -1 0 0 0 +20 34 -1 0 0 0 +34 35 -1 0 0 0 +35 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 +28 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 +22 21 -1 0 0 0 +21 20 -1 0 0 0 +8 22 -1 0 0 0 +7 21 -1 0 0 0 +10 24 -1 0 0 0 +9 23 -1 0 0 0 +12 26 -1 0 0 0 +11 25 -1 0 0 0 +14 28 -1 0 0 0 +13 27 -1 0 0 0 +16 30 -1 0 0 0 +15 29 -1 0 0 0 +18 32 -1 0 0 0 +17 31 -1 0 0 0 +36 37 -1 0 0 0 +37 38 -1 0 0 0 +38 39 -1 0 0 0 +39 36 -1 0 0 0 +40 41 -1 0 0 0 +41 42 -1 0 0 0 +42 43 -1 0 0 0 +43 40 -1 0 0 0 +44 45 -1 0 0 0 +45 46 -1 0 0 0 +46 47 -1 0 0 0 +47 44 -1 0 0 0 +48 49 -1 0 0 0 +49 50 -1 0 0 0 +50 51 -1 0 0 0 +51 48 -1 0 0 0 +52 53 -1 0 0 0 +53 54 -1 0 0 0 +54 55 -1 0 0 0 +55 52 -1 0 0 0 +56 57 -1 0 0 0 +57 58 -1 0 0 0 +58 59 -1 0 0 0 +59 56 -1 0 0 0 +60 61 -1 0 0 0 +61 62 -1 0 0 0 +62 63 -1 0 0 0 +63 60 -1 0 0 0 +64 65 -1 0 0 0 +65 66 -1 0 0 0 +66 67 -1 0 0 0 +67 64 -1 0 0 0 +68 69 -1 0 0 0 +69 70 -1 0 0 0 +70 71 -1 0 0 0 +71 68 -1 0 0 0 +72 73 -1 0 0 0 +73 74 -1 0 0 0 +74 75 -1 0 0 0 +75 72 -1 0 0 0 +76 77 -1 0 0 0 +77 78 -1 0 0 0 +78 79 -1 0 0 0 +79 76 -1 0 0 0 +80 81 -1 0 0 0 +81 82 -1 0 0 0 +82 83 -1 0 0 0 +83 80 -1 0 0 0 +[NumArcSegments] = 0 +[NumHoles] = 0 +[NumBlockLabels] = 21 +-50 -50 1 -1 0 0 0 1 2 +-15 0 2 5 0 0 0 1 0 +662.7 0 3 5 0 0 0 1 0 +320 -26.25 3 1 0 0 0 1 0 +320 -15.75 3 1 0 0 0 1 0 +320 -5.25 3 1 0 0 0 1 0 +320 5.25 3 1 0 0 0 1 0 +320 15.75 3 1 0 0 0 1 0 +320 26.25 3 1 0 0 0 1 0 +462.64 28.377 2 0.5 0 270 0 1 0 +462.64 24.123 2 0.5 0 270 0 1 0 +462.64 17.915 2 0.5 0 270 0 1 0 +462.64 13.585 2 0.5 0 270 0 1 0 +462.64 7.466 2 0.5 0 270 0 1 0 +462.64 3.034 2 0.5 0 270 0 1 0 +462.64 -2.920 2 0.5 0 270 0 1 0 +462.64 -7.580 2 0.5 0 270 0 1 0 +462.64 -13.293 2 0.5 0 270 0 1 0 +462.64 -18.207 2 0.5 0 270 0 1 0 +462.64 -23.666 2 0.5 0 270 0 1 0 +462.64 -28.834 2 0.5 0 270 0 1 0 diff --git a/examples/guitar_strings.fem b/examples/guitar_strings.fem index f610d64..ef80025 100644 --- a/examples/guitar_strings.fem +++ b/examples/guitar_strings.fem @@ -6,7 +6,7 @@ [LengthUnits] = millimeters [ProblemType] = planar [Coordinates] = cartesian -[Comment] = "guitar pickup geometry: 25.5-inch scale, 6 plain steel strings at real gauges 0.010-0.046 inch, NdFeB bar magnets at the neck and bridge ends. Both magnets present their N face inward toward the strings so flux is injected into each end of every string and cancels at the midpoint." +[Comment] = "diametrically-magnetised NdFeB ring (inner radius 81 mm, outer 111 mm, mag along +x) with six plain steel strings (.010-.046 inch gauges) stretched across the bore. each string spans about 162 mm from the N inner-face on the left to the S inner-face on the right. flux runs through every string in +x and closes through the rest of the ring body and surrounding air." [PointProps] = 0 [BdryProps] = 1 @@ -82,101 +82,93 @@ = 0 [CircuitProps] = 0 -[NumPoints] = 36 --100 -100 0 0 -750 -100 0 0 -750 100 0 0 --100 100 0 0 --30 -30 0 0 --30 30 0 0 -0 -30 0 0 -0 -26.377 0 0 -0 -26.123 0 0 -0 -15.915 0 0 -0 -15.585 0 0 -0 -5.466 0 0 -0 -5.034 0 0 -0 4.920 0 0 -0 5.580 0 0 -0 15.293 0 0 -0 16.207 0 0 -0 25.666 0 0 -0 26.834 0 0 -0 30 0 0 -647.7 -30 0 0 -647.7 -26.377 0 0 -647.7 -26.123 0 0 -647.7 -15.915 0 0 -647.7 -15.585 0 0 -647.7 -5.466 0 0 -647.7 -5.034 0 0 -647.7 4.920 0 0 -647.7 5.580 0 0 -647.7 15.293 0 0 -647.7 16.207 0 0 -647.7 25.666 0 0 -647.7 26.834 0 0 -647.7 30 0 0 -677.7 -30 0 0 -677.7 30 0 0 -[NumSegments] = 48 +[NumPoints] = 32 +-150 -150 0 0 +312 -150 0 0 +312 150 0 0 +-150 150 0 0 +-30 0 0 0 +81 -111 0 0 +192 0 0 0 +81 111 0 0 +0.149 -4.920 0 0 +0.192 -5.580 0 0 +1.457 -15.293 0 0 +1.638 -16.207 0 0 +4.174 -25.666 0 0 +4.574 -26.834 0 0 +157.426 -26.834 0 0 +157.826 -25.666 0 0 +160.362 -16.207 0 0 +160.543 -15.293 0 0 +161.808 -5.580 0 0 +161.851 -4.920 0 0 +161.844 5.034 0 0 +161.816 5.466 0 0 +160.487 15.585 0 0 +160.421 15.915 0 0 +157.672 26.123 0 0 +157.585 26.377 0 0 +4.415 26.377 0 0 +4.328 26.123 0 0 +1.579 15.915 0 0 +1.513 15.585 0 0 +0.184 5.466 0 0 +0.156 5.034 0 0 +[NumSegments] = 16 0 1 -1 1 0 0 1 2 -1 1 0 0 2 3 -1 1 0 0 3 0 -1 1 0 0 -4 5 -1 0 0 0 -5 19 -1 0 0 0 -4 6 -1 0 0 0 -6 7 -1 0 0 0 -7 8 -1 0 0 0 -8 9 -1 0 0 0 -9 10 -1 0 0 0 -10 11 -1 0 0 0 -11 12 -1 0 0 0 -12 13 -1 0 0 0 +12 15 -1 0 0 0 13 14 -1 0 0 0 -14 15 -1 0 0 0 -15 16 -1 0 0 0 -16 17 -1 0 0 0 -17 18 -1 0 0 0 -18 19 -1 0 0 0 -20 21 -1 0 0 0 -21 22 -1 0 0 0 -22 23 -1 0 0 0 -23 24 -1 0 0 0 -24 25 -1 0 0 0 -25 26 -1 0 0 0 -26 27 -1 0 0 0 -27 28 -1 0 0 0 -28 29 -1 0 0 0 -29 30 -1 0 0 0 -30 31 -1 0 0 0 -31 32 -1 0 0 0 -32 33 -1 0 0 0 -20 34 -1 0 0 0 -34 35 -1 0 0 0 -33 35 -1 0 0 0 -7 21 -1 0 0 0 -8 22 -1 0 0 0 -9 23 -1 0 0 0 -10 24 -1 0 0 0 -11 25 -1 0 0 0 -12 26 -1 0 0 0 -13 27 -1 0 0 0 -14 28 -1 0 0 0 -15 29 -1 0 0 0 -16 30 -1 0 0 0 -17 31 -1 0 0 0 -18 32 -1 0 0 0 -[NumArcSegments] = 0 +10 17 -1 0 0 0 +11 16 -1 0 0 0 +8 19 -1 0 0 0 +9 18 -1 0 0 0 +30 21 -1 0 0 0 +31 20 -1 0 0 0 +28 23 -1 0 0 0 +29 22 -1 0 0 0 +26 25 -1 0 0 0 +27 24 -1 0 0 0 +[NumArcSegments] = 28 +4 5 90 10 0 0 0 +5 6 90 10 0 0 0 +6 7 90 10 0 0 0 +7 4 90 10 0 0 0 +8 9 0.47 10 0 0 0 +9 10 6.94 10 0 0 0 +10 11 0.66 10 0 0 0 +11 12 6.94 10 0 0 0 +12 13 0.83 10 0 0 0 +13 14 141.36 10 0 0 0 +14 15 0.83 10 0 0 0 +15 16 6.94 10 0 0 0 +16 17 0.66 10 0 0 0 +17 18 6.94 10 0 0 0 +18 19 0.47 10 0 0 0 +19 20 7.04 10 0 0 0 +20 21 0.31 10 0 0 0 +21 22 7.23 10 0 0 0 +22 23 0.23 10 0 0 0 +23 24 7.48 10 0 0 0 +24 25 0.19 10 0 0 0 +25 26 142.00 10 0 0 0 +26 27 0.19 10 0 0 0 +27 28 7.48 10 0 0 0 +28 29 0.23 10 0 0 0 +29 30 7.23 10 0 0 0 +30 31 0.31 10 0 0 0 +31 8 7.04 10 0 0 0 [NumHoles] = 0 [NumBlockLabels] = 9 -325 75 1 -1 0 0 0 1 2 --15 0 2 5 0 0 0 1 0 -662.7 0 2 5 0 180 0 1 0 -325 -26.25 3 1 0 0 0 1 0 -325 -15.75 3 1 0 0 0 1 0 -325 -5.25 3 1 0 0 0 1 0 -325 5.25 3 1 0 0 0 1 0 -325 15.75 3 1 0 0 0 1 0 -325 26.25 3 1 0 0 0 1 0 +-100 0 1 -1 0 0 0 1 2 +81 0 1 -1 0 0 0 1 0 +81 96 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 diff --git a/examples/guitar_strings.femsim b/examples/guitar_strings.femsim new file mode 100644 index 0000000..b2036b4 --- /dev/null +++ b/examples/guitar_strings.femsim @@ -0,0 +1,63 @@ +# femm42 simulation metadata +# track node indices reference the doc loaded above; editing the doc may invalidate them. + +[source] +fem = /Volumes/External/Repositories/femm42src/examples/guitar_strings.fem + +[sim] +dt = 50us +interval = 16.667ms +subdivisions = 20 +buffer_size = 243 +match_cycle = true +loop_playback = true +fundamental_hz = 82.41 + +[track] +label = track 1 +anchor_a = 17 +anchor_b = 30 +axis = +y +expression = series(s, t, 82.41, 3) +members = 18 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 + +[track] +label = track 2 +anchor_a = 15 +anchor_b = 28 +axis = +y +expression = series(s, t, 103.83, 3) +members = 16 29 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 + +[track] +label = track 3 +anchor_a = 13 +anchor_b = 26 +axis = +y +expression = series(s, t, 123.47, 3) +members = 14 27 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 + +[track] +label = track 4 +anchor_a = 11 +anchor_b = 24 +axis = +y +expression = series(s, t, 164.81, 3) +members = 12 25 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 + +[track] +label = track 5 +anchor_a = 9 +anchor_b = 22 +axis = +y +expression = series(s, t, 246.94, 3) +members = 10 23 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 + +[track] +label = track 6 +anchor_a = 7 +anchor_b = 20 +axis = +y +expression = series(s, t, 329.63, 3) +members = 8 21 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 + diff --git a/examples/guitar_strings_5hsum.femsim b/examples/guitar_strings_5hsum.femsim new file mode 100644 index 0000000..b0bdc44 --- /dev/null +++ b/examples/guitar_strings_5hsum.femsim @@ -0,0 +1,63 @@ +# femm42 simulation metadata +# track node indices reference the doc loaded above; editing the doc may invalidate them. + +[source] +fem = /Volumes/External/Repositories/femm42src/examples/guitar_bar_and_discs.fem + +[sim] +dt = 50us +interval = 16.667ms +subdivisions = 20 +buffer_size = 243 +match_cycle = true +loop_playback = true +fundamental_hz = 82.41 + +[track] +label = track 1 +anchor_a = 17 +anchor_b = 30 +axis = +y +expression = series(s, t, 82.41, 5) +members = 18 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 + +[track] +label = track 2 +anchor_a = 15 +anchor_b = 28 +axis = +y +expression = series(s, t, 103.83, 5) +members = 16 29 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 + +[track] +label = track 3 +anchor_a = 13 +anchor_b = 26 +axis = +y +expression = series(s, t, 123.47, 5) +members = 14 27 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 + +[track] +label = track 4 +anchor_a = 11 +anchor_b = 24 +axis = +y +expression = series(s, t, 164.81, 5) +members = 12 25 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 + +[track] +label = track 5 +anchor_a = 9 +anchor_b = 22 +axis = +y +expression = series(s, t, 246.94, 5) +members = 10 23 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 + +[track] +label = track 6 +anchor_a = 7 +anchor_b = 20 +axis = +y +expression = series(s, t, 329.63, 5) +members = 8 21 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 + diff --git a/examples/half-size_guitar_ring.femsess b/examples/half-size_guitar_ring.femsess new file mode 100644 index 0000000..dc1b16c --- /dev/null +++ b/examples/half-size_guitar_ring.femsess @@ -0,0 +1,702 @@ +# femm42 session v1 +[base-doc] +[Format] = 4.00000000000000000e0 +[Frequency] = 0.00000000000000000e0 +[Precision] = 1.00000000000000002e-8 +[MinAngle] = 2.50000000000000000e1 +[DoSmartMesh] = 1 +[Depth] = 5.00000000000000000e0 +[LengthUnits] = millimeters +[ProblemType] = planar +[Coordinates] = cartesian +[ACSolver] = 0 +[PrevType] = 0 +[PrevSoln] = "" +[Comment] = "diametrically-magnetised NdFeB ring (inner radius 81 mm, outer 111 mm, mag along +x) with six plain steel strings (.010-.046 inch gauges) stretched across the bore. each string spans about 162 mm from the N inner-face on the left to the S inner-face on the right. flux runs through every string in +x and closes through the rest of the ring body and surrounding air." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + +[BlockProps] = 3 + + = "Air" + = 1.00000000000000000e0 + = 1.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0 + = 1.00000000000000000e0 + = 0 + = 0.00000000000000000e0 + = 0 + + + = "NdFeB" + = 1.05000000000000004e0 + = 1.05000000000000004e0 + = 9.15000000000000000e5 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 6.67000000000000037e-1 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0 + = 1.00000000000000000e0 + = 0 + = 0.00000000000000000e0 + = 0 + + + = "Steel" + = 2.50000000000000000e3 + = 2.50000000000000000e3 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 5.79999999999999982e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0.00000000000000000e0 + = 0 + = 1.00000000000000000e0 + = 0 + = 0.00000000000000000e0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 260 +-1.50000000000000000e2 -1.50000000000000000e2 0 0 +3.12000000000000000e2 -1.50000000000000000e2 0 0 +3.12000000000000000e2 1.50000000000000000e2 0 0 +-1.50000000000000000e2 1.50000000000000000e2 0 0 +-3.00000000000000000e1 0.00000000000000000e0 0 0 +8.10000000000000000e1 -1.11000000000000000e2 0 0 +1.92000000000000000e2 0.00000000000000000e0 0 0 +8.10000000000000000e1 1.11000000000000000e2 0 0 +1.48999999999999994e-1 -4.91999999999999993e0 0 0 +1.92000000000000004e-1 -5.58000000000000007e0 0 0 +1.45700000000000007e0 -1.52929999999999993e1 0 0 +1.63799999999999990e0 -1.62070000000000007e1 0 0 +4.17400000000000038e0 -2.56660000000000004e1 0 0 +4.57399999999999984e0 -2.68339999999999996e1 0 0 +1.57425999999999988e2 -2.68339999999999996e1 0 0 +1.57825999999999993e2 -2.56660000000000004e1 0 0 +1.60361999999999995e2 -1.62070000000000007e1 0 0 +1.60543000000000006e2 -1.52929999999999993e1 0 0 +1.61807999999999993e2 -5.58000000000000007e0 0 0 +1.61850999999999999e2 -4.91999999999999993e0 0 0 +1.61843999999999994e2 5.03399999999999981e0 0 0 +1.61816000000000003e2 5.46600000000000019e0 0 0 +1.60486999999999995e2 1.55850000000000009e1 0 0 +1.60420999999999992e2 1.59149999999999991e1 0 0 +1.57671999999999997e2 2.61230000000000011e1 0 0 +1.57585000000000008e2 2.63769999999999989e1 0 0 +4.41500000000000004e0 2.63769999999999989e1 0 0 +4.32800000000000029e0 2.61230000000000011e1 0 0 +1.57899999999999996e0 1.59149999999999991e1 0 0 +1.51299999999999990e0 1.55850000000000009e1 0 0 +1.83999999999999997e-1 5.46600000000000019e0 0 0 +1.56000000000000000e-1 5.03399999999999981e0 0 0 +1.22165999999999997e1 -2.68339999999999996e1 0 0 +1.98591999999999977e1 -2.68339999999999996e1 0 0 +2.75017999999999958e1 -2.68339999999999996e1 0 0 +3.51443999999999974e1 -2.68339999999999996e1 0 0 +4.27869999999999919e1 -2.68339999999999996e1 0 0 +5.04295999999999935e1 -2.68339999999999996e1 0 0 +5.80721999999999881e1 -2.68339999999999996e1 0 0 +6.57147999999999968e1 -2.68339999999999996e1 0 0 +7.33573999999999842e1 -2.68339999999999996e1 0 0 +8.09999999999999858e1 -2.68339999999999996e1 0 0 +8.86425999999999874e1 -2.68339999999999996e1 0 0 +9.62851999999999890e1 -2.68339999999999996e1 0 0 +1.03927799999999991e2 -2.68339999999999996e1 0 0 +1.11570399999999978e2 -2.68339999999999996e1 0 0 +1.19212999999999980e2 -2.68339999999999996e1 0 0 +1.26855599999999981e2 -2.68339999999999996e1 0 0 +1.34498199999999997e2 -2.68339999999999996e1 0 0 +1.42140799999999984e2 -2.68339999999999996e1 0 0 +1.49783400000000000e2 -2.68339999999999996e1 0 0 +1.18566000000000003e1 -2.56660000000000004e1 0 0 +1.95391999999999975e1 -2.56660000000000004e1 0 0 +2.72217999999999947e1 -2.56660000000000004e1 0 0 +3.49043999999999954e1 -2.56660000000000004e1 0 0 +4.25869999999999962e1 -2.56660000000000004e1 0 0 +5.02695999999999898e1 -2.56660000000000004e1 0 0 +5.79521999999999906e1 -2.56660000000000004e1 0 0 +6.56347999999999985e1 -2.56660000000000004e1 0 0 +7.33173999999999921e1 -2.56660000000000004e1 0 0 +8.10000000000000000e1 -2.56660000000000004e1 0 0 +8.86825999999999937e1 -2.56660000000000004e1 0 0 +9.63651999999999873e1 -2.56660000000000004e1 0 0 +1.04047799999999995e2 -2.56660000000000004e1 0 0 +1.11730399999999989e2 -2.56660000000000004e1 0 0 +1.19412999999999997e2 -2.56660000000000004e1 0 0 +1.27095599999999990e2 -2.56660000000000004e1 0 0 +1.34778199999999998e2 -2.56660000000000004e1 0 0 +1.42460799999999978e2 -2.56660000000000004e1 0 0 +1.50143399999999986e2 -2.56660000000000004e1 0 0 +9.57419999999999938e0 -1.62070000000000007e1 0 0 +1.75103999999999971e1 -1.62070000000000007e1 0 0 +2.54465999999999966e1 -1.62070000000000007e1 0 0 +3.33827999999999960e1 -1.62070000000000007e1 0 0 +4.13189999999999955e1 -1.62070000000000007e1 0 0 +4.92551999999999950e1 -1.62070000000000007e1 0 0 +5.71913999999999945e1 -1.62070000000000007e1 0 0 +6.51276000000000010e1 -1.62070000000000007e1 0 0 +7.30638000000000005e1 -1.62070000000000007e1 0 0 +8.10000000000000000e1 -1.62070000000000007e1 0 0 +8.89361999999999995e1 -1.62070000000000007e1 0 0 +9.68723999999999990e1 -1.62070000000000007e1 0 0 +1.04808599999999998e2 -1.62070000000000007e1 0 0 +1.12744799999999998e2 -1.62070000000000007e1 0 0 +1.20680999999999997e2 -1.62070000000000007e1 0 0 +1.28617199999999997e2 -1.62070000000000007e1 0 0 +1.36553399999999982e2 -1.62070000000000007e1 0 0 +1.44489599999999996e2 -1.62070000000000007e1 0 0 +1.52425800000000010e2 -1.62070000000000007e1 0 0 +9.41130000000000067e0 -1.52929999999999993e1 0 0 +1.73656000000000006e1 -1.52929999999999993e1 0 0 +2.53199000000000041e1 -1.52929999999999993e1 0 0 +3.32742000000000004e1 -1.52929999999999993e1 0 0 +4.12285000000000039e1 -1.52929999999999993e1 0 0 +4.91828000000000074e1 -1.52929999999999993e1 0 0 +5.71371000000000038e1 -1.52929999999999993e1 0 0 +6.50914000000000073e1 -1.52929999999999993e1 0 0 +7.30456999999999965e1 -1.52929999999999993e1 0 0 +8.10000000000000000e1 -1.52929999999999993e1 0 0 +8.89543000000000035e1 -1.52929999999999993e1 0 0 +9.69086000000000070e1 -1.52929999999999993e1 0 0 +1.04862900000000010e2 -1.52929999999999993e1 0 0 +1.12817200000000000e2 -1.52929999999999993e1 0 0 +1.20771500000000003e2 -1.52929999999999993e1 0 0 +1.28725800000000021e2 -1.52929999999999993e1 0 0 +1.36680100000000010e2 -1.52929999999999993e1 0 0 +1.44634399999999999e2 -1.52929999999999993e1 0 0 +1.52588700000000017e2 -1.52929999999999993e1 0 0 +8.27280000000000015e0 -5.58000000000000007e0 0 0 +1.63536000000000001e1 -5.58000000000000007e0 0 0 +2.44344000000000001e1 -5.58000000000000007e0 0 0 +3.25152000000000001e1 -5.58000000000000007e0 0 0 +4.05959999999999965e1 -5.58000000000000007e0 0 0 +4.86768000000000001e1 -5.58000000000000007e0 0 0 +5.67576000000000036e1 -5.58000000000000007e0 0 0 +6.48383999999999929e1 -5.58000000000000007e0 0 0 +7.29191999999999894e1 -5.58000000000000007e0 0 0 +8.09999999999999858e1 -5.58000000000000007e0 0 0 +8.90807999999999964e1 -5.58000000000000007e0 0 0 +9.71615999999999929e1 -5.58000000000000007e0 0 0 +1.05242399999999989e2 -5.58000000000000007e0 0 0 +1.13323200000000000e2 -5.58000000000000007e0 0 0 +1.21403999999999996e2 -5.58000000000000007e0 0 0 +1.29484800000000007e2 -5.58000000000000007e0 0 0 +1.37565600000000018e2 -5.58000000000000007e0 0 0 +1.45646400000000000e2 -5.58000000000000007e0 0 0 +1.53727200000000011e2 -5.58000000000000007e0 0 0 +8.23409999999999975e0 -4.91999999999999993e0 0 0 +1.63192000000000021e1 -4.91999999999999993e0 0 0 +2.44043000000000028e1 -4.91999999999999993e0 0 0 +3.24894000000000034e1 -4.91999999999999993e0 0 0 +4.05745000000000005e1 -4.91999999999999993e0 0 0 +4.86596000000000046e1 -4.91999999999999993e0 0 0 +5.67447000000000088e1 -4.91999999999999993e0 0 0 +6.48298000000000059e1 -4.91999999999999993e0 0 0 +7.29149000000000029e1 -4.91999999999999993e0 0 0 +8.10000000000000000e1 -4.91999999999999993e0 0 0 +8.90851000000000113e1 -4.91999999999999993e0 0 0 +9.71702000000000083e1 -4.91999999999999993e0 0 0 +1.05255300000000005e2 -4.91999999999999993e0 0 0 +1.13340400000000017e2 -4.91999999999999993e0 0 0 +1.21425500000000014e2 -4.91999999999999993e0 0 0 +1.29510600000000011e2 -4.91999999999999993e0 0 0 +1.37595700000000022e2 -4.91999999999999993e0 0 0 +1.45680800000000005e2 -4.91999999999999993e0 0 0 +1.53765900000000016e2 -4.91999999999999993e0 0 0 +8.24039999999999928e0 5.03399999999999981e0 0 0 +1.63247999999999962e1 5.03399999999999981e0 0 0 +2.44091999999999949e1 5.03399999999999981e0 0 0 +3.24935999999999936e1 5.03399999999999981e0 0 0 +4.05779999999999959e1 5.03399999999999981e0 0 0 +4.86623999999999910e1 5.03399999999999981e0 0 0 +5.67467999999999861e1 5.03399999999999981e0 0 0 +6.48311999999999955e1 5.03399999999999981e0 0 0 +7.29155999999999977e1 5.03399999999999981e0 0 0 +8.10000000000000000e1 5.03399999999999981e0 0 0 +8.90843999999999880e1 5.03399999999999981e0 0 0 +9.71687999999999903e1 5.03399999999999981e0 0 0 +1.05253199999999993e2 5.03399999999999981e0 0 0 +1.13337599999999981e2 5.03399999999999981e0 0 0 +1.21421999999999983e2 5.03399999999999981e0 0 0 +1.29506399999999985e2 5.03399999999999981e0 0 0 +1.37590799999999973e2 5.03399999999999981e0 0 0 +1.45675199999999990e2 5.03399999999999981e0 0 0 +1.53759599999999978e2 5.03399999999999981e0 0 0 +8.26559999999999917e0 5.46600000000000019e0 0 0 +1.63472000000000008e1 5.46600000000000019e0 0 0 +2.44287999999999990e1 5.46600000000000019e0 0 0 +3.25103999999999971e1 5.46600000000000019e0 0 0 +4.05919999999999987e1 5.46600000000000019e0 0 0 +4.86735999999999933e1 5.46600000000000019e0 0 0 +5.67551999999999950e1 5.46600000000000019e0 0 0 +6.48367999999999967e1 5.46600000000000019e0 0 0 +7.29183999999999912e1 5.46600000000000019e0 0 0 +8.10000000000000000e1 5.46600000000000019e0 0 0 +8.90815999999999946e1 5.46600000000000019e0 0 0 +9.71631999999999891e1 5.46600000000000019e0 0 0 +1.05244799999999998e2 5.46600000000000019e0 0 0 +1.13326399999999992e2 5.46600000000000019e0 0 0 +1.21408000000000001e2 5.46600000000000019e0 0 0 +1.29489599999999996e2 5.46600000000000019e0 0 0 +1.37571200000000005e2 5.46600000000000019e0 0 0 +1.45652799999999985e2 5.46600000000000019e0 0 0 +1.53734399999999994e2 5.46600000000000019e0 0 0 +9.46170000000000044e0 1.55850000000000009e1 0 0 +1.74103999999999992e1 1.55850000000000009e1 0 0 +2.53590999999999980e1 1.55850000000000009e1 0 0 +3.33078000000000003e1 1.55850000000000009e1 0 0 +4.12564999999999955e1 1.55850000000000009e1 0 0 +4.92051999999999978e1 1.55850000000000009e1 0 0 +5.71538999999999930e1 1.55850000000000009e1 0 0 +6.51025999999999954e1 1.55850000000000009e1 0 0 +7.30512999999999977e1 1.55850000000000009e1 0 0 +8.10000000000000000e1 1.55850000000000009e1 0 0 +8.89487000000000023e1 1.55850000000000009e1 0 0 +9.68974000000000046e1 1.55850000000000009e1 0 0 +1.04846100000000007e2 1.55850000000000009e1 0 0 +1.12794799999999995e2 1.55850000000000009e1 0 0 +1.20743499999999997e2 1.55850000000000009e1 0 0 +1.28692199999999985e2 1.55850000000000009e1 0 0 +1.36640899999999988e2 1.55850000000000009e1 0 0 +1.44589599999999990e2 1.55850000000000009e1 0 0 +1.52538299999999992e2 1.55850000000000009e1 0 0 +9.52109999999999879e0 1.59149999999999991e1 0 0 +1.74631999999999969e1 1.59149999999999991e1 0 0 +2.54052999999999969e1 1.59149999999999991e1 0 0 +3.33473999999999933e1 1.59149999999999991e1 0 0 +4.12894999999999968e1 1.59149999999999991e1 0 0 +4.92315999999999931e1 1.59149999999999991e1 0 0 +5.71736999999999966e1 1.59149999999999991e1 0 0 +6.51157999999999930e1 1.59149999999999991e1 0 0 +7.30578999999999894e1 1.59149999999999991e1 0 0 +8.09999999999999858e1 1.59149999999999991e1 0 0 +8.89420999999999822e1 1.59149999999999991e1 0 0 +9.68841999999999786e1 1.59149999999999991e1 0 0 +1.04826299999999975e2 1.59149999999999991e1 0 0 +1.12768399999999986e2 1.59149999999999991e1 0 0 +1.20710499999999982e2 1.59149999999999991e1 0 0 +1.28652599999999978e2 1.59149999999999991e1 0 0 +1.36594699999999989e2 1.59149999999999991e1 0 0 +1.44536799999999999e2 1.59149999999999991e1 0 0 +1.52478899999999982e2 1.59149999999999991e1 0 0 +1.19952000000000005e1 2.61230000000000011e1 0 0 +1.96623999999999981e1 2.61230000000000011e1 0 0 +2.73295999999999957e1 2.61230000000000011e1 0 0 +3.49968000000000004e1 2.61230000000000011e1 0 0 +4.26640000000000015e1 2.61230000000000011e1 0 0 +5.03311999999999955e1 2.61230000000000011e1 0 0 +5.79983999999999966e1 2.61230000000000011e1 0 0 +6.56655999999999977e1 2.61230000000000011e1 0 0 +7.33327999999999918e1 2.61230000000000011e1 0 0 +8.10000000000000000e1 2.61230000000000011e1 0 0 +8.86671999999999940e1 2.61230000000000011e1 0 0 +9.63343999999999880e1 2.61230000000000011e1 0 0 +1.04001599999999996e2 2.61230000000000011e1 0 0 +1.11668799999999990e2 2.61230000000000011e1 0 0 +1.19335999999999999e2 2.61230000000000011e1 0 0 +1.27003199999999993e2 2.61230000000000011e1 0 0 +1.34670400000000001e2 2.61230000000000011e1 0 0 +1.42337599999999981e2 2.61230000000000011e1 0 0 +1.50004799999999989e2 2.61230000000000011e1 0 0 +1.20735000000000010e1 2.63769999999999989e1 0 0 +1.97320000000000029e1 2.63769999999999989e1 0 0 +2.73905000000000030e1 2.63769999999999989e1 0 0 +3.50490000000000066e1 2.63769999999999989e1 0 0 +4.27075000000000031e1 2.63769999999999989e1 0 0 +5.03660000000000068e1 2.63769999999999989e1 0 0 +5.80245000000000033e1 2.63769999999999989e1 0 0 +6.56830000000000069e1 2.63769999999999989e1 0 0 +7.33415000000000106e1 2.63769999999999989e1 0 0 +8.10000000000000142e1 2.63769999999999989e1 0 0 +8.86585000000000178e1 2.63769999999999989e1 0 0 +9.63170000000000215e1 2.63769999999999989e1 0 0 +1.03975500000000025e2 2.63769999999999989e1 0 0 +1.11634000000000015e2 2.63769999999999989e1 0 0 +1.19292500000000018e2 2.63769999999999989e1 0 0 +1.26951000000000022e2 2.63769999999999989e1 0 0 +1.34609499999999997e2 2.63769999999999989e1 0 0 +1.42268000000000001e2 2.63769999999999989e1 0 0 +1.49926500000000004e2 2.63769999999999989e1 0 0 +[NumSegments] = 244 +0 1 -1 1 0 0 +1 2 -1 1 0 0 +2 3 -1 1 0 0 +3 0 -1 1 0 0 +13 32 -1 0 0 0 +32 33 -1 0 0 0 +33 34 -1 0 0 0 +34 35 -1 0 0 0 +35 36 -1 0 0 0 +36 37 -1 0 0 0 +37 38 -1 0 0 0 +38 39 -1 0 0 0 +39 40 -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 14 -1 0 0 0 +12 51 -1 0 0 0 +51 52 -1 0 0 0 +52 53 -1 0 0 0 +53 54 -1 0 0 0 +54 55 -1 0 0 0 +55 56 -1 0 0 0 +56 57 -1 0 0 0 +57 58 -1 0 0 0 +58 59 -1 0 0 0 +59 60 -1 0 0 0 +60 61 -1 0 0 0 +61 62 -1 0 0 0 +62 63 -1 0 0 0 +63 64 -1 0 0 0 +64 65 -1 0 0 0 +65 66 -1 0 0 0 +66 67 -1 0 0 0 +67 68 -1 0 0 0 +68 69 -1 0 0 0 +69 15 -1 0 0 0 +11 70 -1 0 0 0 +70 71 -1 0 0 0 +71 72 -1 0 0 0 +72 73 -1 0 0 0 +73 74 -1 0 0 0 +74 75 -1 0 0 0 +75 76 -1 0 0 0 +76 77 -1 0 0 0 +77 78 -1 0 0 0 +78 79 -1 0 0 0 +79 80 -1 0 0 0 +80 81 -1 0 0 0 +81 82 -1 0 0 0 +82 83 -1 0 0 0 +83 84 -1 0 0 0 +84 85 -1 0 0 0 +85 86 -1 0 0 0 +86 87 -1 0 0 0 +87 88 -1 0 0 0 +88 16 -1 0 0 0 +10 89 -1 0 0 0 +89 90 -1 0 0 0 +90 91 -1 0 0 0 +91 92 -1 0 0 0 +92 93 -1 0 0 0 +93 94 -1 0 0 0 +94 95 -1 0 0 0 +95 96 -1 0 0 0 +96 97 -1 0 0 0 +97 98 -1 0 0 0 +98 99 -1 0 0 0 +99 100 -1 0 0 0 +100 101 -1 0 0 0 +101 102 -1 0 0 0 +102 103 -1 0 0 0 +103 104 -1 0 0 0 +104 105 -1 0 0 0 +105 106 -1 0 0 0 +106 107 -1 0 0 0 +107 17 -1 0 0 0 +9 108 -1 0 0 0 +108 109 -1 0 0 0 +109 110 -1 0 0 0 +110 111 -1 0 0 0 +111 112 -1 0 0 0 +112 113 -1 0 0 0 +113 114 -1 0 0 0 +114 115 -1 0 0 0 +115 116 -1 0 0 0 +116 117 -1 0 0 0 +117 118 -1 0 0 0 +118 119 -1 0 0 0 +119 120 -1 0 0 0 +120 121 -1 0 0 0 +121 122 -1 0 0 0 +122 123 -1 0 0 0 +123 124 -1 0 0 0 +124 125 -1 0 0 0 +125 126 -1 0 0 0 +126 18 -1 0 0 0 +8 127 -1 0 0 0 +127 128 -1 0 0 0 +128 129 -1 0 0 0 +129 130 -1 0 0 0 +130 131 -1 0 0 0 +131 132 -1 0 0 0 +132 133 -1 0 0 0 +133 134 -1 0 0 0 +134 135 -1 0 0 0 +135 136 -1 0 0 0 +136 137 -1 0 0 0 +137 138 -1 0 0 0 +138 139 -1 0 0 0 +139 140 -1 0 0 0 +140 141 -1 0 0 0 +141 142 -1 0 0 0 +142 143 -1 0 0 0 +143 144 -1 0 0 0 +144 145 -1 0 0 0 +145 19 -1 0 0 0 +31 146 -1 0 0 0 +146 147 -1 0 0 0 +147 148 -1 0 0 0 +148 149 -1 0 0 0 +149 150 -1 0 0 0 +150 151 -1 0 0 0 +151 152 -1 0 0 0 +152 153 -1 0 0 0 +153 154 -1 0 0 0 +154 155 -1 0 0 0 +155 156 -1 0 0 0 +156 157 -1 0 0 0 +157 158 -1 0 0 0 +158 159 -1 0 0 0 +159 160 -1 0 0 0 +160 161 -1 0 0 0 +161 162 -1 0 0 0 +162 163 -1 0 0 0 +163 164 -1 0 0 0 +164 20 -1 0 0 0 +30 165 -1 0 0 0 +165 166 -1 0 0 0 +166 167 -1 0 0 0 +167 168 -1 0 0 0 +168 169 -1 0 0 0 +169 170 -1 0 0 0 +170 171 -1 0 0 0 +171 172 -1 0 0 0 +172 173 -1 0 0 0 +173 174 -1 0 0 0 +174 175 -1 0 0 0 +175 176 -1 0 0 0 +176 177 -1 0 0 0 +177 178 -1 0 0 0 +178 179 -1 0 0 0 +179 180 -1 0 0 0 +180 181 -1 0 0 0 +181 182 -1 0 0 0 +182 183 -1 0 0 0 +183 21 -1 0 0 0 +29 184 -1 0 0 0 +184 185 -1 0 0 0 +185 186 -1 0 0 0 +186 187 -1 0 0 0 +187 188 -1 0 0 0 +188 189 -1 0 0 0 +189 190 -1 0 0 0 +190 191 -1 0 0 0 +191 192 -1 0 0 0 +192 193 -1 0 0 0 +193 194 -1 0 0 0 +194 195 -1 0 0 0 +195 196 -1 0 0 0 +196 197 -1 0 0 0 +197 198 -1 0 0 0 +198 199 -1 0 0 0 +199 200 -1 0 0 0 +200 201 -1 0 0 0 +201 202 -1 0 0 0 +202 22 -1 0 0 0 +28 203 -1 0 0 0 +203 204 -1 0 0 0 +204 205 -1 0 0 0 +205 206 -1 0 0 0 +206 207 -1 0 0 0 +207 208 -1 0 0 0 +208 209 -1 0 0 0 +209 210 -1 0 0 0 +210 211 -1 0 0 0 +211 212 -1 0 0 0 +212 213 -1 0 0 0 +213 214 -1 0 0 0 +214 215 -1 0 0 0 +215 216 -1 0 0 0 +216 217 -1 0 0 0 +217 218 -1 0 0 0 +218 219 -1 0 0 0 +219 220 -1 0 0 0 +220 221 -1 0 0 0 +221 23 -1 0 0 0 +27 222 -1 0 0 0 +222 223 -1 0 0 0 +223 224 -1 0 0 0 +224 225 -1 0 0 0 +225 226 -1 0 0 0 +226 227 -1 0 0 0 +227 228 -1 0 0 0 +228 229 -1 0 0 0 +229 230 -1 0 0 0 +230 231 -1 0 0 0 +231 232 -1 0 0 0 +232 233 -1 0 0 0 +233 234 -1 0 0 0 +234 235 -1 0 0 0 +235 236 -1 0 0 0 +236 237 -1 0 0 0 +237 238 -1 0 0 0 +238 239 -1 0 0 0 +239 240 -1 0 0 0 +240 24 -1 0 0 0 +26 241 -1 0 0 0 +241 242 -1 0 0 0 +242 243 -1 0 0 0 +243 244 -1 0 0 0 +244 245 -1 0 0 0 +245 246 -1 0 0 0 +246 247 -1 0 0 0 +247 248 -1 0 0 0 +248 249 -1 0 0 0 +249 250 -1 0 0 0 +250 251 -1 0 0 0 +251 252 -1 0 0 0 +252 253 -1 0 0 0 +253 254 -1 0 0 0 +254 255 -1 0 0 0 +255 256 -1 0 0 0 +256 257 -1 0 0 0 +257 258 -1 0 0 0 +258 259 -1 0 0 0 +259 25 -1 0 0 0 +[NumArcSegments] = 28 +4 5 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +5 6 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +6 7 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +7 4 9.00000000000000000e1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +8 9 4.69999999999999973e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +9 10 6.94000000000000039e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +10 11 6.60000000000000031e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +11 12 6.94000000000000039e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +12 13 8.29999999999999960e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +13 14 1.41360000000000014e2 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +14 15 8.29999999999999960e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +15 16 6.94000000000000039e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +16 17 6.60000000000000031e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +17 18 6.94000000000000039e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +18 19 4.69999999999999973e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +19 20 7.04000000000000004e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +20 21 3.09999999999999998e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +21 22 7.23000000000000043e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +22 23 2.30000000000000010e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +23 24 7.48000000000000043e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +24 25 1.90000000000000002e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +25 26 1.42000000000000000e2 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +26 27 1.90000000000000002e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +27 28 7.48000000000000043e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +28 29 2.30000000000000010e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +29 30 7.23000000000000043e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +30 31 3.09999999999999998e-1 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +31 8 7.04000000000000004e0 1.00000000000000000e1 0 0 0 1.00000000000000000e1 +[NumHoles] = 0 +[NumBlockLabels] = 9 +-1.00000000000000000e2 0.00000000000000000e0 1 -1 0 0.00000000000000000e0 0 1 2 +8.10000000000000000e1 0.00000000000000000e0 1 -1 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 9.60000000000000000e1 2 5.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 -2.62500000000000000e1 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 -1.57500000000000000e1 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 -5.25000000000000000e0 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 5.25000000000000000e0 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 1.57500000000000000e1 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +8.10000000000000000e1 2.62500000000000000e1 3 1.00000000000000000e0 0 0.00000000000000000e0 0 1 0 +[/base-doc] +# femm42 simulation metadata +# track node indices reference the doc loaded above; editing the doc may invalidate them. + +[source] +fem = /Volumes/External/Repositories/femm42src/examples/guitar_strings.fem + +[sim] +dt = 82.410us +interval = 16.667ms +subdivisions = 20 +buffer_size = 148 +match_cycle = true +loop_playback = true +fundamental_hz = 82.41 + +[track] +label = track 1 +anchor_a = 17 +anchor_b = 30 +axis = +y +expression = series(s, t, 82.41, 5) +members = 18 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 + +[track] +label = track 2 +anchor_a = 15 +anchor_b = 28 +axis = +y +expression = series(s, t, 103.83, 5) +members = 16 29 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 + +[track] +label = track 3 +anchor_a = 13 +anchor_b = 26 +axis = +y +expression = series(s, t, 123.47, 5) +members = 14 27 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 + +[track] +label = track 4 +anchor_a = 11 +anchor_b = 24 +axis = +y +expression = series(s, t, 164.81, 5) +members = 12 25 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 + +[track] +label = track 5 +anchor_a = 9 +anchor_b = 22 +axis = +y +expression = series(s, t, 246.94, 5) +members = 10 23 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 + +[track] +label = track 6 +anchor_a = 7 +anchor_b = 20 +axis = +y +expression = series(s, t, 329.63, 5) +members = 8 21 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 + diff --git a/examples/magnetized_string.fem b/examples/magnetized_string.fem index 42db87e..38dd1ce 100644 --- a/examples/magnetized_string.fem +++ b/examples/magnetized_string.fem @@ -6,7 +6,7 @@ [LengthUnits] = millimeters [ProblemType] = planar [Coordinates] = cartesian -[Comment] = "single low-E steel string (1.168 mm gauge, 25.5-inch scale). NdFeB bar magnets touch each end of the string with N faces facing inward. Flux is injected from both ends, the two flows cancel at the midpoint, producing a null point in the centre of the rod." +[Comment] = "single low-E steel string (1.168 mm gauge, 12.75-inch half-scale). NdFeB bar magnets touch each end of the string with the same polarity along +x: the west magnet's N face contacts the west string end, the east magnet's S face contacts the east string end. Flux runs the full length of the rod in one direction and returns through air, producing a strong axial field everywhere along the wire." [PointProps] = 0 [BdryProps] = 1 @@ -83,22 +83,22 @@ [CircuitProps] = 0 [NumPoints] = 16 --100 -30 0 0 -750 -30 0 0 -750 30 0 0 --100 30 0 0 +-100 -120 0 0 +400 -120 0 0 +400 120 0 0 +-100 120 0 0 -30 -5 0 0 -30 5 0 0 0 -5 0 0 0 -0.584 0 0 0 0.584 0 0 0 5 0 0 -647.7 -5 0 0 -647.7 -0.584 0 0 -647.7 0.584 0 0 -647.7 5 0 0 -677.7 5 0 0 -677.7 -5 0 0 +323.85 -5 0 0 +323.85 -0.584 0 0 +323.85 0.584 0 0 +323.85 5 0 0 +353.85 5 0 0 +353.85 -5 0 0 [NumSegments] = 18 0 1 -1 1 0 0 1 2 -1 1 0 0 @@ -121,7 +121,7 @@ [NumArcSegments] = 0 [NumHoles] = 0 [NumBlockLabels] = 4 -325 20 1 -1 0 0 0 1 2 +162 90 1 -1 0 0 0 1 2 -15 0 2 5 0 0 0 1 0 -662.7 0 2 5 0 180 0 1 0 -325 0 3 1 0 0 0 1 0 +338.85 0 2 5 0 0 0 1 0 +162 0 3 1 0 0 0 1 0 diff --git a/examples/ring_magnet_string.fem b/examples/ring_magnet_string.fem new file mode 100644 index 0000000..f327b6a --- /dev/null +++ b/examples/ring_magnet_string.fem @@ -0,0 +1,117 @@ +[Format] = 4.0 +[Frequency] = 0 +[Precision] = 1e-008 +[MinAngle] = 25 +[Depth] = 1 +[LengthUnits] = millimeters +[ProblemType] = axisymmetric +[Coordinates] = cartesian +[Comment] = "axisymmetric meridional cross-section: a steel wire on the z-axis passes through the hole of an axially-magnetised NdFeB ring (M along +z). top end of the wire emerges past the ring's N face, bottom end past the S face. revolved around r = 0 the ring stays a continuous body." +[PointProps] = 0 +[BdryProps] = 1 + + = "A=0" + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + +[BlockProps] = 3 + + = "Air" + = 1 + = 1 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "NdFeB" + = 1.05 + = 1.05 + = 915000 + = 0 + = 0 + = 0 + = 0.667 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + + + = "Steel" + = 2500 + = 2500 + = 0 + = 0 + = 0 + = 0 + = 5.8 + = 0 + = 0 + = 0 + = 0 + = 0 + = 1 + = 0 + = 0 + = 0 + +[CircuitProps] = 0 +[NumPoints] = 12 +0 -100 0 0 +50 -100 0 0 +50 100 0 0 +0 100 0 0 +0 -80 0 0 +0.584 -80 0 0 +0.584 80 0 0 +0 80 0 0 +3 -10 0 0 +8 -10 0 0 +8 10 0 0 +3 10 0 0 +[NumSegments] = 13 +0 1 -1 1 0 0 +1 2 -1 1 0 0 +2 3 -1 1 0 0 +0 4 -1 0 0 0 +4 7 -1 0 0 0 +7 3 -1 0 0 0 +4 5 -1 0 0 0 +5 6 -1 0 0 0 +6 7 -1 0 0 0 +8 9 -1 0 0 0 +9 10 -1 0 0 0 +10 11 -1 0 0 0 +11 8 -1 0 0 0 +[NumArcSegments] = 0 +[NumHoles] = 0 +[NumBlockLabels] = 3 +25 50 1 -1 0 0 0 1 2 +5.5 0 2 1 0 90 0 1 0 +0.1 0 3 0.2 0 0 0 1 0 diff --git a/scripts/macos/build.sh b/scripts/macos/build.sh index d5f1e77..0a43588 100755 --- a/scripts/macos/build.sh +++ b/scripts/macos/build.sh @@ -10,6 +10,7 @@ case "$(uname -s)" in esac export MACOSX_DEPLOYMENT_TARGET=11.0 +export PROFILE=release BUILD="$ROOT/build" APP="$BUILD/bin/femm.app" diff --git a/scripts/macos/build_ffi.sh b/scripts/macos/build_ffi.sh index 6e4050e..5cc4baa 100755 --- a/scripts/macos/build_ffi.sh +++ b/scripts/macos/build_ffi.sh @@ -9,7 +9,11 @@ BUILD=${BUILD:-"$ROOT/build/ffi"} CXX=${CXX:-clang++} LD=${LD:-ld} AR=${AR:-ar} -CXXFLAGS=${CXXFLAGS:-"-std=c++17 -fno-exceptions -fno-rtti -O2 -w"} +case "${PROFILE:-release}" in + release) DEFAULT_CXXFLAGS="-std=c++17 -fno-exceptions -fno-rtti -O3 -DNDEBUG -w" ;; + *) DEFAULT_CXXFLAGS="-std=c++17 -fno-exceptions -fno-rtti -O0 -g -w" ;; +esac +CXXFLAGS=${CXXFLAGS:-$DEFAULT_CXXFLAGS} mkdir -p "$BUILD"/{fkn,liblua,belasolv,csolv,hsolv,ffi} diff --git a/scripts/macos/build_triangle.sh b/scripts/macos/build_triangle.sh index 8de1f88..64d7ef8 100755 --- a/scripts/macos/build_triangle.sh +++ b/scripts/macos/build_triangle.sh @@ -7,7 +7,11 @@ BUILD="${BUILD:-$ROOT/build/triangle}" mkdir -p "$BUILD" CC=${CC:-clang} -CFLAGS=${CFLAGS:-"-O2 -w"} +case "${PROFILE:-release}" in + release) DEFAULT_CFLAGS="-O3 -DNDEBUG -w" ;; + *) DEFAULT_CFLAGS="-O0 -g -w" ;; +esac +CFLAGS=${CFLAGS:-$DEFAULT_CFLAGS} $CC $CFLAGS -o "$BUILD/triangle" "$ROOT/triangle/triangle.c" -lm