engine: geometry.face.split, a face split along a datum plane (#508)

geometry.face.split {name, face, datum} records a face split; it is
applied after the merges whenever the design is opened or cut again,
follows its datum (which it holds), undoes, and is saved with the
model. geometry.face.split.delete removes it.

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-16 15:40:18 -04:00
1 parent 7cd3dcfaa0
commit 54e367970f
12 files changed
+172 -10

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ is committed and listed here with its exports.
| assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.face.split.svg, assets/icons/geometry.face.split.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.split.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>geometry.face.split</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 8 L12 4 L21 8 L12 12 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10 L16.5 6" fill="none" stroke="#f0b44a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 4.5 L9 2.5 M15 9.5 L19 7.5" fill="none" stroke="#4f9ce8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.5 1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.
@@ -0,0 +1 @@
{"shapes": [{"d": "M3 8 L12 4 L21 8 L12 12 Z", "fill": "soft", "stroke": "line"}, {"d": "M3 8 L3 16 L12 20 L12 12 M12 20 L21 16 L21 8", "stroke": "line"}, {"line": [7.5, 10, 16.5, 6], "stroke": "warm", "width": 2}, {"d": "M5 4.5 L9 2.5 M15 9.5 L19 7.5", "stroke": "accent", "width": 1.5, "dash": [1.5, 1.5]}]}
+8
View File
@@ -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.face.split",
"Split Face",
Geometry,
"Prepare",
"Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them",
&["geometry.face.split.delete"],
),
covering(
"geometry.stitch",
"Stitch",
+12 -3
View File
@@ -470,15 +470,24 @@ fn cuts(model: &Model) -> Vec<Cut> {
.collect()
}
/// `design` cut by `model`'s partitions, then with its face merges (the design as opened when
/// the model has neither).
/// `design` cut by `model`'s partitions, then with its face merges and its face splits (those
/// along a suppressed datum left out; the design as opened when the model has none).
pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
let merges: Vec<Merge> = model
.face_merges()
.iter()
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
.collect();
Ok(design.partitioned(&cuts(model))?.merged(&merges)?)
let splits: Vec<fieldcraft_geometry::face_split::FaceSplit> = model
.face_splits()
.iter()
.filter(|s| !model.datum_suppressed(&s.datum))
.filter_map(|s| {
let (point, normal) = model.datum(&s.datum)?.plane()?;
Some(fieldcraft_geometry::face_split::FaceSplit { name: s.name.clone(), body: s.body.clone(), face: s.face.clone(), point, normal })
})
.collect();
Ok(design.partitioned(&cuts(model))?.merged(&merges)?.split(&splits)?)
}
/// Cut the session's design by its model's partitions again (and merge its faces again). The bodies that are gone (cells
+6
View File
@@ -1740,6 +1740,12 @@ pub const STITCH: &[Field] = &[
.req(),
];
pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch);
pub const FACE_SPLIT: &[Field] = &[
NAME,
group("face", "Face", FACE_ROW).help("The face to split.").req(),
reference("datum", "Datum plane", C::DatumPlane).help("The plane the face is split along.").req(),
];
pub const FACE_SPLIT_DELETE: &[Field] = existing!("Face split", C::FaceSplit);
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(),
+2
View File
@@ -76,6 +76,8 @@ pub static ALL: &[CommandSpec] = &[
virtual_faces::FACES_MERGE,
virtual_faces::FACES_MERGE_DELETE,
virtual_faces::FACE_ABSORB,
virtual_faces::FACE_SPLIT,
virtual_faces::FACE_SPLIT_DELETE,
stitch::STITCH,
stitch::STITCH_DELETE,
mesh::NODE_ADD,
+119 -6
View File
@@ -45,6 +45,26 @@ pub const FACE_ABSORB: CommandSpec = CommandSpec {
run: absorb,
};
pub const FACE_SPLIT: CommandSpec = CommandSpec {
id: "geometry.face.split",
label: "Split Face",
params: r#"{"name": "Band", "face": {"body": "Base", "face": "F1:+z"}, "datum": "Mid" (a datum plane)} — splits a face along the curve where a datum plane crosses it, for meshing only (virtual topology; partitions cut whole bodies): the meshers, the checks and the viewport take it as two faces on its surface, `split:<name>:a` on the plane's negative side and `split:<name>:b` on the other, with the cut as an edge between them (a row of nodes along it; seed it like any edge), and the face's edges cut where the plane crosses them. The B-rep is not changed. Applied after the face merges, by persistent names, whenever the design is opened (a merge cannot name a split face); moving the datum moves the cut, and the datum cannot be deleted while a split uses it. The body loses its mesh (`replaced`); sets naming the face no longer resolve (`stale_sets`). A plane that does not cross the face is an error. Delete with geometry.face.split.delete. One undo step"#,
form: Some(super::forms::FACE_SPLIT),
kind: CommandKind::Model,
requires: &[Requirement::Design, Requirement::DatumPlane],
run: split,
};
pub const FACE_SPLIT_DELETE: CommandSpec = CommandSpec {
id: "geometry.face.split.delete",
label: "Delete Face Split",
params: r#"{"name": "Band"} — removes a face split: the face is one face again; the body loses its mesh (`replaced`)"#,
form: Some(super::forms::FACE_SPLIT_DELETE),
kind: CommandKind::Model,
requires: &[Requirement::FaceSplit],
run: split_delete,
};
/// Most faces one merge lists.
const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES;
@@ -75,12 +95,12 @@ fn stale_sets(s: &Session, body: &str) -> Vec<String> {
s.model.sets().iter().filter(|(_, set)| set.geometry.iter().any(gone)).map(|(n, _)| n.clone()).collect()
}
/// Apply the model's partitions and merges to the design again, failing with the error of a merge
/// named in `merges` when it has one; the meshes of `bodies` go (their faces changed).
fn reapply(s: &mut Session, merges: &[String], bodies: &[String], reply: Value) -> Result<Value> {
/// Apply the model's partitions, merges and splits to the design again, failing with the error of
/// one of `features` (`merge <name>`, `split <name>`) when it has one; the meshes of `bodies` go
/// (their faces changed).
fn reapply(s: &mut Session, features: &[String], bodies: &[String], reply: Value) -> Result<Value> {
let mut cleared = super::datum::recut(s)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?;
let features: Vec<String> = merges.iter().map(|m| format!("merge {m}")).collect();
let errors: Vec<String> =
design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect();
if !errors.is_empty() {
@@ -110,7 +130,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone(), into: None })?;
let face = merged_face_name(&name);
let reply = json!({"name": name, "body": body, "face": face});
let mut reply = reapply(s, std::slice::from_ref(&name), std::slice::from_ref(&body), reply)?;
let mut reply = reapply(s, &[format!("merge {name}")], std::slice::from_ref(&body), reply)?;
let shape = s.design.as_deref().and_then(|d| d.body(&body));
let brep = shape.and_then(|b| b.face_index(&face)).and_then(|f| shape?.merged_faces().into_iter().find(|(g, _)| *g == f)).map_or(0, |(_, n)| n);
if let Some(o) = reply.as_object_mut() {
@@ -200,7 +220,29 @@ fn absorb(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped))));
}
let bodies: Vec<String> = shapes.into_iter().map(|b| b.name).collect();
reapply(s, &merges, &bodies, json!({"absorbed": absorbed, "skipped": skipped}))
let features: Vec<String> = merges.iter().map(|m| format!("merge {m}")).collect();
reapply(s, &features, &bodies, json!({"absorbed": absorbed, "skipped": skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?.to_string();
let r: GeometryRef = super::req(p, "face")?;
let Entity::Face(face) = r.entity else { return Err(EngineError::invalid("face", "give {\"body\", \"face\"}")) };
let datum: String = super::req(p, "datum")?;
super::datum::datum_plane_of(&s.model, &datum)?;
s.model.add_face_split(fieldcraft_model::FaceSplit { name: name.clone(), body: r.body.clone(), face: face.clone(), datum })?;
let feature = format!("split:{name}");
let faces = [format!("{feature}:a"), format!("{feature}:b")];
let reply = json!({"name": name, "body": r.body, "faces": faces});
reapply(s, &[format!("split {name}")], std::slice::from_ref(&r.body), reply)
}
fn split_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = super::name(p)?;
let removed = s.model.remove_face_split(name)?;
let mut cleared = super::datum::recut(s)?;
cleared.extend(super::replace::clear(&mut s.model, [removed.body.as_str()]));
Ok(cleared.report_if_any(s, json!({"deleted": name, "body": removed.body})))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -507,4 +549,75 @@ mod tests {
assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}");
assert!(s.model.face_merges()[0].into.is_some());
}
/// The plate's top face (z = 10), by name.
fn top(s: &Session) -> String {
let b = &s.design.as_ref().unwrap().bodies[0];
b.faces()
.iter()
.zip(b.face_surfaces().unwrap())
.find(|(_, x)| matches!(x, Some(FaceSurface::Plane { normal, point }) if normal[2] > 0.5 && point[2] > 9.0))
.unwrap()
.0
.name
.clone()
}
/// Mesh nodes on the top along x = `x`.
fn nodes_along(s: &Session, x: f64) -> usize {
s.model.nodes().values().filter(|n| (n.xyz[0] - x).abs() < 1e-6 && (n.xyz[2] - 10.0).abs() < 1e-6).count()
}
/// Splitting the top along a datum plane gives two faces the tet mesher tags, with a row of
/// nodes along the cut; the split follows the datum, holds it, is saved and replayed, and
/// undoes.
#[test]
fn a_split_face_meshes_with_a_node_row_along_the_cut() {
let (mut s, dir) = super::super::datum::tests::plate();
let body = s.design.as_ref().unwrap().bodies[0].name.clone();
let face = json!({"body": body, "face": top(&s)});
s.execute("geometry.datum", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 30.3}})).unwrap();
let mesh = json!({"size": 3});
s.execute("mesh.tet.generate", &mesh).unwrap();
let before = nodes_along(&s, 30.3);
let r = s.execute("geometry.face.split", &json!({"name": "Band", "face": face, "datum": "Cut"})).unwrap();
assert_eq!(r["faces"], json!(["split:Band:a", "split:Band:b"]), "{r}");
assert!(r["replaced"].is_array(), "{r}");
assert_eq!(faces(&s).len(), 9);
s.execute("mesh.tet.generate", &mesh).unwrap();
let after = nodes_along(&s, 30.3);
assert!(after >= 10 && after > before, "a node row along the 30 mm cut: {before} -> {after}");
let c = s.model.classification();
let used: BTreeSet<String> =
c.elements().values().flat_map(|t| t.faces.values()).filter_map(|&t| c.entity(t)?.entity.name().map(str::to_string)).collect();
assert!(used.contains("split:Band:a") && used.contains("split:Band:b"), "{used:?}");
// The datum holds; moving it moves the cut.
assert!(s.execute("geometry.datum.delete", &json!({"name": "Cut"})).is_err());
s.execute("geometry.datum.edit", &json!({"name": "Cut", "by": {"type": "offset", "plane": "yz", "offset": 35.5}})).unwrap();
s.execute("mesh.tet.generate", &mesh).unwrap();
assert!(nodes_along(&s, 35.5) >= 10);
// Saved and replayed.
let file = dir.join("split.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!(faces(&t), faces(&s));
// Bad splits change nothing; delete brings the face back.
let journal = s.journal.len();
for p in [
json!({"name": "X", "face": face, "datum": "nope"}),
json!({"name": "X", "face": {"body": body, "face": "nope"}, "datum": "Cut"}),
json!({"name": "X", "face": {"body": body}, "datum": "Cut"}),
json!({"name": "Band", "face": face, "datum": "Cut"}),
json!({"name": "X", "face": {"body": body, "face": "split:Band:b"}, "datum": "Cut"}),
] {
assert!(s.execute("geometry.face.split", &p).is_err(), "{p}");
}
assert_eq!(s.journal.len(), journal);
s.execute("geometry.face.split.delete", &json!({"name": "Band"})).unwrap();
assert_eq!(faces(&s).len(), 8);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(faces(&s).len(), 9);
std::fs::remove_dir_all(dir).ok();
}
}
+7
View File
@@ -36,6 +36,8 @@ pub enum Requirement {
FaceMerge,
/// A stitch.
Stitch,
/// A face split.
FaceSplit,
/// An edge seed.
Seed,
/// At least one mesh node.
@@ -85,6 +87,7 @@ impl Requirement {
Requirement::Partition => !m.partitions().is_empty(),
Requirement::FaceMerge => !m.face_merges().is_empty(),
Requirement::Stitch => !m.stitches().is_empty(),
Requirement::FaceSplit => !m.face_splits().is_empty(),
Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements,
@@ -127,6 +130,7 @@ impl Requirement {
Requirement::Partition => "a partition",
Requirement::FaceMerge => "a face merge",
Requirement::Stitch => "a stitch",
Requirement::FaceSplit => "a face split",
Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh",
@@ -159,6 +163,7 @@ impl Requirement {
Requirement::Partition => "geometry.partition",
Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Stitch => "geometry.stitch",
Requirement::FaceSplit => "geometry.face.split",
Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
@@ -218,6 +223,7 @@ impl crate::schema::Collection {
C::Partition => Requirement::Partition,
C::FaceMerge => Requirement::FaceMerge,
C::Stitch => Requirement::Stitch,
C::FaceSplit => Requirement::FaceSplit,
}
}
}
@@ -360,6 +366,7 @@ mod tests {
C::Partition,
C::FaceMerge,
C::Stitch,
C::FaceSplit,
];
let empty = Session::default();
for c in all {
+4
View File
@@ -221,6 +221,8 @@ pub enum Collection {
FaceMerge,
/// A stitch (`geometry.stitch`).
Stitch,
/// A face split (`geometry.face.split`).
FaceSplit,
}
impl Collection {
@@ -248,6 +250,7 @@ impl Collection {
Collection::Partition => "partition",
Collection::FaceMerge => "face merge",
Collection::Stitch => "stitch",
Collection::FaceSplit => "face split",
}
}
}
@@ -279,6 +282,7 @@ impl Session {
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())),
Collection::FaceSplit => Box::new(m.face_splits().iter().map(|f| f.name.as_str())),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
+2 -1
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
81 ready, 37 coming soon.
82 ready, 37 coming soon.
## Geometry
@@ -24,6 +24,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`), (`geometry.partition.suppress`) |
| Prepare | Merge Faces — Mesh adjacent faces as one (virtual topology): a fillet in pieces, a face split by an imprint; the B-rep is unchanged | ready | `geometry.faces.merge`, (`geometry.faces.merge.delete`) |
| Prepare | Absorb Faces — Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found | ready | `geometry.face.absorb` |
| Prepare | Split Face — Split a face along a datum plane for meshing only: two faces on its surface, the cut an edge between them | ready | `geometry.face.split`, (`geometry.face.split.delete`) |
| Prepare | Stitch — Close an imported surface's gaps under a tolerance: open points welded, T-junctions split | ready | `geometry.stitch`, (`geometry.stitch.delete`) |
| Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |