forked from 1b-agent/fieldcraft-upstream
engine: geometry.stitch, imported surfaces stitched for meshing (#507)
geometry.stitch {name, surface, tolerance} records a stitch; the
surfaces keep what was read (TaggedSurface::original) and are stitched
again from it whenever a surface is imported, the model opened, or a
stitch added or deleted (geometry.stitch.delete). Opening a model also
warns about merges that no longer apply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
12b3128f53
commit
4e6fd59409
15 files changed
+259
-8
No files matched your search
@@ -309,6 +309,14 @@ pub static CATALOGUE: &[Capability] = &[
|
||||
"Prepare",
|
||||
"Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found",
|
||||
),
|
||||
covering(
|
||||
"geometry.stitch",
|
||||
"Stitch",
|
||||
Geometry,
|
||||
"Prepare",
|
||||
"Close an imported surface's gaps under a tolerance: open points welded, T-junctions split",
|
||||
&["geometry.stitch.delete"],
|
||||
),
|
||||
soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"),
|
||||
// ---- Mesh ----
|
||||
soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"),
|
||||
|
||||
@@ -231,7 +231,12 @@ impl Session {
|
||||
}
|
||||
});
|
||||
if let Some(d) = &design {
|
||||
warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message)));
|
||||
warnings.extend(
|
||||
d.errors
|
||||
.iter()
|
||||
.filter(|e| e.feature.starts_with("partition ") || e.feature.starts_with("merge "))
|
||||
.map(|e| format!("{}: {}", e.feature, e.message)),
|
||||
);
|
||||
}
|
||||
sources.design = Some(source);
|
||||
}
|
||||
@@ -241,7 +246,7 @@ impl Session {
|
||||
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
|
||||
sources.meshes.push(source);
|
||||
}
|
||||
let session = Session {
|
||||
let mut session = Session {
|
||||
model: file.model,
|
||||
design,
|
||||
surfaces,
|
||||
@@ -252,6 +257,8 @@ impl Session {
|
||||
view: file.view,
|
||||
..Session::default()
|
||||
};
|
||||
let (_, failed) = super::stitch::restitch(&mut session);
|
||||
warnings.extend(failed.into_iter().map(|(n, why)| format!("stitch `{n}`: {why}")));
|
||||
(session, warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -1723,6 +1723,14 @@ pub const FACE_ABSORB: &[Field] = &[
|
||||
text("into", "Into").help("The neighbour face that absorbs it, by persistent name.").hint("the neighbour sharing the longest edges"),
|
||||
flag("from_check", "From check").help("Absorb every small and sliver face the last Check Geometry found, each into its best neighbour."),
|
||||
];
|
||||
pub const STITCH: &[Field] = &[
|
||||
NAME,
|
||||
text("surface", "Surface").help("The imported surface to stitch, by name.").req(),
|
||||
positive("tolerance", "Tolerance", D::Length)
|
||||
.help("Gaps narrower than this close: open points this close become one, and a point this close to another open edge splits it.")
|
||||
.req(),
|
||||
];
|
||||
pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch);
|
||||
pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge);
|
||||
pub const MESH_PLAN: &[Field] = &[
|
||||
reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(),
|
||||
|
||||
@@ -520,7 +520,17 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
s.surfaces.extend(surfaces.into_iter().map(Arc::new));
|
||||
s.sources.meshes.push(source);
|
||||
Ok(json!({"path": path, "surfaces": added}))
|
||||
// The model's stitches of surfaces by these names apply to them too.
|
||||
let (_, failed) = super::stitch::restitch(s);
|
||||
let mut reply = json!({"path": path, "surfaces": added});
|
||||
let warnings: Vec<String> =
|
||||
failed.into_iter().filter(|(_, why)| !why.starts_with("no surface")).map(|(n, why)| format!("stitch `{n}`: {why}")).collect();
|
||||
if !warnings.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.insert("warnings".into(), json!(warnings));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -660,7 +670,7 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn package(model: &str) -> Vec<u8> {
|
||||
pub(crate) fn package(model: &str) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(model.as_bytes()).unwrap();
|
||||
|
||||
@@ -1075,7 +1075,7 @@ mod tests {
|
||||
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 }));
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None }));
|
||||
}
|
||||
let r = s.execute("mesh.surface.check", &json!({"surface": 0, "feature_angle": 180})).unwrap();
|
||||
assert_eq!((r["valid"].clone(), r["feature_edges"].clone()), (json!(true), json!({"face_boundary": 3, "sharp": 0})));
|
||||
@@ -1308,7 +1308,7 @@ mod tests {
|
||||
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 }));
|
||||
s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None }));
|
||||
}
|
||||
let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap();
|
||||
assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4)));
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod replace;
|
||||
mod results;
|
||||
pub(crate) mod seeds;
|
||||
pub(crate) mod solve;
|
||||
pub(crate) mod stitch;
|
||||
mod symmetry;
|
||||
mod tags;
|
||||
pub mod update;
|
||||
@@ -74,6 +75,8 @@ pub static ALL: &[CommandSpec] = &[
|
||||
virtual_faces::FACES_MERGE,
|
||||
virtual_faces::FACES_MERGE_DELETE,
|
||||
virtual_faces::FACE_ABSORB,
|
||||
stitch::STITCH,
|
||||
stitch::STITCH_DELETE,
|
||||
mesh::NODE_ADD,
|
||||
mesh::NODE_REMOVE,
|
||||
mesh::ELEMENT_ADD,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Stitching imported surfaces (`geometry.stitch`): their gaps under a tolerance closed for
|
||||
//! meshing ([`fieldcraft_geometry::stitch`]).
|
||||
//!
|
||||
//! A stitch is part of the model (`fieldcraft_model::virtual_faces::Stitch`), naming the surface
|
||||
//! by name. The surfaces keep what was read ([`TaggedSurface::original`]); [`restitch`] stitches
|
||||
//! each again from it by the model's stitches, in order, whenever a surface is imported or the
|
||||
//! model is opened, and after every stitch or delete, so the meshers always take the stitched
|
||||
//! surface. Undo restores the surfaces with the model.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_geometry::tagged_mesh::TaggedSurface;
|
||||
use fieldcraft_model::Stitch;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::requirement::Requirement;
|
||||
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
|
||||
|
||||
pub const STITCH: CommandSpec = CommandSpec {
|
||||
id: "geometry.stitch",
|
||||
label: "Stitch Surface",
|
||||
params: r#"{"name": "Gaps", "surface": "Mesh1" (an imported surface, by name), "tolerance": 0.05 (mm)} — closes the gaps of an imported surface under the tolerance, for meshing: points on open edges closer than it become one point (triangles that collapse go), and a point on an open edge within it of the inside of another open edge splits that edge's triangle (a T-junction). Edges that are not open are never touched, so a closed surface is unchanged; triangles keep their face tags. Kept in the model and applied again, in order, whenever the surface is imported or the model opened (the file is not changed). Replies the open edges before and after (`open_before`, `open_after`: 0 means closed), the points `merged`, the triangles `split` and `removed`; the surface loses its mesh (`replaced`). A surface the session lacks is an error. One undo step"#,
|
||||
form: Some(super::forms::STITCH),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Geometry],
|
||||
run: stitch,
|
||||
};
|
||||
|
||||
pub const STITCH_DELETE: CommandSpec = CommandSpec {
|
||||
id: "geometry.stitch.delete",
|
||||
label: "Delete Stitch",
|
||||
params: r#"{"name": "Gaps"} — removes a stitch: the surface is stitched again by the ones left (as read when none); it loses its mesh (`replaced`)"#,
|
||||
form: Some(super::forms::STITCH_DELETE),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Stitch],
|
||||
run: delete,
|
||||
};
|
||||
|
||||
/// The name a surface goes by (and its mesh is tagged with): its own, or `Surface<index>`.
|
||||
pub(crate) fn surface_name(index: usize, t: &TaggedSurface) -> String {
|
||||
if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() }
|
||||
}
|
||||
|
||||
/// Stitch the session's surfaces again from what was read, by the model's stitches. Replies each
|
||||
/// stitch's report by name, and the stitches whose surface is not there or that failed (with why).
|
||||
pub(crate) fn restitch(s: &mut Session) -> (Vec<(String, Value)>, Vec<(String, String)>) {
|
||||
let mut reports = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let stitches = s.model.stitches().to_vec();
|
||||
for st in stitches.iter().filter(|st| !s.surfaces.iter().enumerate().any(|(i, t)| surface_name(i, t) == st.surface)) {
|
||||
failed.push((st.name.clone(), format!("no surface `{}`", st.surface)));
|
||||
}
|
||||
for (i, slot) in s.surfaces.iter_mut().enumerate() {
|
||||
let name = surface_name(i, slot);
|
||||
let mine: Vec<&Stitch> = stitches.iter().filter(|st| st.surface == name).collect();
|
||||
if mine.is_empty() && slot.original.is_none() {
|
||||
continue;
|
||||
}
|
||||
let raw = slot.original.clone().unwrap_or_else(|| Arc::new(slot.mesh.clone()));
|
||||
let mut mesh = raw.as_ref().clone();
|
||||
for st in mine {
|
||||
match fieldcraft_geometry::stitch::stitch(&mesh, st.tolerance) {
|
||||
Ok((m, r)) => {
|
||||
mesh = m;
|
||||
let report =
|
||||
json!({"open_before": r.open_before, "open_after": r.open_after, "merged": r.merged, "split": r.split, "removed": r.removed});
|
||||
reports.push((st.name.clone(), report));
|
||||
}
|
||||
Err(e) => failed.push((st.name.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
let original = (!s.model.stitches().iter().all(|st| st.surface != name)).then_some(raw);
|
||||
*slot = Arc::new(TaggedSurface { name: slot.name.clone(), mesh, faces: slot.faces, original });
|
||||
}
|
||||
(reports, failed)
|
||||
}
|
||||
|
||||
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?.to_string();
|
||||
let surface: String = super::req(p, "surface")?;
|
||||
let tolerance: f64 = super::req(p, "tolerance")?;
|
||||
s.model.add_stitch(Stitch { name: name.clone(), surface: surface.clone(), tolerance })?;
|
||||
let (reports, failed) = restitch(s);
|
||||
if let Some((_, why)) = failed.iter().find(|(n, _)| *n == name) {
|
||||
return Err(EngineError::Params(format!("stitch `{name}`: {why}")));
|
||||
}
|
||||
let mut reply = reports.into_iter().find(|(n, _)| *n == name).map(|(_, r)| r).unwrap_or_else(|| json!({}));
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("surface".into(), json!(surface));
|
||||
}
|
||||
let cleared = super::replace::clear(&mut s.model, [surface.as_str()]);
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let removed = s.model.remove_stitch(name)?;
|
||||
restitch(s);
|
||||
let cleared = super::replace::clear(&mut s.model, [removed.surface.as_str()]);
|
||||
Ok(cleared.report_if_any(s, json!({"deleted": name, "surface": removed.surface})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A unit cube in a 3MF whose top face is raised 0.01 mm: open along the top's rim.
|
||||
fn gapped_cube(dir: &std::path::Path) -> String {
|
||||
let m = fieldcraft_geometry::stitch::test_cube(0.01, false);
|
||||
let vertices: String = m.positions.iter().map(|p| format!(r#"<vertex x="{}" y="{}" z="{}"/>"#, p[0], p[1], p[2])).collect();
|
||||
let triangles: String = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.map(|(t, f)| format!(r#"<triangle v1="{}" v2="{}" v3="{}" ft:face="{f}"/>"#, t[0], t[1], t[2]))
|
||||
.collect();
|
||||
let model = format!(
|
||||
r#"<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:ft="{}"><resources><object id="1" name="Cube" ft:faces="6"><mesh><vertices>{vertices}</vertices><triangles>{triangles}</triangles></mesh></object></resources><build><item objectid="1"/></build></model>"#,
|
||||
fieldcraft_geometry::tagged_mesh::FACE_TAGS_NS
|
||||
);
|
||||
let path = dir.join("cube.3mf");
|
||||
std::fs::write(&path, super::super::geometry::tests::package(&model)).unwrap();
|
||||
path.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The gapped cube does not mesh; stitched under a tolerance over the gap it does; the stitch
|
||||
/// is saved and applied again when the model is opened; undo and delete bring the gap back.
|
||||
#[test]
|
||||
fn a_stitched_surface_meshes_and_the_stitch_is_replayed() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-{}-{:?}", std::process::id(), std::thread::current().id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap();
|
||||
let tet = json!({"surface": 0, "size": 0.25});
|
||||
assert!(s.execute("mesh.tet.generate", &tet).is_err(), "open: no volume to fill");
|
||||
assert!(
|
||||
s.execute("geometry.stitch", &json!({"name": "Small", "surface": "Cube", "tolerance": 0.001})).unwrap()["open_after"].as_u64() > Some(0)
|
||||
);
|
||||
let r = s.execute("geometry.stitch", &json!({"name": "Gaps", "surface": "Cube", "tolerance": 0.05})).unwrap();
|
||||
assert_eq!((r["open_after"].as_u64(), r["merged"].as_u64()), (Some(0), Some(4)), "{r}");
|
||||
s.execute("mesh.tet.generate", &tet).unwrap();
|
||||
assert!(!s.model.elements().is_empty());
|
||||
let file = dir.join("stitched.fieldcraft");
|
||||
s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(t.surfaces[0].mesh, s.surfaces[0].mesh);
|
||||
assert!(t.surfaces[0].original.is_some());
|
||||
// Undo the mesh and the stitch: the gap is back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.execute("mesh.tet.generate", &tet).is_err());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("mesh.tet.generate", &tet).unwrap();
|
||||
let r = s.execute("geometry.stitch.delete", &json!({"name": "Gaps"})).unwrap();
|
||||
assert!(r["replaced"].is_array(), "{r}");
|
||||
assert!(s.execute("mesh.tet.generate", &tet).is_err());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_stitches_change_nothing() {
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-bad-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap();
|
||||
let journal = s.journal.len();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "A", "surface": "Cube"}),
|
||||
json!({"name": "A", "surface": "Nope", "tolerance": 0.1}),
|
||||
json!({"name": "A", "surface": "Cube", "tolerance": 0}),
|
||||
json!({"name": "A", "surface": "Cube", "tolerance": "x"}),
|
||||
json!({"name": "1A", "surface": "Cube", "tolerance": 0.1}),
|
||||
] {
|
||||
assert!(s.execute("geometry.stitch", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("geometry.stitch.delete", &json!({"name": "A"})).is_err());
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert!(s.model.stitches().is_empty() && s.surfaces[0].original.is_none());
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ pub enum Requirement {
|
||||
Partition,
|
||||
/// A face merge.
|
||||
FaceMerge,
|
||||
/// A stitch.
|
||||
Stitch,
|
||||
/// An edge seed.
|
||||
Seed,
|
||||
/// At least one mesh node.
|
||||
@@ -82,6 +84,7 @@ impl Requirement {
|
||||
Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()),
|
||||
Requirement::Partition => !m.partitions().is_empty(),
|
||||
Requirement::FaceMerge => !m.face_merges().is_empty(),
|
||||
Requirement::Stitch => !m.stitches().is_empty(),
|
||||
Requirement::Seed => !m.seeds().is_empty(),
|
||||
Requirement::Nodes => !m.nodes().is_empty(),
|
||||
Requirement::Mesh => has_elements,
|
||||
@@ -123,6 +126,7 @@ impl Requirement {
|
||||
Requirement::DatumPoint => "a datum point",
|
||||
Requirement::Partition => "a partition",
|
||||
Requirement::FaceMerge => "a face merge",
|
||||
Requirement::Stitch => "a stitch",
|
||||
Requirement::Seed => "an edge seed",
|
||||
Requirement::Nodes => "mesh nodes",
|
||||
Requirement::Mesh | Requirement::Analysable => "a mesh",
|
||||
@@ -154,6 +158,7 @@ impl Requirement {
|
||||
Requirement::DatumPoint => "geometry.datum.point",
|
||||
Requirement::Partition => "geometry.partition",
|
||||
Requirement::FaceMerge => "geometry.faces.merge",
|
||||
Requirement::Stitch => "geometry.stitch",
|
||||
Requirement::Seed => "mesh.seed.edge",
|
||||
Requirement::Nodes => "mesh.node.add",
|
||||
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
|
||||
@@ -212,6 +217,7 @@ impl crate::schema::Collection {
|
||||
C::DatumPoint => Requirement::DatumPoint,
|
||||
C::Partition => Requirement::Partition,
|
||||
C::FaceMerge => Requirement::FaceMerge,
|
||||
C::Stitch => Requirement::Stitch,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,6 +359,7 @@ mod tests {
|
||||
C::DatumPoint,
|
||||
C::Partition,
|
||||
C::FaceMerge,
|
||||
C::Stitch,
|
||||
];
|
||||
let empty = Session::default();
|
||||
for c in all {
|
||||
|
||||
@@ -219,6 +219,8 @@ pub enum Collection {
|
||||
Partition,
|
||||
/// A face merge (`geometry.faces.merge`).
|
||||
FaceMerge,
|
||||
/// A stitch (`geometry.stitch`).
|
||||
Stitch,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
@@ -245,6 +247,7 @@ impl Collection {
|
||||
Collection::DatumPoint => "datum point",
|
||||
Collection::Partition => "partition",
|
||||
Collection::FaceMerge => "face merge",
|
||||
Collection::Stitch => "stitch",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,6 +278,7 @@ impl Session {
|
||||
Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())),
|
||||
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())),
|
||||
Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())),
|
||||
Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())),
|
||||
};
|
||||
names.take(MAX_NAMES).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user