diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 5c6b1c31..14615691 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -173,7 +173,9 @@ impl FeatureKind { } } FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt), - FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt), + FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } => { + faces.iter_mut().for_each(pt) + } FeatureKind::Draft { faces, neutral, pull, .. } => { faces.iter_mut().for_each(pt); map_plane_ref(m, neutral); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a4870fa5..83f22302 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -372,6 +372,14 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, + /// Remove faces (at the given points) and heal the gap from the neighbouring planes, as when + /// a fillet, chamfer, hole or boss is taken off. A picked face brings the rest of its surface + /// (both halves of a hole's wall). + DeleteFace { + faces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// A box around bodies, grown by a margin. BoundingSolid { bodies: Vec, @@ -931,6 +939,7 @@ impl FeatureKind { FeatureKind::Move { .. } => "MoveFeature", FeatureKind::Scale { .. } => "ScaleFeature", FeatureKind::OffsetFace { .. } => "OffsetFacesFeature", + FeatureKind::DeleteFace { .. } => "DeleteFaceFeature", FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", FeatureKind::Pipe { .. } => "PipeFeature", FeatureKind::Emboss { .. } => "EmbossFeature", @@ -991,6 +1000,7 @@ impl FeatureKind { FeatureKind::Move { .. } => "Move", FeatureKind::Scale { .. } => "Scale", FeatureKind::OffsetFace { .. } => "OffsetFace", + FeatureKind::DeleteFace { .. } => "DeleteFace", FeatureKind::BoundingSolid { .. } => "BoundingSolid", FeatureKind::Pipe { .. } => "Pipe", FeatureKind::Emboss { .. } => "Emboss", @@ -1031,6 +1041,7 @@ impl FeatureKind { FeatureKind::Shell { faces, .. } | FeatureKind::Draft { faces, .. } | FeatureKind::OffsetFace { faces, .. } + | FeatureKind::DeleteFace { 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], @@ -1168,7 +1179,7 @@ impl FeatureKind { v.extend(depth.iter().map(String::as_str)); } FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v), - FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } => {} + FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {} FeatureKind::Stitch { tolerance, .. } => v.push(tolerance), FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 8e6c6461..113fdd30 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2627,6 +2627,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } + FeatureKind::DeleteFace { faces, body } => { + 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!("Delete Face: no face of {} at {p:?}", mb.name)))?; + if !picked.contains(&fi) { + picked.push(fi); + } + } + let all = kernel::with_same_surface(&mb.body, &picked); + let nb = kernel::delete_faces(&mb.body, &all)?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, nb, mb.feature); + } + Ok(()) + } FeatureKind::BoundingSolid { bodies, margin } => { let m = val(vals, margin, Kind::Length)?; let mut bb = solvecraft_geom::Aabb3::EMPTY; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 7e58da79..e02e41d2 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -246,6 +246,7 @@ impl FeatureKind { | FeatureKind::SheetConvert { .. } | FeatureKind::Align { .. } | FeatureKind::Remove { .. } + | FeatureKind::DeleteFace { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index 8b77caf9..6450cd4a 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use solvecraft_doc::FeatureKind; +use solvecraft_geom::Vec3; use super::CommandSpec; use crate::params::{bad, bool_}; @@ -63,6 +64,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let mut entities = Vec::new(); let mut features: Vec = Vec::new(); let mut bodies: Vec = Vec::new(); + let mut faces: Vec<(String, Vec)> = Vec::new(); for x in &items { match x { Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()), @@ -78,7 +80,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { }, Sel::Axis { .. } => skip("the origin axes can't be deleted".into()), Sel::Body { name } => bodies.push(name.clone()), - Sel::Face { .. } => skip("Delete Face: not available yet".into()), + Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) { + Some((_, ps)) => ps.push(*point), + None => faces.push((body.clone(), vec![*point])), + }, Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()), Sel::Profile { .. } => skip("a profile goes with its sketch".into()), } @@ -93,6 +98,11 @@ fn apply(s: &mut Session, p: &Value) -> Result { done = true; } } + // Faces of a body that is going anyway are left to it; the rest become Delete Face features. + for (body, points) in faces.iter().filter(|(b, _)| !bodies.contains(b)) { + sub(s, "face.delete", json!({ "faces": points, "body": body }))?; + done = true; + } if !features.is_empty() { let ids: Vec = features.iter().map(u64::to_string).collect(); sub(s, "timeline.delete", json!({ "features": ids }))?; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index f2f950b9..25047029 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -66,6 +66,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "MODIFY") .icon("scale") .params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"), + 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 (a fillet, chamfer, hole or boss taken off)"), CommandSpec::new("solid.offset_face", "Offset Face", offset_face) .at("SOLID", "MODIFY") .icon("offset_face") @@ -1188,6 +1192,11 @@ fn scale(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors }) } +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) }) +} + fn offset_face(s: &mut Session, p: &Value) -> Result { let cmd = "solid.offset_face"; let faces = face_points(p, cmd)?; diff --git a/crates/engine/src/delete_face_tests.rs b/crates/engine/src/delete_face_tests.rs new file mode 100644 index 00000000..845b7121 --- /dev/null +++ b/crates/engine/src/delete_face_tests.rs @@ -0,0 +1,57 @@ +//! Delete Face: a fillet, a hole and a chamfer taken off, healed from the neighbouring planes. + +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}"), + } +} + +fn volume(s: &mut Session, b: &str) -> f64 { + run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0]["volume_mm3"].as_f64().unwrap_or(0.0) +} + +#[test] +fn delete_a_fillet_face() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"})); + run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 20]], "radius": 5})); + assert!(volume(&mut s, "B") < 24000.0 - 1.0); + // A point on the fillet's surface: 45° round from its axis (35, ·, 15). + let h = 5.0 * std::f64::consts::FRAC_1_SQRT_2; + let r = run(&mut s, "face.delete", json!({"faces": [[35.0 + h, 15, 15.0 + h]]})); + assert!(r["feature"].is_u64(), "{r}"); + assert!((volume(&mut s, "B") - 24000.0).abs() < 1e-3 * 24000.0, "{}", volume(&mut s, "B")); + // Undo-able as a timeline feature, and named after its kind. + let f = s.doc.features.last().unwrap(); + assert_eq!(f.kind.type_name(), "DeleteFaceFeature"); +} + +/// A through hole picked in the Delete selection: both halves of its wall go, and the plate +/// closes up. +#[test] +fn delete_a_hole_through_the_selection() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "body_name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"base": [25, 15, -1], "radius": 4, "height": 12, "body_name": "Drill"})); + run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Drill"], "operation": "cut"})); + let v = volume(&mut s, "Plate"); + assert!(v < 15000.0 - 100.0, "{v}"); + run(&mut s, "selection.delete", json!({"items": [{"type": "face", "body": "Plate", "index": 0, "point": [29, 15, 5]}]})); + assert!((volume(&mut s, "Plate") - 15000.0).abs() < 1e-3 * 15000.0, "{}", volume(&mut s, "Plate")); +} + +/// A face whose neighbours can't close the gap is refused, and the design is unchanged. +#[test] +fn a_box_face_cannot_be_deleted() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); + let n = s.doc.features.len(); + let e = s.execute("face.delete", &json!({"faces": [[5, 5, 10]]})).unwrap_err().to_string(); + assert!(e.contains("Delete Face"), "{e}"); + assert_eq!(s.doc.features.len(), n); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9002a445..287cd547 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -425,6 +425,8 @@ impl Session { } } +#[cfg(test)] +mod delete_face_tests; #[cfg(test)] mod extent_tests; #[cfg(test)] diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 82654461..df2a8a5c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -56,6 +56,7 @@ pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step}; pub use iges_out::iges_export_bodies; pub use measure::{BodyMeasure, measure}; pub use meshbody::{MAX_TRIANGLES, mesh_body}; +pub use offset::with_same_surface; pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index d8eb0769..b0b371e5 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -747,7 +747,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom: /// The chosen faces and every face on the same surface joined to them (the halves of a hole's /// wall), as face indices. -pub(crate) fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { +pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec { let faces: Vec = b.solid.face_iter().cloned().collect(); let size = b.size(); let tol = (size * 1e-7).max(1e-9) * 100.0; diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 3534fc05..a416fab2 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -231,11 +231,11 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() { assert!(app.session.doc.features.len() < n); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.doc.features.len(), n); - // A face alone: not available yet, nothing changes. + // A face its neighbours can't close (a side of the plate): refused, nothing changes. let b0 = app.session.model.state().bodies[0].name.clone(); app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap(); crate::delete::delete_selection(&mut app); - assert!(app.status.as_ref().is_some_and(|s| s.0.contains("not available yet")), "{:?}", app.status); + assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status); assert_eq!(app.session.doc.features.len(), n); }