Untrim: holes removed and a face restored to its natural bounds (#1150)

Adds surface.untrim (#761); the SurfaceUntrim feature is first saved in format 21.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-14 20:29:25 -04:00
1 parent 734f69ad72
commit 16c49c3e86
25 files changed
+961 -18

No files matched your search

+62 -1
View File
@@ -1,5 +1,5 @@
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
//! Loft, Patch, Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature.
//! Loft, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
@@ -49,6 +49,12 @@ pub enum Surf {
distance: String,
},
Trim,
/// `holes`: an index into [`UNTRIM_HOLES`].
Untrim {
holes: usize,
outer: bool,
in_place: bool,
},
SplitFace,
}
@@ -68,6 +74,7 @@ impl Surf {
Surf::Thicken { .. } => "THICKEN",
Surf::Extend { .. } => "EXTEND",
Surf::Trim => "TRIM",
Surf::Untrim { .. } => "UNTRIM",
Surf::SplitFace => "SPLIT FACE",
}
}
@@ -83,6 +90,9 @@ impl Surf {
}
}
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
/// The sides a surface extrude goes to (labels, and the `direction` parameter).
const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric", "symmetric"), ("Flipped", "negative")];
@@ -105,6 +115,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
SelInput::new("Trimming tool", PLANES | FACES, false),
SelInput::new("Side to keep", FACES, false),
],
Surf::Untrim { .. } => vec![SelInput::new("Face", FACES, false), SelInput::new("Holes (edges on them)", EDGES, true)],
Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)],
}
}
@@ -125,6 +136,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
"surface.trim" => Surf::Trim,
"surface.untrim" => Surf::Untrim { holes: 0, outer: false, in_place: false },
"solid.split_face" => Surf::SplitFace,
_ => return None,
};
@@ -187,6 +199,17 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction);
ui.end_row();
}
Surf::Untrim { holes, outer, in_place } => {
row_label(ui, "Holes");
combo(ui, "ut_holes", &UNTRIM_HOLES, holes);
ui.end_row();
row_label(ui, "Restore outer boundary");
ui.checkbox(outer, "");
ui.end_row();
row_label(ui, "In place (surfaces)");
ui.checkbox(in_place, "");
ui.end_row();
}
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
}
enter
@@ -402,6 +425,33 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
p[key] = t;
("surface.trim", p)
}
Surf::Untrim { holes, outer, in_place } => {
need(0, "the face to untrim")?;
let (body, point) = items(inputs, 0)
.first()
.and_then(|x| if let Sel::Face { body, point, .. } = x { Some((body.clone(), pt(*point))) } else { None })
.ok_or("pick the face to untrim")?;
let mut p = json!({"face": {"body": body, "point": point}});
match holes {
0 => p["holes"] = json!("all"),
1 => {
let picked: Vec<Value> =
items(inputs, 1).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
if picked.is_empty() {
return Err("pick edges of the holes to remove".into());
}
p["holes"] = json!(picked);
}
_ => p["holes"] = json!([]),
}
if *outer {
p["outer"] = json!(true);
}
if *in_place {
p["in_place"] = json!(true);
}
("surface.untrim", p)
}
Surf::SplitFace => {
need(0, "the faces to split")?;
need(1, "the splitting tool")?;
@@ -519,6 +569,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
crate::dialogs::face_sel(s, *keep).into_iter().collect(),
],
),
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
let (index, picked) = match holes {
None => (0, Vec::new()),
Some(points) if points.is_empty() => (2, Vec::new()),
Some(points) => (1, points.iter().filter_map(edge).collect()),
};
(
Surf::Untrim { holes: index, outer: *outer, in_place: *in_place },
vec![crate::dialogs::face_sel(s, face.point).into_iter().collect(), picked],
)
}
FeatureKind::SplitFace { faces, plane, tool, .. } => (
Surf::SplitFace,
vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()],
+35
View File
@@ -968,6 +968,41 @@ fn surface_loft_dialog_makes_a_section_per_sketch() {
assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]);
}
#[test]
fn untrim_dialog_takes_a_face_its_holes_and_the_outer_boundary() {
// A 40 × 20 × 2 plate with three holes: its top untrimmed (every hole) is the whole 800 mm²
// rectangle; one hole picked by an edge; Edit Feature brings the picks and options back.
let mut app = app_with(json!([
{"command": "solid.box", "params": {"length": 40, "width": 20, "height": 2, "body_name": "Plate"}},
{"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]}},
{"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [20, 10, -1], "operation": "cut", "targets": ["Plate"]}},
{"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [30, 10, -1], "operation": "cut", "targets": ["Plate"]}}
]));
let mut d = start(&mut app, vec![], "surface.untrim");
assert!(apply_commands(&app, &d).is_err(), "no face yet");
d.inputs[0].items = vec![Sel::Face { body: "Plate".into(), index: 0, point: Vec3::new(5.0, 5.0, 2.0) }];
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!((id.as_str(), &p["face"], &p["holes"]), ("surface.untrim", &json!({"body": "Plate", "point": [5.0, 5.0, 2.0]}), &json!("all")), "{p}");
app.session.execute(&id, &p).unwrap();
let m = app.session.model.state().bodies.last().unwrap().mesh().measure();
assert!((m.area - 800.0).abs() < 1e-6, "{}", m.area);
app.session.execute("edit.undo", &json!({})).unwrap();
// Picked holes: none picked is refused; an edge of the middle hole sends its point.
if let Kind::Surface(crate::dialogs_surface::Surf::Untrim { holes, outer, .. }) = &mut d.kind {
*holes = 1;
*outer = true;
}
assert!(apply_commands(&app, &d).is_err(), "no holes picked");
d.inputs[1].items = vec![Sel::Edge { body: "Plate".into(), index: 0, point: Vec3::new(22.0, 10.0, 2.0) }];
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!((&p["holes"], &p["outer"]), (&json!([[22.0, 10.0, 2.0]]), &json!(true)), "{p}");
app.session.execute(&id, &p).unwrap();
let fid = app.session.doc.features.last().unwrap().id;
let back = crate::dialogs::for_feature(&app, fid, None).unwrap();
assert!(matches!(back.kind, Kind::Surface(crate::dialogs_surface::Surf::Untrim { holes: 1, outer: true, in_place: false })));
assert_eq!((back.inputs[0].items.len(), back.inputs[1].items.len()), (1, 1));
}
#[test]
fn offset_surface_dialog_takes_faces_a_distance_and_the_chain() {
// A box top picked, moved up 5: a 10 × 10 square at z = 15; Edit Feature brings it back.
+1
View File
@@ -19,6 +19,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
FeatureKind::SurfaceLoft { .. } => "surface_loft",
FeatureKind::SurfaceOffset { .. } => "surface_offset",
FeatureKind::SurfaceUntrim { .. } => "untrim",
FeatureKind::Revolve { .. } => "revolve",
FeatureKind::Fillet { .. } => "fillet",
FeatureKind::Chamfer { .. } => "chamfer",