forked from 1b-agent/fieldcraft-upstream
Geometry: persistent edge names, after the faces each edge separates
A design edge is named by its faces' persistent names, sorted and joined by `|`, numbered `~k` by position when several separate the same faces. The names follow the faces through a design edit and a partition replay. Part of #230. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
6bfa0b410a
commit
a11b971dae
4 files changed
+227
-8
No files matched your search
@@ -755,8 +755,8 @@ mod tests {
|
||||
// A tagged tetrahedron, and the same with a face missing.
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
}
|
||||
@@ -956,8 +956,8 @@ mod tests {
|
||||
|
||||
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
let t = vec![[0, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0 };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0 };
|
||||
let tet = SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() };
|
||||
let mut s = Session::default();
|
||||
for mesh in [tet, open] {
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 }));
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Persistent names of a body's B-rep edges.
|
||||
//!
|
||||
//! An edge is named after the faces it separates: the persistent names of its faces, sorted and
|
||||
//! joined by `|` (`F2:side:l1|F2:top`), the scheme SolidCraft names edges by. Where several edges
|
||||
//! separate the same faces (the top and bottom rim of a hole's half-cylinder against two caps
|
||||
//! cannot repeat, but the two edges between a fillet and a face it splits can), they are numbered
|
||||
//! `~0`, `~1`, … in order of position along the axis they spread most along, so the names do not
|
||||
//! depend on the order the kernel lists edges in. Because the names come from the face names, an
|
||||
//! edge keeps its name through an edit of the design that keeps its faces' names, and the cut
|
||||
//! edges of a partition are named after the `partition:<name>:a|b` faces.
|
||||
//!
|
||||
//! A shape built in code has no face names; its edges are named after the face indices.
|
||||
|
||||
use crate::{Result, Shape, SurfaceMesh};
|
||||
|
||||
/// Tessellation tolerance (a fraction of the body's size) of [`Shape::edges`]: the edge polylines
|
||||
/// are for display, picking and matching edges by position.
|
||||
const EDGE_TOLERANCE: f64 = 1e-3;
|
||||
|
||||
/// A B-rep edge of a body.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Edge {
|
||||
/// The persistent name (see the module docs).
|
||||
pub name: String,
|
||||
/// The faces it bounds, by face index (one or two; both the same for a seam).
|
||||
pub faces: Vec<u32>,
|
||||
/// A polyline along it, start to end.
|
||||
pub points: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
/// The length of its polyline.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.points.windows(2).map(|w| distance(w[0], w[1])).sum()
|
||||
}
|
||||
}
|
||||
|
||||
fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The length-weighted centre of a polyline (its first point when it has no length).
|
||||
fn centre(points: &[[f64; 3]]) -> [f64; 3] {
|
||||
let mut sum = [0.0; 3];
|
||||
let mut weight = 0.0;
|
||||
for w in points.windows(2) {
|
||||
let l = distance(w[0], w[1]);
|
||||
for (k, s) in sum.iter_mut().enumerate() {
|
||||
*s += l * 0.5 * (w[0][k] + w[1][k]);
|
||||
}
|
||||
weight += l;
|
||||
}
|
||||
if weight > 0.0 { sum.map(|s| s / weight) } else { points.first().copied().unwrap_or_default() }
|
||||
}
|
||||
|
||||
/// Number the repeated names `~0`, `~1`, … by position: along the axis their centres spread most
|
||||
/// along, then the other two. Positions are compared on a grid of a millionth of that spread, so
|
||||
/// a tessellation within a different tolerance numbers them the same.
|
||||
fn number_repeats(names: &mut [String], centres: &[[f64; 3]]) {
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
groups.entry(n.clone()).or_default().push(i);
|
||||
}
|
||||
for (name, mut same) in groups {
|
||||
if same.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let at = |i: usize| centres.get(i).copied().unwrap_or([0.0; 3]);
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for c in same.iter().map(|&i| at(i)) {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(c[k]);
|
||||
hi[k] = hi[k].max(c[k]);
|
||||
}
|
||||
}
|
||||
let spread = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
|
||||
let axes = if spread[0] >= spread[1] && spread[0] >= spread[2] {
|
||||
[0, 1, 2]
|
||||
} else if spread[1] >= spread[2] {
|
||||
[1, 2, 0]
|
||||
} else {
|
||||
[2, 0, 1]
|
||||
};
|
||||
let grid = spread.iter().fold(0.0f64, |m, s| m.max(*s)).max(1e-9) * 1e-6;
|
||||
let key = |i: usize| axes.map(|k| (at(i)[k] / grid).round() as i64);
|
||||
same.sort_by_key(|&i| (key(i), i));
|
||||
for (k, i) in same.into_iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(i) {
|
||||
*slot = format!("{name}~{k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// The name of face `f`: its persistent name, or its index for a shape without names.
|
||||
fn face_label(&self, f: u32) -> String {
|
||||
match self.faces.get(f as usize) {
|
||||
Some(face) if !face.name.is_empty() => face.name.clone(),
|
||||
_ => f.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The persistent names of the edges of `mesh` (a tessellation of this shape), by edge index.
|
||||
pub fn edge_names(&self, mesh: &SurfaceMesh) -> Vec<String> {
|
||||
let mut names: Vec<String> = mesh
|
||||
.edge_faces
|
||||
.iter()
|
||||
.map(|faces| {
|
||||
let mut n: Vec<String> = faces.iter().map(|&f| self.face_label(f)).collect();
|
||||
n.sort();
|
||||
n.join("|")
|
||||
})
|
||||
.collect();
|
||||
names.resize(mesh.edges.len(), String::new());
|
||||
let centres: Vec<[f64; 3]> = mesh.edges.iter().map(|e| centre(e)).collect();
|
||||
number_repeats(&mut names, ¢res);
|
||||
names
|
||||
}
|
||||
|
||||
/// The body's edges with their persistent names, from a tessellation fine enough to place them.
|
||||
pub fn edges(&self) -> Result<Vec<Edge>> {
|
||||
let mesh = self.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
|
||||
Ok(self.edges_of(&mesh))
|
||||
}
|
||||
|
||||
/// The edges of `mesh` (a tessellation of this shape) with their persistent names.
|
||||
fn edges_of(&self, mesh: &SurfaceMesh) -> Vec<Edge> {
|
||||
let names = self.edge_names(mesh);
|
||||
names
|
||||
.into_iter()
|
||||
.zip(&mesh.edges)
|
||||
.enumerate()
|
||||
.map(|(i, (name, points))| Edge { name, faces: mesh.edge_faces.get(i).cloned().unwrap_or_default(), points: points.clone() })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::sample::{self, Plate};
|
||||
use crate::{Cut, open_design};
|
||||
|
||||
fn names(edges: &[super::Edge]) -> Vec<String> {
|
||||
let mut n: Vec<String> = edges.iter().map(|e| e.name.clone()).collect();
|
||||
n.sort();
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_edge_of_the_plate_has_its_own_name_from_its_faces() {
|
||||
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let body = &d.bodies[0];
|
||||
let edges = body.edges().unwrap();
|
||||
// A box with a through hole split in two half-cylinders: 12 box edges, 2 × 2 rim arcs and
|
||||
// 2 seams.
|
||||
assert_eq!(edges.len(), 18, "{:?}", names(&edges));
|
||||
let mut unique = names(&edges);
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), edges.len(), "{unique:?}");
|
||||
let faces: Vec<&str> = body.faces().iter().map(|f| f.name.as_str()).collect();
|
||||
for e in &edges {
|
||||
let base = e.name.split('~').next().unwrap();
|
||||
assert!(base.split('|').all(|f| faces.contains(&f)), "{} is not named after the faces {faces:?}", e.name);
|
||||
assert!(e.length() > 0.0, "{}", e.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_names_survive_a_parameter_change_and_a_finer_tessellation() {
|
||||
let a = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let b = open_design(&sample::plate_design(&Plate { width: 70.0, thickness: 15.0, ..Plate::default() }).unwrap()).unwrap();
|
||||
let (ea, eb) = (a.bodies[0].edges().unwrap(), b.bodies[0].edges().unwrap());
|
||||
assert_eq!(names(&ea), names(&eb));
|
||||
// The through-thickness edges are as long as the plate is thick, before and after.
|
||||
for e in &ea {
|
||||
let other = eb.iter().find(|x| x.name == e.name).unwrap();
|
||||
if (e.length() - 10.0).abs() < 1e-6 {
|
||||
assert!((other.length() - 15.0).abs() < 1e-6, "{}: {}", e.name, other.length());
|
||||
}
|
||||
}
|
||||
let fine = a.bodies[0].tessellate(1e-3).unwrap();
|
||||
let mut n = a.bodies[0].edge_names(&fine);
|
||||
n.sort();
|
||||
assert_eq!(n, names(&ea));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partition_cut_edges_are_named_after_the_cut_faces() {
|
||||
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let cut = Cut { name: "Mid".into(), point: [5.0, 15.0, 0.0], normal: [1.0, 0.0, 0.0], bodies: Vec::new() };
|
||||
let p = d.partitioned(&[cut]).unwrap();
|
||||
for (cell, side) in p.bodies.iter().zip(["a", "b"]) {
|
||||
let edges = cell.edges().unwrap();
|
||||
let cut_edges: Vec<&str> = edges.iter().map(|e| e.name.as_str()).filter(|n| n.contains(&format!("partition:Mid:{side}"))).collect();
|
||||
// The cut face is a rectangle: four edges.
|
||||
assert_eq!(cut_edges.len(), 4, "{} {cut_edges:?}", cell.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,14 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod design;
|
||||
mod edges;
|
||||
pub mod partition;
|
||||
pub mod sample;
|
||||
#[cfg(feature = "scripts")]
|
||||
mod script;
|
||||
|
||||
pub use design::{Design, FeatureError, MAX_DESIGN_BYTES, open_design};
|
||||
pub use edges::Edge;
|
||||
pub use partition::{Cut, CutReport};
|
||||
pub mod tagged_mesh;
|
||||
#[cfg(feature = "scripts")]
|
||||
@@ -129,6 +131,10 @@ pub struct SurfaceMesh {
|
||||
/// the boundary points they share, accurate to the kernel's linear tolerance, so two copies
|
||||
/// can differ by up to twice it; boundary surface preparation welds at this distance.
|
||||
pub weld_distance: f64,
|
||||
/// The B-rep edges as polylines, by edge index (empty for a surface without B-rep edges).
|
||||
pub edges: Vec<Vec<[f64; 3]>>,
|
||||
/// Per edge: the B-rep faces it bounds (one or two), by face index.
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
|
||||
@@ -208,6 +214,8 @@ impl Shape {
|
||||
triangles: m.triangles,
|
||||
tri_face: m.tri_face,
|
||||
weld_distance: 2.0 * LINEAR_TOLERANCE,
|
||||
edges: m.edges.into_iter().map(|e| e.into_iter().map(xyz).collect()).collect(),
|
||||
edge_faces: m.edge_faces,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,11 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result<Tagge
|
||||
}
|
||||
let faces = o.faces.unwrap_or_else(|| o.tags.iter().max().map_or(1, |f| f.saturating_add(1)));
|
||||
let name = if o.name.is_empty() { format!("Mesh{}", k + 1) } else { o.name };
|
||||
Ok(TaggedSurface { name, mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags, weld_distance: 0.0 }, faces })
|
||||
Ok(TaggedSurface {
|
||||
name,
|
||||
mesh: SurfaceMesh { positions: o.positions, triangles: o.triangles, tri_face: o.tags, weld_distance: 0.0, ..SurfaceMesh::default() },
|
||||
faces,
|
||||
})
|
||||
}
|
||||
|
||||
/// The surfaces of a 3MF package, one per build item, in millimetres.
|
||||
@@ -475,8 +479,8 @@ mod tests {
|
||||
assert_eq!(bytes, write_tagged(&[("Block", &mesh, faces), ("Again", &mesh, faces)]), "deterministic bytes");
|
||||
let got = read_3mf(&bytes).unwrap();
|
||||
assert_eq!(got.len(), 2);
|
||||
// The file carries no weld distance.
|
||||
let mesh = SurfaceMesh { weld_distance: 0.0, ..mesh };
|
||||
// The file carries no weld distance and no B-rep edges.
|
||||
let mesh = SurfaceMesh { weld_distance: 0.0, edges: Vec::new(), edge_faces: Vec::new(), ..mesh };
|
||||
assert_eq!(got[0], TaggedSurface { name: "Block".into(), mesh: mesh.clone(), faces: 6 });
|
||||
assert_eq!(got[1].name, "Again");
|
||||
assert_eq!(got[1].mesh, mesh);
|
||||
@@ -574,7 +578,13 @@ mod tests {
|
||||
assert!(read_3mf(b"PK\x03\x04garbage").is_err());
|
||||
let good = write_tagged(&[(
|
||||
"T",
|
||||
&SurfaceMesh { positions: vec![[0.0; 3]; 3], triangles: vec![[0, 1, 2]], tri_face: vec![0], weld_distance: 0.0 },
|
||||
&SurfaceMesh {
|
||||
positions: vec![[0.0; 3]; 3],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
tri_face: vec![0],
|
||||
weld_distance: 0.0,
|
||||
..SurfaceMesh::default()
|
||||
},
|
||||
1,
|
||||
)]);
|
||||
for cut in [10, good.len() / 2, good.len() - 1] {
|
||||
|
||||
Reference in new issue
Block a user