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:
1 parent
b702f0efde
commit
753b0a2eab
16 files changed
+322
-19
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -937,6 +937,19 @@ pub enum FeatureKind {
|
||||
curves: Vec<String>,
|
||||
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<Vec3>,
|
||||
},
|
||||
/// 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<Vec3>,
|
||||
},
|
||||
/// 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 { .. } => {}
|
||||
}
|
||||
|
||||
@@ -3543,7 +3543,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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 { .. }
|
||||
|
||||
@@ -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<String, Value>, 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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32> {
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -186,6 +186,67 @@ pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result<Body> {
|
||||
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<Vec<usize>> {
|
||||
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<Body>, Option<Body>)> {
|
||||
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<mt::Face>| rebuilt_body(surface_body(picked), &[&body.solid], &Rebuild::default());
|
||||
let pieces: Vec<Body> = chosen.iter().filter_map(|i| faces.get(*i)).map(|f| piece(vec![(*f).clone()])).collect();
|
||||
let rest: Vec<mt::Face> = 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> {
|
||||
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<mt::Face> = 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<usize> {
|
||||
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::<Vec<_>>(), 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 {
|
||||
|
||||
@@ -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<SelInput> {
|
||||
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<SelInput>)> {
|
||||
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<String> {
|
||||
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<Value>), 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<Strin
|
||||
("surface.patch", json!({"edges": edges, "body": body}))
|
||||
}
|
||||
}
|
||||
Surf::Unstitch | Surf::ReverseNormal => {
|
||||
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<SelI
|
||||
}
|
||||
(Surf::Patch, vec![sels])
|
||||
}
|
||||
FeatureKind::Unstitch { body, faces } | FeatureKind::ReverseNormal { body, faces } => {
|
||||
let sels: Vec<Sel> = 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()],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-4
@@ -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 <id> '<json>'`, 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 <id> '<json>'`, 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)
|
||||
|
||||
Reference in new issue
Block a user