From 68271137a5f026c6f7fefb00f15df5af37fc8192 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 18:44:32 +0000 Subject: [PATCH] Split Face (FusionSplitFaceCommand): a timeline feature dividing the picked faces where a plane, another body's face (its surface extended) or a sketch's curves meet them, through kernel::split_faces; the solid is unchanged and the pieces take the face's name with #k. Rebase fixes: duplicate Chamfer and Combine specs; the Delete selection's face test expects Delete Face's refusal Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 7 +++ crates/doc/src/clipboard.rs | 6 +++ crates/doc/src/document.rs | 25 +++++++++-- crates/doc/src/eval.rs | 40 +++++++++++++++++ crates/doc/src/params.rs | 1 + crates/engine/src/cmd/delete.rs | 3 +- crates/engine/src/cmd/features.rs | 27 ++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/split_face_tests.rs | 64 +++++++++++++++++++++++++++ 9 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/split_face_tests.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 14615691..6dc2ffb4 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -162,6 +162,13 @@ impl FeatureKind { pt(&mut t.point); } } + FeatureKind::SplitFace { faces, plane, tool, .. } => { + faces.iter_mut().for_each(pt); + map_plane_ref(m, plane); + if let Some(t) = tool { + pt(&mut t.point); + } + } FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => { pt(origin); dir(d); diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 023f3f66..81272631 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -40,6 +40,12 @@ impl FeatureKind { FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => { plane_names(plane, names) } + FeatureKind::SplitFace { plane, sketch, .. } => { + plane_names(plane, names); + if let Some(s) = sketch { + sk(s); + } + } FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Emboss { sketch, .. } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 83f22302..b173ab00 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -355,6 +355,19 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] tool: Option, }, + /// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane, + /// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept + /// along its normal). The pieces are named after the face (`#k`). + SplitFace { + faces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + plane: PlaneRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + tool: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sketch: Option, + }, /// Scale bodies about a point (uniform, or per axis). Scale { bodies: Vec, @@ -936,6 +949,7 @@ impl FeatureKind { FeatureKind::Shell { .. } => "ShellFeature", FeatureKind::Draft { .. } => "DraftFeature", FeatureKind::Split { .. } => "SplitBodyFeature", + FeatureKind::SplitFace { .. } => "SplitFaceFeature", FeatureKind::Move { .. } => "MoveFeature", FeatureKind::Scale { .. } => "ScaleFeature", FeatureKind::OffsetFace { .. } => "OffsetFacesFeature", @@ -997,6 +1011,7 @@ impl FeatureKind { FeatureKind::Shell { .. } => "Shell", FeatureKind::Draft { .. } => "Draft", FeatureKind::Split { .. } => "Split", + FeatureKind::SplitFace { .. } => "SplitFace", FeatureKind::Move { .. } => "Move", FeatureKind::Scale { .. } => "Scale", FeatureKind::OffsetFace { .. } => "OffsetFace", @@ -1042,6 +1057,7 @@ impl FeatureKind { | FeatureKind::Draft { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } + | FeatureKind::SplitFace { faces, .. } | FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(), FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(), FeatureKind::Hole { to: Some(t), .. } => vec![t], @@ -1060,9 +1076,10 @@ impl FeatureKind { pub fn expressions(&self) -> Vec<&str> { let mut v: Vec<&str> = Vec::new(); match self { - FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => { - plane_exprs(plane, &mut v) - } + FeatureKind::Sketch { plane, .. } + | FeatureKind::ConstructionPlane { plane } + | FeatureKind::Split { plane, .. } + | FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::Extrude { extent, .. } => { v.push(&extent.distance); if let Some(d) = &extent.distance2 { @@ -1551,7 +1568,7 @@ impl Document { FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id), FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id), FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id), - FeatureKind::SheetFold { sketch: Some(s), .. } if *s == id => gone.push(f.id), + FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 113fdd30..3c40c5fd 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2414,6 +2414,46 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } apply_op(st, f, made, *operation, &[]) } + FeatureKind::SplitFace { faces, body, plane, tool, sketch } => { + let i = body_at(st, body, faces)?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let mut picked: Vec = Vec::new(); + for p in faces { + let fi = crate::appearance::face_index_at(&mb, *p) + .ok_or_else(|| DocError::Invalid(format!("Split Face: no face of {} at {p:?}", mb.name)))?; + if !picked.contains(&fi) { + picked.push(fi); + } + } + let nb = match (tool, sketch) { + (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_faces(&mb.body, &picked, &kernel::SplitTool::Face { body: &tb.body, face })? + } + (None, Some(sid)) => { + let ss = st.sketch(*sid).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?; + let curves: Vec> = (0..ss.sketch.curves.len()) + .filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction)) + .map(|ci| ss.sketch.polyline(ci)) + .filter(|pl| pl.len() >= 2) + .collect(); + if curves.is_empty() { + return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name))); + } + kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })? + } + (None, None) => { + let pl = doc.resolve_plane(vals, plane, 0)?; + kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Plane(pl))? + } + }; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, nb, mb.feature); + } + Ok(()) + } 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}`"))) }; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index e02e41d2..a1763874 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -34,6 +34,7 @@ impl FeatureKind { FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } + | FeatureKind::SplitFace { plane, .. } | FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v), FeatureKind::Extrude { extent, .. } => { v.push(("Distance", &mut extent.distance, L)); diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index 6450cd4a..ec2498ca 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -231,7 +231,8 @@ mod tests { let (mut s, _) = part(); let b = s.model.state().bodies[0].name.clone(); let e = s.execute("selection.delete", &json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap_err(); - assert!(e.to_string().contains("not available yet"), "{e}"); + // A side of the part its neighbours can't close: Delete Face refuses it. + assert!(e.to_string().contains("Delete Face"), "{e}"); for p in [ json!({}), json!({"items": "x"}), diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 25047029..655b28f2 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -33,8 +33,6 @@ pub static COMMANDS: &[CommandSpec] = &[ .key("F") .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"), CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"), - CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"), - CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"), CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"), CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect) .at("SOLID", "CREATE") @@ -94,6 +92,12 @@ 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("solid.split_face", "Split Face", split_face) + .at("SOLID", "MODIFY") + .icon("split") + .params( + "faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)", + ), CommandSpec::new("solid.split_body", "Split Body", split_body) .at("SOLID", "MODIFY") .icon("split") @@ -835,6 +839,25 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result { ) } +fn split_face(s: &mut Session, p: &Value) -> Result { + let cmd = "solid.split_face"; + let faces = face_points(p, cmd)?; + let body = str_(p, "body").map(str::to_string); + let xy = || solvecraft_doc::PlaneRef::Origin { name: "XY".into() }; + let kind = 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"))?; + FeatureKind::SplitFace { faces, body, plane: xy(), tool: Some(solvecraft_doc::FaceAt { body: tb, point }), sketch: None } + } else if p.get("sketch").is_some_and(|v| !v.is_null()) { + let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?; + FeatureKind::SplitFace { faces, body, plane: xy(), tool: None, sketch: Some(sketch) } + } else { + let plane = plane_param(s, p.get("plane"), cmd)?; + FeatureKind::SplitFace { faces, body, plane, tool: None, sketch: None } + }; + add_feature(s, p, kind) +} + fn split_body(s: &mut Session, p: &Value) -> Result { let cmd = "solid.split_body"; let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 287cd547..94675c69 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -430,6 +430,8 @@ mod delete_face_tests; #[cfg(test)] mod extent_tests; #[cfg(test)] +mod split_face_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod thread_tests; diff --git a/crates/engine/src/split_face_tests.rs b/crates/engine/src/split_face_tests.rs new file mode 100644 index 00000000..721ad4c5 --- /dev/null +++ b/crates/engine/src/split_face_tests.rs @@ -0,0 +1,64 @@ +//! Split Face: the top of a box divided by a plane, by another body's face and by sketch curves. + +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +/// (kernel faces, volume) of a body: the merged count joins pieces of one surface back up. +fn faces_volume(s: &mut Session, b: &str) -> (u64, f64) { + let m = run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone(); + (m["kernel"]["faces"].as_u64().unwrap_or(0), m["volume_mm3"].as_f64().unwrap_or(0.0)) +} + +fn boxed() -> Session { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"})); + s +} + +#[test] +fn split_a_face_by_a_plane() { + let mut s = boxed(); + run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})); + let (f, v) = faces_volume(&mut s, "B"); + assert_eq!(f, 7); + assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}"); + assert_eq!(s.doc.features.last().unwrap().kind.type_name(), "SplitFaceFeature"); +} + +#[test] +fn split_a_face_by_another_bodys_face() { + let mut s = boxed(); + run(&mut s, "solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"})); + // The tool's face at y = 10, extended down through the box's top. + run(&mut s, "solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})); + assert_eq!(faces_volume(&mut s, "B").0, 7); +} + +#[test] +fn split_a_face_by_sketch_curves() { + let mut s = boxed(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cuts"})); + run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 35]]})); + run(&mut s, "sketch.line", json!({"points": [[30, -5], [30, 35]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.split_face", json!({"faces": [[20, 15, 20]], "body": "B", "sketch": "Cuts"})); + let (f, v) = faces_volume(&mut s, "B"); + assert_eq!(f, 8, "two lines make three pieces of the top"); + assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}"); +} + +#[test] +fn a_tool_that_misses_is_refused() { + let mut s = boxed(); + let n = s.doc.features.len(); + assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err()); + assert_eq!(s.doc.features.len(), n); +}