Delete Face (face.delete, and faces in the Delete selection): a timeline feature that removes faces and heals the gap from the neighbouring planes through kernel::delete_faces; a picked face brings the rest of its surface (both halves of a hole's wall); face picks are stored by persistent name; a face its neighbours can't close is refused with the kernel's reason

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-09 12:47:56 +00:00
1 parent 9219eb5dc1
commit 3db6119cc6
11 files changed
+117 -6

No files matched your search

+3 -1
View File
@@ -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);
+12 -1
View File
@@ -372,6 +372,14 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// 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<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<String>,
@@ -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),
+18
View File
@@ -2627,6 +2627,24 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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<usize> = 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;
+1
View File
@@ -246,6 +246,7 @@ impl FeatureKind {
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
+11 -1
View File
@@ -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<Value> {
let mut entities = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
let mut faces: Vec<(String, Vec<Vec3>)> = 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<Value> {
},
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<Value> {
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<String> = features.iter().map(u64::to_string).collect();
sub(s, "timeline.delete", json!({ "features": ids }))?;
+9
View File
@@ -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<Value> {
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
}
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) })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
+57
View File
@@ -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);
}
+2
View File
@@ -425,6 +425,8 @@ impl Session {
}
}
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
+1
View File
@@ -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};
+1 -1
View File
@@ -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<usize> {
pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
let size = b.size();
let tol = (size * 1e-7).max(1e-9) * 100.0;
+2 -2
View File
@@ -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);
}