triangle build
auto-invalidation of cache
This commit is contained in:
jess 2026-05-13 00:40:17 -07:00
parent 4e6a655683
commit 74ab29ee8d
7 changed files with 278 additions and 6 deletions

1
.gitignore vendored
View File

@ -4,4 +4,5 @@ build/ffi/
*.a
target/
assets/old/
assets/icons/
Cargo.lock

View File

@ -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<CanvasMessage> for DocCanvas<'a> {
@ -214,6 +222,27 @@ impl<'a> canvas::Program<CanvasMessage> 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))

View File

@ -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<Mesh>,
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<Mesh, String> {
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<std::path::PathBuf> {
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::{

View File

@ -7,6 +7,7 @@ pub mod parser;
pub mod writer;
pub mod edit;
pub mod poly;
pub mod mesh;
use num_complex::Complex64;

View File

@ -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<MeshNode>,
pub elements: Vec<MeshElement>,
}
/// 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<Path>) -> Result<Self, MeshLoadError> {
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<Vec<MeshNode>, 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<Vec<MeshElement>, 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::<f64>().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);
}
}

14
scripts/macos/build_triangle.sh Executable file
View File

@ -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"

View File

@ -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);
}