Unstitch and Reverse Normal (#856)

Unstitch takes a body's faces out as surface bodies; Reverse Normal turns a surface body's faces over. Both are saved in format 18. Closes #766.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 16:45:31 -04:00
1 parent b702f0efde
commit 753b0a2eab
16 files changed
+322 -19

No files matched your search

@@ -241,7 +241,7 @@ fn user_defined_material_survives_save_and_reopen() {
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
run(&mut s, "file.save", json!({"path": path}));
assert!(s.doc.to_json().contains("\"solidcraft/17\""));
assert!(s.doc.to_json().contains(&format!("\"{}\"", solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT))));
run(&mut s, "file.new", json!({}));
run(&mut s, "doc.open", json!({"path": path}));
+87 -6
View File
@@ -66,14 +66,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "MODIFY")
.icon("untrim")
.params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"),
planned!("surface.unstitch", "Unstitch", "766")
CommandSpec::new("surface.unstitch", "Unstitch", unstitch)
.at("SURFACE", "MODIFY")
.icon("unstitch")
.params("planned: body; faces?: [[x,y,z]] (points on the faces to split off; default: every face its own surface body)"),
planned!("surface.reverse_normal", "Reverse Normal", "766")
.params("body; faces?: [[x,y,z]] (points on the faces to take out, each its own surface body; the rest stay together under the body's name; default: every face its own surface body). A solid comes apart into surfaces"),
CommandSpec::new("surface.reverse_normal", "Reverse Normal", reverse_normal)
.at("SURFACE", "MODIFY")
.icon("reverse_normal")
.params("planned: body (a surface); faces?: [[x,y,z]] (default: all of them)"),
.params("body (a surface); faces?: [[x,y,z]] (points on the faces to turn over; default: all of them)"),
planned!("surface.fillet", "Surface Fillet", "769")
.at("SURFACE", "MODIFY")
.icon("surface_fillet")
@@ -110,6 +110,29 @@ fn surface_extrude(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent })
}
/// A body by name, and points on some of its faces (none: all of them).
fn body_faces(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<solidcraft_geom::Vec3>)> {
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let faces: Vec<solidcraft_geom::Vec3> = p.get("faces").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if faces.len() > 10_000 {
return Err(bad(cmd, "too many faces"));
}
Ok((body, faces))
}
fn unstitch(s: &mut Session, p: &Value) -> Result<Value> {
let (body, faces) = body_faces(s, p, "surface.unstitch")?;
add_feature(s, p, FeatureKind::Unstitch { body, faces })
}
fn reverse_normal(s: &mut Session, p: &Value) -> Result<Value> {
let (body, faces) = body_faces(s, p, "surface.reverse_normal")?;
add_feature(s, p, FeatureKind::ReverseNormal { body, faces })
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
@@ -255,6 +278,66 @@ mod tests {
assert!((volume(&s, "Walls") - 180.0).abs() < 1e-9);
}
/// Unstitch a box: six surface bodies whose faces keep the names they had on the box,
/// stitched back into a solid of the same volume; two faces taken out leave the other four
/// under the box's name; undo, redo and reopening.
#[test]
fn unstitch_a_box_and_stitch_it_back() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Box"}));
let names_before: Vec<String> = solidcraft_doc::naming::face_names(s.model.state().body("Box").unwrap()).to_vec();
run(&mut s, "surface.unstitch", json!({"body": "Box"}));
let st = s.world_state();
assert!(st.body("Box").is_none());
let pieces: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(pieces.len(), 6);
let mut names_after: Vec<String> = Vec::new();
for n in &pieces {
let b = st.body(n).unwrap();
assert!(b.body.is_surface() && b.body.face_count() == 1);
names_after.extend(solidcraft_doc::naming::face_names(s.model.state().body(n).unwrap()).iter().cloned());
}
let (mut a, mut b) = (names_before.clone(), names_after);
a.sort();
b.sort();
assert_eq!(a, b, "the faces keep their names");
run(&mut s, "surface.stitch", json!({"bodies": pieces}));
let st = s.world_state();
let back = st.bodies.first().unwrap();
assert!(!back.body.is_surface());
assert!((solidcraft_kernel::measure(&back.body).unwrap().volume - 1000.0).abs() < 1e-9);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().body("Box").is_some());
run(&mut s, "surface.unstitch", json!({"body": "Box", "faces": [[5, 5, 10], [5, 5, 0]]}));
let st = s.world_state();
assert_eq!(st.bodies.len(), 3);
assert_eq!(st.body("Box").unwrap().body.face_count(), 4);
let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(cold.world_state().bodies.len(), 3);
assert!(s.execute("surface.unstitch", &json!({"body": "Nope"})).is_err());
assert!(s.execute("surface.unstitch", &json!({})).is_err());
}
/// A patch turned over thickens the other way; a solid is not turned over.
#[test]
fn reverse_normal_turns_the_thicken_side() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": 2, "body_name": "Up"}));
let up = solidcraft_kernel::measure(&s.world_state().body("Up").unwrap().body).unwrap().bbox;
run(&mut s, "edit.undo", json!({}));
run(&mut s, "surface.reverse_normal", json!({"body": "Sheet"}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": 2, "body_name": "Down"}));
let down = solidcraft_kernel::measure(&s.world_state().body("Down").unwrap().body).unwrap().bbox;
assert!((up.max.z - down.max.z - 2.0).abs() < 1e-9 && (up.min.z - down.min.z - 2.0).abs() < 1e-9, "{up:?} vs {down:?}");
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [20, 0, 0], "body_name": "Cube"}));
assert!(s.execute("surface.reverse_normal", &json!({"body": "Cube"})).is_err());
}
/// The planned surface commands are registered but refuse to run: disabled with the
/// issue that tracks them, an error (never a panic) from every caller, and the design left
/// as it was.
@@ -269,8 +352,6 @@ mod tests {
"surface.boundary",
"surface.blend",
"surface.untrim",
"surface.unstitch",
"surface.reverse_normal",
"surface.fillet",
"inspect.continuity",
];