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 <noreply@anthropic.com>
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

+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)]