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.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.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.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.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/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 | | 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", "Prepare",
"Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found", "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( covering(
"geometry.stitch", "geometry.stitch",
"Stitch", "Stitch",
+12 -3
View File
@@ -470,15 +470,24 @@ fn cuts(model: &Model) -> Vec<Cut> {
.collect() .collect()
} }
/// `design` cut by `model`'s partitions, then with its face merges (the design as opened when /// `design` cut by `model`'s partitions, then with its face merges and its face splits (those
/// the model has neither). /// 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> { pub(crate) fn partitioned(design: &Design, model: &Model) -> Result<Design> {
let merges: Vec<Merge> = model let merges: Vec<Merge> = model
.face_merges() .face_merges()
.iter() .iter()
.map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() }) .map(|m| Merge { name: m.name.clone(), body: m.body.clone(), faces: m.faces.clone(), into: m.into.clone() })
.collect(); .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 /// 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(), .req(),
]; ];
pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch); 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 FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge);
pub const MESH_PLAN: &[Field] = &[ 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(), 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,
virtual_faces::FACES_MERGE_DELETE, virtual_faces::FACES_MERGE_DELETE,
virtual_faces::FACE_ABSORB, virtual_faces::FACE_ABSORB,
virtual_faces::FACE_SPLIT,
virtual_faces::FACE_SPLIT_DELETE,
stitch::STITCH, stitch::STITCH,
stitch::STITCH_DELETE, stitch::STITCH_DELETE,
mesh::NODE_ADD, mesh::NODE_ADD,
+119 -6
View File
@@ -45,6 +45,26 @@ pub const FACE_ABSORB: CommandSpec = CommandSpec {
run: absorb, 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. /// Most faces one merge lists.
const MAX_FACES: usize = fieldcraft_model::virtual_faces::MAX_FACE_MERGE_FACES; 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() 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 /// Apply the model's partitions, merges and splits to the design again, failing with the error of
/// named in `merges` when it has one; the meshes of `bodies` go (their faces changed). /// one of `features` (`merge <name>`, `split <name>`) when it has one; the meshes of `bodies` go
fn reapply(s: &mut Session, merges: &[String], bodies: &[String], reply: Value) -> Result<Value> { /// (their faces changed).
fn reapply(s: &mut Session, features: &[String], bodies: &[String], reply: Value) -> Result<Value> {
let mut cleared = super::datum::recut(s)?; let mut cleared = super::datum::recut(s)?;
let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; 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> = let errors: Vec<String> =
design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect(); design.errors.iter().filter(|e| features.contains(&e.feature)).map(|e| format!("{}: {}", e.feature, e.message)).collect();
if !errors.is_empty() { 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 })?; s.model.add_face_merge(FaceMerge { name: name.clone(), body: body.clone(), faces: faces.clone(), into: None })?;
let face = merged_face_name(&name); let face = merged_face_name(&name);
let reply = json!({"name": name, "body": body, "face": face}); 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 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); 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() { 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)))); return Err(EngineError::Params(format!("no face could be absorbed: {}", json!(skipped))));
} }
let bodies: Vec<String> = shapes.into_iter().map(|b| b.name).collect(); 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> { fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -507,4 +549,75 @@ mod tests {
assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}"); assert_eq!(r["absorbed"][0]["name"], "Riser", "{r}");
assert!(s.model.face_merges()[0].into.is_some()); 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, FaceMerge,
/// A stitch. /// A stitch.
Stitch, Stitch,
/// A face split.
FaceSplit,
/// An edge seed. /// An edge seed.
Seed, Seed,
/// At least one mesh node. /// At least one mesh node.
@@ -85,6 +87,7 @@ impl Requirement {
Requirement::Partition => !m.partitions().is_empty(), Requirement::Partition => !m.partitions().is_empty(),
Requirement::FaceMerge => !m.face_merges().is_empty(), Requirement::FaceMerge => !m.face_merges().is_empty(),
Requirement::Stitch => !m.stitches().is_empty(), Requirement::Stitch => !m.stitches().is_empty(),
Requirement::FaceSplit => !m.face_splits().is_empty(),
Requirement::Seed => !m.seeds().is_empty(), Requirement::Seed => !m.seeds().is_empty(),
Requirement::Nodes => !m.nodes().is_empty(), Requirement::Nodes => !m.nodes().is_empty(),
Requirement::Mesh => has_elements, Requirement::Mesh => has_elements,
@@ -127,6 +130,7 @@ impl Requirement {
Requirement::Partition => "a partition", Requirement::Partition => "a partition",
Requirement::FaceMerge => "a face merge", Requirement::FaceMerge => "a face merge",
Requirement::Stitch => "a stitch", Requirement::Stitch => "a stitch",
Requirement::FaceSplit => "a face split",
Requirement::Seed => "an edge seed", Requirement::Seed => "an edge seed",
Requirement::Nodes => "mesh nodes", Requirement::Nodes => "mesh nodes",
Requirement::Mesh | Requirement::Analysable => "a mesh", Requirement::Mesh | Requirement::Analysable => "a mesh",
@@ -159,6 +163,7 @@ impl Requirement {
Requirement::Partition => "geometry.partition", Requirement::Partition => "geometry.partition",
Requirement::FaceMerge => "geometry.faces.merge", Requirement::FaceMerge => "geometry.faces.merge",
Requirement::Stitch => "geometry.stitch", Requirement::Stitch => "geometry.stitch",
Requirement::FaceSplit => "geometry.face.split",
Requirement::Seed => "mesh.seed.edge", Requirement::Seed => "mesh.seed.edge",
Requirement::Nodes => "mesh.node.add", Requirement::Nodes => "mesh.node.add",
Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate",
@@ -218,6 +223,7 @@ impl crate::schema::Collection {
C::Partition => Requirement::Partition, C::Partition => Requirement::Partition,
C::FaceMerge => Requirement::FaceMerge, C::FaceMerge => Requirement::FaceMerge,
C::Stitch => Requirement::Stitch, C::Stitch => Requirement::Stitch,
C::FaceSplit => Requirement::FaceSplit,
} }
} }
} }
@@ -360,6 +366,7 @@ mod tests {
C::Partition, C::Partition,
C::FaceMerge, C::FaceMerge,
C::Stitch, C::Stitch,
C::FaceSplit,
]; ];
let empty = Session::default(); let empty = Session::default();
for c in all { for c in all {
+4
View File
@@ -221,6 +221,8 @@ pub enum Collection {
FaceMerge, FaceMerge,
/// A stitch (`geometry.stitch`). /// A stitch (`geometry.stitch`).
Stitch, Stitch,
/// A face split (`geometry.face.split`).
FaceSplit,
} }
impl Collection { impl Collection {
@@ -248,6 +250,7 @@ impl Collection {
Collection::Partition => "partition", Collection::Partition => "partition",
Collection::FaceMerge => "face merge", Collection::FaceMerge => "face merge",
Collection::Stitch => "stitch", 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::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::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::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() 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, 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. 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 ## 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 | 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 | 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 | 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 | 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) | | | Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | |