diff --git a/.gitignore b/.gitignore index 76a071b..4325418 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ build/ffi/ *.a target/ assets/old/ +assets/icons/ Cargo.lock diff --git a/crates/femm-app/src/doc_canvas.rs b/crates/femm-app/src/doc_canvas.rs index 420b7ec..1f35388 100644 --- a/crates/femm-app/src/doc_canvas.rs +++ b/crates/femm-app/src/doc_canvas.rs @@ -1,6 +1,7 @@ //! draws a FemmDoc on an iced canvas: nodes, segments, arcs, block labels, with pan/zoom and click-to-add. use femm_doc_mag::FemmDoc; +use femm_doc_mag::mesh::Mesh; use iced::widget::canvas::{ self, Action, Canvas, Event, Frame, Geometry, Path, Stroke, Text, path::Builder, }; @@ -20,6 +21,8 @@ const LABEL_COLOR: Color = Color::from_rgb(0.25, 0.45, 0.85); const PENDING_COLOR: Color = Color::from_rgb(0.85, 0.30, 0.30); const SELECT_COLOR: Color = Color::from_rgb(0.95, 0.20, 0.20); const SELECT_STROKE: f32 = 2.4; +const MESH_COLOR: Color = Color::from_rgba(0.55, 0.55, 0.55, 0.55); +const MESH_STROKE: f32 = 0.5; /// active editing mode on the canvas. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -56,9 +59,13 @@ pub struct ViewState { cursor_world: Option<(f64, f64)>, } -/// constructs the canvas widget for a doc reference. -pub fn view<'a>(doc: &'a FemmDoc, tool: Tool) -> Element<'a, CanvasMessage> { - Canvas::new(DocCanvas { doc, tool }) +/// constructs the canvas widget for a doc reference and an optional mesh overlay. +pub fn view<'a>( + doc: &'a FemmDoc, + tool: Tool, + mesh: Option<&'a Mesh>, +) -> Element<'a, CanvasMessage> { + Canvas::new(DocCanvas { doc, tool, mesh }) .width(Length::Fill) .height(Length::Fill) .into() @@ -67,6 +74,7 @@ pub fn view<'a>(doc: &'a FemmDoc, tool: Tool) -> Element<'a, CanvasMessage> { struct DocCanvas<'a> { doc: &'a FemmDoc, tool: Tool, + mesh: Option<&'a Mesh>, } impl<'a> canvas::Program for DocCanvas<'a> { @@ -214,6 +222,27 @@ impl<'a> canvas::Program for DocCanvas<'a> { let view = ViewTransform::fit(self.doc, bounds, state); + if let Some(mesh) = self.mesh { + for el in &mesh.elements { + let (Some(a), Some(b), Some(c)) = ( + mesh.nodes.get(el.v0 as usize), + mesh.nodes.get(el.v1 as usize), + mesh.nodes.get(el.v2 as usize), + ) else { continue }; + let pa = view.map(a.x, a.y); + let pb = view.map(b.x, b.y); + let pc = view.map(c.x, c.y); + let tri = Path::new(|p| { + p.move_to(pa); + p.line_to(pb); + p.line_to(pc); + p.close(); + }); + frame.stroke(&tri, + Stroke::default().with_width(MESH_STROKE).with_color(MESH_COLOR)); + } + } + for s in &self.doc.segments { if let (Some(p0), Some(p1)) = (self.doc.nodes.get(s.n0 as usize), self.doc.nodes.get(s.n1 as usize)) diff --git a/crates/femm-app/src/main.rs b/crates/femm-app/src/main.rs index 07cff7e..0185351 100644 --- a/crates/femm-app/src/main.rs +++ b/crates/femm-app/src/main.rs @@ -4,21 +4,25 @@ mod doc_canvas; use doc_canvas::{CanvasMessage, Tool}; use femm_doc_mag::FemmDoc; +use femm_doc_mag::mesh::Mesh; use iced::widget::{button, column, container, row, text}; use iced::{Element, Length, Task}; const DEMO_FEM: &str = include_str!("../assets/demo.fem"); const ADD_TOLERANCE: f64 = 0.5; +const MIN_ANGLE_DEG: f64 = 30.0; #[derive(Debug, Clone)] enum Message { OpenFem, SelectTool(Tool), Canvas(CanvasMessage), + RunMesh, } struct App { doc: FemmDoc, + mesh: Option, source_label: String, status: String, tool: Tool, @@ -29,6 +33,7 @@ impl App { let doc = FemmDoc::parse(DEMO_FEM).unwrap_or_default(); let app = App { doc, + mesh: None, source_label: String::from("demo.fem (embedded)"), status: String::new(), tool: Tool::Select, @@ -57,6 +62,7 @@ impl App { match FemmDoc::open(&path) { Ok(d) => { self.doc = d; + self.mesh = None; self.source_label = label; self.status = String::new(); } @@ -69,14 +75,28 @@ impl App { Message::SelectTool(t) => { self.tool = t; } + Message::RunMesh => { + match run_mesh(&self.doc) { + Ok(m) => { + self.status = format!("meshed: {} nodes, {} elements", m.nodes.len(), m.elements.len()); + self.mesh = Some(m); + } + Err(e) => { + self.mesh = None; + self.status = format!("mesh failed: {e}"); + } + } + } Message::Canvas(CanvasMessage::Click { world, tool }) => { match tool { Tool::AddNode => { let idx = self.doc.add_node(world.0, world.1, ADD_TOLERANCE); + self.mesh = None; self.status = format!("node {idx} at ({:.3}, {:.3})", world.0, world.1); } Tool::AddBlockLabel => { let idx = self.doc.add_block_label(world.0, world.1, ADD_TOLERANCE); + self.mesh = None; self.status = format!("block label {idx} at ({:.3}, {:.3})", world.0, world.1); } Tool::Select | Tool::AddSegment => {} @@ -86,6 +106,7 @@ impl App { let n0 = self.doc.add_node(from.0, from.1, ADD_TOLERANCE) as i32; let n1 = self.doc.add_node(to.0, to.1, ADD_TOLERANCE) as i32; if self.doc.add_segment(n0, n1) { + self.mesh = None; self.status = format!( "segment {n0} -> {n1} ({} total)", self.doc.segments.len(), @@ -104,6 +125,7 @@ impl App { let a = self.doc.delete_selected_arcs(); let b = self.doc.delete_selected_block_labels(); let total = n + s + a + b; + if total > 0 { self.mesh = None; } self.status = if total == 0 { String::from("nothing selected") } else { @@ -130,12 +152,14 @@ impl App { tool_button("Add Node", Tool::AddNode, self.tool), tool_button("Add Segment", Tool::AddSegment, self.tool), tool_button("Add Label", Tool::AddBlockLabel, self.tool), + button("Mesh").on_press(Message::RunMesh), text(&self.source_label).size(13), stats, ] .spacing(8); - let canvas = doc_canvas::view(&self.doc, self.tool).map(Message::Canvas); + let canvas = doc_canvas::view(&self.doc, self.tool, self.mesh.as_ref()) + .map(Message::Canvas); let mut body = column![toolbar].spacing(8).padding(12); body = body.push(canvas); @@ -150,6 +174,48 @@ impl App { } } +/// saves the doc to a temp .fem and matching .poly, invokes Triangle, and reads back the mesh. +fn run_mesh(doc: &FemmDoc) -> Result { + let dir = std::env::temp_dir().join("femm42-mesh"); + std::fs::create_dir_all(&dir).map_err(|e| format!("temp dir: {e}"))?; + let stem = dir.join("active"); + let fem_path = stem.with_extension("fem"); + let poly_path = stem.with_extension("poly"); + + doc.save(&fem_path).map_err(|e| format!("save .fem: {e}"))?; + doc.save_poly(&poly_path).map_err(|e| format!("save .poly: {e}"))?; + + let triangle = locate_triangle() + .ok_or_else(|| String::from("triangle binary not found at build/triangle/triangle - run scripts/macos/build_triangle.sh"))?; + + let stem_str = stem.to_str().ok_or_else(|| String::from("non-utf8 path"))?; + let status = std::process::Command::new(&triangle) + .args([ + "-p", "-P", "-e", "-A", "-a", "-z", "-Q", "-I", + &format!("-q{MIN_ANGLE_DEG}"), + ]) + .arg(stem_str) + .status() + .map_err(|e| format!("exec triangle: {e}"))?; + if !status.success() { + return Err(format!("triangle exited with {status}")); + } + + Mesh::load(&stem).map_err(|e| format!("read mesh: {e}")) +} + +/// resolves the Triangle binary path, preferring the build dir then $PATH. +fn locate_triangle() -> Option { + let here = std::env::current_dir().ok()?; + let built = here.join("build/triangle/triangle"); + if built.is_file() { return Some(built); } + if let Ok(env_path) = std::env::var("FEMM_TRIANGLE") { + let p = std::path::PathBuf::from(env_path); + if p.is_file() { return Some(p); } + } + None +} + /// flips the selected flag on whichever entity sits nearest to (x, y) in doc world coords. fn toggle_closest(doc: &mut FemmDoc, x: f64, y: f64) { use femm_doc_mag::geom_math::{ diff --git a/crates/femm-doc-mag/src/lib.rs b/crates/femm-doc-mag/src/lib.rs index 7c931e2..e2e54c5 100644 --- a/crates/femm-doc-mag/src/lib.rs +++ b/crates/femm-doc-mag/src/lib.rs @@ -7,6 +7,7 @@ pub mod parser; pub mod writer; pub mod edit; pub mod poly; +pub mod mesh; use num_complex::Complex64; diff --git a/crates/femm-doc-mag/src/mesh.rs b/crates/femm-doc-mag/src/mesh.rs new file mode 100644 index 0000000..752c5a9 --- /dev/null +++ b/crates/femm-doc-mag/src/mesh.rs @@ -0,0 +1,161 @@ +//! readers for Triangle's .node and .ele output files. + +use std::path::Path; +use thiserror::Error; + +/// 2D triangular mesh consumed by the canvas overlay and by the post-processor. +#[derive(Debug, Default, Clone)] +pub struct Mesh { + pub nodes: Vec, + pub elements: Vec, +} + +/// position and optional boundary marker for a single mesh node. +#[derive(Debug, Clone, Copy)] +pub struct MeshNode { + pub x: f64, + pub y: f64, + pub marker: i32, +} + +/// triangle element with three zero-based node indices and an optional region attribute. +#[derive(Debug, Clone, Copy)] +pub struct MeshElement { + pub v0: u32, + pub v1: u32, + pub v2: u32, + pub attribute: i32, +} + +#[derive(Debug, Error)] +pub enum MeshLoadError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("malformed header in {file}: {reason}")] + BadHeader { file: &'static str, reason: String }, + #[error("malformed row in {file}: {reason}")] + BadRow { file: &'static str, reason: String }, +} + +impl Mesh { + /// loads .1.node and .1.ele next to the given path stem (Triangle's default output). + pub fn load(stem: impl AsRef) -> Result { + let stem = stem.as_ref(); + let node_path = with_extension(stem, "1.node"); + let ele_path = with_extension(stem, "1.ele"); + let nodes = parse_node(&std::fs::read_to_string(&node_path)?)?; + let elements = parse_ele(&std::fs::read_to_string(&ele_path)?)?; + Ok(Mesh { nodes, elements }) + } +} + +/// parses a .node file: header `count 2 0 nbm`, then count rows of `idx x y [marker]`. +pub fn parse_node(src: &str) -> Result, MeshLoadError> { + let mut lines = src.lines().filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty()); + let header = lines.next().ok_or(MeshLoadError::BadHeader { + file: ".node", + reason: "empty file".into(), + })?; + let mut hparts = header.split_whitespace(); + let count: usize = hparts.next() + .and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadHeader { file: ".node", reason: format!("expected count, got {header:?}") })?; + let _dim = hparts.next(); + let _attrs = hparts.next(); + let has_marker = hparts.next().map(|s| s.trim() == "1").unwrap_or(false); + + let mut nodes = Vec::with_capacity(count); + for line in lines.take(count) { + let mut parts = line.split_whitespace(); + let _idx: u32 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".node", reason: line.into() })?; + let x: f64 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".node", reason: line.into() })?; + let y: f64 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".node", reason: line.into() })?; + let marker = if has_marker { + parts.next().and_then(|s| s.parse().ok()).unwrap_or(0) + } else { + 0 + }; + nodes.push(MeshNode { x, y, marker }); + } + Ok(nodes) +} + +/// parses a .ele file: header `count 3 natt`, then count rows of `idx v0 v1 v2 [attr0 ...]`. +pub fn parse_ele(src: &str) -> Result, MeshLoadError> { + let mut lines = src.lines().filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty()); + let header = lines.next().ok_or(MeshLoadError::BadHeader { + file: ".ele", + reason: "empty file".into(), + })?; + let mut hparts = header.split_whitespace(); + let count: usize = hparts.next() + .and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadHeader { file: ".ele", reason: format!("expected count, got {header:?}") })?; + let _nodes_per = hparts.next(); + let natt: usize = hparts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + + let mut elements = Vec::with_capacity(count); + for line in lines.take(count) { + let mut parts = line.split_whitespace(); + let _idx: u32 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".ele", reason: line.into() })?; + let v0: u32 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".ele", reason: line.into() })?; + let v1: u32 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".ele", reason: line.into() })?; + let v2: u32 = parts.next().and_then(|s| s.parse().ok()) + .ok_or(MeshLoadError::BadRow { file: ".ele", reason: line.into() })?; + let attribute = if natt > 0 { + parts.next().and_then(|s| s.parse::().ok()).map(|f| f as i32).unwrap_or(0) + } else { + 0 + }; + elements.push(MeshElement { v0, v1, v2, attribute }); + } + Ok(elements) +} + +fn with_extension(stem: &Path, ext: &str) -> std::path::PathBuf { + let mut s = stem.to_path_buf(); + s.set_extension(ext); + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_node_with_markers() { + // 3 nodes, 2D, no attributes, with markers; comment line at the start is ignored. + let src = "# triangle .node\n3 2 0 1\n0 0.0 0.0 2\n1 1.0 0.0 0\n2 0.0 1.0 0\n"; + let nodes = parse_node(src).unwrap(); + assert_eq!(nodes.len(), 3); + assert_eq!(nodes[0].marker, 2); + assert!((nodes[2].y - 1.0).abs() < 1e-12); + } + + #[test] + fn parses_ele_with_region_attribute() { + // 2 triangles, 3 nodes per element, 1 region attribute column. + let src = "2 3 1\n0 0 1 2 1.0\n1 1 2 3 2.0\n"; + let els = parse_ele(src).unwrap(); + assert_eq!(els.len(), 2); + assert_eq!(els[0].v0, 0); + assert_eq!(els[0].v1, 1); + assert_eq!(els[0].v2, 2); + assert_eq!(els[0].attribute, 1); + assert_eq!(els[1].attribute, 2); + } + + #[test] + fn parses_node_without_marker_column() { + let src = "2 2 0 0\n0 0.0 0.0\n1 1.0 0.0\n"; + let nodes = parse_node(src).unwrap(); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].marker, 0); + } +} diff --git a/scripts/macos/build_triangle.sh b/scripts/macos/build_triangle.sh new file mode 100755 index 0000000..8de1f88 --- /dev/null +++ b/scripts/macos/build_triangle.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD="${BUILD:-$ROOT/build/triangle}" + +mkdir -p "$BUILD" + +CC=${CC:-clang} +CFLAGS=${CFLAGS:-"-O2 -w"} + +$CC $CFLAGS -o "$BUILD/triangle" "$ROOT/triangle/triangle.c" -lm + +echo "built: $BUILD/triangle" diff --git a/triangle/triangle.c b/triangle/triangle.c index 9431394..fd0a1c6 100644 --- a/triangle/triangle.c +++ b/triangle/triangle.c @@ -1469,7 +1469,7 @@ VOID *memptr; void syntax() { - ShellExecute(NULL, "open", "https://www.cs.cmu.edu/~quake/triangle.switch.html", NULL, NULL, SW_SHOWNORMAL); + printf("Triangle switches: https://www.cs.cmu.edu/~quake/triangle.switch.html\n"); triexit(0); } @@ -1485,7 +1485,7 @@ void syntax() void info() { - ShellExecute(NULL, "open", "https://www.cs.cmu.edu/~quake/triangle.help.html", NULL, NULL, SW_SHOWNORMAL); + printf("Triangle help: https://www.cs.cmu.edu/~quake/triangle.help.html\n"); triexit(0); }