diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a5c6647e..8b023af8 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -607,6 +607,10 @@ pub enum FeatureKind { faces: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, + /// 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() } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index fc31eb75..db8d4fd1 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -4134,7 +4134,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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 = Vec::new(); @@ -4146,7 +4146,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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); } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 94b505c8..f251f5f1 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -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; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 6c5f3128..9308f6e4 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { fn delete_face(s: &mut Session, p: &Value) -> Result { 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 { diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 1952a11e..9bb83691 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -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. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2818b3f0..5d07a10a 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index c57c67bb..4cf04708 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -247,6 +247,21 @@ pub fn reverse_faces(body: &Body, at: &[Vec3]) -> Result { 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.require_brep("Delete Face")?; + if faces.is_empty() { + return Err(KernelError::Invalid("no faces given".into())); + } + let left: Vec = 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 { 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::>()).is_err()); + assert!(remove_faces(&b, &[]).is_err()); + } + #[test] fn patch_and_thicken_a_square() { let r = Region2 {