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:
1 parent
9219eb5dc1
commit
3db6119cc6
11 files changed
+117
-6
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -246,6 +246,7 @@ impl FeatureKind {
|
||||
| FeatureKind::SheetConvert { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::DeleteFace { .. }
|
||||
| FeatureKind::BoundaryFill { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
|
||||
@@ -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 }))?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -425,6 +425,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user