parent
4e6a655683
commit
74ab29ee8d
|
|
@ -4,4 +4,5 @@ build/ffi/
|
|||
*.a
|
||||
target/
|
||||
assets/old/
|
||||
assets/icons/
|
||||
Cargo.lock
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub mod parser;
|
|||
pub mod writer;
|
||||
pub mod edit;
|
||||
pub mod poly;
|
||||
pub mod mesh;
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue