Split Body by a face of a body: FusionSplitBodyCommand tool {body, point}

The Split feature takes an optional face tool (FaceAt, the face through a
point on a named body) and splits with kernel split_body, the tool's
surface extended; otherwise the plane as before. Engine test: a box split
by a cylinder's side into the core and the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 17:31:57 +00:00
1 parent 34be218639
commit feaaba156e
6 files changed
+129 -69

No files matched your search

+11 -1
View File
@@ -312,10 +312,13 @@ pub enum FeatureKind {
ConstructionPlane {
plane: PlaneRef,
},
/// Split a body with a plane into two bodies.
/// Split a body into the parts on either side of a plane, or of a face of a body (the face
/// through `point`, its surface extended) when `tool` is set.
Split {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
@@ -630,6 +633,13 @@ pub enum FeatureKind {
},
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceAt {
pub body: String,
pub point: Vec3,
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
+15 -4
View File
@@ -2016,13 +2016,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
doc.resolve_plane(vals, plane, 0)?;
Ok(())
}
FeatureKind::Split { body, plane } => {
let pl = doc.resolve_plane(vals, plane, 0)?;
FeatureKind::Split { body, plane, tool } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) };
let parts = kernel::split_by_plane(&mb.body, &pl)?;
let parts = match tool {
// A face of a body (its surface extended).
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })?
}
None => {
let pl = doc.resolve_plane(vals, plane, 0)?;
kernel::split_by_plane(&mb.body, &pl)?
}
};
if parts.len() < 2 {
return Err(DocError::Invalid("the plane does not split the body".into()));
return Err(DocError::Invalid("the tool does not split the body".into()));
}
let mut it = parts.into_iter();
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
+24 -2
View File
@@ -88,7 +88,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
@@ -771,8 +774,27 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
let st = s.model.state();
let tbody = st.body(&tb).ok_or_else(|| bad(cmd, format!("no body `{tb}`")))?;
if solvecraft_doc::appearance::face_index_at(tbody, point).is_none() {
return Err(bad(cmd, "`tool.point` is not on a face of the tool body"));
}
return add_feature(
s,
p,
FeatureKind::Split {
body,
plane: solvecraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solvecraft_doc::FaceAt { body: tb, point }),
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body, plane })
add_feature(s, p, FeatureKind::Split { body, plane, tool: None })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
+17
View File
@@ -1041,6 +1041,23 @@ fn iges_export_and_open() {
assert_eq!(st.bodies[1].body.color(), Some([1.0, 0.0, 0.0]));
}
/// Split Body by a face of another body (its surface extended): a box cut by a cylinder's
/// side into the core and the rest; volumes add up exactly.
#[test]
fn split_body_by_a_face() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
run(&mut s, "PrimitiveCylinder", json!({"radius": 4, "height": 40, "base": [5, 10, -5], "body_name": "Tool"}));
run(&mut s, "FusionSplitBodyCommand", json!({"body": "Block", "tool": {"body": "Tool", "point": [9, 10, 10]}}));
let st = s.world_state();
let mut v: Vec<f64> =
st.bodies.iter().filter(|b| b.name.starts_with("Block")).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).collect();
v.sort_by(f64::total_cmp);
let core = PI * 16.0 * 30.0;
assert!(v.len() == 2 && rel(v[0], core) < 1e-3 && rel(v[1], 6000.0 - core) < 1e-3, "{v:?}");
assert!(s.execute("FusionSplitBodyCommand", &json!({"body": "Block", "tool": {"body": "Tool", "point": [100, 0, 0]}})).is_err());
}
/// Components export as a STEP assembly: products, occurrences, placements.
#[test]
fn components_export_as_step_assembly() {
+1 -1
View File
@@ -2625,7 +2625,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Split { body, plane } => {
FeatureKind::Split { body, plane, .. } => {
let mut d = start("FusionSplitBodyCommand")?;
if let Some(inp) = d.inputs.first_mut() {
inp.items = vec![Sel::Body { name: body.clone() }];