diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 603be115..5dade15f 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -337,6 +337,7 @@ impl FeatureKind { | FeatureKind::MeshConvert { .. } | FeatureKind::BodyTransfer { .. } => {} FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), + FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => faces.iter_mut().for_each(pt), FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { map_plane_ref(m, plane); if let Some(t) = tool { diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index d9a6099f..0add07c0 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -321,6 +321,8 @@ impl FeatureKind { | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } + | K::Unstitch { body, .. } + | K::ReverseNormal { body, .. } | K::MeshConvert { body, .. } => mapped_name(body, bodies), _ => {} } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index e9919394..a5c6647e 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -937,6 +937,19 @@ pub enum FeatureKind { curves: Vec, extent: Extent, }, + /// A body taken apart: each face at `faces` (every face when empty) its own surface body, + /// the faces left one surface body under the body's name. A solid comes apart into surfaces. + Unstitch { + body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + faces: Vec, + }, + /// A surface body with the faces at `faces` (every face when empty) turned over. + ReverseNormal { + body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + faces: Vec, + }, /// A planar surface body extended outward by `distance` past the edges through `edges`. SurfaceExtend { body: String, @@ -1286,6 +1299,8 @@ impl FeatureKind { | K::Thicken { .. } | K::SurfaceExtend { .. } | K::SurfaceTrim { .. } + | K::Unstitch { .. } + | K::ReverseNormal { .. } | K::Move { .. } | K::Import { .. } | K::MeshImport { .. } @@ -1374,6 +1389,8 @@ impl FeatureKind { FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", FeatureKind::SurfaceExtend { .. } => "ExtendFeature", + FeatureKind::Unstitch { .. } => "UnstitchFeature", + FeatureKind::ReverseNormal { .. } => "ReverseNormalFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", FeatureKind::MeshConvert { .. } => "MeshConvertFeature", @@ -1441,6 +1458,8 @@ impl FeatureKind { FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", FeatureKind::SurfaceExtend { .. } => "Extend", + FeatureKind::Unstitch { .. } => "Unstitch", + FeatureKind::ReverseNormal { .. } => "ReverseNormal", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", FeatureKind::MeshConvert { .. } => "ConvertMesh", @@ -1669,6 +1688,7 @@ impl FeatureKind { FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), + FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {} FeatureKind::Patch { .. } => {} FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {} } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index f5799db5..1c0a4826 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3543,7 +3543,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Thicken { .. } | FeatureKind::SurfaceTrim { .. } | FeatureKind::SurfaceExtend { .. } - | FeatureKind::SurfaceExtrude { .. } => surface::eval(doc, vals, f, st), + | FeatureKind::SurfaceExtrude { .. } + | FeatureKind::Unstitch { .. } + | FeatureKind::ReverseNormal { .. } => surface::eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 513be987..71541f3c 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -1,5 +1,5 @@ -//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend and Surface Extrude -//! (kernel `surfaces`). +//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude, +//! Unstitch and Reverse Normal (kernel `surfaces`). use super::*; use solidcraft_geom::Seg2; @@ -113,6 +113,37 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st.bodies.push(ModelBody::new(name, body, f.id)); Ok(()) } + FeatureKind::Unstitch { body, faces } => { + let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let (pieces, rest) = kernel::unstitch(&mb.body, faces)?; + let source = mb.traced(); + // The faces left keep the body's name; each face taken out is a new surface body. + match rest { + Some(rest) => { + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::traced_new(mb.name.clone(), source.then(rest), mb.feature); + } + } + None => { + st.bodies.remove(i); + } + } + for (k, piece) in pieces.into_iter().enumerate() { + let name = new_name(st, f, k); + st.bodies.push(ModelBody::traced_new(name, source.then(piece), f.id)); + } + Ok(()) + } + FeatureKind::ReverseNormal { body, faces } => { + let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let turned = mb.traced().then(kernel::reverse_faces(&mb.body, faces)?); + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::traced_new(mb.name, turned, mb.feature); + } + Ok(()) + } _ => Err(DocError::Invalid("not a surface feature".into())), } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 9f812f64..d97e984b 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -104,13 +104,17 @@ //! Construction Geometries); a sketch that shows its construction geometry saves as before. //! A `SurfaceExtrude` feature (Surface Extrude: `sketch`, optional `profiles`, `curves` and //! `extent`, walls only) is first saved in 17; 0.8.0 would refuse the unknown feature. +//! - 18: surface features 0.9.0 doesn't know: `Unstitch` (a body's faces taken out as surface +//! bodies) and `ReverseNormal` (a surface body's faces turned over), each a `body` and +//! optional `faces` points. Version 17 designs need no transformation; the version stamp makes +//! 0.9.0 (which reads up to 17) ask for an update instead of meeting a feature it can't read. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 17; +pub const FORMAT: u32 = 18; pub fn format_string(n: u32) -> String { format!("solidcraft/{n}") @@ -127,7 +131,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 16] = [ +const JSON_STEPS: [JsonStep; 17] = [ |_| Ok(()), v2_to_v3, v3_to_v4, @@ -144,6 +148,7 @@ const JSON_STEPS: [JsonStep; 16] = [ |_| Ok(()), |_| Ok(()), |_| Ok(()), + |_| Ok(()), ]; /// Stored face and edge names were worked out from geometry before format 14; they get the diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 88a55d4b..3b51e6e2 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -277,6 +277,7 @@ impl FeatureKind { FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)), FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v), FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)), + FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {} FeatureKind::Patch { .. } => {} FeatureKind::Combine { .. } | FeatureKind::SheetBase { .. } diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs index b9dc1723..611b885d 100644 --- a/crates/engine/src/cmd/component_properties_tests.rs +++ b/crates/engine/src/cmd/component_properties_tests.rs @@ -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})); diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index e7be55d0..1952a11e 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -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 { 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)> { + 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 = 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 { + 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 { + 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> { 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 = 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 = st.bodies.iter().map(|b| b.name.clone()).collect(); + assert_eq!(pieces.len(), 6); + let mut names_after: Vec = 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", ]; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 29a2597b..15efb822 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -112,7 +112,7 @@ pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_kind::{FaceSurface, face_surface}; -pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, stitch, thicken, trim}; +pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, reverse_faces, stitch, thicken, trim, unstitch}; pub use sweep_path::sweep_path; pub use topo::{CylinderFace, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 54c9c258..c57c67bb 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -186,6 +186,67 @@ pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result { Ok(rebuilt_body(copied, &[&body.solid], &Rebuild::default())) } +/// The faces picked by points on them (each once, in the order picked); every face when no +/// point is given. +fn picked_faces(body: &Body, at: &[Vec3]) -> Result> { + if at.len() > 10_000 { + return Err(KernelError::Invalid("too many faces picked".into())); + } + if at.is_empty() { + return Ok((0..body.face_count()).collect()); + } + let mut chosen = Vec::new(); + for p in at { + let i = nearest_face(body, *p)?; + if !chosen.contains(&i) { + chosen.push(i); + } + } + Ok(chosen) +} + +/// A body's faces taken apart: each picked face (by points on it; every face when none are +/// given) its own surface body, and the faces left, if any, one surface body. A solid comes +/// apart into surfaces. Histories are over `[body]`: each face is the body's. +pub fn unstitch(body: &Body, at: &[Vec3]) -> Result<(Vec, Option)> { + body.require_brep("unstitch")?; + let chosen = picked_faces(body, at)?; + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + if faces.len() < 2 { + return Err(KernelError::Invalid("the body has one face: nothing to unstitch".into())); + } + let piece = |picked: Vec| rebuilt_body(surface_body(picked), &[&body.solid], &Rebuild::default()); + let pieces: Vec = chosen.iter().filter_map(|i| faces.get(*i)).map(|f| piece(vec![(*f).clone()])).collect(); + let rest: Vec = faces.iter().enumerate().filter(|(i, _)| !chosen.contains(i)).map(|(_, f)| (*f).clone()).collect(); + Ok((pieces, (!rest.is_empty()).then(|| piece(rest)))) +} + +/// A surface body with the picked faces (by points on them; every face when none are given) +/// turned over: their normals point the other way. A solid is refused (its faces face out). +/// The history is over `[body]`: each face is the body's, turned over or not. +pub fn reverse_faces(body: &Body, at: &[Vec3]) -> Result { + body.require_brep("reverse normal")?; + if !body.is_surface() { + return Err(KernelError::Invalid("a solid's faces face out: unstitch it into surfaces first".into())); + } + let chosen = picked_faces(body, at)?; + let mut rec = Rebuild::default(); + let faces: Vec = body + .solid + .face_iter() + .enumerate() + .map(|(i, f)| { + if !chosen.contains(&i) { + return f.clone(); + } + let turned = f.inverse(); + rec.whole(&turned, f); + turned + }) + .collect(); + Ok(rebuilt_body(surface_body(faces), &[&body.solid], &rec)) +} + /// Index of the face nearest a point (over its mesh triangles). fn nearest_face(body: &Body, p: Vec3) -> Result { let m = body.tessellate(body.size() * 2e-3)?; @@ -710,6 +771,42 @@ mod tests { assert!(extrude_sheet(&Plane::XY, &[nan], 0.0, 1.0).is_err()); } + /// A box taken apart into its six faces (each a 10 × 10 surface) and stitched back into + /// the same solid; two picked faces come off on their own, the other four stay together; + /// a one-face body has nothing to take apart. + #[test] + fn unstitch_a_box_and_stitch_it_back() { + let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); + let (pieces, rest) = unstitch(&b, &[]).unwrap(); + assert!(rest.is_none() && pieces.len() == 6); + for p in &pieces { + assert!(p.is_surface() && p.face_count() == 1 && rel(measure(p).unwrap().area, 100.0) < 1e-9); + } + let back = stitch(&pieces.iter().collect::>(), 1e-6).unwrap(); + assert!(!back.is_surface() && rel(measure(&back).unwrap().volume, 1000.0) < 1e-9); + let (two, rest) = unstitch(&b, &[Vec3::new(5.0, 5.0, 10.0), Vec3::new(5.0, 5.0, 0.0)]).unwrap(); + let rest = rest.unwrap(); + assert!(two.len() == 2 && rest.is_surface() && rest.face_count() == 4); + assert!(unstitch(&two[0], &[]).is_err()); + } + + /// A patch thickened along its normal grows up; turned over, the same thickness grows down. + /// A solid's faces are not turned over. + #[test] + fn reverse_faces_turns_the_thicken_side() { + let r = Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]), + holes: vec![], + }; + let s = patch_region(&Plane::XY, &r).unwrap(); + let up = measure(&thicken(&s, 2.0, false).unwrap()).unwrap().bbox; + let turned = reverse_faces(&s, &[]).unwrap(); + assert!(turned.is_surface() && turned.face_count() == 1); + let down = measure(&thicken(&turned, 2.0, false).unwrap()).unwrap().bbox; + assert!((up.min.z - down.min.z - 2.0).abs() < 1e-9 && (up.max.z - down.max.z - 2.0).abs() < 1e-9, "{up:?} vs {down:?}"); + assert!(reverse_faces(&box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap(), &[]).is_err()); + } + #[test] fn patch_and_thicken_a_square() { let r = Region2 { diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 00b82f28..8cd348fa 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -20,6 +20,8 @@ pub enum Surf { direction: usize, }, Patch, + Unstitch, + ReverseNormal, Stitch { tolerance: String, }, @@ -43,6 +45,8 @@ impl Surf { match self { Surf::Extrude { .. } => "SURFACE EXTRUDE", Surf::Patch => "PATCH", + Surf::Unstitch => "UNSTITCH", + Surf::ReverseNormal => "REVERSE NORMAL", Surf::Stitch { .. } => "STITCH", Surf::ConvertMesh { .. } => "CONVERT MESH", Surf::Thicken { .. } => "THICKEN", @@ -69,6 +73,8 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], + Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], + Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)], Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)], Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)], Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)], @@ -87,6 +93,8 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 }, "surface.patch" => Surf::Patch, + "surface.unstitch" => Surf::Unstitch, + "surface.reverse_normal" => Surf::ReverseNormal, "surface.stitch" => Surf::Stitch { tolerance: String::new() }, "mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() }, "surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 }, @@ -136,7 +144,7 @@ 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::Patch | Surf::Trim | Surf::SplitFace => {} + Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal => {} } enter } @@ -153,6 +161,20 @@ fn bodies(inputs: &[SelInput], i: usize) -> Vec { items(inputs, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect() } +/// One body and points on the picked faces of it (none: a whole body picked). +fn body_and_faces(picks: &[Sel]) -> Result<(String, Vec), String> { + let body_of = |x: &Sel| match x { + Sel::Body { name } | Sel::Face { body: name, .. } => Some(name.clone()), + _ => None, + }; + let body = picks.iter().find_map(body_of).ok_or("pick a body or some of its faces")?; + if picks.iter().any(|x| body_of(x).as_ref() != Some(&body)) { + return Err("pick faces of one body".into()); + } + let faces = picks.iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect(); + Ok((body, faces)) +} + /// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other /// face as {body, point} (its surface extended). fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> { @@ -210,6 +232,12 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + need(0, "a body or some of its faces")?; + let (body, faces) = body_and_faces(items(inputs, 0))?; + let cmd = if matches!(k, Surf::Unstitch) { "surface.unstitch" } else { "surface.reverse_normal" }; + (cmd, json!({"body": body, "faces": faces})) + } Surf::Stitch { tolerance } => { need(0, "the surfaces to stitch")?; let mut p = json!({ "bodies": bodies(inputs, 0) }); @@ -315,6 +343,14 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec { + let sels: Vec = if faces.is_empty() { + vec![Sel::Body { name: body.clone() }] + } else { + faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect() + }; + (if matches!(kind, FeatureKind::Unstitch { .. }) { Surf::Unstitch } else { Surf::ReverseNormal }, vec![sels]) + } FeatureKind::Stitch { bodies, tolerance } => ( Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } }, vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()], diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index eb3d4616..7e1a367f 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -829,6 +829,31 @@ fn surface_extrude_dialog_takes_profiles_and_curves() { assert_eq!(back.inputs[0].items.len(), 2); } +#[test] +fn unstitch_and_reverse_normal_dialogs_take_a_body_or_faces() { + // A whole box picked: Unstitch sends the body alone; two of its faces: their points. + let mut app = app_with(json!([{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Box"}}])); + let mut d = start(&mut app, vec![], "surface.unstitch"); + d.inputs[0].items = vec![Sel::Body { name: "Box".into() }]; + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((id.as_str(), &p["body"], &p["faces"]), ("surface.unstitch", &json!("Box"), &json!([])), "{p}"); + let top = Vec3::new(5.0, 5.0, 10.0); + d.inputs[0].items = vec![Sel::Face { body: "Box".into(), index: 0, point: top }]; + let (_, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!(p["faces"], json!([[5.0, 5.0, 10.0]]), "{p}"); + app.session.execute("surface.unstitch", &p).unwrap(); + assert_eq!(app.session.model.state().bodies.len(), 2); + let fid = app.session.doc.features.last().unwrap().id; + assert!(matches!(crate::dialogs::for_feature(&app, fid, None).unwrap().kind, Kind::Surface(_))); + app.dialog = None; + let sheet = app.session.model.state().bodies.last().unwrap().name.clone(); + let mut d = start(&mut app, vec![], "surface.reverse_normal"); + d.inputs[0].items = vec![Sel::Body { name: sheet.clone() }]; + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((id.as_str(), &p["body"]), ("surface.reverse_normal", &json!(sheet)), "{p}"); + app.session.execute(&id, &p).unwrap(); +} + #[test] fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() { // A box with its top cut away by a sideways cylinder: its rim is a closed loop of four diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index bf5dcb41..a1238281 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -12,6 +12,8 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Sketch { .. } => "sketch", FeatureKind::Extrude { .. } => "extrude", FeatureKind::SurfaceExtrude { .. } => "surface_extrude", + FeatureKind::Unstitch { .. } => "unstitch", + FeatureKind::ReverseNormal { .. } => "reverse_normal", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", diff --git a/docs/design-container.md b/docs/design-container.md index da9fab6d..d5d8bf08 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/17`). +`document.json` with the versioned parametric document (currently `solidcraft/18`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads. diff --git a/docs/features.md b/docs/features.md index 0cc6afdd..198509f1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **336 commands.** Planned (registered, not built yet): 12. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **338 commands.** Planned (registered, not built yet): 10. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (98) @@ -145,7 +145,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (6, 11 planned) +## Surfaces (8, 9 planned) | Command | Id | Toolbar | |---|---|---| @@ -163,8 +163,8 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE | | Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE | | Untrim *(planned: [#761](https://github.com/bherbruck/solidcraft/issues/761))* | `surface.untrim` | SURFACE › MODIFY | -| Unstitch *(planned: [#766](https://github.com/bherbruck/solidcraft/issues/766))* | `surface.unstitch` | SURFACE › MODIFY | -| Reverse Normal *(planned: [#766](https://github.com/bherbruck/solidcraft/issues/766))* | `surface.reverse_normal` | SURFACE › MODIFY | +| Unstitch | `surface.unstitch` | SURFACE › MODIFY | +| Reverse Normal | `surface.reverse_normal` | SURFACE › MODIFY | | Surface Fillet *(planned: [#769](https://github.com/bherbruck/solidcraft/issues/769))* | `surface.fillet` | SURFACE › MODIFY | ## Construction geometry (19)