face.delete heal: false leaves an open surface body (#861)

Delete Face takes heal (default true, as before). With heal: false the picked faces are removed and the gap stays open, leaving the remaining faces as a surface body (kernel remove_faces); deleting every face is refused. Saved only when false, noted under format 18.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 19:53:17 -04:00
1 parent c616f50bd0
commit b52d4390a0
7 files changed
+80 -5

No files matched your search

+12
View File
@@ -607,6 +607,10 @@ pub enum FeatureKind {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// Close the gap by extending the neighbouring faces (the default); `false` leaves it
/// open, the faces left a surface body.
#[serde(default = "yes", skip_serializing_if = "is_yes")]
heal: bool,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
@@ -1745,6 +1749,14 @@ fn all_profiles() -> ProfileSel {
ProfileSel::All
}
fn yes() -> bool {
true
}
fn is_yes(b: &bool) -> bool {
*b
}
fn zero_expr() -> String {
"0".into()
}
+3 -2
View File
@@ -4134,7 +4134,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::DeleteFace { faces, body } => {
FeatureKind::DeleteFace { faces, body, heal } => {
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<usize> = Vec::new();
@@ -4146,7 +4146,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
}
let all = kernel::with_same_surface(&mb.body, &picked);
let nb = Traced::step(kernel::delete_faces(&mb.body, &all)?, &[&mb.traced()]);
let left = if *heal { kernel::delete_faces(&mb.body, &all)? } else { kernel::remove_faces(&mb.body, &all)? };
let nb = Traced::step(left, &[&mb.traced()]);
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
}
+3
View File
@@ -113,6 +113,9 @@
//! `letter_spacing` (mm), `line_spacing` (times the font's line distance), `path` (the id of a
//! curve of the same sketch the baseline follows) and `reverse`. A text without `style` is set
//! as it always was; 0.9.0 would drop the style and set the text plain, off its curve.
//! Under the same version, Delete Face can leave the gap open (`heal: false` on the feature:
//! the faces left are a surface body); a Delete Face without it heals, as it always did, and
//! saves as before. 0.9.0 would heal a gap the design leaves open.
use serde_json::Value;
+3 -2
View File
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?. The neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off"),
.params("faces: [[x,y,z] points on the faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
@@ -1649,7 +1649,8 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string) })
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
+26
View File
@@ -338,6 +338,32 @@ mod tests {
assert!(s.execute("surface.reverse_normal", &json!({"body": "Cube"})).is_err());
}
/// Delete Face without healing: a box's top taken off leaves a five-face surface; patching
/// the opening and stitching gives back a box of the same volume. By default the gap still
/// heals (here it can't: refused); deleting every face is refused.
#[test]
fn delete_face_without_healing_leaves_an_open_surface() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
run(&mut s, "face.delete", json!({"faces": [[5, 5, 10]], "heal": false}));
let st = s.world_state();
let open = &st.body("Box").unwrap().body;
assert!(open.is_surface() && open.face_count() == 5);
let rim = json!([[5, 0, 10], [10, 5, 10], [5, 10, 10], [0, 5, 10]]);
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Box", "body_name": "Lid"}));
run(&mut s, "surface.stitch", json!({"bodies": ["Box", "Lid"]}));
assert!(!s.world_state().body("Box").unwrap().body.is_surface());
assert!((volume(&s, "Box") - 1000.0).abs() < 1e-9);
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&cold, "Box") - 1000.0).abs() < 1e-9);
// By default the gap heals, and a box's sides can't close its top: refused, the box kept.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Healed"}));
assert!(s.execute("face.delete", &json!({"faces": [[25, 5, 10]], "body": "Healed"})).is_err());
assert!(!s.world_state().body("Healed").unwrap().body.is_surface());
let all = json!([[25, 5, 10], [25, 5, 0], [25, 0, 5], [25, 10, 5], [20, 5, 5], [30, 5, 5]]);
assert!(s.execute("face.delete", &json!({"faces": all, "body": "Healed", "heal": false})).is_err());
}
/// The planned surface commands are registered but refuse to run: disabled with the
/// issue that tracks them, an error (never a panic) from every caller, and the design left
/// as it was.
+1 -1
View File
@@ -113,7 +113,7 @@ pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_kind::{FaceSurface, face_surface};
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, reverse_faces, stitch, thicken, trim, unstitch};
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, stitch, thicken, trim, unstitch};
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
+32
View File
@@ -247,6 +247,21 @@ pub fn reverse_faces(body: &Body, at: &[Vec3]) -> Result<Body> {
Ok(rebuilt_body(surface_body(faces), &[&body.solid], &rec))
}
/// A body with some of its faces (indices in its face order) taken away and the gap left open:
/// a surface body of the faces left. Deleting every face is refused. The history is over
/// `[body]`: each face left is the body's.
pub fn remove_faces(body: &Body, faces: &[usize]) -> Result<Body> {
body.require_brep("Delete Face")?;
if faces.is_empty() {
return Err(KernelError::Invalid("no faces given".into()));
}
let left: Vec<mt::Face> = body.solid.face_iter().enumerate().filter(|(i, _)| !faces.contains(i)).map(|(_, f)| f.clone()).collect();
if left.is_empty() {
return Err(KernelError::Invalid("that would delete every face of the body".into()));
}
Ok(rebuilt_body(surface_body(left), &[&body.solid], &Rebuild::default()))
}
/// Index of the face nearest a point (over its mesh triangles).
fn nearest_face(body: &Body, p: Vec3) -> Result<usize> {
let m = body.tessellate(body.size() * 2e-3)?;
@@ -807,6 +822,23 @@ mod tests {
assert!(reverse_faces(&box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap(), &[]).is_err());
}
/// A box's top taken away without healing: a five-face surface; patching the opening and
/// stitching closes the same box. Every face, or none, is refused.
#[test]
fn remove_faces_leaves_the_gap_open() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let top = nearest_face(&b, Vec3::new(5.0, 5.0, 10.0)).unwrap();
let open = remove_faces(&b, &[top]).unwrap();
assert!(open.is_surface() && open.face_count() == 5);
assert!(rel(measure(&open).unwrap().area, 500.0) < 1e-9);
let lid = patch_edges(&open, &[Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)])
.unwrap();
let back = stitch(&[&open, &lid], 1e-6).unwrap();
assert!(!back.is_surface() && rel(measure(&back).unwrap().volume, 1000.0) < 1e-9);
assert!(remove_faces(&b, &(0..6).collect::<Vec<_>>()).is_err());
assert!(remove_faces(&b, &[]).is_err());
}
#[test]
fn patch_and_thicken_a_square() {
let r = Region2 {