Compare commits

...
Author SHA1 Message Date
jyvikoandClaude Opus 5.5 3561b0830b 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:27:03 -04:00
jyvikoandClaude Opus 5.5 99c4a2628f model: face splits along datum planes (#508)
A datum plane a split uses is in use, as one a partition cuts by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:14:54 -04:00
jyvikoandClaude Opus 5.5 c5862b61c9 geometry: a face split along a plane for meshing only (#508)
The face's triangles are cut along the plane, split:<name>:a on its
negative side and :b on the other, both on the face's surface, with the
cut as a new edge between them and the face's edges cut where the plane
crosses them. Applied after the merges; the B-rep is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 15:14:54 -04:00
20 changed files with 694 additions and 20 deletions

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()
} }
+1 -1
View File
@@ -129,7 +129,7 @@ pub(crate) fn rebuild(doc: &Document) -> Result<Design> {
.map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i), members: Vec::new() }) .map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i), members: Vec::new() })
.collect()) .collect())
})?; })?;
bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new() }); bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new(), cuts: Vec::new() });
} }
// A feature that failed leaves a different solid than SolidCraft's: each one names the bodies // A feature that failed leaves a different solid than SolidCraft's: each one names the bodies
// it would have changed, so opening never hands back a silently different solid. // it would have changed, so opening never hands back a silently different solid.
+402
View File
@@ -0,0 +1,402 @@
//! Virtual topology: a face split in two along its curve through a plane, for meshing only.
//!
//! A partition cuts whole bodies into cells with the kernel; a [`FaceSplit`] splits one face of
//! a body along the curve where a plane crosses it, in what the meshers take from the body (the
//! B-rep is unchanged): the face's triangles are cut along the plane, the pieces on the plane's
//! negative side are `split:<name>:a` and the others `split:<name>:b` (the partition's
//! convention), both on the face's surface, and the cut is a new edge between them, so the
//! meshers put a row of nodes along it. Edges of the face that the plane crosses are cut there
//! too. Splits apply after the merges ([`crate::virtual_faces`]), by persistent names, whenever
//! the design is opened; a merge cannot name a split face.
use std::collections::BTreeMap;
use crate::edges::EDGE_TOLERANCE;
use crate::{Design, Error, Face, FeatureError, Result, Shape, SurfaceMesh};
/// Most splits a design takes.
pub const MAX_SPLITS: usize = 1024;
/// A face of a body split by a plane.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceSplit {
/// Names the two faces (`split:<name>:a|b`).
pub name: String,
pub body: String,
pub face: String,
/// A point on the plane and its normal (any length but zero).
pub point: [f64; 3],
pub normal: [f64; 3],
}
/// One split as a body applies it: the face it cuts (kept as the `a` side), the `b` face's
/// index, and the plane (unit normal).
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Cut {
a: u32,
b: u32,
point: [f64; 3],
normal: [f64; 3],
}
impl Cut {
pub(crate) fn faces(&self) -> (u32, u32) {
(self.a, self.b)
}
}
fn signed(c: &Cut, p: [f64; 3]) -> f64 {
(0..3).map(|k| (p[k] - c.point[k]) * c.normal[k]).sum()
}
impl Design {
/// The design with `splits` applied in order (after its partitions and merges). A split that
/// fails (a body or face that is not there, a plane that does not cross the face) is listed
/// in [`Design::errors`] (feature `split <name>`) and changes nothing.
pub fn split(mut self, splits: &[FaceSplit]) -> Result<Design> {
if splits.len() > MAX_SPLITS {
return Err(Error::Invalid(format!("a design takes at most {MAX_SPLITS} face splits")));
}
for sp in splits {
let feature = format!("split {}", sp.name);
let Some(body) = self.bodies.iter_mut().find(|b| b.name == sp.body) else {
self.errors.push(FeatureError { feature, message: format!("no body `{}`", sp.body), bodies: vec![sp.body.clone()] });
continue;
};
match body.split_face(&sp.name, &sp.face, sp.point, sp.normal) {
Ok(split) => *body = split,
Err(e) => self.errors.push(FeatureError { feature, message: e.to_string(), bodies: vec![sp.body.clone()] }),
}
}
if self.bodies.iter().any(|b| !b.cuts.is_empty()) {
let opened = self.source.iter().map(|s| self.bodies.iter().find(|b| b.name == s.name).unwrap_or(s).clone()).collect();
self.opened = std::sync::Arc::new(opened);
}
Ok(self)
}
}
impl Shape {
/// This shape with face `face` split along the plane through `point` normal to `normal`:
/// `split:<name>:a` on its negative side, `split:<name>:b` on the other.
pub fn split_face(&self, name: &str, face: &str, point: [f64; 3], normal: [f64; 3]) -> Result<Shape> {
let len = normal.iter().map(|v| v * v).sum::<f64>().sqrt();
if !(point.iter().chain(&normal).all(|v| v.is_finite() && v.abs() <= 1e9) && len > 1e-12) {
return Err(Error::Invalid("a split needs a plane with a finite point and a non-zero normal".into()));
}
let named = self.faces_named(face);
let [a] = named.as_slice() else {
return Err(Error::Invalid(format!("body `{}` has no face `{face}` (or several pieces of it: name one)", self.name)));
};
let (an, bn) = (format!("split:{name}:a"), format!("split:{name}:b"));
if self.faces.iter().any(|f| f.name == an || f.name == bn) {
return Err(Error::Invalid(format!("body `{}` already has the faces of a split `{name}`", self.name)));
}
let b = u32::try_from(self.faces.len()).map_err(|_| Error::Invalid("too many faces".into()))?;
let mut shape = self.clone();
let original = shape.faces.get(*a as usize).cloned().ok_or_else(|| Error::Invalid("no such face".into()))?;
let members = if original.members.is_empty() { vec![original.name.clone()] } else { original.members.clone() };
if let Some(f) = shape.faces.get_mut(*a as usize) {
*f = Face { name: an, exact: original.exact, members: members.clone() };
}
shape.faces.push(Face { name: bn, exact: original.exact, members });
shape.cuts.push(Cut { a: *a, b, point, normal: normal.map(|v| v / len) });
// The plane must cross the face: both pieces have triangles.
let mesh = shape.tessellate((self.size()? * EDGE_TOLERANCE).max(1e-4))?;
for side in [*a, b] {
if !mesh.tri_face.contains(&side) {
return Err(Error::Invalid(format!("the plane does not cross face `{face}` of body `{}`", self.name)));
}
}
Ok(shape)
}
}
/// `mesh` with each of `cuts` applied in order (see the module docs).
pub(crate) fn apply(mesh: &mut SurfaceMesh, cuts: &[Cut]) {
for c in cuts {
cut(mesh, c);
}
}
fn lerp(p: [f64; 3], q: [f64; 3], t: f64) -> [f64; 3] {
[p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]), p[2] + t * (q[2] - p[2])]
}
fn cut(mesh: &mut SurfaceMesh, c: &Cut) {
let scale = mesh.positions.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(1.0);
let eps = 1e-12 * scale;
let side = |d: f64| {
if d > eps {
1
} else if d < -eps {
-1
} else {
0
}
};
// A new point on the side (u, v) where the plane crosses it, once per side.
let mut made: BTreeMap<(u32, u32), u32> = BTreeMap::new();
let mut segments: Vec<(u32, u32)> = Vec::new();
let n = mesh.triangles.len();
for t in 0..n {
let (Some(&tri), Some(&tag)) = (mesh.triangles.get(t), mesh.tri_face.get(t)) else { continue };
if tag != c.a {
continue;
}
let pos = |i: u32, m: &SurfaceMesh| m.positions.get(i as usize).copied().unwrap_or([0.0; 3]);
let s = tri.map(|i| side(signed(c, pos(i, mesh))));
let tag_of = |sg: i32| if sg > 0 { c.b } else { c.a };
if s.iter().all(|&x| x >= 0) || s.iter().all(|&x| x <= 0) {
let sg = s.iter().copied().find(|&x| x != 0).unwrap_or(-1);
if let Some(f) = mesh.tri_face.get_mut(t) {
*f = tag_of(sg);
}
// A side on the plane between pieces of both sides is the cut too (picked up when
// chaining from the other triangles' points; left to the edges otherwise).
continue;
}
let mut point_on = |u: u32, v: u32, m: &mut SurfaceMesh| -> u32 {
let key = (u.min(v), u.max(v));
if let Some(&i) = made.get(&key) {
return i;
}
let (p, q) = (pos(key.0, m), pos(key.1, m));
let (dp, dq) = (signed(c, p), signed(c, q));
let x = lerp(p, q, dp / (dp - dq));
let i = u32::try_from(m.positions.len()).unwrap_or(u32::MAX);
m.positions.push(x);
made.insert(key, i);
i
};
let rot = |k: usize| [tri[k], tri[(k + 1) % 3], tri[(k + 2) % 3]];
let rs = |k: usize| [s[k], s[(k + 1) % 3], s[(k + 2) % 3]];
let mut out: Vec<([u32; 3], i32)> = Vec::with_capacity(3);
if let Some(k) = (0..3).find(|&k| s[k] == 0) {
// A corner on the plane, the other two on either side.
let ([z, u, v], [_, su, sv]) = (rot(k), rs(k));
let m = point_on(u, v, mesh);
out.push(([z, u, m], su));
out.push(([z, m, v], sv));
segments.push((z, m));
} else {
// One corner alone on its side.
let k = (0..3).find(|&k| s[k] != s[(k + 1) % 3] && s[k] != s[(k + 2) % 3]).unwrap_or(0);
let ([l, u, v], [sl, su, _]) = (rot(k), rs(k));
let (pu, pv) = (point_on(l, u, mesh), point_on(l, v, mesh));
out.push(([l, pu, pv], sl));
out.push(([pu, u, v], su));
out.push(([pu, v, pv], su));
segments.push((pu, pv));
}
let mut first = true;
for (tri, sg) in out {
if first {
if let (Some(slot), Some(f)) = (mesh.triangles.get_mut(t), mesh.tri_face.get_mut(t)) {
*slot = tri;
*f = tag_of(sg);
}
first = false;
} else {
mesh.triangles.push(tri);
mesh.tri_face.push(tag_of(sg));
}
}
}
// The face's edges: cut where the plane crosses them, each piece on its side's face.
let mut edges = Vec::with_capacity(mesh.edges.len() + 1);
let mut edge_faces = Vec::with_capacity(mesh.edges.len() + 1);
for (points, faces) in std::mem::take(&mut mesh.edges).into_iter().zip(std::mem::take(&mut mesh.edge_faces)) {
if !faces.contains(&c.a) {
edges.push(points);
edge_faces.push(faces);
continue;
}
let mut piece: Vec<[f64; 3]> = Vec::new();
let mut piece_side = 0;
let mut flush = |piece: &mut Vec<[f64; 3]>, sg: i32| {
if piece.len() >= 2 {
let on = if sg > 0 { c.b } else { c.a };
edges.push(std::mem::take(piece));
edge_faces.push(faces.iter().map(|&f| if f == c.a { on } else { f }).collect());
}
piece.clear();
};
for (k, &p) in points.iter().enumerate() {
let sp = side(signed(c, p));
if k > 0
&& let Some(&q) = points.get(k - 1)
{
let sq = side(signed(c, q));
if sp != 0 && sq != 0 && sp != sq {
let (dq, dp) = (signed(c, q), signed(c, p));
let x = lerp(q, p, dq / (dq - dp));
piece.push(x);
flush(&mut piece, piece_side);
piece.push(x);
}
}
if sp != 0 {
piece_side = sp;
}
piece.push(p);
}
flush(&mut piece, piece_side);
}
// The cut: its segments chained into polylines, between the two faces. Copies of one point
// (a tessellation may repeat points) are one point for the chaining.
let close = (2.0 * mesh.weld_distance).max(eps);
let mut canon: BTreeMap<u32, u32> = BTreeMap::new();
let mut reps: Vec<(u32, [f64; 3])> = Vec::new();
for &i in segments.iter().flat_map(|(u, v)| [u, v]) {
if canon.contains_key(&i) {
continue;
}
let p = mesh.positions.get(i as usize).copied().unwrap_or([0.0; 3]);
let r = match reps.iter().find(|(_, q)| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f64>().sqrt() <= close) {
Some((r, _)) => *r,
None => {
reps.push((i, p));
i
}
};
canon.insert(i, r);
}
let segments: Vec<(u32, u32)> =
segments.iter().map(|(u, v)| (canon.get(u).copied().unwrap_or(*u), canon.get(v).copied().unwrap_or(*v))).filter(|(u, v)| u != v).collect();
for line in chain(&segments) {
let points: Vec<[f64; 3]> = line.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()).collect();
if points.len() >= 2 {
edges.push(points);
edge_faces.push(vec![c.a, c.b]);
}
}
mesh.edges = edges;
mesh.edge_faces = edge_faces;
}
/// Segments (point index pairs) chained end to end into polylines, in a fixed order.
fn chain(segments: &[(u32, u32)]) -> Vec<Vec<u32>> {
let mut at: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for (i, &(u, v)) in segments.iter().enumerate() {
at.entry(u).or_default().push(i);
at.entry(v).or_default().push(i);
}
let mut used = vec![false; segments.len()];
let mut lines = Vec::new();
// Start at ends (points on one segment) first, then what is left (closed loops).
let ends: Vec<usize> = at.values().filter(|s| s.len() == 1).flatten().copied().collect();
let starts: Vec<usize> = ends.into_iter().chain(0..segments.len()).collect();
for s in starts {
if used.get(s).copied().unwrap_or(true) {
continue;
}
let Some(&(u, v)) = segments.get(s) else { continue };
// From an end segment, start at its free point.
let (mut line, mut last) = if at.get(&v).is_some_and(|x| x.len() == 1) { (vec![v, u], u) } else { (vec![u, v], v) };
if let Some(x) = used.get_mut(s) {
*x = true;
}
while let Some(&next) = at.get(&last).and_then(|x| x.iter().find(|&&i| !used.get(i).copied().unwrap_or(true))) {
if let Some(x) = used.get_mut(next) {
*x = true;
}
let Some(&(a, b)) = segments.get(next) else { break };
last = if a == last { b } else { a };
line.push(last);
}
lines.push(line);
}
lines
}
#[cfg(test)]
mod tests {
use crate::open_design;
use crate::sample::{self, Plate};
use super::*;
/// The plate's top (z = 10, 40 × 30 with the Ø10 hole at (15, 15)) split at x = 30: two
/// faces on the top's plane, a straight cut edge between them along x = 30, the top's rim
/// edges at y = 0 and y = 30 cut there; triangles all on one side of the plane each.
#[test]
fn the_plates_top_splits_along_a_plane() {
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
let body = d.bodies[0].name.clone();
let b = &d.bodies[0];
let surfaces = b.face_surfaces().unwrap();
let top = b
.faces()
.iter()
.zip(&surfaces)
.find(|(_, s)| matches!(s, Some(crate::FaceSurface::Plane { normal, point }) if normal[2] > 0.5 && point[2] > 9.0))
.unwrap()
.0
.name
.clone();
let split = |x: f64| FaceSplit { name: "S".into(), body: body.clone(), face: top.clone(), point: [x, 0.0, 0.0], normal: [1.0, 0.0, 0.0] };
let d = d.split(&[split(30.0)]).unwrap();
assert!(d.errors.is_empty(), "{:?}", d.errors);
let b = &d.bodies[0];
assert_eq!(b.faces().len(), 9);
let (fa, fb) = (b.face_index("split:S:a").unwrap(), b.face_index("split:S:b").unwrap());
let s = b.face_surfaces().unwrap();
assert_eq!(s[fa as usize], s[fb as usize]);
let mesh = b.tessellate(0.01).unwrap();
for (t, &f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let x = t.map(|i| mesh.positions[i as usize][0]);
if f == fa {
assert!(x.iter().all(|&v| v <= 30.0 + 1e-9), "{x:?}");
} else if f == fb {
assert!(x.iter().all(|&v| v >= 30.0 - 1e-9), "{x:?}");
}
}
// The area is kept: 40 × 30 less the hole, split 30 × 30 − π25 and 10 × 30.
let area = |f: u32| -> f64 {
mesh.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, g)| **g == f)
.map(|(t, _)| {
let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
let (u, v) = ([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]);
0.5 * ((u[1] * v[2] - u[2] * v[1]).powi(2) + (u[2] * v[0] - u[0] * v[2]).powi(2) + (u[0] * v[1] - u[1] * v[0]).powi(2)).sqrt()
})
.sum()
};
assert!((area(fb) - 300.0).abs() < 1e-6, "{}", area(fb));
assert!((area(fa) - (900.0 - std::f64::consts::PI * 25.0)).abs() < 0.5, "{}", area(fa));
let edges = b.edges().unwrap();
let cut: Vec<_> = edges.iter().filter(|e| e.faces.contains(&fa) && e.faces.contains(&fb)).collect();
assert_eq!(cut.len(), 1, "{:?}", edges.iter().map(|e| &e.name).collect::<Vec<_>>());
assert!((cut[0].length() - 30.0).abs() < 1e-9 && cut[0].points.iter().all(|p| (p[0] - 30.0).abs() < 1e-9));
// The top's rim edges along x are cut at x = 30: four pieces where there were two.
let rim = edges.iter().filter(|e| e.faces.iter().any(|&f| f == fa || f == fb) && !(e.faces.contains(&fa) && e.faces.contains(&fb))).count();
assert_eq!(rim, 2 + 2 + 2 + 2, "four sides, the two along x in two pieces, and the hole's two rim arcs");
}
#[test]
fn bad_splits_are_listed() {
let d = open_design(&sample::plate_design(&Plate::default()).unwrap()).unwrap();
let body = d.bodies[0].name.clone();
let face = d.bodies[0].faces()[0].name.clone();
let sp = |n: &str, b: &str, f: &str, x: f64, normal: [f64; 3]| FaceSplit {
name: n.into(),
body: b.into(),
face: f.into(),
point: [x, 0.0, 0.0],
normal,
};
let d = d
.split(&[
sp("Far", &body, &face, 1000.0, [1.0, 0.0, 0.0]),
sp("NoFace", &body, "nope", 20.0, [1.0, 0.0, 0.0]),
sp("NoBody", "x", &face, 20.0, [1.0, 0.0, 0.0]),
sp("Zero", &body, &face, 20.0, [0.0; 3]),
])
.unwrap();
assert_eq!(d.errors.len(), 4, "{:?}", d.errors);
assert_eq!(d.bodies[0].faces().len(), 8);
}
}
+5 -2
View File
@@ -20,6 +20,7 @@
pub mod check; pub mod check;
mod design; mod design;
mod edges; mod edges;
pub mod face_split;
pub mod features; pub mod features;
pub mod partition; pub mod partition;
pub mod sample; pub mod sample;
@@ -70,6 +71,8 @@ pub struct Shape {
faces: Vec<Face>, faces: Vec<Face>,
/// The virtual face (index into `faces`) of each B-rep face; empty: they are the same. /// The virtual face (index into `faces`) of each B-rep face; empty: they are the same.
face_map: Vec<u32>, face_map: Vec<u32>,
/// Faces split along a plane ([`face_split`]), applied after the merges, in order.
cuts: Vec<face_split::Cut>,
} }
/// A B-rep face's persistent identity. /// A B-rep face's persistent identity.
@@ -156,7 +159,7 @@ fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
pub fn block(a: [f64; 3], b: [f64; 3]) -> Result<Shape> { pub fn block(a: [f64; 3], b: [f64; 3]) -> Result<Shape> {
let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] }; let v = |p: [f64; 3]| solidcraft_geom::Vec3 { x: p[0], y: p[1], z: p[2] };
let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?; let body = guard("block", || solidcraft_kernel::box_solid(v(a), v(b)))?;
Ok(Shape { name: "Block".into(), body, faces: Vec::new(), face_map: Vec::new() }) Ok(Shape { name: "Block".into(), body, faces: Vec::new(), face_map: Vec::new(), cuts: Vec::new() })
} }
impl Shape { impl Shape {
@@ -246,7 +249,7 @@ impl Shape {
centroid: xyz(m.centroid), centroid: xyz(m.centroid),
bbox_min: xyz(m.bbox.min), bbox_min: xyz(m.bbox.min),
bbox_max: xyz(m.bbox.max), bbox_max: xyz(m.bbox.max),
faces: if self.face_map.is_empty() { m.faces } else { self.faces.len() }, faces: if self.face_map.is_empty() && self.cuts.is_empty() { m.faces } else { self.faces.len() },
}) })
} }
+1 -1
View File
@@ -277,7 +277,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Split> {
let parent = Faces::of(body, tol)?; let parent = Faces::of(body, tol)?;
let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len()); let mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len());
for part in parts { for part in parts {
let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new() }; let mut shape = Shape { name: String::new(), body: part, faces: Vec::new(), face_map: Vec::new(), cuts: Vec::new() };
let m = shape.measure()?; let m = shape.measure()?;
let negative = dot(sub(m.centroid, cut.point), n) < 0.0; let negative = dot(sub(m.centroid, cut.point), n) < 0.0;
shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false, members: Vec::new() }).collect(); shape.faces = (0..m.faces).map(|_| Face { name: String::new(), exact: false, members: Vec::new() }).collect();
+29 -3
View File
@@ -50,7 +50,7 @@ pub fn merged_face_name(name: &str) -> String {
/// Whether a feature error comes from the model's partitions or merges (applied again from the /// Whether a feature error comes from the model's partitions or merges (applied again from the
/// design as opened) rather than from the design's own timeline. /// design as opened) rather than from the design's own timeline.
pub(crate) fn derived(feature: &str) -> bool { pub(crate) fn derived(feature: &str) -> bool {
feature.starts_with("partition ") || feature.starts_with("merge ") feature.starts_with("partition ") || feature.starts_with("merge ") || feature.starts_with("split ")
} }
impl Design { impl Design {
@@ -119,6 +119,9 @@ impl Shape {
if faces.len() > MAX_MERGE_FACES { if faces.len() > MAX_MERGE_FACES {
return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces"))); return Err(Error::Invalid(format!("a merge takes at most {MAX_MERGE_FACES} faces")));
} }
if !self.cuts.is_empty() {
return Err(Error::Invalid("merges come before splits: a merge cannot take a split face".into()));
}
let mut members = BTreeSet::new(); let mut members = BTreeSet::new();
for f in faces { for f in faces {
let found = self.faces_named(f); let found = self.faces_named(f);
@@ -162,7 +165,7 @@ impl Shape {
} }
let brep = if self.face_map.is_empty() { (0..u32::try_from(self.faces.len()).unwrap_or(0)).collect() } else { self.face_map.clone() }; let brep = if self.face_map.is_empty() { (0..u32::try_from(self.faces.len()).unwrap_or(0)).collect() } else { self.face_map.clone() };
let face_map = brep.iter().map(|&v| renumber.get(v as usize).copied().unwrap_or(v)).collect(); let face_map = brep.iter().map(|&v| renumber.get(v as usize).copied().unwrap_or(v)).collect();
Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map }) Ok(Shape { name: self.name.clone(), body: self.body.clone(), faces: kept, face_map, cuts: Vec::new() })
} }
/// The neighbour face `face` shares the longest edges with (by name): where a tiny or sliver /// The neighbour face `face` shares the longest edges with (by name): where a tiny or sliver
@@ -214,6 +217,8 @@ impl Shape {
/// for a free-form one. /// for a free-form one.
pub fn member_surfaces(&self, face: u32) -> Result<Vec<Option<FaceSurface>>> { pub fn member_surfaces(&self, face: u32) -> Result<Vec<Option<FaceSurface>>> {
let brep = self.brep_face_surfaces()?; let brep = self.brep_face_surfaces()?;
// The b side of a split is made of the face it was split from.
let face = self.cuts.iter().rev().fold(face, |f, c| if c.faces().1 == f { c.faces().0 } else { f });
if self.face_map.is_empty() { if self.face_map.is_empty() {
return Ok(brep.get(face as usize).copied().into_iter().collect()); return Ok(brep.get(face as usize).copied().into_iter().collect());
} }
@@ -223,6 +228,21 @@ impl Shape {
/// `brep` (surfaces by B-rep face) by face: a merged face has its B-rep faces' surface when /// `brep` (surfaces by B-rep face) by face: a merged face has its B-rep faces' surface when
/// they share one, else none. /// they share one, else none.
pub(crate) fn virtual_surfaces(&self, brep: Vec<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> { pub(crate) fn virtual_surfaces(&self, brep: Vec<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> {
let mut out = self.merged_surfaces(brep)?;
for c in &self.cuts {
let (a, b) = c.faces();
let s = out.get(a as usize).copied().flatten();
if out.len() <= b as usize {
out.resize(b as usize + 1, None);
}
if let Some(slot) = out.get_mut(b as usize) {
*slot = s;
}
}
Ok(out)
}
fn merged_surfaces(&self, brep: Vec<Option<FaceSurface>>) -> Result<Vec<Option<FaceSurface>>> {
if self.face_map.is_empty() { if self.face_map.is_empty() {
return Ok(brep); return Ok(brep);
} }
@@ -243,7 +263,13 @@ impl Shape {
/// `mesh` (a tessellation of the B-rep) with the merged faces: triangles tagged by face, /// `mesh` (a tessellation of the B-rep) with the merged faces: triangles tagged by face,
/// the edges inside a merged face dropped and the pieces of an edge it leaves joined. /// the edges inside a merged face dropped and the pieces of an edge it leaves joined.
pub(crate) fn virtual_mesh(&self, mut mesh: SurfaceMesh) -> SurfaceMesh { pub(crate) fn virtual_mesh(&self, mesh: SurfaceMesh) -> SurfaceMesh {
let mut mesh = self.merged_mesh(mesh);
crate::face_split::apply(&mut mesh, &self.cuts);
mesh
}
fn merged_mesh(&self, mut mesh: SurfaceMesh) -> SurfaceMesh {
if self.face_map.is_empty() { if self.face_map.is_empty() {
return mesh; return mesh;
} }
+2 -1
View File
@@ -174,7 +174,8 @@ impl Model {
/// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\`` /// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\``
/// strings. /// strings.
pub fn datum_users(&self, name: &str) -> Vec<String> { pub fn datum_users(&self, name: &str) -> Vec<String> {
self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name)).collect() let partitions = self.partitions.iter().filter(|p| p.datum == name).map(|p| format!("partition `{}`", p.name));
partitions.chain(self.face_splits().iter().filter(|s| s.datum == name).map(|s| format!("face split `{}`", s.name))).collect()
} }
/// The partitions, in the order they cut. /// The partitions, in the order they cut.
+12 -2
View File
@@ -85,7 +85,7 @@ pub use steps::{
}; };
pub use tie::Tie; pub use tie::Tie;
pub use units::UnitSystem; pub use units::UnitSystem;
pub use virtual_faces::{FaceMerge, Stitch}; pub use virtual_faces::{FaceMerge, FaceSplit, Stitch};
/// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight /// The largest node or element label. Nastran's GRID and element ids stop at 99 999 999 (eight
/// digits in a small field), the tightest of the three formats. /// digits in a small field), the tightest of the three formats.
@@ -180,6 +180,9 @@ pub struct Model {
/// Stitches of imported surfaces, in the order they apply. /// Stitches of imported surfaces, in the order they apply.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
stitches: Vec<Stitch>, stitches: Vec<Stitch>,
/// Face splits along datum planes, in the order they apply (after the merges).
#[serde(skip_serializing_if = "Vec::is_empty")]
face_splits: Vec<FaceSplit>,
} }
/// A [`Model`] as stored, before it is checked. /// A [`Model`] as stored, before it is checked.
@@ -236,6 +239,8 @@ struct ModelData {
face_merges: Vec<FaceMerge>, face_merges: Vec<FaceMerge>,
#[serde(default)] #[serde(default)]
stitches: Vec<Stitch>, stitches: Vec<Stitch>,
#[serde(default)]
face_splits: Vec<FaceSplit>,
} }
impl TryFrom<ModelData> for Model { impl TryFrom<ModelData> for Model {
@@ -331,6 +336,9 @@ impl TryFrom<ModelData> for Model {
for v in d.stitches { for v in d.stitches {
m.add_stitch(v)?; m.add_stitch(v)?;
} }
for v in d.face_splits {
m.add_face_split(v)?;
}
Ok(m) Ok(m)
} }
} }
@@ -465,6 +473,7 @@ impl Model {
mesh_plans: self.mesh_plans.clone(), mesh_plans: self.mesh_plans.clone(),
face_merges: self.face_merges.clone(), face_merges: self.face_merges.clone(),
stitches: self.stitches.clone(), stitches: self.stitches.clone(),
face_splits: self.face_splits.clone(),
} }
} }
@@ -515,7 +524,7 @@ impl Model {
} }
/// Counts of each kind of definition, for summaries. /// Counts of each kind of definition, for summaries.
pub fn definition_counts(&self) -> [(&'static str, usize); 18] { pub fn definition_counts(&self) -> [(&'static str, usize); 19] {
[ [
("sets", self.sets.len()), ("sets", self.sets.len()),
("materials", self.materials.len()), ("materials", self.materials.len()),
@@ -535,6 +544,7 @@ impl Model {
("seeds", self.seeds.len()), ("seeds", self.seeds.len()),
("face_merges", self.face_merges.len()), ("face_merges", self.face_merges.len()),
("stitches", self.stitches.len()), ("stitches", self.stitches.len()),
("face_splits", self.face_splits.len()),
] ]
} }
} }
+70
View File
@@ -33,6 +33,59 @@ pub struct FaceMerge {
pub into: Option<String>, pub into: Option<String>,
} }
/// Most face splits a model holds.
pub const MAX_FACE_SPLITS: usize = 1024;
/// A face of a body split along a datum plane for meshing (`split:<name>:a|b`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceSplit {
pub name: String,
pub body: String,
pub face: String,
/// The datum plane it splits along.
pub datum: String,
}
impl Model {
/// The face splits, in the order they apply (after the merges).
pub fn face_splits(&self) -> &[FaceSplit] {
&self.face_splits
}
/// Add a face split after the others.
pub fn add_face_split(&mut self, split: FaceSplit) -> Result<()> {
let name = split.name.clone();
check_name("face split", &name)?;
if self.face_splits.iter().any(|s| s.name == name) {
return Err(Error::Invalid(format!("face split `{name}` already exists")));
}
if self.face_splits.len() >= MAX_FACE_SPLITS {
return Err(Error::Limit(format!("a model holds at most {MAX_FACE_SPLITS} face splits")));
}
let invalid = |m: String| context("face split", &name, Error::Invalid(m));
for n in [&split.body, &split.face] {
if n.trim().is_empty() || n.len() > MAX_GEOMETRY_NAME_LEN {
return Err(invalid(format!("body and face names are 1 to {MAX_GEOMETRY_NAME_LEN} bytes")));
}
}
match self.datum(&split.datum) {
None => return Err(context("face split", &name, Error::NotFound(format!("datum `{}`", split.datum)))),
Some(d) if d.plane().is_none() => {
return Err(invalid(format!("datum `{}` is a datum {}; a split is along a plane", split.datum, d.kind())));
}
Some(_) => {}
}
self.face_splits.push(split);
Ok(())
}
pub fn remove_face_split(&mut self, name: &str) -> Result<FaceSplit> {
let i = self.face_splits.iter().position(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("face split `{name}`")))?;
Ok(self.face_splits.remove(i))
}
}
/// Most stitches a model holds. /// Most stitches a model holds.
pub const MAX_STITCHES: usize = 1024; pub const MAX_STITCHES: usize = 1024;
@@ -132,6 +185,23 @@ mod tests {
FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None } FaceMerge { name: name.into(), body: "Base".into(), faces: faces.iter().map(|f| f.to_string()).collect(), into: None }
} }
#[test]
fn splits_need_a_datum_plane_and_hold_it() {
let mut m = Model::default();
m.add_datum("Mid", crate::Datum::Plane { point: [0.0; 3], normal: [1.0, 0.0, 0.0] }).unwrap();
m.add_datum("P", crate::Datum::Point { point: [0.0; 3] }).unwrap();
let sp = |n: &str, d: &str| FaceSplit { name: n.into(), body: "Base".into(), face: "F1:+z".into(), datum: d.into() };
m.add_face_split(sp("S", "Mid")).unwrap();
for bad in [sp("S", "Mid"), sp("T", "P"), sp("U", "nope"), FaceSplit { face: String::new(), ..sp("V", "Mid") }] {
assert!(m.add_face_split(bad).is_err());
}
assert!(m.remove_datum("Mid").is_err(), "in use by the split");
let back: Model = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.face_splits(), m.face_splits());
m.remove_face_split("S").unwrap();
m.remove_datum("Mid").unwrap();
}
#[test] #[test]
fn stitches_are_kept_in_order_and_checked() { fn stitches_are_kept_in_order_and_checked() {
let mut m = Model::default(); let mut m = Model::default();
+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) | |