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:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 20:46:05 -04:00
1 parent 6bfa0b410a
commit a11b971dae
4 files changed
+227 -8

No files matched your search

+4 -4
View File
@@ -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 }));
+201
View File
@@ -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, &centres);
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);
}
}
}
+8
View File
@@ -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,
})
}
}
+14 -4
View File
@@ -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] {