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.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()
}
+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() })
.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
// 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;
mod design;
mod edges;
pub mod face_split;
pub mod features;
pub mod partition;
pub mod sample;
@@ -70,6 +71,8 @@ pub struct Shape {
faces: Vec<Face>,
/// The virtual face (index into `faces`) of each B-rep face; empty: they are the same.
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.
@@ -156,7 +159,7 @@ fn xyz(v: solidcraft_geom::Vec3) -> [f64; 3] {
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 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 {
@@ -246,7 +249,7 @@ impl Shape {
centroid: xyz(m.centroid),
bbox_min: xyz(m.bbox.min),
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 mut cells: Vec<(bool, [f64; 3], Shape)> = Vec::with_capacity(parts.len());
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 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();
+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
/// design as opened) rather than from the design's own timeline.
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 {
@@ -119,6 +119,9 @@ impl Shape {
if faces.len() > MAX_MERGE_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();
for f in faces {
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 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
@@ -214,6 +217,8 @@ impl Shape {
/// for a free-form one.
pub fn member_surfaces(&self, face: u32) -> Result<Vec<Option<FaceSurface>>> {
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() {
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
/// they share one, else none.
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() {
return Ok(brep);
}
@@ -243,7 +263,13 @@ impl Shape {
/// `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.
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() {
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\``
/// strings.
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.
+12 -2
View File
@@ -85,7 +85,7 @@ pub use steps::{
};
pub use tie::Tie;
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
/// 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.
#[serde(skip_serializing_if = "Vec::is_empty")]
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.
@@ -236,6 +239,8 @@ struct ModelData {
face_merges: Vec<FaceMerge>,
#[serde(default)]
stitches: Vec<Stitch>,
#[serde(default)]
face_splits: Vec<FaceSplit>,
}
impl TryFrom<ModelData> for Model {
@@ -331,6 +336,9 @@ impl TryFrom<ModelData> for Model {
for v in d.stitches {
m.add_stitch(v)?;
}
for v in d.face_splits {
m.add_face_split(v)?;
}
Ok(m)
}
}
@@ -465,6 +473,7 @@ impl Model {
mesh_plans: self.mesh_plans.clone(),
face_merges: self.face_merges.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.
pub fn definition_counts(&self) -> [(&'static str, usize); 18] {
pub fn definition_counts(&self) -> [(&'static str, usize); 19] {
[
("sets", self.sets.len()),
("materials", self.materials.len()),
@@ -535,6 +544,7 @@ impl Model {
("seeds", self.seeds.len()),
("face_merges", self.face_merges.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>,
}
/// 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.
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 }
}
#[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]
fn stitches_are_kept_in_order_and_checked() {
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,
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) | |