diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index aca21f34..c63f39c5 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -335,6 +335,51 @@ pub fn to_script(recipe: &Value) -> Result { let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null); out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}})); } + Some("sm_flange") => match f.get("type").and_then(Value::as_str) { + Some("edge") => { + let mut p = json!({"type": "edge", "edges": edge_points(f), "height": f.get("height"), "name": name}); + if let Some(a) = f.get("angle") { + p["angle"] = a.clone(); + } + if f.get("flip").and_then(Value::as_bool) == Some(true) { + p["flip"] = json!(true); + } + out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": p})); + } + Some("contour") => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": { + "type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})), + _ => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": { + "type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})), + }, + Some("sheet_metal_rule_edit") => { + let rule = f.get("rule").cloned().unwrap_or(json!("Steel (mm)")); + let mut p = json!({"name": rule}); + if let Some(set) = f.get("set") { + for (from, to) in [("thickness", "thickness"), ("kFactor", "k_factor"), ("bendRadius", "bend_radius")] { + if let Some(v) = set.get(from) { + p[to] = v.clone(); + } + } + } + // "thickness 3 mm (final state…)": the last step of a sequence. + if let Some(last) = f.get("sequence").and_then(Value::as_array).and_then(|a| a.last()).and_then(Value::as_str) { + let w: Vec<&str> = last.split_whitespace().collect(); + if let (Some(&"thickness"), Some(v), Some(u)) = (w.first(), w.get(1), w.get(2)) { + p["thickness"] = json!(format!("{v} {u}")); + } + } + out.push(json!({"command": "FusionSheetMetalRulesCommand", "params": p})); + } + Some("sm_hem") => { + let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null); + let mut p = json!({"edges": [e], "length": f.get("length"), "name": name}); + if f.get("flipped").and_then(Value::as_bool) == Some(true) { + p["flip"] = json!(true); + } + out.push(json!({"command": "FusionSheetMetalHemFlangeCommand", "params": p})); + } + Some("sm_unfold") => out.push(json!({"command": "FusionSheetmetalUnfoldCommand", "params": {"name": name}})), + Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})), Some("path_pattern") => { let path = f.get("path").cloned().unwrap_or(Value::Null); let mut p = json!({"path_sketch": path.get("sketch"), "path": path.get("curves"), "count": f.get("count"), "name": name}); diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 5b774e10..4863db69 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -187,6 +187,9 @@ impl FeatureKind { dir(direction); } FeatureKind::Thread { face, .. } => pt(face), + FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt), + FeatureKind::SheetConvert { face, .. } => pt(face), + FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {} FeatureKind::Coil { base, axis, .. } => { pt(base); dir(axis); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 5be74627..90fa1533 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -406,6 +406,70 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, + /// Sheet metal base flange: a sheet from a closed profile (thickness from the rule). + SheetBase { + sketch: u64, + profiles: ProfileSel, + #[serde(default, skip_serializing_if = "Option::is_none")] + rule: Option, + /// Thickness grows against the sketch normal. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + }, + /// Sheet metal contour flange: a sheet bent along an open chain of sketch lines. + SheetContour { + sketch: u64, + curves: Vec, + distance: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + rule: Option, + /// Material on the other side of the chain. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + /// Width the other way along the sketch normal. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + reverse: bool, + }, + /// Edge flanges on sheet edges (points on the top or bottom edges). + SheetFlange { + edges: Vec, + height: String, + angle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + radius: Option, + /// inside | outside | middle. + #[serde(default = "inside_str")] + position: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Flat hems on sheet edges. + SheetHem { + edges: Vec, + length: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + gap: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + flip: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Unfold a sheet body flat (all bends), or refold it. + SheetUnfold { + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + refold: bool, + }, + /// Convert a plate body (constant thickness from the face at `face`) to sheet metal. + SheetConvert { + body: String, + face: Vec3, + #[serde(default, skip_serializing_if = "Option::is_none")] + rule: Option, + }, /// Remove bodies from the model (from here on in the timeline). Remove { bodies: Vec, @@ -575,6 +639,19 @@ fn z_axis() -> Vec3 { } impl FeatureKind { + /// A sheet metal feature (its result depends on the sheet metal rules). + pub fn is_sheet(&self) -> bool { + matches!( + self, + FeatureKind::SheetBase { .. } + | FeatureKind::SheetContour { .. } + | FeatureKind::SheetFlange { .. } + | FeatureKind::SheetHem { .. } + | FeatureKind::SheetUnfold { .. } + | FeatureKind::SheetConvert { .. } + ) + } + /// Fusion-like type name (timeline tooltip, `inspect`). pub fn type_name(&self) -> &'static str { match self { @@ -606,6 +683,13 @@ impl FeatureKind { FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", FeatureKind::Pipe { .. } => "PipeFeature", FeatureKind::Emboss { .. } => "EmbossFeature", + FeatureKind::SheetBase { .. } => "BaseFlangeFeature", + FeatureKind::SheetContour { .. } => "ContourFlangeFeature", + FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature", + FeatureKind::SheetHem { .. } => "HemFeature", + FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature", + FeatureKind::SheetUnfold { .. } => "RefoldFeature", + FeatureKind::SheetConvert { .. } => "ConvertToSheetMetalFeature", FeatureKind::Coil { .. } => "CoilFeature", FeatureKind::Rib { web: false, .. } => "RibFeature", FeatureKind::Rib { .. } => "WebFeature", @@ -647,6 +731,13 @@ impl FeatureKind { FeatureKind::BoundingSolid { .. } => "BoundingSolid", FeatureKind::Pipe { .. } => "Pipe", FeatureKind::Emboss { .. } => "Emboss", + FeatureKind::SheetBase { .. } => "BaseFlange", + FeatureKind::SheetContour { .. } => "ContourFlange", + FeatureKind::SheetFlange { .. } => "EdgeFlange", + FeatureKind::SheetHem { .. } => "Hem", + FeatureKind::SheetUnfold { refold: false, .. } => "Unfold", + FeatureKind::SheetUnfold { .. } => "Refold", + FeatureKind::SheetConvert { .. } => "ConvertToSheetMetal", FeatureKind::Coil { .. } => "Coil", FeatureKind::Rib { web: false, .. } => "Rib", FeatureKind::Rib { .. } => "Web", @@ -728,6 +819,16 @@ impl FeatureKind { } } FeatureKind::Emboss { depth, .. } => v.push(depth), + FeatureKind::SheetContour { distance, .. } => v.push(distance), + FeatureKind::SheetFlange { height, angle, radius, .. } => { + v.extend([height.as_str(), angle]); + v.extend(radius.iter().map(String::as_str)); + } + FeatureKind::SheetHem { length, gap, .. } => { + v.push(length); + v.extend(gap.iter().map(String::as_str)); + } + FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {} FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => { v.extend([diameter.as_str(), pitch, turns, section_size]); v.extend(start_angle.iter().map(String::as_str)); @@ -763,6 +864,10 @@ pub struct Feature { pub kind: FeatureKind, } +fn inside_str() -> String { + "inside".into() +} + fn zero_expr() -> String { "0".into() } @@ -795,6 +900,9 @@ pub struct Document { /// Joints between occurrences, joint origins and motion links. #[serde(default, skip_serializing_if = "crate::joints::Assembly::is_empty")] pub assembly: crate::joints::Assembly, + /// Sheet metal rules. + #[serde(default, skip_serializing_if = "crate::sheet::SheetSettings::is_empty")] + pub sheet: crate::sheet::SheetSettings, /// Favourite parameters (by name). #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] pub favorites: std::collections::BTreeSet, @@ -854,6 +962,7 @@ impl Document { components: Vec::new(), occurrences: Vec::new(), assembly: Default::default(), + sheet: Default::default(), body_components: Default::default(), materials: Default::default(), favorites: Default::default(), @@ -995,6 +1104,7 @@ impl Document { FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id), FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id), FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id), + FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 58d01bf4..2b6253b0 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -185,6 +185,8 @@ pub struct ModelState { pub bodies: Vec, pub sketches: Vec, pub body_counter: usize, + /// Sheet metal bodies (by body name) and their flat patterns. + pub sheets: Vec, } impl ModelState { @@ -287,7 +289,21 @@ impl Model { let marker = doc.marker.unwrap_or(usize::MAX); for (i, f) in doc.features.iter().enumerate() { let rolled_back = i >= marker; - let fp = fingerprint(prev_fp, f, &vals, rolled_back); + let mut fp = fingerprint(prev_fp, f, &vals, rolled_back); + // Sheet metal features also depend on the rules (and the parameters they use). + if f.kind.is_sheet() { + let mut h = std::collections::hash_map::DefaultHasher::new(); + fp.hash(&mut h); + serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); + for r in &doc.sheet.rules { + for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] { + for n in expr::references(e) { + vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h); + } + } + } + fp = h.finish(); + } prev_fp = fp; if reuse && let Some(old) = self.results.get(i) @@ -1371,6 +1387,12 @@ mod more; fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option) -> Result<()> { match &f.kind { + FeatureKind::SheetBase { .. } + | FeatureKind::SheetContour { .. } + | FeatureKind::SheetFlange { .. } + | FeatureKind::SheetHem { .. } + | FeatureKind::SheetUnfold { .. } + | FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } @@ -1400,6 +1422,22 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Coil { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => { let tools = feature_tools(vals, f, st)?; + // Cuts through sheet metal become cut-outs of its flat pattern. + if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() { + let handled = more::sheet_cut(f, st, &tools, targets)?; + if !handled.is_empty() { + let rest: Vec = st + .bodies + .iter() + .map(|b| b.name.clone()) + .filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n))) + .collect(); + if rest.is_empty() { + return Ok(()); + } + return apply_op(st, f, tools, *operation, &rest); + } + } apply_op(st, f, tools, *operation, targets) } FeatureKind::Hole { thread, diameter, .. } => { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 78de5569..84e71446 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -2,6 +2,7 @@ //! kernel's extrude, boolean, offset and transform operations). use super::*; +use crate::apply_point; use solvecraft_geom::{Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { @@ -239,3 +240,307 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { } pts.last().copied().unwrap_or_default() } + +/// Rebuild a sheet body's solid from its model. +fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> { + let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?; + let solid = sheet.solid()?; + let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?; + if let Some(mb) = st.bodies.get_mut(i) { + *mb = ModelBody::new(mb.name.clone(), solid, mb.feature); + } + Ok(()) +} + +/// The sheet a feature works on: by body name, else the one with an edge nearest the pick. +fn sheet_for(st: &ModelState, body: &Option, pick: Option) -> Result { + if let Some(n) = body { + return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body"))); + } + let p = pick.ok_or_else(|| DocError::Invalid("no sheet metal body".into()))?; + st.sheets + .iter() + .enumerate() + .filter_map(|(i, s)| s.edge_at(p).map(|e| (i, e.4))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + .ok_or_else(|| DocError::Invalid("there is no sheet metal body".into())) +} + +/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines. +fn mirror_y(r: &Region2) -> Region2 { + let f = |p: Vec2| Vec2::new(p.x, -p.y); + let m = |l: &Loop2| { + let segs: Vec = l + .segs + .iter() + .flat_map(|s| match *s { + Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }], + Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }], + other => { + let pts: Vec = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect(); + pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect() + } + }) + .collect(); + Loop2 { segs }.ccw() + }; + Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() } +} + +pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { + use crate::sheet; + match &f.kind { + FeatureKind::SheetBase { sketch, profiles, rule, flip } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); + let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); + let r = doc.sheet_rule(rule.as_deref()); + let rv = doc.rule_values(vals, &r)?; + for (k, region) in regions.into_iter().enumerate() { + let (plane, region) = if *flip { + (Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 }, mirror_y(®ion)) + } else { + (ss.plane, region) + }; + let name = new_name(st, f, k); + let sh = sheet::base_flange(&name, &r.name, &rv, plane, region); + let solid = sh.solid()?; + st.bodies.push(ModelBody::new(name, solid, f.id)); + st.sheets.push(sh); + } + Ok(()) + } + FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); + for id in curves { + let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?; + if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) { + return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into())); + } + } + let pts = chain_points(&ss, curves)?; + let w = val(vals, distance, Kind::Length)?; + if !(w > 0.0) { + return Err(DocError::Invalid("the contour flange distance must be positive".into())); + } + let r = doc.sheet_rule(rule.as_deref()); + let rv = doc.rule_values(vals, &r)?; + let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane }; + let pts: Vec = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts }; + // Mirroring the sketch swaps the material side too. + let name = new_name(st, f, 0); + let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?; + let solid = sh.solid()?; + st.bodies.push(ModelBody::new(name, solid, f.id)); + st.sheets.push(sh); + Ok(()) + } + FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => { + let i = sheet_for(st, body, edges.first().copied())?; + let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?); + let rr = match radius { + Some(e) => val(vals, e, Kind::Length)?, + None => { + let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default(); + doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius + } + }; + let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; + if sh.flat { + return Err(DocError::Invalid("refold the sheet before adding flanges".into())); + } + sh.add_flanges(edges, h, a, rr, position, *flip)?; + rebuild_sheet(st, i) + } + FeatureKind::SheetHem { edges, length, gap, flip, body } => { + let i = sheet_for(st, body, edges.first().copied())?; + let l = val(vals, length, Kind::Length)?; + let g = match gap { + Some(e) => val(vals, e, Kind::Length)?, + None => { + let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default(); + doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.hem_gap + } + }; + let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; + for e in edges { + sh.add_hem(*e, l, g, *flip)?; + } + rebuild_sheet(st, i) + } + FeatureKind::SheetUnfold { body, refold } => { + let i = match body { + Some(_) => sheet_for(st, body, None)?, + None => st.sheets.iter().rposition(|s| s.flat == *refold).ok_or_else(|| { + DocError::Invalid(if *refold { "no unfolded sheet to refold".into() } else { "no sheet metal body to unfold".into() }) + })?, + }; + if let Some(sh) = st.sheets.get_mut(i) { + sh.flat = !*refold; + } + rebuild_sheet(st, i) + } + FeatureKind::SheetConvert { body, face, rule } => { + let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?; + let tol = (mb.body.size() * 1e-3).max(1e-3); + let mesh = mb.body.tessellate(tol)?; + // Thickness: the body's extent along the face normal. + let ds: Vec = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect(); + let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max)); + let t = hi - lo; + // The face outline: boundary edges of the triangles on that face's plane. + let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?; + let on: Vec<[u32; 3]> = mesh + .triangles + .iter() + .copied() + .filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - *face).dot(n) - lo).abs() < tol))) + .collect(); + // Boundary edges by position (meshes may repeat vertices per triangle). + let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default(); + let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64); + let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default(); + let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect(); + for (a, b) in &edges { + let (ka, kb) = (q(*a), q(*b)); + *count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1; + } + let mut boundary: Vec<(Vec3, Vec3)> = edges + .into_iter() + .filter(|(a, b)| { + let (ka, kb) = (q(*a), q(*b)); + count.get(&(ka.min(kb), ka.max(kb))) == Some(&1) + }) + .collect(); + // Chain into loops (plane coordinates). + let mut loops: Vec> = Vec::new(); + for _ in 0..10_000 { + let Some((s0, mut cur)) = boundary.pop() else { break }; + let mut lp = vec![s0]; + for _ in 0..boundary.len() + 1 { + if q(cur) == q(s0) { + break; + } + lp.push(cur); + match boundary.iter().position(|(a, _)| q(*a) == q(cur)) { + Some(k) => cur = boundary.remove(k).1, + None => break, + } + } + let raw: Vec = lp.iter().map(|p| plane.to_local(*p)).collect(); + // Drop points in the middle of straight runs. + let m = raw.len(); + let pts: Vec = (0..m) + .filter_map(|i| { + let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?); + ((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c) + }) + .collect(); + if pts.len() >= 3 { + loops.push(pts); + } + } + loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs())); + let mut it = loops.into_iter(); + let outer = Loop2::polygon(&it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?).ccw(); + let region = Region2 { outer, holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() }; + let area = region.area().abs(); + let vol = kernel::measure(&mb.body)?.volume; + if (area * t - vol).abs() > 1e-3 * vol.max(1.0) { + return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into())); + } + let r = doc.sheet_rule(rule.as_deref()); + let mut rv = doc.rule_values(vals, &r)?; + rv.thickness = t; + let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() }); + sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); + let solid = sh.solid()?; + if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) { + *b = ModelBody::new(b.name.clone(), solid, b.feature); + } + st.sheets.retain(|s| &s.body != body); + st.sheets.push(sh); + Ok(()) + } + _ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))), + } +} + +fn poly_area(p: &[Vec2]) -> f64 { + let m = p.len(); + (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 +} + +/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern +/// (parallel to a panel, through its thickness). Returns the sheet bodies handled. +pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result> { + let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) }; + let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; + let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); + let n = ss.plane.normal(); + // How far the tools reach along the sketch normal. + let mut span = (f64::MAX, f64::MIN); + for t in tools { + for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions { + let d = (p - ss.plane.origin).dot(n); + span = (span.0.min(d), span.1.max(d)); + } + } + let mut handled = Vec::new(); + for i in 0..st.sheets.len() { + let Some(sh) = st.sheets.get(i).cloned() else { continue }; + if !targets.is_empty() && !targets.contains(&sh.body) { + continue; + } + let candidates: Vec<(Option, Region2)> = if sh.flat { + if sh.frame.normal().cross(n).len() > 1e-6 { + Vec::new() + } else { + vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })] + } + } else { + sh.parallel_panels(&ss.plane) + }; + let mut added = Vec::new(); + for (pi, panel) in candidates { + // The panel's faces along the sketch normal must both be inside the cut. + let w = sh.world(pi); + let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n); + let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n); + if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 { + continue; + } + let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::>() } else { panel.outer.polyline(1e-3) }; + for r in ®ions { + let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue }; + let fp = flat.outer.polyline(1e-3); + let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p)); + if overlaps && !added.iter().any(|x: &Region2| x == &flat) { + added.push(flat); + } + } + } + if added.is_empty() { + continue; + } + if let Some(s) = st.sheets.get_mut(i) { + s.holes.extend(added); + } + rebuild_sheet(st, i)?; + handled.push(sh.body.clone()); + } + Ok(handled) +} + +fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool { + let mut inside = false; + let m = poly.len(); + for i in 0..m { + let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue }; + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { + inside = !inside; + } + } + inside +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d281188e..fb3d60e5 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ pub mod joints; mod params; pub mod project; mod project3d; +pub mod sheet; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 4af0bce1..046ec099 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -138,6 +138,20 @@ impl FeatureKind { } } FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)), + FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)), + FeatureKind::SheetFlange { height, angle, radius, .. } => { + v.push(("Height", height, L)); + v.push(("Angle", angle, A)); + if let Some(r) = radius { + v.push(("Bend radius", r, L)); + } + } + FeatureKind::SheetHem { length, gap, .. } => { + v.push(("Length", length, L)); + if let Some(g) = gap { + v.push(("Gap", g, L)); + } + } FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => { v.push(("Diameter", diameter, L)); v.push(("Pitch", pitch, L)); @@ -155,6 +169,9 @@ impl FeatureKind { } FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v), FeatureKind::Combine { .. } + | FeatureKind::SheetBase { .. } + | FeatureKind::SheetUnfold { .. } + | FeatureKind::SheetConvert { .. } | FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::Loft { .. } diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs new file mode 100644 index 00000000..544ec519 --- /dev/null +++ b/crates/doc/src/sheet.rs @@ -0,0 +1,1282 @@ +//! Sheet metal: rules (thickness, K-factor, bend radius, reliefs as expressions that may use +//! parameters), and the sheet body model behind every sheet-metal feature. +//! +//! A sheet body is kept as its flat pattern: the base panel, flanges hanging off panel edges +//! (each a bend zone of width BA = θ·(R + K·T) followed by a flat panel), and cut-outs, all in +//! the flat frame (the base panel's bottom face; thickness grows along its normal). Folding maps +//! each flange's flat coordinates through its parents' bends: past a bend line, a point loses +//! the bend zone's width and turns by θ about the bend axis (R from the inner face). The folded +//! solid is built from extruded pieces (panels, and bend zones as annular sectors) joined +//! together; the flat solid is the outline extruded through the thickness. + +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; +use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec}; + +use crate::expr::{self, Kind, Value}; +use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul}; + +/// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SheetRule { + pub name: String, + pub thickness: String, + pub k_factor: String, + pub bend_radius: String, + #[serde(default = "rule_relief_width")] + pub relief_width: String, + #[serde(default = "rule_relief_depth")] + pub relief_depth: String, + #[serde(default = "rule_corner_relief")] + pub corner_relief: String, + /// Inner radius of flat hems. + #[serde(default = "rule_hem_gap")] + pub hem_gap: String, + /// Gap between flanges that meet at a corner. + #[serde(default = "rule_gap")] + pub gap: String, +} + +fn rule_gap() -> String { + "Thickness".into() +} + +fn rule_relief_width() -> String { + "Thickness".into() +} +fn rule_relief_depth() -> String { + "Thickness * 0.5".into() +} +fn rule_corner_relief() -> String { + "Thickness * 4.0".into() +} +fn rule_hem_gap() -> String { + "0.01 mm".into() +} + +impl SheetRule { + /// Fusion's default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness. + pub fn steel() -> SheetRule { + SheetRule { + name: "Steel (mm)".into(), + thickness: "2.5 mm".into(), + k_factor: "0.44".into(), + bend_radius: "Thickness".into(), + relief_width: rule_relief_width(), + relief_depth: rule_relief_depth(), + corner_relief: rule_corner_relief(), + hem_gap: rule_hem_gap(), + gap: rule_gap(), + } + } +} + +/// The design's sheet metal rules and the one new sheet bodies use. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SheetSettings { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, +} + +impl SheetSettings { + pub fn is_empty(&self) -> bool { + self.rules.is_empty() && self.active.is_none() + } +} + +/// A rule's values (mm, unit-less K). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct RuleValues { + pub thickness: f64, + pub k_factor: f64, + pub bend_radius: f64, + pub relief_width: f64, + pub relief_depth: f64, + pub corner_relief: f64, + pub hem_gap: f64, + pub gap: f64, +} + +impl Document { + /// A rule by name, the active one, or the default steel rule. + pub fn sheet_rule(&self, name: Option<&str>) -> SheetRule { + let want = name.or(self.sheet.active.as_deref()); + want.and_then(|n| self.sheet.rules.iter().find(|r| r.name == n)) + .or_else(|| self.sheet.rules.first()) + .cloned() + .unwrap_or_else(SheetRule::steel) + } + + /// Evaluate a rule: `Thickness` in its expressions is the rule's thickness. + pub fn rule_values(&self, vals: &std::collections::BTreeMap, r: &SheetRule) -> Result { + let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))); + let t = expr::eval_with(&r.thickness, &look)?.to_kind(Kind::Length)?; + if !(t > 0.0 && t < 1e4) { + return Err(DocError::Invalid("the sheet thickness must be positive".into())); + } + let with_t = |e: &str, k: Kind| -> Result { + let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) }; + expr::eval_with(e, &f)?.to_kind(k) + }; + let v = RuleValues { + thickness: t, + k_factor: with_t(&r.k_factor, Kind::Unitless)?, + bend_radius: with_t(&r.bend_radius, Kind::Length)?, + relief_width: with_t(&r.relief_width, Kind::Length)?, + relief_depth: with_t(&r.relief_depth, Kind::Length)?, + corner_relief: with_t(&r.corner_relief, Kind::Length)?, + hem_gap: with_t(&r.hem_gap, Kind::Length)?, + gap: with_t(&r.gap, Kind::Length)?, + }; + if !(0.0..=1.0).contains(&v.k_factor) || v.bend_radius < 0.0 { + return Err(DocError::Invalid("the K-factor must be 0…1 and the bend radius positive".into())); + } + Ok(v) + } +} + +/// A flange: a bend along a panel edge and the flat panel past it (flat coordinates). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct SheetFlange { + /// The flange it hangs off (None = the base panel). + pub parent: Option, + /// Bend start line: from `p` along `d` for `len`; `n` points into the flange. + pub p: Vec2, + pub d: Vec2, + pub len: f64, + pub n: Vec2, + /// Bend angle (radians; positive bends toward the top face, +z of the flat frame). + pub angle: f64, + /// Inner bend radius. + pub radius: f64, + /// Flat length of the panel past the bend zone. + pub leg: f64, + /// The panel runs on past the bend's ends by these (toward a neighbouring flange). + #[serde(default)] + pub ext: (f64, f64), +} + +/// A sheet metal body (see the module docs). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct SheetBody { + pub body: String, + pub rule: String, + pub t: f64, + pub k: f64, + /// The flat frame: base panel bottom face; z = thickness direction. + pub frame: Plane, + pub base: Region2, + pub flanges: Vec, + /// Cut-outs (flat coordinates) through the sheet. + pub holes: Vec, + /// Unfolded. + pub flat: bool, + /// Gap between flanges that meet at a corner. + pub gap: f64, +} + +/// A piece of the sheet: a panel (None = the base) or a flange's bend zone. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Piece { + Panel(Option), + Zone(usize), +} + +/// A flat piece of the sheet (counter-clockwise outer loop, clockwise holes). +#[derive(Clone, Debug)] +struct Cell { + piece: Piece, + outer: Vec, + holes: Vec>, +} + +fn rev_edge(e: EdgeSpec) -> EdgeSpec { + match e { + EdgeSpec::Line { a, b } => EdgeSpec::Line { a: b, b: a }, + EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: b, b: a, mid }, + } +} + +fn dedup(p: &[Vec2]) -> Vec { + let mut v: Vec = Vec::new(); + for q in p { + if v.last().is_none_or(|l| l.dist(*q) > 1e-9) { + v.push(*q); + } + } + if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.dist(*b) < 1e-9) { + v.pop(); + } + v +} + +/// A bend for the bend table. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BendInfo { + pub index: usize, + pub angle_deg: f64, + pub radius: f64, + pub up: bool, + pub allowance: f64, + pub length: f64, + /// Centre line in flat coordinates. + pub start: Vec2, + pub end: Vec2, +} + +fn rot_about(o: Vec3, k: Vec3, a: f64) -> Mat { + crate::rigid(Vec3::ZERO, o, k, a) +} + +fn translate(t: Vec3) -> Mat { + let mut m = IDENTITY; + m[3] = [t.x, t.y, t.z, 1.0]; + m +} + +fn v3(v: Vec2) -> Vec3 { + Vec3::new(v.x, v.y, 0.0) +} + +fn plane_of(m: &Mat, o: Vec3, x: Vec3, y: Vec3) -> Result { + Plane::new(apply_point(m, o), apply_vector(m, x), apply_vector(m, y)).ok_or_else(|| DocError::Invalid("sheet: degenerate plane".into())) +} + +fn rect(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Region2 { + let mut lp = Loop2::polygon(&[a, b, c, d]); + if lp.signed_area() < 0.0 { + lp = lp.reversed(); + } + Region2 { outer: lp, holes: Vec::new() } +} + +/// Round to a 1e-9 mm grid (keeps trimmed edges exactly where the kernel expects them). +fn snap(v: Vec2) -> Vec2 { + let r = |x: f64| (x * 1e9).round() / 1e9; + Vec2::new(r(v.x), r(v.y)) +} + +/// Clip a polygon to the half-plane (p − o)·n ≥ 0. +fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec { + let mut out = Vec::new(); + let m = poly.len(); + for i in 0..m { + let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + 1) % m)) else { continue }; + let (da, db) = ((a - o).dot(n), (b - o).dot(n)); + if da >= 0.0 { + out.push(a); + } + if (da >= 0.0) != (db >= 0.0) { + let t = da / (da - db); + out.push(snap(a + (b - a) * t)); + } + } + out +} + +impl SheetBody { + /// Bend allowance of a flange. + pub fn allowance(&self, f: &SheetFlange) -> f64 { + f.angle.abs() * (f.radius + self.k * self.t) + } + + /// Fold transform of flange `i`'s panel (flat 3D coordinates → folded, in the flat frame). + pub fn fold(&self, i: usize) -> Mat { + let mut chain = Vec::new(); + let mut cur = Some(i); + for _ in 0..1000 { + let Some(c) = cur else { break }; + chain.push(c); + cur = self.flanges.get(c).and_then(|f| f.parent); + } + let mut m = IDENTITY; + for c in chain.into_iter().rev() { + if let Some(f) = self.flanges.get(c) { + m = mat_mul(&m, &self.bend_motion(f)); + } + } + m + } + + /// The fold of one bend: drop the bend zone, then turn about the bend axis. + fn bend_motion(&self, f: &SheetFlange) -> Mat { + let ba = self.allowance(f); + let (axis_o, k, a) = self.bend_axis(f); + mat_mul(&rot_about(axis_o, k, a), &translate(v3(f.n) * -ba)) + } + + /// Bend axis point, direction and signed turn (flat coordinates). + fn bend_axis(&self, f: &SheetFlange) -> (Vec3, Vec3, f64) { + let up = f.angle >= 0.0; + let z = if up { self.t + f.radius } else { -f.radius }; + let o = v3(f.p) + Vec3::Z * z; + let k = v3(f.n).cross(Vec3::Z); + (o, k, if up { f.angle.abs() } else { -f.angle.abs() }) + } + + /// Flat region of a flange's panel. + pub fn panel(&self, f: &SheetFlange) -> Region2 { + let ba = self.allowance(f); + let a = f.p + f.n * ba - f.d * f.ext.0; + let b = f.p + f.n * ba + f.d * (f.len + f.ext.1); + rect(a, b, b + f.n * f.leg, a + f.n * f.leg) + } + + /// Flat region of a flange's bend zone. + pub fn zone(&self, f: &SheetFlange) -> Region2 { + let ba = self.allowance(f); + let (u0, u1) = self.zone_span(f); + let (a, b) = (f.p + f.d * u0, f.p + f.d * u1); + rect(a, b, b + f.n * ba, a + f.n * ba) + } + + /// The part of a bend along its parent's edge: where a later flange trimmed that edge back + /// (a corner), the bend stops and the flange panel runs on alone (a corner relief). + pub fn zone_span(&self, f: &SheetFlange) -> (f64, f64) { + if f.parent.is_some() { + return (0.0, f.len); + } + let pts = self.base.outer.ccw().polyline(1e-3); + let m = pts.len(); + let mut span: Option<(f64, f64)> = None; + for k in 0..m { + let (Some(&a), Some(&b)) = (pts.get(k), pts.get((k + 1) % m)) else { continue }; + let on = (a - f.p).dot(f.n).abs() < 1e-6 && (b - f.p).dot(f.n).abs() < 1e-6; + if !on { + continue; + } + let (ua, ub) = ((a - f.p).dot(f.d), (b - f.p).dot(f.d)); + let (lo, hi) = (ua.min(ub).max(0.0), ua.max(ub).min(f.len)); + if hi > lo + 1e-9 { + span = Some(span.map_or((lo, hi), |(x, y)| (x.min(lo), y.max(hi)))); + } + } + span.unwrap_or((0.0, f.len)) + } + + fn frame_mat(&self) -> Mat { + let fr = &self.frame; + let n = fr.normal(); + [[fr.x.x, fr.x.y, fr.x.z, 0.0], [fr.y.x, fr.y.y, fr.y.z, 0.0], [n.x, n.y, n.z, 0.0], [fr.origin.x, fr.origin.y, fr.origin.z, 1.0]] + } + + /// Flat coordinates (x, y, z) → world, through the fold of flange `i` (None = base). + pub fn world(&self, i: Option) -> Mat { + let f = match i { + Some(i) if !self.flat => self.fold(i), + _ => IDENTITY, + }; + mat_mul(&self.frame_mat(), &f) + } + + /// Where a flat point of a piece lands (folded), at height `z` in the sheet. + fn map3(&self, piece: Piece, q: Vec2, z: f64) -> Vec3 { + match piece { + Piece::Panel(i) => apply_point(&self.world(i), v3(q) + Vec3::Z * z), + Piece::Zone(i) => { + let Some(f) = self.flanges.get(i) else { return Vec3::ZERO }; + let s = (q - f.p).dot(f.n); + let q0 = q - f.n * s; + let neutral = (f.radius + self.k * self.t).max(1e-12); + let phi = (s / neutral).clamp(0.0, f.angle.abs()); + let (axis_o, k, turn) = self.bend_axis(f); + let m = mat_mul(&self.world(f.parent), &rot_about(axis_o, k, turn.signum() * phi)); + apply_point(&m, v3(q0) + Vec3::Z * z) + } + } + } + + /// A flat edge of a piece as a 3D edge at height `z`. + fn edge3(&self, piece: Piece, e: &Seg2, z: f64) -> Result { + let (a, b) = (e.start(), e.end()); + let (pa, pb) = (self.map3(piece, a, z), self.map3(piece, b, z)); + match (piece, e) { + (Piece::Panel(_), Seg2::Line { .. }) => Ok(EdgeSpec::Line { a: pa, b: pb }), + (Piece::Panel(_), Seg2::Arc { .. }) => Ok(EdgeSpec::Arc { a: pa, b: pb, mid: self.map3(piece, e.point_at(0.5), z) }), + (Piece::Zone(i), Seg2::Line { .. }) => { + let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("bend".into()))?; + let dlt = b - a; + if dlt.dot(f.n).abs() < 1e-9 * dlt.len().max(1.0) { + Ok(EdgeSpec::Line { a: pa, b: pb }) + } else if dlt.dot(f.d).abs() < 1e-9 * dlt.len().max(1.0) { + Ok(EdgeSpec::Arc { a: pa, b: pb, mid: self.map3(piece, (a + b) * 0.5, z) }) + } else { + Err(DocError::Invalid("not supported yet: cut-out edges running across a bend at an angle".into())) + } + } + (Piece::Zone(_), _) => Err(DocError::Invalid("not supported yet: curved cut-outs across a bend".into())), + (_, _) => Err(DocError::Invalid("not supported yet: spline or conic cut-outs in sheet metal".into())), + } + } + + /// The pieces of the sheet as cells: base and flange panels and bend zones, each less the + /// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge + /// must be convex polygons). + fn cells(&self) -> Result> { + let mut pieces: Vec<(Piece, Vec)> = + self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect(); + for (i, f) in self.flanges.iter().enumerate() { + pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3)))); + } + let mut out = Vec::new(); + for (piece, poly) in pieces { + let mut inner: Vec> = Vec::new(); + let mut crossing: Vec> = Vec::new(); + for h in &self.holes { + let hp = dedup(&h.outer.ccw().polyline(1e-3)); + if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) { + inner.push(h.outer.ccw().reversed().segs.clone()); + } else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) { + if !is_convex(&hp) { + return Err(DocError::Invalid( + "not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(), + )); + } + crossing.push(hp); + } + } + if crossing.is_empty() { + let outer = Loop2::polygon(&poly).segs; + out.push(Cell { + piece, + outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer }, + holes: inner, + }); + continue; + } + let mut parts = vec![poly]; + for h in &crossing { + parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect(); + } + let loops = merge_loops(&parts); + let (outers, holes): (Vec>, Vec>) = loops.into_iter().partition(|l| signed_area(l) > 0.0); + for o in outers { + let mut hs: Vec> = + holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect(); + hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned()); + out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs }); + } + } + Ok(out) + } + + /// Faces of the solid made by `cells` between heights `z0` and `z1`. + fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result> { + // Split straight edges where other cells' corners touch them, so neighbours share edges. + let corners: Vec = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect(); + let split_loop = |segs: &[Seg2]| -> Vec { + let mut v = Vec::new(); + for e in segs { + let Seg2::Line { a, b } = *e else { + v.push(*e); + continue; + }; + let d = b - a; + let l2 = d.len2(); + let mut ts: Vec = corners + .iter() + .filter_map(|p| { + let t = (*p - a).dot(d) / l2.max(1e-30); + ((t > 1e-9 && t < 1.0 - 1e-9) && (a + d * t).dist(*p) < 1e-7).then_some(t) + }) + .collect(); + ts.sort_by(|x, y| x.total_cmp(y)); + ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + let mut prev = a; + for t in ts { + let q = a + d * t; + v.push(Seg2::Line { a: prev, b: q }); + prev = q; + } + v.push(Seg2::Line { a: prev, b }); + } + v + }; + let cells: Vec = cells + .iter() + .map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() }) + .collect(); + let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; + // Shared with a neighbour: the same flat edge the other way, landing in the same place + // (flat neighbours across a corner of two bends do not). + let shared = |ci: usize, e: &Seg2| { + let Some(me) = cells.get(ci) else { return false }; + let here = self.map3(me.piece, e.point_at(0.5), z0); + cells.iter().enumerate().any(|(cj, c)| { + cj != ci + && c.outer.iter().chain(c.holes.iter().flatten()).any(|g| { + near(g.start(), e.end()) + && near(g.end(), e.start()) + && matches!((g, e), (Seg2::Line { .. }, Seg2::Line { .. })) + && self.map3(c.piece, g.point_at(0.5), z0).dist(here) < 1e-6 + }) + }) + }; + let mut faces = Vec::new(); + for (ci, c) in cells.iter().enumerate() { + // Top (z1, outward +z) and bottom (z0, outward −z, loops reversed). + let loops = |z: f64, rev: bool| -> Result>> { + std::iter::once(&c.outer) + .chain(c.holes.iter()) + .map(|l| { + let mut v: Vec = l.iter().map(|e| self.edge3(c.piece, e, z)).collect::>()?; + if rev { + v = v.into_iter().rev().map(rev_edge).collect(); + } + Ok(v) + }) + .collect() + }; + let centre = c.outer.iter().map(Seg2::start).fold(Vec2::default(), |a, b| a + b) * (1.0 / c.outer.len().max(1) as f64); + for (z, rev, sign) in [(z1, false, 1.0), (z0, true, -1.0)] { + let surface = match c.piece { + Piece::Panel(_) => SurfSpec::Plane, + Piece::Zone(i) => { + let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("bend".into()))?; + let ss: Vec = c.outer.iter().map(|e| (e.start() - f.p).dot(f.n)).collect(); + let (s0, s1) = (ss.iter().cloned().fold(f64::MAX, f64::min), ss.iter().cloned().fold(f64::MIN, f64::max)); + let us: Vec = c.outer.iter().map(|e| (e.start() - f.p).dot(f.d)).collect(); + let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max)); + let neutral = (f.radius + self.k * self.t).max(1e-12); + let (axis_o, k, turn) = self.bend_axis(f); + let wp = self.world(f.parent); + let q = |u: f64| f.p + f.n * s0 + f.d * u; + SurfSpec::Revolved { + a: self.map3(c.piece, q(u0), z), + b: self.map3(c.piece, q(u1), z), + axis_origin: apply_point(&wp, axis_o), + axis: apply_vector(&wp, k), + angle: turn.signum() * (s1 - s0) / neutral, + } + } + }; + let p = self.map3(c.piece, centre, z); + let outward = (self.map3(c.piece, centre, z + 1.0) - p) * sign; + faces.push(FaceSpec { loops: loops(z, rev)?, surface, probe: Some((p, outward)) }); + } + // Walls along edges no other cell shares. + for e in c.outer.iter().chain(c.holes.iter().flatten()) { + if shared(ci, e) { + continue; + } + let (a, b) = (e.start(), e.end()); + let bottom = self.edge3(c.piece, e, z0)?; + let top = rev_edge(self.edge3(c.piece, e, z1)?); + let up_b = EdgeSpec::Line { a: self.map3(c.piece, b, z0), b: self.map3(c.piece, b, z1) }; + let down_a = EdgeSpec::Line { a: self.map3(c.piece, a, z1), b: self.map3(c.piece, a, z0) }; + let surface = match (c.piece, e) { + (Piece::Panel(_), Seg2::Arc { .. }) => { + let (pa, pb, pm) = (self.map3(c.piece, a, z0), self.map3(c.piece, b, z0), self.map3(c.piece, e.point_at(0.5), z0)); + SurfSpec::Extruded { a: pa, b: pb, mid: pm, dir: self.map3(c.piece, a, z1) - pa } + } + _ => SurfSpec::Plane, + }; + let mid = e.point_at(0.5); + let tdir = e.point_at(0.51) - e.point_at(0.49); + let right = Vec2::new(tdir.y, -tdir.x); + let pm = self.map3(c.piece, mid, (z0 + z1) / 2.0); + let outward = self.map3(c.piece, mid + right * 1e-3, (z0 + z1) / 2.0) - pm; + faces.push(FaceSpec { loops: vec![vec![bottom, up_b, top, down_a]], surface, probe: Some((pm, outward)) }); + } + } + Ok(faces) + } + + /// The folded (or, when unfolded, flat) solid, sewn from its faces. + pub fn solid(&self) -> Result { + if self.flat { + return self.flat_solid(); + } + Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?) + } + + /// The flat pattern as one extrusion (outline with its cut-outs). + pub fn flat_solid(&self) -> Result { + let loops = self.outline()?; + let mut loops: Vec = loops.into_iter().map(|l| Loop2::polygon(&l)).collect(); + loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); + let mut it = loops.into_iter(); + let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw(); + let mut holes: Vec = it.map(|l| l.ccw().reversed()).collect(); + let op = outer.polyline(1e-3); + for h in &self.holes { + let hp = h.outer.polyline(1e-3); + if hp.iter().all(|p| point_in(&op, *p)) { + holes.push(h.outer.ccw().reversed()); + } else { + return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into())); + } + } + let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; + kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into())) + } + + /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. + pub fn outline(&self) -> Result>> { + let mut polys: Vec> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))]; + for f in &self.flanges { + polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3))); + if f.leg > 1e-9 { + polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3))); + } + } + let loops = merge_loops(&polys); + if loops.is_empty() { + return Err(DocError::Invalid("empty flat pattern".into())); + } + Ok(loops) + } + + /// The bends: angle, radius, direction, allowance and centre line (flat coordinates). + pub fn bends(&self) -> Vec { + self.flanges + .iter() + .enumerate() + .map(|(i, f)| { + let ba = self.allowance(f); + let (u0, u1) = self.zone_span(f); + let a = f.p + f.n * (ba / 2.0) + f.d * u0; + BendInfo { + index: i + 1, + angle_deg: f.angle.abs().to_degrees(), + radius: f.radius, + up: f.angle >= 0.0, + allowance: ba, + length: u1 - u0, + start: a, + end: a + f.d * (u1 - u0), + } + }) + .collect() + } + + /// Topology counted as Fusion does: a round hole is one face bounded by two closed circular + /// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to + /// the sewn (faces, edges, vertices). + pub fn round_hole_correction(&self) -> (i64, i64, i64) { + let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64); + for h in &self.holes { + let arcs: Vec<(Vec2, f64)> = + h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); + let n = h.outer.segs.len() as i64; + let full = !arcs.is_empty() + && arcs.len() == h.outer.segs.len() + && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); + if full && n > 1 { + df += 1 - n; + de += 2 - 3 * n; + dv += 2 - 2 * n; + } + } + (df, de, dv) + } + + /// Bounding size of the flat pattern (x, y in the flat frame). + pub fn flat_size(&self) -> Result<(f64, f64)> { + let loops = self.outline()?; + let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in loops.iter().flatten() { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + Ok((hi.x - lo.x, hi.y - lo.y)) + } + + /// Panels with their regions and world placements: (flange index or None, region). + fn panels(&self) -> Vec<(Option, Region2)> { + let mut v = vec![(None, self.base.clone())]; + for (i, f) in self.flanges.iter().enumerate() { + if f.leg > 1e-9 { + v.push((Some(i), self.panel(f))); + } + } + v + } + + /// The panel edge nearest a world point: (panel, edge a, edge b in flat coordinates, on the + /// top face?, distance). + pub fn edge_at(&self, p: Vec3) -> Option<(Option, Vec2, Vec2, bool, f64)> { + let mut best: Option<(Option, Vec2, Vec2, bool, f64)> = None; + for (pi, r) in self.panels() { + let w = self.world(pi); + let Some(inv) = mat_inverse(&w) else { continue }; + let q = apply_point(&inv, p); + let pts = r.outer.ccw().polyline(1e-3); + let m = pts.len(); + for i in 0..m { + let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; + if a.dist(b) < 1e-9 { + continue; + } + for (z, top) in [(0.0, false), (self.t, true)] { + let q2 = Vec2::new(q.x, q.y); + let d = b - a; + let t = ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0); + let dist = ((a + d * t) - q2).len().hypot(q.z - z); + if best.as_ref().is_none_or(|x| dist < x.4) { + best = Some((pi, a, b, top, dist)); + } + } + } + } + best + } + + /// Trim a panel's edge a→b back by `by` (toward the panel interior). + fn trim(&mut self, panel: Option, a: Vec2, b: Vec2, by: f64) -> Result<()> { + if by.abs() < 1e-12 { + return Ok(()); + } + let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; + // Interior side of a counter-clockwise loop is to the left. + let inward = Vec2::new(-d.y, d.x); + match panel { + None => { + let pts = self.base.outer.ccw().polyline(1e-3); + let cut = clip(&pts, a + inward * by, inward); + if cut.len() < 3 { + return Err(DocError::Invalid("the flange's setback removes the whole panel".into())); + } + self.base = Region2 { outer: Loop2::polygon(&cut).ccw(), holes: self.base.holes.clone() }; + } + Some(i) => { + // A flange panel: its far edge shortens the leg; side edges are not trimmed. + let Some(f) = self.flanges.get_mut(i) else { return Err(DocError::Invalid("flange".into())) }; + if inward.dot(f.n * -1.0) > 0.99 { + f.leg -= by; + if f.leg < -1e-9 { + return Err(DocError::Invalid("the flange's setback is longer than the panel".into())); + } + } else { + return Err(DocError::Invalid("not supported yet: a flange on the side of another flange".into())); + } + } + } + Ok(()) + } + + /// Add an edge flange on the panel edge nearest `pick` (world): `height` to the outer face + /// from the virtual sharp, `angle` (0…180°), inside / outside / middle bend position. + pub fn add_flange(&mut self, pick: Vec3, height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result { + self.add_flanges(&[pick], height, angle, radius, position, flip)?.into_iter().next().ok_or_else(|| DocError::Invalid("flange".into())) + } + + /// Edge flanges on several edges at once: every edge is trimmed back first, so flanges on + /// adjacent edges stop at each other's bends (the corners stay open). + pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result> { + if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) { + return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into())); + } + if picks.is_empty() || picks.len() > 1000 { + return Err(DocError::Invalid("pick 1…1000 edges".into())); + } + let sharp = (radius + self.t) * (angle / 2.0).tan(); + let setback = match position { + "inside" | "" => sharp, + "outside" => 0.0, + "middle" | "mid" => sharp / 2.0, + other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))), + }; + let leg = height - sharp; + if leg < -1e-9 { + return Err(DocError::Invalid(format!("the flange height must be at least {sharp:.3} mm for this bend"))); + } + // Find every edge before anything moves. + let mut found = Vec::new(); + for p in picks { + let (panel, a, b, top, dist) = self.edge_at(*p).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; + if dist > self.t * 2.0 + 1e-3 { + return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into())); + } + let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; + found.push((panel, a, d, top)); + } + for (panel, a, d, _) in &found { + self.trim(*panel, *a, *a + *d, setback)?; + } + let mut made = Vec::new(); + for (panel, a, d, top) in found { + let outward = Vec2::new(d.y, -d.x); + let line_o = a - outward * setback; + // The trimmed edge: along d, on the moved line. + let (start, len) = match panel { + None => { + let pts = self.base.outer.ccw().polyline(1e-3); + let m = pts.len(); + (0..m) + .filter_map(|k| { + let (p0, p1) = (*pts.get(k)?, *pts.get((k + 1) % m)?); + let e = p1 - p0; + let on = (p0 - line_o).dot(outward).abs() < 1e-6 && (p1 - line_o).dot(outward).abs() < 1e-6; + (on && e.dot(d) > 0.0).then_some((p0, e.len())) + }) + .next() + .ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))? + } + Some(i) => { + let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("flange".into()))?; + let u0 = (line_o - f.p).dot(f.d).clamp(0.0, f.len); + (f.p + f.n * (self.allowance(f) + f.leg) + f.d * u0, f.len) + } + }; + let up = top != flip; + self.flanges.push(SheetFlange { + parent: panel, + p: snap(start), + d, + len, + n: outward, + angle: if up { angle } else { -angle }, + radius, + leg: leg.max(0.0), + ext: (0.0, 0.0), + }); + made.push(self.flanges.len() - 1); + } + self.close_corners(&made); + Ok(made) + } + + /// Where two new 90° flanges on the base meet at a corner, run their panels on toward each + /// other until the gap between their inner corners is the rule's gap. + fn close_corners(&mut self, made: &[usize]) { + let ends: Vec<(usize, bool, Vec2)> = made + .iter() + .filter_map(|&i| self.flanges.get(i).map(|f| (i, f))) + .filter(|(_, f)| f.parent.is_none() && (f.angle.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-9) + .flat_map(|(i, f)| [(i, false, f.p), (i, true, f.p + f.d * f.len)]) + .collect(); + for &(i, at_end, c) in &ends { + let Some(f) = self.flanges.get(i).cloned() else { continue }; + let other = ends.iter().find(|&&(j, _, c2)| j != i && c2.dist(c) < 1e-6).and_then(|&(j, _, _)| self.flanges.get(j).cloned()); + let Some(g) = other else { continue }; + if g.n.dot(f.n).abs() > 1e-6 || (g.angle > 0.0) != (f.angle > 0.0) { + continue; + } + // The neighbour's setback: its bend starts that far in from the corner of the outer faces. + let setback = (g.radius + self.t) * (g.angle.abs() / 2.0).tan(); + let ext = setback - (self.t + self.gap / std::f64::consts::SQRT_2); + if ext <= 1e-9 { + continue; + } + if let Some(x) = self.flanges.get_mut(i) { + if at_end { + x.ext.1 = ext; + } else { + x.ext.0 = ext; + } + } + } + } + + /// A flat hem: a 180° bend with a tiny radius at the panel edge, `length` to its outer face. + pub fn add_hem(&mut self, pick: Vec3, length: f64, gap: f64, flip: bool) -> Result { + let (panel, a, b, top, dist) = self.edge_at(pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; + if dist > self.t * 2.0 + 1e-3 { + return Err(DocError::Invalid("pick an edge of the sheet".into())); + } + let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; + let outward = Vec2::new(d.y, -d.x); + let leg = length - (gap + self.t); + if leg < 0.0 { + return Err(DocError::Invalid("the hem is shorter than its bend".into())); + } + // A hem folds back over the face it starts from (toward the bottom unless flipped). + let up = !top == flip; + let up = !up; + self.flanges.push(SheetFlange { + parent: panel, + p: a, + d, + len: a.dist(b), + n: outward, + angle: if up { std::f64::consts::PI } else { -std::f64::consts::PI }, + radius: gap, + leg, + ext: (0.0, 0.0), + }); + Ok(self.flanges.len() - 1) + } + + /// Map a region in a world plane to flat coordinates through a panel's placement. + pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Option { + let inv = mat_inverse(&self.world(panel))?; + let map = |p: Vec2| { + let q = apply_point(&inv, plane.to_world(p)); + Vec2::new(q.x, q.y) + }; + // A rigid map of the plane: lines and arcs stay lines and arcs (mirrored or not). + let (o, ex, ey) = (map(Vec2::default()), map(Vec2::new(1.0, 0.0)), map(Vec2::new(0.0, 1.0))); + let (ax, ay) = (ex - o, ey - o); + let mirrored = ax.cross(ay) < 0.0; + let rot = ax.y.atan2(ax.x); + let seg = |sg: &Seg2| -> Seg2 { + match *sg { + Seg2::Line { a, b } => Seg2::Line { a: map(a), b: map(b) }, + Seg2::Arc { center, radius, start, sweep } => { + if mirrored { + Seg2::Arc { center: map(center), radius, start: rot - start, sweep: -sweep } + } else { + Seg2::Arc { center: map(center), radius, start: rot + start, sweep } + } + } + other => Seg2::Line { a: map(other.start()), b: map(other.end()) }, + } + }; + let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. })); + let mut lp = if curved_other { + Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::>()) + } else { + Loop2 { segs: r.outer.segs.iter().map(seg).collect() } + }; + if lp.signed_area() < 0.0 { + lp = lp.reversed(); + } + Some(Region2 { outer: lp, holes: Vec::new() }) + } + + /// Panels whose plane is parallel to `plane` (world): (panel, region). + pub fn parallel_panels(&self, plane: &Plane) -> Vec<(Option, Region2)> { + self.panels() + .into_iter() + .filter(|(pi, _)| { + let w = self.world(*pi); + let n = apply_vector(&w, Vec3::Z); + n.cross(plane.normal()).len() < 1e-6 + }) + .collect() + } +} + +fn point_in(poly: &[Vec2], p: Vec2) -> bool { + let mut inside = false; + let m = poly.len(); + for i in 0..m { + let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue }; + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { + inside = !inside; + } + } + inside +} + +/// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are +/// split where corners touch them, edges running both ways cancel, the rest chain into loops +/// (outer loops counter-clockwise, holes clockwise; straight runs merged). +fn merge_loops(polys: &[Vec]) -> Vec> { + let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); + for p in polys { + let m = p.len(); + for i in 0..m { + if let (Some(&a), Some(&b)) = (p.get(i), p.get((i + 1) % m)) { + segs.push((a, b)); + } + } + } + let pts: Vec = segs.iter().flat_map(|(a, b)| [*a, *b]).collect(); + let mut split: Vec<(Vec2, Vec2)> = Vec::new(); + for (a, b) in &segs { + let d = *b - *a; + let l2 = d.len2(); + if l2 < 1e-18 { + continue; + } + let mut ts: Vec = pts + .iter() + .filter_map(|p| { + let t = (*p - *a).dot(d) / l2; + ((t > 1e-9 && t < 1.0 - 1e-9) && (*a + d * t).dist(*p) < 1e-7).then_some(t) + }) + .collect(); + ts.push(0.0); + ts.push(1.0); + ts.sort_by(|x, y| x.total_cmp(y)); + ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); + for w in ts.windows(2) { + split.push((*a + d * w[0], *a + d * w[1])); + } + } + let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; + let keep: Vec<(Vec2, Vec2)> = split + .iter() + .enumerate() + .filter(|(i, (a, b))| !split.iter().enumerate().any(|(j, (c, d))| j != *i && same(*a, *d) && same(*b, *c))) + .map(|(_, s)| *s) + .collect(); + let mut loops = Vec::new(); + let mut used = vec![false; keep.len()]; + for s0 in 0..keep.len() { + if used.get(s0).copied().unwrap_or(true) { + continue; + } + let mut lp = Vec::new(); + let mut cur = s0; + for _ in 0..keep.len() + 1 { + if let Some(u) = used.get_mut(cur) { + *u = true; + } + let Some(&(a, b)) = keep.get(cur) else { break }; + lp.push(a); + match (0..keep.len()).find(|j| !used.get(*j).copied().unwrap_or(true) && keep.get(*j).is_some_and(|(c, _)| same(*c, b))) { + Some(n) => cur = n, + None => break, + } + } + let m = lp.len(); + let clean: Vec = (0..m) + .filter_map(|i| { + let (p, q, r) = (*lp.get((i + m - 1) % m)?, *lp.get(i)?, *lp.get((i + 1) % m)?); + ((q - p).cross(r - q).abs() > 1e-9 * (q - p).len().max(1.0) * (r - q).len().max(1.0)).then_some(q) + }) + .collect(); + if clean.len() >= 3 { + loops.push(clean); + } + } + loops +} + +fn signed_area(p: &[Vec2]) -> f64 { + let m = p.len(); + (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 +} + +/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces. +fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec> { + let mut out = Vec::new(); + let mut rest = a.to_vec(); + let m = h.len(); + for i in 0..m { + let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue }; + let Some(d) = (q - p).normalized() else { continue }; + let left = Vec2::new(-d.y, d.x); + let outside = dedup(&clip(&rest, p, left * -1.0)); + if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 { + out.push(outside); + } + rest = dedup(&clip(&rest, p, left)); + if rest.len() < 3 { + break; + } + } + out +} + +/// Do two polygons' edges cross? +fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { + let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) }; + (0..a.len()).filter_map(|i| seg(a, i)).any(|(p, q)| { + (0..b.len()).filter_map(|j| seg(b, j)).any(|(r, s)| { + let d1 = (q - p).cross(r - p); + let d2 = (q - p).cross(s - p); + let d3 = (s - r).cross(p - r); + let d4 = (s - r).cross(q - r); + d1 * d2 < -1e-12 && d3 * d4 < -1e-12 + }) + }) +} + +fn is_convex(p: &[Vec2]) -> bool { + let m = p.len(); + (0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) { + (Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9, + _ => true, + }) +} + +/// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal. +pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody { + SheetBody { + body: body.into(), + rule: rule.into(), + t: rv.thickness, + k: rv.k_factor, + frame: plane, + base: region, + flanges: Vec::new(), + holes: Vec::new(), + flat: false, + gap: rv.gap, + } +} + +/// A sheet from an open chain of lines (contour flange): `pts` in `plane`, the material on the +/// right of the travel (or the left when `flip`), `width` along the plane normal. +pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pts: &[Vec2], width: f64, flip: bool) -> Result { + if pts.len() < 2 { + return Err(DocError::Invalid("a contour flange needs an open chain of lines".into())); + } + let (t, r) = (rv.thickness, rv.bend_radius); + let seg_dir = |i: usize| -> Result<(Vec2, f64)> { + let (Some(a), Some(b)) = (pts.get(i), pts.get(i + 1)) else { return Err(DocError::Invalid("chain".into())) }; + let l = a.dist(*b); + Ok(((*b - *a).normalized().ok_or_else(|| DocError::Invalid("zero-length line in the contour".into()))?, l)) + }; + let side = if flip { -1.0 } else { 1.0 }; + // Material side in the sketch: right of travel. + let (d0, _) = seg_dir(0)?; + let m0 = Vec2::new(d0.y, -d0.x) * side; + let start = pts.first().copied().unwrap_or_default(); + let (x, z) = (plane.dir_to_world(d0), plane.dir_to_world(m0)); + let frame = Plane::new(plane.to_world(start), x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?; + // Width along the sketch normal: +y or −y of the frame. + let ysign = if frame.y.dot(plane.normal()) >= 0.0 { 1.0 } else { -1.0 }; + let n = pts.len() - 1; + // Turn at each corner: angle and whether it bends toward the material (up). + let mut corners = Vec::new(); + for i in 0..n.saturating_sub(1) { + let (da, _) = seg_dir(i)?; + let (db, _) = seg_dir(i + 1)?; + let cross = da.cross(db) * side; + let ang = da.dot(db).clamp(-1.0, 1.0).acos(); + if ang < 1e-9 { + return Err(DocError::Invalid("straight corners in a contour flange".into())); + } + // Right-hand material: turning right (negative cross) bends toward it. + corners.push((ang, cross < 0.0)); + } + let setback = |ang: f64, up: bool| if up { (r + t) * (ang / 2.0).tan() } else { r * (ang / 2.0).tan() }; + let mut sheet = SheetBody { + body: body.into(), + rule: rule.into(), + t, + k: rv.k_factor, + frame, + base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() }, + flanges: Vec::new(), + holes: Vec::new(), + flat: false, + gap: rv.gap, + }; + let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) }; + // First segment: the base panel. + let (_, l0) = seg_dir(0)?; + let end0 = l0 - corners.first().map(|(a, u)| setback(*a, *u)).unwrap_or(0.0); + if end0 <= 0.0 { + return Err(DocError::Invalid("a contour line is shorter than its bends".into())); + } + sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(end0, y0), Vec2::new(end0, y1), Vec2::new(0.0, y1)); + let mut parent: Option = None; + let mut at = end0; + for (i, (ang, up)) in corners.iter().enumerate() { + let (_, l) = seg_dir(i + 1)?; + let before = setback(*ang, *up); + let after = corners.get(i + 1).map(|(a, u)| setback(*a, *u)).unwrap_or(0.0); + let leg = l - before - after; + if leg < -1e-9 { + return Err(DocError::Invalid("a contour line is shorter than its bends".into())); + } + // The material flips sides relative to the panel when the bend goes down. + let f = SheetFlange { + parent, + p: Vec2::new(at, y0), + d: Vec2::new(0.0, 1.0), + len: y1 - y0, + n: Vec2::new(1.0, 0.0), + angle: if *up { *ang } else { -*ang }, + radius: r, + leg: leg.max(0.0), + ext: (0.0, 0.0), + }; + let ba = sheet.allowance(&f); + sheet.flanges.push(f); + parent = Some(sheet.flanges.len() - 1); + at += ba + leg.max(0.0); + } + Ok(sheet) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn steel() -> RuleValues { + RuleValues { + thickness: 2.5, + k_factor: 0.44, + bend_radius: 2.5, + relief_width: 2.5, + relief_depth: 1.25, + corner_relief: 10.0, + hem_gap: 0.01, + gap: 2.5, + } + } + + fn plate(w: f64, h: f64) -> SheetBody { + let r = rect(Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)); + base_flange( + "Body1", + "Steel (mm)", + &steel(), + Plane::new(Vec3::ZERO, Vec3::X, Vec3::Y) + .unwrap_or_else(|| Plane::named("XY").unwrap_or(Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y })), + r, + ) + } + + fn vol(b: &Body) -> f64 { + kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN) + } + + #[test] + fn edge_flange_matches_fusion() { + // Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge. + let mut s = plate(100.0, 60.0); + s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, std::f64::consts::FRAC_PI_2, 2.5, "inside", false).unwrap(); + let b = s.solid().unwrap(); + assert!((vol(&b) - 18972.622).abs() < 2.0, "{}", vol(&b)); + let (w, h) = s.flat_size().unwrap(); + assert!((w - 100.0).abs() < 1e-9 && (h - 75.654867).abs() < 1e-5, "{w} {h}"); + let bends = s.bends(); + assert_eq!(bends.len(), 1); + assert!((bends[0].allowance - std::f64::consts::FRAC_PI_2 * 3.6).abs() < 1e-9); + // Unfolded: the same volume, flat. + s.flat = true; + let f = s.solid().unwrap(); + assert!((vol(&f) - 100.0 * 75.654867 * 2.5).abs() < 2.0); + // Oracle 70: 45°, height 15. + let mut s = plate(100.0, 60.0); + s.add_flange(Vec3::new(50.0, 0.0, 2.5), 15.0, std::f64::consts::FRAC_PI_4, 2.5, "inside", false).unwrap(); + assert!((vol(&s.solid().unwrap()) - 18450.777).abs() < 2.0); + assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5); + } + + #[test] + fn contour_flange_matches_fusion() { + // Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide. + let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap(); + let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, -30.0), Vec2::new(70.0, -30.0)]; + let s = contour_flange("Body1", "Steel (mm)", &steel(), &xz, &pts, 50.0, false).unwrap(); + let b = s.solid().unwrap(); + assert!((vol(&b) - 12097.622).abs() < 1.2, "{}", vol(&b)); + let m = kernel::measure(&b).unwrap(); + assert!((m.bbox.max.z - 32.5).abs() < 1e-6 && (m.bbox.max.y - 50.0).abs() < 1e-6, "{:?}", m.bbox); + assert!((s.flat_size().unwrap().0 - 96.309734).abs() < 1e-5, "{:?}", s.flat_size()); + } + + #[test] + fn hem_and_holes() { + // Oracle 73: flange then a flat hem of 8 on its top edge. + let mut s = plate(100.0, 60.0); + s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, std::f64::consts::FRAC_PI_2, 2.5, "inside", false).unwrap(); + s.add_hem(Vec3::new(50.0, 0.0, 20.0), 8.0, 0.01, false).unwrap(); + let b = s.solid().unwrap(); + assert!((vol(&b) - 21334.723).abs() < 2.0, "{}", vol(&b)); + assert!((s.flat_size().unwrap().1 - 84.632035).abs() < 1e-4, "{:?}", s.flat_size()); + // A hole in the base and a slot across the bend. + let mut s = plate(80.0, 50.0); + s.add_flange(Vec3::new(40.0, 0.0, 2.5), 20.0, std::f64::consts::FRAC_PI_2, 2.5, "inside", false).unwrap(); + s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(40.0, 30.0), 3.0), holes: vec![] }); + let v0 = vol(&s.solid().unwrap()); + s.holes.push(rect(Vec2::new(35.0, -12.0), Vec2::new(45.0, -12.0), Vec2::new(45.0, 10.0), Vec2::new(35.0, 10.0))); + let v1 = vol(&s.solid().unwrap()); + let ba = std::f64::consts::FRAC_PI_2 * 3.6; + let want = 10.0 * 2.5 * (22.0 - ba) + 10.0 * std::f64::consts::FRAC_PI_2 * (2.5 + 1.25) * 2.5; + assert!((v0 - v1 - want).abs() < 1.0, "{v0} {v1} {want}"); + s.flat = true; + assert!(s.solid().is_ok()); + } +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index a3449553..8f865955 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -65,6 +65,18 @@ fn measure(s: &mut Session, p: &Value) -> Result { if !want.is_empty() && bodies.len() != want.len() { return Err(bad("MeasureCommand", "unknown body name")); } + // Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its + // B-rep faces as they are, without merging coplanar neighbours. + let sheets = s.model.state().sheets.clone(); + for b in &mut bodies { + if let Some(sh) = sheets.iter().find(|x| b["name"].as_str() == Some(x.body.as_str())) { + let (df, de, dv) = sh.round_hole_correction(); + for (k, d) in [("faces", df), ("edges", de), ("vertices", dv)] { + b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d); + } + b["sheet_metal"] = json!(true); + } + } let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum(); let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum(); let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 02bcbe59..8a5a248a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod measure_sel; mod section; pub(crate) use measure_sel::measure_items; mod parameters; +mod sheet; mod sketch; mod sketch_constraints; mod sketch_create; @@ -127,6 +128,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); v.extend(features_more::COMMANDS.iter()); + v.extend(sheet::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs new file mode 100644 index 00000000..dd0bafbc --- /dev/null +++ b/crates/engine/src/cmd/sheet.rs @@ -0,0 +1,333 @@ +//! Sheet metal: rules, Flange (base, edge, contour), Hem, Unfold/Refold, Convert to Sheet Metal, +//! the flat pattern (outline, bend lines, bend table) and its DXF export. + +use serde_json::{Value, json}; +use solvecraft_doc::FeatureKind; +use solvecraft_doc::expr::Kind; +use solvecraft_doc::sheet::{SheetBody, SheetRule}; +use solvecraft_geom::{Vec2, Vec3}; + +use super::CommandSpec; +use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_id}; +use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params( + "base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \ + edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \ + position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)", + ), + CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem) + .at("SHEET METAL", "CREATE") + .icon("flange") + .params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"), + CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last sheet body) — all bends"), + CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last unfolded sheet)"), + CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert) + .at("SHEET METAL", "CREATE") + .icon("flat") + .params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"), + CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("params").params( + "name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool", + ), + CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"), + CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern) + .at("SHEET METAL", "CREATE") + .icon("flat") + .noundo() + .params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"), + CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) + .noundo() + .params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"), +]; + +fn points(p: &Value, k: &str, cmd: &str) -> Result> { + let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] on sheet edges")))?; + if a.is_empty() || a.len() > 1000 { + return Err(bad(cmd, format!("`{k}` must list 1…1000 points"))); + } + a.iter() + .map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))) + .collect() +} + +fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result> { + match str_(p, "rule") { + Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())), + Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))), + None => Ok(None), + } +} + +fn flange(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionSheetMetalFlangeCommand"; + let ty = match str_(p, "type") { + Some(t) => t.to_ascii_lowercase(), + None if p.get("edges").is_some() => "edge".into(), + None if p.get("curves").is_some() => "contour".into(), + None => "base".into(), + }; + match ty.as_str() { + "base" => { + let sketch = feature_sketch(s, p, cmd)?; + let rule = rule_param(s, p, cmd)?; + add_feature(s, p, FeatureKind::SheetBase { sketch, profiles: profiles(p, cmd)?, rule, flip: bool_(p, "flip").unwrap_or(false) }) + } + "contour" => { + let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?; + let curves = string_list(p, "curves"); + if curves.is_empty() || curves.len() > 1000 { + return Err(bad(cmd, "`curves` must list the open chain's lines")); + } + let distance = req_expr(cmd, p, "distance")?; + check_expr(s, &distance, Kind::Length, cmd, "distance")?; + let rule = rule_param(s, p, cmd)?; + add_feature( + s, + p, + FeatureKind::SheetContour { + sketch, + curves, + distance, + rule, + flip: bool_(p, "flip").unwrap_or(false), + reverse: bool_(p, "reverse").unwrap_or(false), + }, + ) + } + "edge" => { + let edges = points(p, "edges", cmd)?; + let height = req_expr(cmd, p, "height")?; + check_expr(s, &height, Kind::Length, cmd, "height")?; + let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into()); + check_expr(s, &angle, Kind::Angle, cmd, "angle")?; + let radius = expr(p, "radius"); + if let Some(r) = &radius { + check_expr(s, r, Kind::Length, cmd, "radius")?; + } + let position = str_(p, "position").unwrap_or("inside").to_ascii_lowercase(); + if !matches!(position.as_str(), "inside" | "outside" | "middle") { + return Err(bad(cmd, "`position` must be inside, outside or middle")); + } + let body = str_(p, "body").map(str::to_string); + add_feature(s, p, FeatureKind::SheetFlange { edges, height, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), body }) + } + other => Err(bad(cmd, format!("unknown flange type `{other}` (base, edge or contour)"))), + } +} + +fn hem(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionSheetMetalHemFlangeCommand"; + let edges = points(p, "edges", cmd)?; + let length = req_expr(cmd, p, "length")?; + check_expr(s, &length, Kind::Length, cmd, "length")?; + let gap = expr(p, "gap"); + if let Some(g) = &gap { + check_expr(s, g, Kind::Length, cmd, "gap")?; + } + add_feature( + s, + p, + FeatureKind::SheetHem { edges, length, gap, flip: bool_(p, "flip").unwrap_or(false), body: str_(p, "body").map(str::to_string) }, + ) +} + +fn unfold(s: &mut Session, p: &Value) -> Result { + add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: false }) +} + +fn refold(s: &mut Session, p: &Value) -> Result { + add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: true }) +} + +fn convert(s: &mut Session, p: &Value) -> Result { + let cmd = "ConvertToSheetMetalCmd"; + 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 face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?; + let rule = rule_param(s, p, cmd)?; + add_feature(s, p, FeatureKind::SheetConvert { body, face, rule }) +} + +fn rules(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionSheetMetalRulesCommand"; + let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string(); + if bool_(p, "delete").unwrap_or(false) { + let doc = s.doc_mut(); + let before = doc.sheet.rules.len(); + doc.sheet.rules.retain(|r| r.name != name); + if doc.sheet.rules.len() == before { + return Err(bad(cmd, format!("no rule `{name}`"))); + } + if doc.sheet.active.as_deref() == Some(name.as_str()) { + doc.sheet.active = None; + } + return Ok(json!({"deleted": name})); + } + let mut rule = + s.doc.sheet.rules.iter().find(|r| r.name == name).cloned().unwrap_or_else(|| SheetRule { name: name.clone(), ..s.doc.sheet_rule(None) }); + for (k, slot) in [ + ("thickness", &mut rule.thickness), + ("k_factor", &mut rule.k_factor), + ("bend_radius", &mut rule.bend_radius), + ("relief_width", &mut rule.relief_width), + ("relief_depth", &mut rule.relief_depth), + ("corner_relief", &mut rule.corner_relief), + ("hem_gap", &mut rule.hem_gap), + ("gap", &mut rule.gap), + ] { + if let Some(e) = expr(p, k) { + *slot = e; + } + } + let (vals, _) = s.doc.param_values(); + let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?; + let doc = s.doc_mut(); + match doc.sheet.rules.iter_mut().find(|r| r.name == name) { + Some(r) => *r = rule.clone(), + None => { + if doc.sheet.rules.len() >= 1000 { + return Err(EngineError::Other("too many rules".into())); + } + doc.sheet.rules.push(rule.clone()); + } + } + if bool_(p, "active").unwrap_or(doc.sheet.active.is_none()) { + doc.sheet.active = Some(name.clone()); + } + s.refresh(); + let errors: Vec = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect(); + Ok(json!({"rule": rule, "values": values, "errors": errors})) +} + +fn list_rules(s: &mut Session, _p: &Value) -> Result { + let (vals, _) = s.doc.param_values(); + let mut all = s.doc.sheet.rules.clone(); + if all.is_empty() { + all.push(SheetRule::steel()); + } + let rules: Vec = all.iter().map(|r| json!({"rule": r, "values": s.doc.rule_values(&vals, r).ok()})).collect(); + let active = s.doc.sheet_rule(None).name; + Ok(json!({"rules": rules, "active": active})) +} + +fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.model.state(); + match str_(p, "body") { + Some(b) => st.sheets.iter().find(|x| x.body == b).cloned().ok_or_else(|| bad(cmd, format!("`{b}` is not a sheet metal body"))), + None => st.sheets.last().cloned().ok_or_else(|| bad(cmd, "there is no sheet metal body")), + } +} + +fn flat_json(sh: &SheetBody) -> Result { + let (w, h) = sh.flat_size()?; + let loops = sh.outline()?; + let pt = |v: &Vec2| json!([v.x, v.y]); + let outline: Vec = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect(); + let holes: Vec = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect(); + let bends: Vec = sh + .bends() + .iter() + .map(|b| { + json!({"bend": b.index, "angle_deg": b.angle_deg, "radius": b.radius, "direction": if b.up { "up" } else { "down" }, + "allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)}) + }) + .collect(); + Ok( + json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}), + ) +} + +fn flat_pattern(s: &mut Session, p: &Value) -> Result { + let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?; + flat_json(&sh) +} + +/// A DXF (R12 entities, mm) of the flat pattern on named layers. +pub fn flat_dxf(sh: &SheetBody) -> Result { + let mut out = String::new(); + let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n")); + w(0, "SECTION"); + w(2, "HEADER"); + w(9, "$INSUNITS"); + w(70, "4"); + w(0, "ENDSEC"); + w(0, "SECTION"); + w(2, "TABLES"); + w(0, "TABLE"); + w(2, "LAYER"); + for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] { + w(0, "LAYER"); + w(2, name); + w(70, "0"); + w(62, colour); + w(6, "CONTINUOUS"); + } + w(0, "ENDTAB"); + w(0, "ENDSEC"); + w(0, "SECTION"); + w(2, "ENTITIES"); + let f = |x: f64| format!("{x:.6}"); + let line = |w: &mut dyn FnMut(i32, &str), layer: &str, a: Vec2, b: Vec2| { + w(0, "LINE"); + w(8, layer); + w(10, &f(a.x)); + w(20, &f(a.y)); + w(11, &f(b.x)); + w(21, &f(b.y)); + }; + for l in sh.outline()? { + let m = l.len(); + for i in 0..m { + if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) { + line(&mut w, "OUTLINE", *a, *b); + } + } + } + for h in &sh.holes { + for seg in &h.outer.segs { + match *seg { + solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => { + let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) }; + w(0, "ARC"); + w(8, "OUTLINE"); + w(10, &f(center.x)); + w(20, &f(center.y)); + w(40, &f(radius)); + w(50, &f(a0.to_degrees())); + w(51, &f(a1.to_degrees())); + } + other => { + let pts: Vec = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect(); + for p in pts.windows(2) { + line(&mut w, "OUTLINE", p[0], p[1]); + } + } + } + } + } + for b in sh.bends() { + line(&mut w, if b.up { "BEND_UP" } else { "BEND_DOWN" }, b.start, b.end); + } + w(0, "ENDSEC"); + w(0, "EOF"); + Ok(out) +} + +fn export_dxf(s: &mut Session, p: &Value) -> Result { + let cmd = "sheet.export_dxf"; + let sh = sheet_of(s, p, cmd)?; + let text = flat_dxf(&sh)?; + if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) { + std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + return Ok(json!({"path": path, "bends": sh.bends().len()})); + } + Ok(json!({"dxf": text, "bends": sh.bends().len()})) +} + +#[cfg(test)] +#[path = "sheet_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs new file mode 100644 index 00000000..b29f67d8 --- /dev/null +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -0,0 +1,143 @@ +use std::f64::consts::{FRAC_PI_2, PI}; + +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) -> f64 { + run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +fn flat(s: &mut Session) -> Value { + run(s, "FusionSheetMetalFlatPatternCmd", json!({})) +} + +/// A w x h base flange on XY. +fn base(s: &mut Session, w: f64, h: f64) { + run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"})); + run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]})); + run(s, "SketchStop", json!({})); + run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"})); +} + +#[test] +fn base_and_edge_flange_follow_the_k_factor() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + assert!(rel(volume(&mut s), 15000.0) < 1e-9); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20})); + // Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5. + let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0; + let f = flat(&mut s); + assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}"); + assert_eq!(f["bends"][0]["direction"], "up"); + assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s)); + let m = run(&mut s, "MeasureCommand", json!({})); + assert_eq!(m["total"]["faces"], 14, "{m}"); + // The angle is a parameter: 45° (oracle 70 uses height 15). + let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default(); + run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"})); + run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"})); + assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s)); + assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5); + // DXF: outline and an up bend line. + let d = run(&mut s, "sheet.export_dxf", json!({})); + let text = d["dxf"].as_str().unwrap_or_default(); + assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}"); + assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge"); +} + +#[test] +fn rules_are_parameters() { + let mut s = Session::default(); + run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"})); + run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"})); + base(&mut s, 80.0, 50.0); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20})); + // Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980. + let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5; + assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6); + assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s)); + // Thicker: everything follows. + run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"})); + let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0; + assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6); + let l = run(&mut s, "sheet.rules", json!({})); + assert_eq!(l["active"], "Custom", "{l}"); + assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err()); +} + +#[test] +fn contour_flange_hem_and_tray() { + // Oracle 72. + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"})); + run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50})); + assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s)); + assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5); + // Oracle 73: flange, then a flat hem of 8. + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20})); + run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8})); + assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s)); + let b = flat(&mut s)["bends"].clone(); + assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}"); + // A tray: four flanges at once stop at each other's bends. + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25})); + let f = flat(&mut s); + // Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across. + assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}"); + let v = volume(&mut s); + // Each flange runs on past its bend toward the corner until the inner corners are the + // rule's gap apart (diagonally): 5 − (2.5 + 2.5/√2) = 0.732 at each of 8 ends. + let section = PI / 4.0 * (25.0 - 6.25) + 2.5 * 20.0; + let ext = 5.0 - (2.5 + 2.5 / std::f64::consts::SQRT_2); + let want = 90.0 * 50.0 * 2.5 + section * (2.0 * 90.0 + 2.0 * 50.0) + 8.0 * ext * 2.5 * 20.0; + assert!(rel(v, want) < 1e-4 && rel(v, 29666.234) < 1e-4, "{v} {want}"); +} + +#[test] +fn unfold_cut_refold_and_convert() { + // Oracle 74: two flanges, unfold, a slot across the bend, refold. + let mut s = Session::default(); + base(&mut s, 80.0, 50.0); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20})); + let folded = volume(&mut s); + run(&mut s, "FusionSheetmetalUnfoldCommand", json!({})); + let m = run(&mut s, "MeasureCommand", json!({})); + assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}"); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"})); + run(&mut s, "sheet.refold", json!({})); + let v = volume(&mut s); + assert!(v < folded - 300.0, "{v} {folded}"); + assert!(rel(v, 15800.304) < 1e-4, "{v}"); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1)); + // Convert a plate. + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"})); + run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]})); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20})); + assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s)); + let mut t = Session::default(); + run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"})); + assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err()); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index c6d566fa..064a52b9 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -26,6 +26,7 @@ mod offset; mod ops; mod polybool; mod polyhedron; +mod sew; mod step; mod step_in; mod step_out; @@ -40,6 +41,7 @@ pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; pub use polybool::planar_boolean; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; +pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs new file mode 100644 index 00000000..e210ec0f --- /dev/null +++ b/crates/kernel/src/sew.rs @@ -0,0 +1,179 @@ +//! Sewing a solid from face descriptions: each face is its boundary loops (lines and circular +//! arcs, counter-clockwise about the outward normal; holes clockwise) and its surface (planar, +//! revolved from a line, or extruded from an arc). Vertices and edges at the same places are +//! shared, so the faces close into one shell without booleans — for solids whose faces are +//! known exactly, such as folded sheet metal. + +use std::collections::HashMap; + +use mt::builder; +use solvecraft_geom::Vec3; +use truck_modeling as mt; + +use crate::body::{Body, Solid, p3, v3}; +use crate::{KernelError, Result, guard}; + +/// A boundary edge. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum EdgeSpec { + Line { + a: Vec3, + b: Vec3, + }, + /// A circular arc from `a` to `b` through `mid`. + Arc { + a: Vec3, + b: Vec3, + mid: Vec3, + }, +} + +impl EdgeSpec { + fn ends(&self) -> (Vec3, Vec3) { + match *self { + EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (a, b), + } + } +} + +/// A face's surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SurfSpec { + /// The plane of the boundary. + Plane, + /// The surface the line `a`→`b` sweeps turning `angle` about the axis. + Revolved { a: Vec3, b: Vec3, axis_origin: Vec3, axis: Vec3, angle: f64 }, + /// The surface an arc sweeps moving along `dir`. + Extruded { a: Vec3, b: Vec3, mid: Vec3, dir: Vec3 }, +} + +/// A face: boundary loops, surface, and a point on it with its outward normal (curved faces). +#[derive(Clone, Debug, PartialEq)] +pub struct FaceSpec { + pub loops: Vec>, + pub surface: SurfSpec, + pub probe: Option<(Vec3, Vec3)>, +} + +type Key = (i64, i64, i64); + +fn key(p: Vec3) -> Key { + let q = |x: f64| (x * 1e6).round() as i64; + (q(p.x), q(p.y), q(p.z)) +} + +#[derive(Default)] +struct Cache { + verts: HashMap, + edges: HashMap<(Key, Key, Option), mt::Edge>, +} + +impl Cache { + fn vertex(&mut self, p: Vec3) -> mt::Vertex { + self.verts.entry(key(p)).or_insert_with(|| builder::vertex(p3(p))).clone() + } + fn edge(&mut self, e: &EdgeSpec) -> mt::Edge { + let (a, b) = e.ends(); + let mid = match e { + EdgeSpec::Arc { mid, .. } => Some(key(*mid)), + EdgeSpec::Line { .. } => None, + }; + let (ka, kb) = (key(a), key(b)); + if let Some(found) = self.edges.get(&(ka, kb, mid)) { + return found.clone(); + } + if let Some(found) = self.edges.get(&(kb, ka, mid)) { + return found.inverse(); + } + let (va, vb) = (self.vertex(a), self.vertex(b)); + let made = match *e { + EdgeSpec::Line { .. } => builder::line(&va, &vb), + EdgeSpec::Arc { mid, .. } => builder::circle_arc(&va, &vb, p3(mid)), + }; + self.edges.insert((ka, kb, mid), made.clone()); + made + } +} + +/// Sew the faces into a closed solid. +pub fn sew(faces: &[FaceSpec]) -> Result { + if faces.len() < 4 || faces.len() > 200_000 { + return Err(KernelError::Invalid("a solid needs 4…200000 faces".into())); + } + for f in faces { + for l in &f.loops { + for e in l { + let (a, b) = e.ends(); + if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 { + return Err(KernelError::Invalid("sew: a degenerate edge".into())); + } + } + } + } + guard("sew", || { + let mut cache = Cache::default(); + let mut out: Vec = Vec::with_capacity(faces.len()); + for (fi, f) in faces.iter().enumerate() { + let wires: Vec = f.loops.iter().map(|l| l.iter().map(|e| cache.edge(e)).collect::>().into()).collect(); + let face = match f.surface { + SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?, + SurfSpec::Revolved { a, b, axis_origin, axis, angle } => { + let k = axis.normalized().ok_or_else(|| KernelError::Invalid("sew: axis".into()))?; + let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b))); + let swept: mt::Shell = builder::rsweep(&builder::line(&va, &vb), p3(axis_origin), v3(k), mt::Rad(angle)); + let tmp = swept.face_iter().next().cloned().ok_or_else(|| KernelError::Failed("sew: revolved surface".into()))?; + curved(tmp.oriented_surface(), wires, f.probe, fi)? + } + SurfSpec::Extruded { a, b, mid, dir } => { + let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b))); + let tmp: mt::Face = builder::tsweep(&builder::circle_arc(&va, &vb, p3(mid)), v3(dir)); + curved(tmp.oriented_surface(), wires, f.probe, fi)? + } + }; + out.push(face); + } + let shell: mt::Shell = out.into(); + let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?; + Body::new(solid) + }) +} + +/// A curved face on `surf`, turned so its normal at the probe points outward. +fn curved(mut surf: mt::Surface, wires: Vec, probe: Option<(Vec3, Vec3)>, fi: usize) -> Result { + use mt::{ParametricSurface3D, SearchParameter}; + if let Some((p, outward)) = probe + && let Some((u, v)) = surf.search_parameter(p3(p), None, 100) + { + let n = surf.normal(u, v); + if Vec3::new(n.x, n.y, n.z).dot(outward) < 0.0 { + surf = mt::Invertible::inverse(&surf); + } + } + mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> FaceSpec { + FaceSpec { + loops: vec![vec![EdgeSpec::Line { a, b }, EdgeSpec::Line { a: b, b: c }, EdgeSpec::Line { a: c, b: d }, EdgeSpec::Line { a: d, b: a }]], + surface: SurfSpec::Plane, + probe: None, + } + } + + #[test] + fn a_sewn_box() { + let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); + let (a, b, c, d) = (p(0., 0., 0.), p(2., 0., 0.), p(2., 3., 0.), p(0., 3., 0.)); + let (e, f, g, h) = (p(0., 0., 4.), p(2., 0., 4.), p(2., 3., 4.), p(0., 3., 4.)); + let faces = vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)]; + let body = sew(&faces).unwrap(); + let m = crate::measure(&body).unwrap(); + assert!((m.volume - 24.0).abs() < 1e-9, "{}", m.volume); + assert_eq!(body.face_count(), 6); + assert!(sew(&faces[..5]).is_err()); + } +}