From d824d3682194a48fc69f6715d80dfd5b983da4f7 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:54:09 +0200 Subject: [PATCH] Convert to Sheet Metal takes bent parts as modelled: radius and chamfer bends, holes (#1314) A Convert to Sheet Metal bend point can now lie on a bend modelled with its radius (a cylinder tangent to two flat faces) or in the middle of a chamfer; either becomes one bend at the faces' virtual sharp, with the rule's radius. Holes and engravings carry over. Bend points on edges convert as before. Recorded under format 21. Tests in cmd::sheet::convert_tests; the sheet-metal guide is updated. --- crates/doc/src/eval_more.rs | 250 +++++-- crates/doc/src/format.rs | 7 + crates/doc/src/sheet.rs | 16 + crates/doc/src/sheet_convert.rs | 597 ++++++++++++++-- crates/engine/src/cmd/sheet.rs | 10 +- crates/engine/src/cmd/sheet_convert_tests.rs | 693 ++++++++++++++++++- docs/book/src/guide/sheet-metal.md | 35 +- 7 files changed, 1480 insertions(+), 128 deletions(-) diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index fcffcb19..497d3b0b 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -646,16 +646,18 @@ pub(super) fn sheet_eval( // along a round wall an arc of the wall's circle (not chords: the plate check below // compares its area with the exact volume). let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?; + // Recesses in one face (an engraved label) become pockets; the outline is the other face's. + let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?; let mut walls = std::collections::HashMap::new(); - let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| round_wall(&mb.body, &plane, n, f)); + let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f)); let mut loops: Vec = - face_outline(&mesh, &plane, *face, n, lo, tol).iter().map(|l| outline_loop(l, &mut wall, tol * 1e-2)).collect(); + face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).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("no outline on that face".into()))?.ccw(); let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() }; let area = region.area().abs(); - let vol = kernel::measure(&mb.body)?.volume; + let vol = kernel::measure(&mb.body)?.volume + recesses.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 without bend edges (`bends`)".into(), @@ -666,6 +668,10 @@ pub(super) fn sheet_eval( 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 mut work = sheet::FlatWork::new(); + for (pocket, z0, z1) in recesses.pockets { + sh.record_pocket(pocket, z0, z1, &mut work)?; + } put_sheet(st, sh) } _ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))), @@ -1134,18 +1140,8 @@ fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec)>> { let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)); let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).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 key = |a: Vec3, b: Vec3| { - let (ka, kb) = (q(a), q(b)); - (ka.min(kb), ka.max(kb)) - }; - 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 { - *count.entry(key(*a, *b)).or_insert(0) += 1; - } // The faces off the plane at each point on it, by position to a millionth of a millimetre // either way (a neighbouring face's mesh may round a shared point differently). let mut touch: std::collections::HashMap<(i64, i64, i64), Vec> = Default::default(); @@ -1169,6 +1165,34 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, let at_b = near(b); near(a).into_iter().find(|f| at_b.contains(f)) }; + chain_loops(mesh, plane, &on, across) +} + +/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh. +fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec> { + chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect() +} + +/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh, each point with +/// `across(it, next)`: the face the outline runs along from it to the next point. +fn chain_loops( + mesh: &solidcraft_geom::Mesh, + plane: &Plane, + on: &[[u32; 3]], + across: impl Fn(Vec3, Vec3) -> Option, +) -> Vec)>> { + // 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 key = |a: Vec3, b: Vec3| { + let (ka, kb) = (q(a), q(b)); + (ka.min(kb), ka.max(kb)) + }; + 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 { + *count.entry(key(*a, *b)).or_insert(0) += 1; + } let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect(); // Chain into loops (plane coordinates). let mut loops: Vec)>> = Vec::new(); @@ -1207,60 +1231,162 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, loops } -/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall -/// standing square on the plane: a cylinder about the plane's normal `n`. -fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> { - match kernel::face_surface(body, f as usize)? { - kernel::FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)), - _ => None, - } +/// What the recesses of a plate (an engraved label, a counterbore) take out of it, for Convert +/// to Sheet Metal: each becomes a pocket of the sheet, from its floor to the face it opens on. +struct Recesses { + /// The level (along the picked face's normal) of the plate's face without recesses, whose + /// outline the sheet takes. + level: f64, + /// Pockets in flat coordinates, with their depth interval in the sheet (0 to its thickness). + pockets: Vec<(Region2, f64, f64)>, + /// The material they take out. + volume: f64, } -/// A loop of [`face_outline`] as segments: each run along one round wall (`wall` gives a face's -/// circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of one radius are -/// one wall, however many faces it comes in (many systems write a round hole's wall as two -/// half-cylinders), so such a hole is one circle. `eps`: how far off its circle a point may be. -fn outline_loop(pts: &[(Vec2, Option)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 { - use std::f64::consts::{PI, TAU}; - let m = pts.len(); - let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default(); - // The circle each segment runs along: its wall's, when both its ends lie on it. - let circles: Vec> = (0..m) - .map(|i| { - let (c, r) = wall(pts.get(i)?.1?)?; - ((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r)) - }) - .collect(); - let circle = |i: usize| circles.get(i % m).copied().flatten(); - let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps); - // Start where no arc runs on across the start (anywhere when the whole loop is one circle). - let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0); - let mut segs = Vec::new(); - let mut i = 0; - while i < m { - let k = start + i; - let Some((c, r)) = circle(k) else { - segs.push(Seg2::Line { a: point(k), b: point(k + 1) }); - i += 1; +/// A planar face of the mesh parallel to the plate: its lowest and highest level, whether its +/// outward normal is the picked face's (it faces up), its area, a point on it, and its +/// triangles' centres and areas. +struct LevelFace { + lo: f64, + hi: f64, + up: bool, + area: f64, + at: Vec3, + tris: Vec<(Vec3, f64)>, +} + +/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`), +/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its +/// thickness, lying in an opening of the face it is cut into, and its walls are square to the +/// plate (walls that are not show as material the plate's volume doesn't account for). A body +/// that is no plate once its recesses are filled has none, so the plate check refuses it as +/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face +/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate +/// raised in places is no plate of the raised thickness). +fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result { + let t = hi - lo; + let eps = t * 1e-3; + let level = |p: Vec3| (p - at).dot(n); + let mut faces: BTreeMap> = BTreeMap::new(); + for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + let Some([a, b, c]) = mesh.tri(tri) else { continue }; + let cr = (b - a).cross(c - a); + let len = cr.len(); + if !(len > 1e-12) { continue; - }; - let mut j = i + 1; - while j < m && same(start + j, (c, r)) { - j += 1; } - let sweep: f64 = (i..j) - .map(|s| { - let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle(); - (d + PI).rem_euclid(TAU) - PI - }) - .sum(); - // Arcs of up to half a turn each. - let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4); - let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64); - segs.extend((0..pieces).map(|p| Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step })); - i = j; + let up = cr.dot(n) > 0.0; + let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() })); + match entry { + Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => { + for p in [a, b, c] { + face.lo = face.lo.min(level(p)); + face.hi = face.hi.max(level(p)); + } + face.area += len / 2.0; + face.tris.push(((a + b + c) / 3.0, len / 2.0)); + } + _ => *entry = None, + } } - Loop2 { segs } + // The floors (face index, level, the face), and the areas of the plate's two faces (at `lo` + // and at `hi`). + let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new(); + let (mut bottom, mut top) = (0.0, 0.0); + for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) { + let d = (face.lo + face.hi) / 2.0; + if face.hi - face.lo > eps { + continue; + } else if (d - hi).abs() <= eps && face.up { + top += face.area; + } else if (d - lo).abs() <= eps && !face.up { + bottom += face.area; + } else if d > lo + eps && d < hi - eps { + floors.push((f, d, face)); + } + } + let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 }; + if floors.is_empty() { + return Ok(none()); + } + // A floor is a recess's where (most of) it lies in an opening of the face it is cut into: + // inside that face's outline and in a hole of it, not on an island in the hole. (Its + // triangles are those at its level within `eps`: `tol` may exceed a recess's depth.) + let loops_at = |level: f64| -> Vec { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() }; + let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo)); + floors.retain(|(_, _, face)| { + let loops = if face.up { &top_loops } else { &bottom_loops }; + let open = |p: Vec2| { + let k = loops.iter().filter(|l| l.contains(p)).count(); + k >= 2 && k.is_multiple_of(2) + }; + face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::() > face.area / 2.0 + }); + let Some(&(_, _, first)) = floors.first() else { return Ok(none()) }; + // Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their + // openings count, and its volume with theirs the outline times the thickness. Else (a bent + // part) they are none. + let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo }; + let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::(); + let outline = bottom + opened(false); + let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum(); + let volume = kernel::measure(body)?.volume; + if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) { + return Ok(none()); + } + let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z); + let up = first.up; + if let Some(other) = floors.iter().find(|f| f.2.up != up) { + return Err(DocError::Invalid(format!( + "not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets", + pt(first.at), + pt(other.2.at) + ))); + } + let depth_of = |d: f64| depth_in(d, up); + if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) { + return Err(DocError::Invalid(format!( + "not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets", + pt(deep.2.at), + depth_of(deep.1), + t + ))); + } + let face_area = if up { top } else { bottom }; + if floors.iter().map(|f| f.2.area).sum::() > face_area { + return Err(DocError::Invalid(format!( + "not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)" + ))); + } + // The floors' outlines, from a mesh fine enough to mark them by. + let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?; + floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1))); + // Floors at one depth are one pocket where they meet. + let mut groups: Vec<(f64, Vec)> = Vec::new(); + for (f, d, ..) in &floors { + let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect(); + let mut loops = boundary_loops(&fine, plane, &on); + loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs())); + let mut it = loops.into_iter(); + let Some(outline) = it.next() else { continue }; + let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() }; + match groups.last_mut() { + Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region), + _ => groups.push((depth_of(*d), vec![region])), + } + } + // The sheet's z starts from the picked face when its plane faces away from that face. + let from_zero = up == (plane.normal().dot(n) < 0.0); + let mut work = sheet::FlatWork::new(); + let (mut pockets, mut volume) = (Vec::new(), 0.0); + for (depth, regions) in groups { + let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) }; + for region in sheet::material_union(regions, &mut work)? { + volume += region.area().abs() * depth; + pockets.push((region, z0, z1)); + } + } + Ok(Recesses { level: if up { lo } else { hi }, pockets, volume }) } /// The body a placed plastic feature joins: by name, else the one under the point. diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 8b170882..3a8f9693 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -184,6 +184,13 @@ //! extrudes save; or `legacy_per_side`). An extent without it keeps the per-side reading //! designs always had, so their thickness and parameters don't change; 0.11.0 asks for an //! update instead of doubling a total-distance extrude. +//! Under the same version, a Convert to Sheet Metal bend point can lie in the middle of a +//! chamfer (a flat strip between two faces), which then is one bend at the two faces' virtual +//! sharp; 0.11.0 bent the chamfer's nearest edge instead or found no edge there. Bend points +//! on edges convert as before. +//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat +//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the +//! rule's radius; 0.11.0 found no edge there or refused the curved face. use serde_json::Value; diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index aba96b19..4b577ebb 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -19,6 +19,7 @@ use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector #[path = "sheet_convert.rs"] mod convert; pub use convert::{bend_edge_names, convert_body}; +pub(crate) use convert::{outline_loop, round_wall}; // Bound the aggregate geometry, including sampled curves and intermediate clipping pieces. // These are work limits, never geometry truncation: a larger input returns a useful error. @@ -446,6 +447,10 @@ pub struct SheetFlange { /// instead of a rectangle `leg` long. #[serde(default, skip_serializing_if = "Option::is_none")] pub shape: Option>, + /// A converted face's panel as drawn: `shape` with arcs where the face's edges are round + /// (flat coordinates, counter-clockwise). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exact: Option, /// Which feature made it: `F.` (persistent edge names). #[serde(default, skip_serializing_if = "String::is_empty")] pub tag: String, @@ -842,6 +847,9 @@ impl SheetBody { /// Flat region of a flange's panel. pub fn panel(&self, f: &SheetFlange) -> Region2 { + if let Some(lp) = &f.exact { + return Region2 { outer: lp.ccw(), holes: Vec::new() }; + } if let Some(sh) = &f.shape { return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() }; } @@ -2167,6 +2175,7 @@ impl SheetBody { radius, leg: leg.max(0.0), shape: None, + exact: None, tag: String::new(), ext: (0.0, 0.0), slope: (0.0, 0.0), @@ -2899,6 +2908,7 @@ impl SheetBody { slope: (0.0, 0.0), tear: (false, false), shape: None, + exact: None, tag: String::new(), }); Ok(self.flanges.len() - 1) @@ -3033,6 +3043,7 @@ impl SheetBody { slope: (0.0, 0.0), tear: (false, false), shape: None, + exact: None, tag: String::new(), }; let ba = self.allowance(&probe); @@ -3097,6 +3108,7 @@ impl SheetBody { Some(j) => { if let Some(f) = self.flanges.get_mut(j) { f.shape = Some(ccw(fixed_part.clone())); + f.exact = None; } } } @@ -3113,6 +3125,7 @@ impl SheetBody { slope: (0.0, 0.0), tear: (false, false), shape: Some(ccw(moving)), + exact: None, tag: String::new(), }); let i = self.flanges.len() - 1; @@ -3238,6 +3251,7 @@ impl SheetBody { Some(j) => { if let Some(f) = self.flanges.get_mut(j) { f.shape = Some(ccw(fixed)); + f.exact = None; } } } @@ -3256,6 +3270,7 @@ impl SheetBody { slope: (0.0, 0.0), tear: (false, false), shape: Some(ccw(moving)), + exact: None, tag: String::new(), }); } @@ -4055,6 +4070,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt slope: (0.0, 0.0), tear: (false, false), shape: None, + exact: None, tag: String::new(), }; let ba = sheet.allowance(&f); diff --git a/crates/doc/src/sheet_convert.rs b/crates/doc/src/sheet_convert.rs index 2003c1fd..be4e80eb 100644 --- a/crates/doc/src/sheet_convert.rs +++ b/crates/doc/src/sheet_convert.rs @@ -11,20 +11,41 @@ //! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's //! gap from the plane halfway between the two. //! +//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii +//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet: +//! the faces on either side are taken out to where their planes meet, and that line is the +//! bend's virtual sharp, bent with the rule's radius at the angle between the two faces. +//! +//! A bend modelled with its radius, a cylinder between two planar faces tangent to it along its +//! length, is picked anywhere on its cylinder, on either skin, and taken the same way: the two +//! faces out to their virtual sharp, bent there with the rule's radius and K-factor. The +//! modelled radius gives way to the rule's, as a sharp edge's does: the flat is measured from +//! the virtual sharps, which don't depend on it. +//! //! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is //! the base panel and every other panel a flange on its parent's edge, its outline taken through //! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a //! sheet made from flanges. //! -//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved -//! edges or holes, loops of bends, two faces that meet along more than one edge. +//! A face's outline is read exactly, as a plate's is: where it runs along a round wall standing +//! square on the face (a rounded corner, a tooth) it is an arc of the wall's circle, and its +//! holes and slots are cut-outs of the sheet where they lie in its panel. Its pockets (an +//! engraved label: an inner loop whose walls end on flat floors, at most half the sheet deep) +//! are pockets of the sheet from that skin, round their islands; on a shell the faces of the +//! other skin are read for theirs. +//! +//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach +//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round +//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than +//! one edge. use std::collections::BTreeMap; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; -use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3}; +use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3}; use solidcraft_kernel::{Body, FaceSurface, face_surface}; -use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap}; +use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap}; use crate::{DocError, ModelBody, Result, apply_point, mat_inverse}; const MAX_BENDS: usize = 1000; @@ -38,11 +59,16 @@ fn at(p: Vec3) -> String { } /// A planar face of the body: its outward normal and its outline, counter-clockwise about the -/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`. +/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`: straight, or an +/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle). +/// `holes`: its inner loops, in `plane`, each with the faces of its walls. struct Face { n: Vec3, pts: Vec, across: Vec>, + round: Vec>, + plane: Plane, + holes: Vec<(Loop2, Vec)>, } /// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between @@ -68,38 +94,168 @@ fn turn(per_edge: &mut [T]) { } } -/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight -/// edges can be one. -fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result { - let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else { - return err("not supported yet: curved faces in a converted sheet (only planar faces convert)"); - }; - // Its edges, each with the face on its other side. - let mut edges = Vec::new(); - for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) { - if !faces.iter().any(|g| *g as usize == f) { +/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall +/// standing square on the plane: a cylinder about the plane's normal `n`. +pub(crate) fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> { + match face_surface(body, f as usize)? { + FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)), + _ => None, + } +} + +/// A loop of points on a face, each with the B-rep face across the loop from it to the next +/// point (the wall it runs along there), as segments: each run along one round wall (`wall` +/// gives a face's circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of +/// one radius are one wall, however many faces it comes in (many systems write a round hole's +/// wall as two half-cylinders), so such a hole is one circle. `eps`: how far off its circle a +/// point may be. +pub(crate) fn outline_loop(pts: &[(Vec2, Option)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 { + Loop2 { segs: outline_segs(pts, wall, eps).into_iter().map(|(seg, _)| seg).collect() } +} + +/// [`outline_loop`], each segment with its wall (an arc's: that of its first point). +fn outline_segs(pts: &[(Vec2, Option)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Vec<(Seg2, Option)> { + let m = pts.len(); + let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default(); + let face = |i: usize| pts.get(i % m).and_then(|x| x.1); + // The circle each segment runs along: its wall's, when both its ends lie on it. + let circles: Vec> = (0..m) + .map(|i| { + let (c, r) = wall(pts.get(i)?.1?)?; + ((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r)) + }) + .collect(); + let circle = |i: usize| circles.get(i % m).copied().flatten(); + let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps); + // Start where no arc runs on across the start (anywhere when the whole loop is one circle). + let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0); + let mut segs = Vec::new(); + let mut i = 0; + while i < m { + let k = start + i; + let Some((c, r)) = circle(k) else { + segs.push((Seg2::Line { a: point(k), b: point(k + 1) }, face(k))); + i += 1; + continue; + }; + let mut j = i + 1; + while j < m && same(start + j, (c, r)) { + j += 1; + } + let sweep: f64 = (i..j) + .map(|s| { + let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle(); + (d + PI).rem_euclid(TAU) - PI + }) + .sum(); + // Arcs of up to half a turn each. + let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4); + let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64); + segs.extend((0..pieces).map(|p| (Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }, face(k)))); + i = j; + } + segs +} + +/// `segs` (a closed loop) with lines that run on in one line joined: a slot's side in two faces +/// is one line. +fn joined_lines(segs: impl IntoIterator, tol: f64) -> Vec { + let on = |p: Vec2, a: Vec2, b: Vec2| p.extend(0.0).dist_to_segment(a.extend(0.0), b.extend(0.0)) <= tol; + let mut out: Vec = Vec::new(); + for seg in segs { + if let (Some(Seg2::Line { a, b }), Seg2::Line { b: c, .. }) = (out.last_mut(), seg) + && on(*b, *a, c) + { + *b = c; continue; } - let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue }; - if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) { - return err("not supported yet: faces with curved edges in a converted sheet"); + out.push(seg); + } + // Across the loop's start. + if let (true, Some(Seg2::Line { a, b }), Some(Seg2::Line { b: c, .. })) = (out.len() > 2, out.last().copied(), out.first().copied()) + && on(b, a, c) + { + out.pop(); + if let Some(Seg2::Line { a: first, .. }) = out.first_mut() { + *first = a; } - edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f))); } - // Chained head to tail from the first one, they close one loop. - let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") }; - let (mut pts, mut tags) = (vec![start], vec![across]); - while end.dist(start) > tol { - let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else { - return err("the outline of a face of the body does not close"); - }; - let (p, q, across) = edges.swap_remove(k); - pts.push(end); - tags.push(across); - end = if p.dist(end) <= tol { q } else { p }; + out +} + +/// Face `f` of the body as a panel outline. Only a planar face can be one. Its loops are read +/// exactly, as a plate's are (an arc where one runs along a round wall standing square on the +/// face): the outer one is the outline, the others are its holes. +fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result { + let Some(FaceSurface::Plane { normal: n, point }) = face_surface(b, f) else { + return err("not supported yet: curved faces in a converted sheet (only planar faces convert)"); + }; + let plane = Plane::from_normal(point, n).ok_or_else(|| DocError::Invalid("a face of the body has no plane".into()))?; + // Its edges, each with the face on its other side. + let mut edges: Vec<(&[Vec3], Option)> = mesh + .edges + .iter() + .zip(&mesh.edge_faces) + .filter(|(line, faces)| line.len() >= 2 && faces.iter().any(|g| *g as usize == f)) + .map(|(line, faces)| (line.as_slice(), faces.iter().map(|g| *g as usize).find(|g| *g != f))) + .collect(); + // Chained head to tail, they close the outline and its holes: each point with the face the + // loop runs along from it to the next point. + let mut loops: Vec)>> = Vec::new(); + while let Some((line, across)) = edges.pop() { + let (Some(&start), Some(&last)) = (line.first(), line.last()) else { continue }; + let mut lp: Vec<(Vec3, Option)> = line.iter().take(line.len().saturating_sub(1)).map(|p| (*p, across)).collect(); + let mut end = last; + while end.dist(start) > tol { + let meets = |p: Option<&Vec3>| p.is_some_and(|p| p.dist(end) <= tol); + let Some(k) = edges.iter().position(|(l, _)| meets(l.first()) || meets(l.last())) else { + return err("the outline of a face of the body does not close"); + }; + let (l, g) = edges.swap_remove(k); + let run: Vec = if meets(l.first()) { l.to_vec() } else { l.iter().rev().copied().collect() }; + lp.extend(run.iter().take(run.len().saturating_sub(1)).map(|p| (*p, g))); + end = run.last().copied().unwrap_or(start); + } + loops.push(lp); } - if !edges.is_empty() { - return err("not supported yet: faces with holes in a converted sheet (cut them after converting)"); + let mut walls = std::collections::HashMap::new(); + let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g)); + // Each loop with the faces of its walls. + let mut shaped: Vec<(Vec<(Seg2, Option)>, Vec)> = loops + .iter() + .map(|lp| { + let pts: Vec<(Vec2, Option)> = lp.iter().map(|(p, g)| (plane.to_local(*p), g.and_then(|g| u32::try_from(g).ok()))).collect(); + let mut walls: Vec = lp.iter().filter_map(|(_, g)| *g).collect(); + walls.sort_unstable(); + walls.dedup(); + (outline_segs(&pts, &mut wall, tol * 10.0), walls) + }) + .collect(); + let area = |segs: &[(Seg2, Option)]| segs.iter().map(|(seg, _)| seg.area_term()).sum::().abs(); + shaped.sort_by(|x, y| area(&y.0).total_cmp(&area(&x.0))); + let mut it = shaped.into_iter(); + let Some((outer, _)) = it.next() else { return err("a face of the body has no edges") }; + let holes = it.map(|(segs, walls)| (Loop2 { segs: joined_lines(segs.into_iter().map(|(seg, _)| seg), tol) }, walls)).collect(); + // The outline's corners, where its segments start (an arc's in quarter turns at most, so that + // the corners alone still make an outline), each with the face along it. + let world = |v: Vec2| plane.to_world(v); + let (mut pts, mut tags, mut round) = (Vec::new(), Vec::new(), Vec::new()); + for (seg, g) in outer { + let g = g.map(|g| g as usize); + if let Seg2::Arc { center, radius, start, sweep } = seg { + let k = ((sweep.abs() / FRAC_PI_2 - 1e-9).ceil() as usize).clamp(1, 8); + let step = sweep / k as f64; + for p in 0..k { + let a = start + step * p as f64; + pts.push(world(center + Vec2::from_angle(a) * radius)); + tags.push(g); + round.push(Some((world(center), world(center + Vec2::from_angle(a + step / 2.0) * radius)))); + } + } else { + pts.push(world(seg.start())); + tags.push(g); + round.push(None); + } } // A straight run along one neighbour is one edge, however the body's edges divide it (a // shell's outer corners are divided at the height of its floor). @@ -107,31 +263,187 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result { let corner = |i: usize| -> bool { let before = (i + m - 1) % m; let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true }; - tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol + let arc = |k: usize| round.get(k).is_some_and(Option::is_some); + tags.get(before) != tags.get(i) || arc(before) || arc(i) || p.dist_to_segment(a, b) > tol }; - let keep: Vec = (0..m).map(corner).collect(); - let (mut pts, mut tags): (Vec, Vec>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip(); + let keep: Vec = (0..m).filter(|i| corner(*i)).collect(); + let mut pts: Vec = keep.iter().filter_map(|i| pts.get(*i).copied()).collect(); + let mut tags: Vec> = keep.iter().filter_map(|i| tags.get(*i).copied()).collect(); + let mut round: Vec> = keep.iter().filter_map(|i| round.get(*i).copied()).collect(); if area_about(&pts, n) < 0.0 { pts.reverse(); turn(&mut tags); + turn(&mut round); } - Ok(Face { n, pts, across: tags }) + Ok(Face { n, pts, across: tags, round, plane, holes }) } /// The face straight through the material behind planar face `f`, when it is parallel to it, /// and how far behind it lies. fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> { let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None }; - // From the middle of the face's largest triangle. + // The face most of it looks through to, from points in its largest triangles: a few of them + // may look at the floor of a label engraved in the other skin. let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len(); - let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t)); - let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?; - let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?; - let g = *mesh.tri_face.get(hit)? as usize; + let mut tris: Vec<[Vec3; 3]> = + mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t)).collect(); + tris.sort_by(|x, y| area(y).total_cmp(&area(x))); + let mut seen: Vec<(usize, f64)> = Vec::new(); + for tri in tris.iter().take(8) { + let middle = (tri[0] + tri[1] + tri[2]) * (1.0 / 3.0); + for from in std::iter::once(middle).chain(tri.iter().map(|v| (middle + *v) * 0.5)) { + let Some(g) = mesh.raycast(from, n * -1.0).and_then(|(_, hit)| mesh.tri_face.get(hit)).map(|g| *g as usize) else { continue }; + match seen.iter_mut().find(|(h, _)| *h == g) { + Some(entry) => entry.1 += area(tri), + None => seen.push((g, area(tri))), + } + } + } + let g = seen.iter().max_by(|x, y| x.1.total_cmp(&y.1))?.0; let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None }; (normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n))) } +/// A chamfer or a bend's cylinder picked as a bend: the face `strip` between the planar faces +/// `a` and `b`, which meet in that bend at the line where their planes cross. +#[derive(Clone, Copy)] +struct Chamfer { + strip: usize, + a: usize, + b: usize, +} + +/// The chamfer or bend cylinder a bend point picks. A bend cylinder ([`bend_cylinder`]) is +/// picked by a point anywhere on it (within `near`, the cylinder being drawn in chords). A +/// chamfer by a point on the middle half of a planar face that runs between two planar faces +/// along two parallel edges, the two faces' planes crossing in a line parallel to them and the +/// strip facing between the two (as the chord of a bend's arc does). A point on any other face, +/// or near a chamfer's edge, picks an edge as before. +fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option { + let b = &mb.body; + let (d, f) = crate::appearance::nearest_face(mb, p)?; + if d <= near + && let Some(c) = bend_cylinder(b, mesh, f, tol) + { + return Some(c); + } + if d > tol { + return None; + } + let strip = outline(b, mesh, f, tol).ok()?; + let plane = |g: usize| match face_surface(b, g) { + Some(FaceSurface::Plane { normal, .. }) => Some(normal), + _ => None, + }; + let m = strip.pts.len(); + let edge = |i: usize| -> Option<(Vec3, Vec3, usize)> { + let (p, q) = (*strip.pts.get(i)?, *strip.pts.get((i + 1) % m)?); + Some((p, (q - p).normalized()?, strip.across.get(i).copied().flatten()?)) + }; + let mut sides = Vec::new(); + for i in 0..m { + for j in i + 1..m { + let (Some((pa, da, a)), Some((pb, db, bb))) = (edge(i), edge(j)) else { continue }; + let (Some(na), Some(nb)) = (plane(a), plane(bb)) else { continue }; + let Some(sharp) = na.cross(nb).normalized() else { continue }; + let lined_up = da.dot(db) < -1.0 + 1e-7 && sharp.dot(da).abs() > 1.0 - 1e-7; + if a != bb && a != f && bb != f && lined_up && strip.n.dot(na) > 1e-9 && strip.n.dot(nb) > 1e-9 { + sides.push((pa, pb, da, a, bb)); + } + } + } + let &[(pa, pb, d, a, bb)] = sides.as_slice() else { return None }; + // Its middle half: a quarter of its width or more from either edge. + let off = |o: Vec3| (p - o - d * (p - o).dot(d)).len(); + let width = off(pa) + off(pb); + (off(pa) >= width / 4.0 && off(pb) >= width / 4.0).then_some(Chamfer { strip: f, a, b: bb }) +} + +/// Face `f` as a bend modelled with its radius: a cylinder with a planar face tangent to it +/// along a straight edge parallel to its axis on either side, the two planes crossing in a line +/// (a bend between 0 and 180°). Other faces along it (the walls of a notch) don't count. +fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option { + let Some(FaceSurface::Cylinder { origin, axis, .. }) = face_surface(b, f) else { return None }; + let mut sides: Vec<(usize, Vec3)> = Vec::new(); + for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) { + let g = match *faces.as_slice() { + [x, y] if x as usize == f => y as usize, + [x, y] if y as usize == f => x as usize, + _ => continue, + }; + let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue }; + let along = (q - p).normalized().is_some_and(|d| d.cross(axis).len() <= 1e-6); + if g == f || !along || line.iter().any(|x| x.dist_to_segment(p, q) > tol) || sides.iter().any(|(h, _)| *h == g) { + continue; + } + // Tangent: the plane's normal is the cylinder's there, square to the axis. + let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue }; + let radial = (p - origin) - axis * (p - origin).dot(axis); + if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) { + sides.push((g, n)); + } + } + let &[(a, na), (bb, nb)] = sides.as_slice() else { return None }; + na.cross(nb).normalized().map(|_| Chamfer { strip: f, a, b: bb }) +} + +/// The cylinder of bend `c` on a shell's other skin: the bend cylinder between the faces +/// straight behind `c`'s two sides, so that a bend picked on either skin is that bend on both. +fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option { + let (x, y) = (behind(b, mesh, c.a)?.0, behind(b, mesh, c.b)?.0); + let beside_x = mesh.edge_faces.iter().filter(|faces| faces.contains(&(x as u32))).flatten().map(|g| *g as usize); + beside_x + .filter(|g| *g != x && *g != c.strip) + .find_map(|g| bend_cylinder(b, mesh, g, tol).filter(|o| (o.a, o.b) == (x, y) || (o.a, o.b) == (y, x))) +} + +/// Face `g`'s outline with each picked chamfer along it taken out: the edge along the chamfer +/// moves to where `g`'s plane meets the plane of the face across the chamfer (its corners +/// sliding along the edges before and after it), and runs along that face. +fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], tol: f64) -> Result { + for c in chamfers { + let other = if c.a == g { + c.b + } else if c.b == g { + c.a + } else { + continue; + }; + let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, other) else { + return err("the face across a chamfer picked as a bend is not flat"); + }; + let m = face.pts.len(); + let along: Vec = (0..m).filter(|i| face.across.get(*i).copied().flatten() == Some(c.strip)).collect(); + let &[k] = along.as_slice() else { + return err("not supported yet: a chamfer picked as a bend that runs along a face more than once"); + }; + // Its corners slide along the edges before and after it: those must be straight. + if [k + m - 1, k + 1].iter().any(|i| face.round.get(i % m).is_some_and(Option::is_some)) { + return err("not supported yet: a round edge of a face that ends at a chamfer picked as a bend"); + } + // Each end of the edge slides along the edge on its other side, out to the other plane. + let slide = |corner: usize, before: usize| -> Option { + let (p, q) = (*face.pts.get(corner % m)?, *face.pts.get(before % m)?); + let d = (p - q).normalized()?; + let s = (o - p).dot(n) / d.dot(n); + (s.is_finite() && s >= -tol).then_some(p + d * s) + }; + let (Some(p), Some(q)) = (slide(k, k + m - 1), slide(k + 1, k + 2)) else { + return err("a face beside a chamfer picked as a bend does not reach the face across it"); + }; + if let Some(x) = face.pts.get_mut(k) { + *x = p; + } + if let Some(x) = face.pts.get_mut((k + 1) % m) { + *x = q; + } + if let Some(x) = face.across.get_mut(k) { + *x = Some(other); + } + } + Ok(face) +} + /// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet, /// stays half the gap from the plane halfway between it and the panel `y` across the rip. /// `e` is the edge's direction round panel `x`. @@ -210,19 +522,58 @@ fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) { } } +/// A panel's trimmed outline as drawn: an edge that was a round edge of the face is an arc again +/// (`round`, per edge of the untrimmed outline `pts`: the circle's centre and the arc's middle), +/// pieces of one circle in a row one arc of up to half a turn. None when the face has no round +/// edges. A round edge keeps both its ends: one that a bend's setback or a cut edge would move is +/// not supported yet. +fn drawn(trimmed: &[Vec2], from: &[Option], pts: &[Vec2], round: &[Option<(Vec2, Vec2)>]) -> Result> { + if round.iter().all(Option::is_none) { + return Ok(None); + } + let (m, n) = (trimmed.len(), pts.len()); + let mut segs: Vec = Vec::with_capacity(m); + for j in 0..m { + let (Some(&a), Some(&b)) = (trimmed.get(j), trimmed.get((j + 1) % m)) else { continue }; + let arc = from.get(j).copied().flatten().and_then(|i| Some((i, round.get(i).copied().flatten()?))); + let Some((i, (c, mid))) = arc else { + segs.push(Seg2::Line { a: snap(a), b: snap(b) }); + continue; + }; + let (Some(&p), Some(&q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue }; + if a.dist(p) > 1e-6 || b.dist(q) > 1e-6 { + return err("not supported yet: a round edge of a face that ends at a bend or a cut edge of the sheet"); + } + let (start, r) = ((p - c).angle(), c.dist(p)); + let half = ((mid - c).angle() - start + PI).rem_euclid(TAU) - PI; + match segs.last_mut() { + Some(Seg2::Arc { center, radius, sweep, .. }) + if center.dist(c) <= 1e-9 && (*radius - r).abs() <= 1e-9 && *sweep * half > 0.0 && (*sweep + 2.0 * half).abs() <= PI + 1e-9 => + { + *sweep += 2.0 * half; + } + _ => segs.push(Seg2::Arc { center: c, radius: r, start, sweep: 2.0 * half }), + } + } + Ok(Some(Loop2 { segs })) +} + /// Names of the edges of `body` at the bend points, to find them again after the body changes. /// A point on no edge gets none: the conversion then says so, instead of bending the edge that -/// happens to be nearest. +/// happens to be nearest. A point on a chamfer or a bend cylinder gets none either: it stays that +/// chamfer's or cylinder's point. pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec { let Some(b) = st.body(body) else { return Vec::new() }; - let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3)); - let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near)); + let (mesh, near, tol) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3), (b.body.size() * 1e-6).max(1e-6)); + let on = |p: &Vec3| { + chamfer_at(b, &mesh, *p, tol, near).is_none() && mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near)) + }; pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect() } /// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the -/// fixed face and `bends` are points on the sharp edges that become bends (the body's -/// coordinates, mm). See the module docs. +/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of +/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs. pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result { if bends.is_empty() || bends.len() > MAX_BENDS { return err(format!("pick 1…{MAX_BENDS} bend edges")); @@ -240,10 +591,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else { return err(format!("no face of the body at the fixed face point {}", at(face))); }; - let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?; - // The two faces along each bend edge. + // The two faces along each bend edge, or on either side of each chamfer or bend cylinder + // (with a bend cylinder its other skin's too). let mut picks = Vec::with_capacity(bends.len()); + let mut chamfers: Vec = Vec::new(); for p in bends { + if let Some(c) = chamfer_at(mb, &mesh, *p, tol, near) { + picks.push((*p, (c.a, c.b))); + for c in std::iter::once(c).chain(other_skin(b, &mesh, &c, tol)) { + if !chamfers.iter().any(|x| x.strip == c.strip) { + chamfers.push(c); + } + } + continue; + } let dist = |e: &Vec| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0)); match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) { @@ -252,15 +613,31 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben None => return err(format!("no edge of the body at the bend point {}", at(*p))), } } + if let Some((p, _)) = picks.iter().find(|(_, (x, y))| chamfers.iter().any(|c| c.strip == *x || c.strip == *y)) { + return err(format!("the bend at {} is on a chamfer picked as a bend (pick the chamfer or its edges, not both)", at(*p))); + } + let face_of = |f: usize| outline(b, &mesh, f, tol).and_then(|face| without_chamfers(b, face, f, &chamfers, tol)); + let fixed = face_of(f0).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?; // A shell: the same small distance of material behind the fixed face and the faces at every // bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too. let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n)); let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g); let backs: Vec> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect(); let shell = thin.filter(|_| backs.iter().all(Option::is_some)); + // On a shell, the face behind each face at a bend (its other skin, read for its pockets). + let skin_behind: BTreeMap = picks + .iter() + .zip(&backs) + .filter(|_| shell.is_some()) + .filter_map(|((_, (x, y)), back)| back.map(|(bx, by)| [(*x, bx), (bx, *x), (*y, by), (by, *y)])) + .flatten() + .collect(); let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9); let r = rv.bend_radius; - let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32))); + let joined = |x: usize, y: usize| { + mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32))) + || chamfers.iter().any(|c| (c.a, c.b) == (x, y) || (c.a, c.b) == (y, x)) + }; let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len()); for (&(p, pair), back) in picks.iter().zip(&backs) { let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y)); @@ -288,8 +665,8 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben } return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p))); } - let child = outline(b, &mesh, to, tol) - .map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?; + let child = + face_of(to).map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?; let Some(parent) = panels.get(&from) else { continue }; let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos(); if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) { @@ -313,12 +690,32 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben // The flat frame: on the fixed face, x along its longest edge, z into the material. let Some(base) = panels.get(&f0) else { return err("fixed face") }; let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1)); - let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1))); + let straight = corners.zip(&base.face.round).filter(|(_, r)| r.is_none()).map(|(c, _)| c); + let longest = straight.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1))); let frame = longest .and_then(|(a, b)| Some((*a, (*b - *a).normalized()?))) .and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n))) .ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?; let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default()); + // How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall + // reaches through the sheet, a pocket's ends on its floor, an island's starts at the face. + let mut points: BTreeMap> = BTreeMap::new(); + for (tri, g) in mesh.triangles.iter().zip(&mesh.tri_face) { + points.entry(*g as usize).or_default().extend(tri.iter().filter_map(|k| mesh.positions.get(*k as usize))); + } + let span = |g: usize, face: &Face| { + let o = face.pts.first().copied().unwrap_or_default(); + let ds = points.get(&g).into_iter().flatten().map(|p| (o - *p).dot(face.n)); + ds.fold((f64::INFINITY, 0.0), |(lo, hi): (f64, f64), d| (lo.min(d.max(0.0)), hi.max(d))) + }; + // The span of a loop's walls: the least any starts at, and the least and most any ends at. + let walls_span = |walls: &[usize], face: &Face| { + walls + .iter() + .map(|g| span(*g, face)) + .fold((f64::INFINITY, f64::INFINITY, 0.0), |(s, lo, hi): (f64, f64, f64), (a, b)| (s.min(a), lo.min(b), hi.max(b))) + }; + let mut work = FlatWork::new(); // Lay the panels out, parents first: each is its face, seen through the inverse of its // fold, with its edges moved in by a bend's setback or a rip's cut. let mut queue: Vec<(usize, Option)> = vec![(f0, None)]; @@ -344,16 +741,105 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben }); } let mut across = panel.face.across.clone(); + let flat = |p: Vec3| { + let q = apply_point(&inv, p); + Vec2::new(q.x, q.y) + }; + let mut round: Vec> = panel.face.round.iter().map(|r| r.map(|(c, m)| (flat(c), flat(m)))).collect(); if signed_area(&pts) < 0.0 { pts.reverse(); turn(&mut by); turn(&mut across); + turn(&mut round); } let first = panel.face.pts.first().copied().unwrap_or_default(); - let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?; + let face_at = |e: DocError| DocError::Invalid(format!("{e} (the face at {})", at(first))); + let (trimmed, from) = inset(&pts, &by).map_err(face_at)?; + let exact = drawn(&trimmed, &from, &pts, &round).map_err(face_at)?; match fl { - None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::>()), - Some(i) => set_panel(&mut sh, i, &trimmed), + None => sh.base.outer = exact.unwrap_or_else(|| Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::>())), + Some(i) => { + set_panel(&mut sh, i, &trimmed); + if let (Some(lp), Some(f)) = (exact, sh.flanges.get_mut(i)) { + f.shape = Some(trimmed.clone()); + f.exact = Some(lp); + } + } + } + // The face's holes: cut-outs of the sheet where they lie, inside its panel. A hole's walls + // reach through the sheet; a pocket's (an engraved label) end inside it, on floors facing + // out as the face does: a pocket of the sheet from that skin, round its islands. On a + // shell the face behind, the other skin, is read for its pockets too. + let region = match fl { + None => sh.base.clone(), + Some(i) => sh.flanges.get(i).map(|f| sh.panel(f)).unwrap_or_default(), + }; + let back = skin_behind.get(&pf).and_then(|g| outline(b, &mesh, *g, tol).ok()); + let skins: Vec<(&Face, bool)> = std::iter::once((&panel.face, true)).chain(back.as_ref().map(|f| (f, false))).collect(); + let bound = if skins.iter().all(|(f, _)| f.holes.is_empty()) { Vec::new() } else { region.outer.ccw().polyline(1e-3) }; + for (face, front) in skins { + for (h, walls) in &face.holes { + let p = face.plane.to_world(h.centroid()); + let (_, reach, deepest) = walls_span(walls, face); + let through = reach >= t - tol; + if through && !front { + continue; + } + let cut = sh.region_to_flat(fl, &face.plane, &Region2 { outer: h.clone(), holes: Vec::new() })?; + let ring = cut.outer.polyline(1e-3); + if !ring.iter().all(|q| point_in(&bound, *q)) || polys_cross(&bound, &ring) { + let kind = if through { "hole" } else { "pocket" }; + return err(format!("not supported yet: the {kind} at {} reaches into a bend (cut it after converting)", at(p))); + } + if through { + sh.holes.push(cut); + continue; + } + let pocket = + |why: &str| err(format!("not supported yet: the pocket at {} in a face of the sheet {why} (cut it after converting)", at(p))); + let eps = t * 1e-3; + if reach > t / 2.0 + eps { + return pocket(&format!("ends {reach:.3} deep, more than half the sheet's {t:.3}")); + } + if deepest - reach > eps { + return pocket("has walls of more than one depth"); + } + // Its floors: the faces at its depth beside its walls, facing out as the face does. + let mut floors: Vec = mesh + .edge_faces + .iter() + .filter(|fs| fs.iter().any(|g| walls.contains(&(*g as usize)))) + .flatten() + .map(|g| *g as usize) + .filter(|g| { + !walls.contains(g) + && matches!(face_surface(b, *g), Some(FaceSurface::Plane { normal, .. }) if normal.dot(face.n) > 1.0 - 1e-9) + }) + .filter(|g| { + let (lo, hi) = span(*g, face); + (lo - reach).abs() <= eps && (hi - reach).abs() <= eps + }) + .collect(); + floors.sort_unstable(); + floors.dedup(); + if floors.is_empty() { + return pocket("has no flat floor"); + } + // The islands left standing in it (a letter's counter): walls from a floor up to the face. + let mut islands = Vec::new(); + for g in floors { + let floor = outline(b, &mesh, g, tol)?; + for (k, kw) in &floor.holes { + let (start, lo, hi) = walls_span(kw, face); + if start > eps || (lo - reach).abs() > eps || (hi - reach).abs() > eps { + return pocket("has a hole or a step in its floor"); + } + islands.push(sh.region_to_flat(fl, &floor.plane, &Region2 { outer: k.clone(), holes: Vec::new() })?.outer.reversed()); + } + } + let (z0, z1) = if front { (0.0, reach) } else { (t - reach, t) }; + sh.record_pocket(Region2 { outer: cut.outer, holes: islands }, z0, z1, &mut work)?; + } } // Its children: flanges on the trimmed edges they bend at. let m = trimmed.len(); @@ -379,6 +865,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben ext: (0.0, 0.0), slope: (0.0, 0.0), shape: None, + exact: None, tag: String::new(), tear: (false, false), }); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 79f2004a..74740c15 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -38,10 +38,14 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SHEET METAL", "CREATE") .icon("convert_sheet") .params( - "body; face: [x,y,z] on its large flat face (a plate of even thickness), or that face's name from model.faces; rule?; \ + "body; face: [x,y,z] on its large flat face (a plate of even thickness; recesses in one face up to half the thickness deep, \ + such as an engraved label, become pockets of the sheet), or that face's name from model.faces; rule?; \ bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \ across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \ - edges between two of those faces that are no bends are cut open with the rule's gap)", + edges between two of those faces that are no bends are cut open with the rule's gap; \ + a point in the middle of a chamfer between two faces, or on a bend modelled with its radius (a cylinder between \ + two flat faces tangent to it, either skin), makes it one bend of the rule's radius at their virtual sharp; \ + the faces' holes become cut-outs and their engravings, up to half the thickness deep, pockets)", ), CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").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; \ @@ -60,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ), CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf) .noundo() - .params("body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those) → DXF (AutoCAD 2000, mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"), + .params("body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those) → DXF (AutoCAD 2000, mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers, and pockets on POCKET__Z_TO__MM"), CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table) .noundo() .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"), diff --git a/crates/engine/src/cmd/sheet_convert_tests.rs b/crates/engine/src/cmd/sheet_convert_tests.rs index 739882af..02faf3f2 100644 --- a/crates/engine/src/cmd/sheet_convert_tests.rs +++ b/crates/engine/src/cmd/sheet_convert_tests.rs @@ -1,5 +1,5 @@ -//! Convert to Sheet Metal: solid and shelled bodies with bend edges, and plates read from STEP -//! files whose walls come in pieces. +//! Convert to Sheet Metal: solid and shelled bodies with bend edges (with holes and round edges in +//! their faces), plates read from STEP files whose walls come in pieces, and plates with recesses. use std::f64::consts::{FRAC_PI_2, PI, SQRT_2}; @@ -386,6 +386,231 @@ fn bend_edges_changed_in_an_edit_are_the_ones_bent() { assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s)); } +/// A channel 40 wide with walls 30 high (outside sizes), 2 thick and 100 long, its two corners +/// drawn as chamfers, as a model without bend radii draws a bend: 4 along each outer face and 2 +/// along each inner one, the chords of an R 2 bend's outside (R + T) and inside (R). +fn chamfered_channel(s: &mut Session) { + let pts = [[0, 30], [0, 4], [4, 0], [36, 0], [40, 4], [40, 30], [38, 30], [38, 4], [36, 2], [4, 2], [2, 4], [2, 30]]; + run(s, "sketch.create", json!({"plane": "XY", "name": "Channel"})); + run(s, "sketch.line", json!({"points": pts, "closed": true})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": "Channel", "distance": 100, "body_name": "Channel"})); +} + +#[test] +fn a_chamfer_picked_in_its_middle_is_one_bend_at_the_virtual_sharp() { + // The rule's radius bends, not the one the chamfers stand for. + let (t, k, r) = (2.0, 0.5, 3.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + chamfered_channel(&mut s); + // The middle of each outer chamfer. + run(&mut s, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 50], "bends": [[2, 2, 50], [38, 2, 50]]})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}"); + // The outside lengths to the virtual sharps less a 90° deduction each: + // 30 + 40 + 30 - 2 (2 (R + T) - π/2 (R + K T)) = 100 - 2 (10 - 2π) = 92.5664. + let across = 100.0 - 2.0 * deduction(FRAC_PI_2, r, t, k); + assert!((across - 92.566371).abs() < 1e-6); + assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // The envelope of the sharp corners stays; the chamfers are gone into the bends. + let (lo, hi) = bbox(&mut s); + assert!( + lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 40.0).abs() < 1e-6 && (hi[1] - 30.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6, + "{lo:?} {hi:?}" + ); + // Three flat strips and two quarter rounds on the neutral radius R + T/2. + let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)); + assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s)); + // The design keeps the chamfers' points and reopens to the same sheet. + let saved = s.doc.to_json(); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + assert!(same_size(flat_size(&mut reopened), (across, 100.0), 1e-6), "{:?}", flat_size(&mut reopened)); + + // Picked on the inner skin, from the inside of the base: the same sheet, bent down. + let mut b = Session::default(); + rule(&mut b, "Test", t, k, r, 0.5); + chamfered_channel(&mut b); + run(&mut b, "sheet.convert", json!({"body": "Channel", "face": [20, 2, 50], "bends": [[3, 3, 50], [37, 3, 50]]})); + assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == "down"), "{:?}", bend_rows(&mut b)); + assert!(same_size(flat_size(&mut b), (across, 100.0), 1e-6), "{:?}", flat_size(&mut b)); + assert!((volume(&mut b) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut b)); + + // Points on a chamfer's edges pick those edges, as before: four 45° bends with the + // chamfers as narrow faces of the sheet. + let mut e = Session::default(); + rule(&mut e, "Test", t, k, r, 0.5); + chamfered_channel(&mut e); + run(&mut e, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 50], "bends": [[0, 4, 50], [4, 0, 50], [36, 0, 50], [40, 4, 50]]})); + let rows = bend_rows(&mut e); + assert!(rows.len() == 4 && rows.iter().all(|row| row.0 == 45), "{rows:?}"); + // A chamfer and one of its edges at once is refused. + let mut f = Session::default(); + rule(&mut f, "Test", t, k, r, 0.5); + chamfered_channel(&mut f); + let both = f.execute("sheet.convert", &json!({"body": "Channel", "face": [20, 0, 50], "bends": [[2, 2, 50], [4, 0, 50]]})); + let msg = both.err().map(|x| x.to_string()).unwrap_or_default(); + assert!(msg.contains("not both"), "{msg}"); +} + +/// A strip `t` thick along the polyline `outer` (its material to the left of the way the line +/// runs, as [`section`]), extruded `len` along z as the body `Section`, its corners bent as a +/// sheet metal model draws them: an arc of radius `ri` on the inside of each bend and one of +/// `ri + t` on the outside, about one axis. Returns, per corner, a point in the middle of its +/// outer arc and one in the middle of its inner arc. +fn bent_section(s: &mut Session, outer: &[(f64, f64)], t: f64, ri: f64, len: f64) -> Vec<([f64; 3], [f64; 3])> { + let n = outer.len(); + let dir = |i: usize| { + let (a, b) = (outer[i], outer[i + 1]); + let l = (b.0 - a.0).hypot(b.1 - a.1); + ((b.0 - a.0) / l, (b.1 - a.1) / l) + }; + let left = |d: (f64, f64)| (-d.1, d.0); + let at = |p: (f64, f64), d: (f64, f64), k: f64| (p.0 + d.0 * k, p.1 + d.1 * k); + // Per corner: where its arc starts and ends on the line's skin and on the other skin, the + // middles of those two arcs, and the middles of its outer and its inner arc. + let mut corners = Vec::new(); + for (i, &c) in outer.iter().enumerate().take(n - 1).skip(1) { + let (d0, d1) = (dir(i - 1), dir(i)); + let (cross, dot) = (d0.0 * d1.1 - d0.1 * d1.0, d0.0 * d1.0 + d0.1 * d1.1); + let theta = cross.abs().atan2(dot); + // Turning toward the material, this skin is the bend's outside. + let (rp, ro) = if cross > 0.0 { (ri + t, ri) } else { (ri, ri + t) }; + let a = at(c, d0, -rp * (theta / 2.0).tan()); + let b = at(c, d1, rp * (theta / 2.0).tan()); + let centre = at(a, left(d0), if cross > 0.0 { rp } else { -rp }); + let (ai, bi) = (at(a, left(d0), t), at(b, left(d1), t)); + let l = (c.0 - centre.0).hypot(c.1 - centre.1); + let mid = |r: f64| (centre.0 + (c.0 - centre.0) / l * r, centre.1 + (c.1 - centre.1) / l * r); + corners.push((a, b, ai, bi, mid(rp), mid(ro), (mid(ri + t), mid(ri)))); + } + let xy = |p: (f64, f64)| [p.0, p.1]; + run(s, "sketch.create", json!({"plane": "XY", "name": "Section"})); + let mut from = outer[0]; + for &(a, b, _, _, m, ..) in &corners { + run(s, "sketch.line", json!({"points": [xy(from), xy(a)]})); + run(s, "sketch.arc.three_point", json!({"start": xy(a), "end": xy(b), "through": xy(m)})); + from = b; + } + let (last, first) = (at(outer[n - 1], left(dir(n - 2)), t), at(outer[0], left(dir(0)), t)); + run(s, "sketch.line", json!({"points": [xy(from), xy(outer[n - 1]), xy(last)]})); + from = last; + for &(_, _, ai, bi, _, m, _) in corners.iter().rev() { + run(s, "sketch.line", json!({"points": [xy(from), xy(bi)]})); + run(s, "sketch.arc.three_point", json!({"start": xy(bi), "end": xy(ai), "through": xy(m)})); + from = ai; + } + run(s, "sketch.line", json!({"points": [xy(from), xy(first), xy(outer[0])]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"sketch": "Section", "distance": len, "body_name": "Section"})); + let z = len / 2.0; + corners.iter().map(|&(.., (m, mi))| ([m.0, m.1, z], [mi.0, mi.1, z])).collect() +} + +#[test] +fn a_bend_modelled_with_its_radius_is_one_bend_of_the_rules_radius() { + // An L: a base 60 and a leg 40 to the sharp corner outside, 2 thick and 100 long, its + // corner bent R 2 inside and R 4 outside. The rule bends R 3: its radius, not the model's. + let (t, k, r) = (2.0, 0.5, 3.0); + let l = |s: &mut Session| { + rule(s, "Test", t, k, r, 0.5); + bent_section(s, &[(0.0, 40.0), (0.0, 0.0), (60.0, 0.0)], t, 2.0, 100.0)[0] + }; + let mut s = Session::default(); + let (outer, inner) = l(&mut s); + run(&mut s, "sheet.convert", json!({"body": "Section", "face": [30, 0, 50], "bends": [outer]})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == "up" && (rows[0].2 - 100.0).abs() < 1e-6, "{rows:?}"); + let allowance = flat(&mut s)["bends"][0]["allowance"].as_f64().unwrap_or(0.0); + assert!((allowance - FRAC_PI_2 * (r + k * t)).abs() < 1e-9, "{allowance}"); + // The outside lengths to the virtual sharp less one 90° deduction: + // 60 + 40 - (2 (R + T) - π/2 (R + K T)) = 100 - (10 - 2π) = 96.2832. + let across = 100.0 - deduction(FRAC_PI_2, r, t, k); + assert!((across - 96.283185).abs() < 1e-6); + assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // The envelope of the sharp corner stays; the round is the rule's, R + T outside. + let (lo, hi) = bbox(&mut s); + assert!( + lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 60.0).abs() < 1e-6 && (hi[1] - 40.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6, + "{lo:?} {hi:?}" + ); + // Two flat strips and a quarter round on the neutral radius R + T/2. + let expect = t * 100.0 * ((60.0 - (r + t)) + (40.0 - (r + t)) + FRAC_PI_2 * (r + t / 2.0)); + assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s)); + // The design keeps the cylinder's point and reopens to the same sheet. + let saved = s.doc.to_json(); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + assert!(same_size(flat_size(&mut reopened), (across, 100.0), 1e-6), "{:?}", flat_size(&mut reopened)); + + // The inner cylinder is the same bend; and from the inside of the base, the sheet's other + // skin is the fixed one, the bend turning down from it, picked on either cylinder: the + // inside lengths to the inner sharp, 58 + 38, less 2 R plus the allowance, the same blank. + assert!((58.0 + 38.0 - 2.0 * r + allowance - across).abs() < 1e-9); + for (face, bend, direction) in [([30.0, 0.0, 50.0], inner, "up"), ([30.0, 2.0, 50.0], outer, "down"), ([30.0, 2.0, 50.0], inner, "down")] { + let mut b = Session::default(); + l(&mut b); + run(&mut b, "sheet.convert", json!({"body": "Section", "face": face, "bends": [bend]})); + assert!(bend_rows(&mut b).iter().all(|row| row.0 == 90 && row.1 == direction), "{face:?} {bend:?}: {:?}", bend_rows(&mut b)); + assert!(same_size(flat_size(&mut b), (across, 100.0), 1e-6), "{face:?} {bend:?}: {:?}", flat_size(&mut b)); + assert!((volume(&mut b) - expect).abs() < 2e-3 * expect, "{face:?} {bend:?}: {} vs {expect}", volume(&mut b)); + } +} + +#[test] +fn a_channel_with_its_bends_modelled_folds_back_into_itself_under_its_own_radius() { + // A channel 40 wide with walls 30 high to the sharp corners outside, 2 thick and 100 long, + // bent R 2 inside, R 4 outside. Under a rule of that radius the sheet is the model. + let (t, k, r) = (2.0, 0.44, 2.0); + let channel = |s: &mut Session| { + rule(s, "Test", t, k, r, 0.5); + bent_section(s, &[(0.0, 30.0), (0.0, 0.0), (40.0, 0.0), (40.0, 30.0)], t, r, 100.0) + }; + let mut s = Session::default(); + let corners = channel(&mut s); + let solid = volume(&mut s); + // Two flat walls, the base and two quarter rounds on the neutral radius R + T/2. + let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)); + assert!((solid - expect).abs() < 2e-3 * expect, "{solid} vs {expect}"); + // One bend picked on its outer cylinder, the other on its inner one. + run(&mut s, "sheet.convert", json!({"body": "Section", "face": [20, 0, 50], "bends": [corners[0].0, corners[1].1]})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 100.0).abs() < 1e-6), "{rows:?}"); + // 30 + 40 + 30 less two deductions, 2 (R + T) - π/2 (R + K T) each. + let across = 100.0 - 2.0 * deduction(FRAC_PI_2, r, t, k); + assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // The same solid as before: its volume and its box. + assert!((volume(&mut s) - solid).abs() < 1e-3 * solid, "{} vs {solid}", volume(&mut s)); + let (lo, hi) = bbox(&mut s); + assert!( + lo.iter().all(|v| v.abs() < 1e-6) && (hi[0] - 40.0).abs() < 1e-6 && (hi[1] - 30.0).abs() < 1e-6 && (hi[2] - 100.0).abs() < 1e-6, + "{lo:?} {hi:?}" + ); + // Unfold and refold keep it. + run(&mut s, "sheet.unfold", json!({})); + let (lo, hi) = bbox(&mut s); + let flat_box = [(hi[0] - lo[0], hi[1] - lo[1]), (hi[0] - lo[0], hi[2] - lo[2]), (hi[1] - lo[1], hi[2] - lo[2])]; + assert!(flat_box.iter().any(|b| same_size(*b, (across, 100.0), 1e-6)), "{lo:?} {hi:?}"); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - solid).abs() < 1e-3 * solid); +} + +#[test] +fn a_filleted_edge_of_a_solid_block_is_a_bend() { + // A block's bottom edge rounded R 5: the faces either side meet at the old sharp edge, + // the rule's thickness taken inward, an L with a 100 base and a 30 wall outside. + let (t, k, r) = (2.0, 0.44, 2.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Block"})); + run(&mut s, "solid.fillet", json!({"edges": [[100, 30, 0]], "radius": 5})); + let h = 5.0 * (1.0 - 0.5f64.sqrt()); + run(&mut s, "sheet.convert", json!({"body": "Block", "face": [50, 30, 0], "bends": [[100.0 - h, 30.0, h]]})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == "up" && (rows[0].2 - 60.0).abs() < 1e-6, "{rows:?}"); + let across = 130.0 - deduction(FRAC_PI_2, r, t, k); + assert!(same_size(flat_size(&mut s), (across, 60.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); +} + #[test] fn edge_flanges_and_cuts_work_on_a_converted_sheet() { let mut s = Session::default(); @@ -803,3 +1028,467 @@ fn a_long_thin_strip_keeps_its_small_round_holes() { assert_eq!(dxf_entities(&mut s), (4, 0, 4), "{l}"); } } + +/// A 60 x 40 x 1 plate on XY. +fn plate(s: &mut Session) { + run(s, "solid.box", json!({"length": 60, "width": 40, "height": 1, "body_name": "Plate"})); +} + +/// The plate with what `draw` sketches on its top cut `depth` deep into it (the profiles at +/// `profiles`, all when none). +fn engraved_plate(s: &mut Session, depth: f64, draw: impl Fn(&mut Session), profiles: &[[f64; 2]]) { + plate(s); + run(s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Label"})); + draw(s); + run(s, "sketch.finish", json!({})); + let mut cut = json!({"sketch": "Label", "distance": depth, "direction": "negative", "operation": "cut"}); + if !profiles.is_empty() { + cut["profiles"] = profiles.iter().map(|p| json!({"point": p})).collect(); + } + run(s, "solid.extrude", cut); +} + +fn label_rectangle(s: &mut Session) { + run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 20]})); +} + +fn contains(s: &Session, p: [f64; 3]) -> bool { + s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2]))) +} + +/// The LINE entities a DXF draws on `layer` (group codes and values alternate, a line each). +fn lines_on(dxf: &str, layer: &str) -> usize { + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let mut in_line = false; + let mut count = 0; + for pair in lines.chunks(2) { + match pair { + ["0", kind] => in_line = *kind == "LINE", + ["8", name] if in_line && *name == layer => count += 1, + _ => {} + } + } + count +} + +/// A plate with an engraving converts: the engraving is a pocket of the sheet, so the sheet keeps +/// the plate's shape, and its outline is the flat pattern's pocket, on the DXF's pocket layer. +/// Picked on the engraved face, the pocket starts from the sheet's z = 0 (the face picked); +/// picked on the other face, it ends at z = T. +#[test] +fn a_plate_with_an_engraving_converts_with_the_engraving_as_a_pocket() { + for (pick, layer) in [([45.0, 30.0, 1.0], "POCKET_BOTTOM_Z0.000000_TO_0.100000_MM"), ([45.0, 30.0, 0.0], "POCKET_TOP_Z0.900000_TO_1.000000_MM")] { + let mut s = Session::default(); + engraved_plate(&mut s, 0.1, label_rectangle, &[]); + run(&mut s, "sheet.convert", json!({"body": "Plate", "face": pick})); + assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{pick:?}: {:?}", s.model.results); + // By hand: 60·40·1 less the engraving's 20·10·0.1; the faces' 2·60·40 + 2·(60 + 40)·1 and + // the engraving's walls, 2·(20 + 10)·0.1. + let m = run(&mut s, "inspect.measure", json!({})); + assert!((m["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) - 2380.0).abs() < 1e-6, "{pick:?}: {m}"); + assert!((m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN) - 5006.0).abs() < 1e-6, "{pick:?}: {m}"); + // It stays in the face it was cut into (the converted plate is as thick, wherever it lies). + let (lo, hi) = bbox(&mut s); + assert!((hi[2] - lo[2] - 1.0).abs() < 1e-9, "{pick:?}: {lo:?} {hi:?}"); + assert!(!contains(&s, [20.0, 15.0, hi[2] - 0.05]) && contains(&s, [20.0, 15.0, hi[2] - 0.15]), "{pick:?}"); + assert!(contains(&s, [45.0, 30.0, hi[2] - 0.05]), "{pick:?}"); + // The flat pattern: the whole plate, no cut-out, and the engraving as a pocket 0.1 deep. + let f = flat(&mut s); + assert!(same_size(flat_size(&mut s), (60.0, 40.0), 1e-9), "{f}"); + assert_eq!(f["cutouts"], json!([]), "{f}"); + let pockets = f["pockets"].as_array().cloned().unwrap_or_default(); + assert_eq!(pockets.len(), 1, "{f}"); + assert!((pockets[0]["depth_mm"].as_f64().unwrap_or(f64::NAN) - 0.1).abs() < 1e-9, "{f}"); + let corners: Vec<(f64, f64)> = + pockets[0]["outline"].as_array().unwrap().iter().map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap())).collect(); + let span = |k: fn(&(f64, f64)) -> f64| corners.iter().map(k).fold(f64::MIN, f64::max) - corners.iter().map(k).fold(f64::MAX, f64::min); + assert!(same_size((span(|p| p.0), span(|p| p.1)), (20.0, 10.0), 1e-9), "{f}"); + // The DXF: the plate's outline and the engraving's, four lines each, on their layers. + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap().to_string(); + assert_eq!((lines_on(&dxf, "OUTLINE"), lines_on(&dxf, layer)), (4, 4), "{pick:?}: {dxf}"); + // Nothing is stored for it: reopened, the design builds the same sheet. + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut reopened) - 2380.0).abs() < 1e-6); + assert_eq!(flat(&mut reopened), f); + } +} + +/// A plate large enough that its tessellation tolerance (1/1000 of its size, 0.72 mm here) is +/// more than its engraving's depth keeps the engraving too. +#[test] +fn a_large_plate_keeps_an_engraving_shallower_than_its_tessellation_tolerance() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 600, "width": 400, "height": 1, "body_name": "Plate"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Label"})); + label_rectangle(&mut s); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Label", "distance": 0.1, "direction": "negative", "operation": "cut"})); + for pick in [[450.0, 300.0, 0.0], [450.0, 300.0, 1.0]] { + let mut s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + run(&mut s, "sheet.convert", json!({"body": "Plate", "face": pick})); + // 600·400·1 less the engraving's 20·10·0.1. + assert!((volume(&mut s) - 239_980.0).abs() < 1e-3, "{pick:?}: {}", volume(&mut s)); + assert_eq!(flat(&mut s)["pockets"].as_array().map(Vec::len), Some(1), "{pick:?}"); + } +} + +/// An engraved ring (an "O": a 5 mm circle round a 3 mm island) is one pocket with a hole; the +/// island stays at full thickness. +#[test] +fn an_engraved_ring_converts_as_a_pocket_round_its_island() { + let mut s = Session::default(); + engraved_plate( + &mut s, + 0.1, + |s| { + run(s, "sketch.circle.center", json!({"center": [20, 20], "radius": 5})); + run(s, "sketch.circle.center", json!({"center": [20, 20], "radius": 3})); + }, + &[[24.0, 20.0]], + ); + // The extrude took the ring between the circles, not the island. + assert!((volume(&mut s) - (2400.0 - 0.1 * PI * (25.0 - 9.0))).abs() < 1e-3, "{}", volume(&mut s)); + run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [45, 30, 1]})); + // The ring's circles are followed within 0.01 mm (1/100 of the thickness): its area within + // 2/3 of that times the circumferences. + let want = 2400.0 - 0.1 * PI * (25.0 - 9.0); + assert!((volume(&mut s) - want).abs() < 0.1 * 0.01 * 2.0 / 3.0 * 2.0 * PI * 8.0, "{} vs {want}", volume(&mut s)); + let (_, hi) = bbox(&mut s); + assert!(!contains(&s, [24.0, 20.0, hi[2] - 0.05]) && contains(&s, [20.0, 20.0, hi[2] - 0.05])); + let f = flat(&mut s); + assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1), "{f}"); + assert_eq!(f["pockets"][0]["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); +} + +/// What a plate's recesses can't be is refused with the reason, and leaves the design as it was: +/// a recess deeper than half the plate, recesses in both faces, a plate raised in places (its +/// recesses larger than the face they are in); a plate with a boss is no plate, as before. A bend +/// across a kept engraving is refused. +#[test] +fn recesses_a_plate_cannot_keep_are_refused_with_the_reason() { + let err = |s: &mut Session, face: [f64; 3]| { + s.execute("sheet.convert", &json!({"body": "Plate", "face": face})).err().map(|e| e.to_string()).unwrap_or_default() + }; + let mut s = Session::default(); + engraved_plate(&mut s, 0.6, label_rectangle, &[]); + let e = err(&mut s, [45.0, 30.0, 1.0]); + assert!(e.contains("0.600 mm deep, more than half the plate's 1.000 mm"), "{e}"); + let mut s = Session::default(); + engraved_plate(&mut s, 0.1, label_rectangle, &[]); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Under"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 20], "p1": [50, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Under", "distance": 0.1, "operation": "cut"})); + let e = err(&mut s, [45.0, 10.0, 1.0]); + assert!(e.contains("recesses in both faces"), "{e}"); + // A recess 0.3 mm deep leaving a 2 mm rim: rather a plate 0.3 mm thinner with a raised rim. + let mut s = Session::default(); + engraved_plate( + &mut s, + 0.3, + |s| { + run(s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [58, 38]})); + }, + &[], + ); + let e = err(&mut s, [1.0, 1.0, 1.0]); + assert!(e.contains("larger than the face they are in"), "{e}"); + // A 0.3 mm boss on the plate: the plate's top round it lies in no opening of the boss's top. + let mut s = Session::default(); + plate(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 1, "name": "Boss"})); + label_rectangle(&mut s); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": 0.3, "operation": "join"})); + let e = err(&mut s, [20.0, 15.0, 1.3]); + assert!(e.contains("not a plate of even thickness"), "{e}"); + assert!(s.model.state().sheets.is_empty()); + // A fold across a kept engraving; one clear of it bends. + let mut s = Session::default(); + engraved_plate(&mut s, 0.1, label_rectangle, &[]); + run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [45, 30, 1]})); + let (_, hi) = bbox(&mut s); + let fold = |x: f64| json!({"points": [[x, 0, hi[2]], [x, 40, hi[2]]], "angle": 90}); + let e = s.execute("sheet.fold", &fold(20.0)).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("crosses a bend zone"), "{e}"); + run(&mut s, "sheet.fold", fold(45.0)); +} + +/// A bent part converted without bend edges is no plate, as before, whichever face is picked: +/// the faces of its legs inside its extent are no recesses. An L of two 2 mm plates at 90° (its +/// legs' faces lie beside its other faces, in no opening of them), a trapezoid section with +/// returns (faces of its legs on both sides of its base) and a tapered tray (its floor lies in +/// the opening of its rim, but filled it is no plate: its walls lean out). +#[test] +fn a_bent_part_without_bend_edges_is_still_no_plate() { + let err = |s: &mut Session, body: &str, face: [f64; 3]| { + s.execute("sheet.convert", &json!({"body": body, "face": face})).err().map(|e| e.to_string()).unwrap_or_default() + }; + let no_plate = "not supported yet: converting bodies that are not a plate of even thickness without bend edges"; + let mut s = Session::default(); + section(&mut s, &[(0.0, 30.0), (0.0, 0.0), (50.0, 0.0)], 2.0, 40.0); + for pick in [[25.0, 0.0, 20.0], [25.0, 2.0, 20.0], [1.0, 30.0, 20.0], [0.0, 15.0, 20.0], [2.0, 15.0, 20.0], [50.0, 1.0, 20.0]] { + let e = err(&mut s, "Section", pick); + assert!(e.contains(no_plate), "{pick:?}: {e}"); + } + let mut s = Session::default(); + trapezoid_section(&mut s, 2.0); + for pick in [[0.0, 0.0, 150.0], [0.0, 2.0, 150.0]] { + let e = err(&mut s, "Section", pick); + assert!(e.contains(no_plate), "{pick:?}: {e}"); + } + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Floor"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [200, 120]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Floor", "distance": 40, "taper": "18 deg", "body_name": "Tray"})); + run(&mut s, "solid.shell", json!({"faces": [[100, 60, 40]], "thickness": 2})); + let e = err(&mut s, "Tray", [100.0, 60.0, 0.0]); + assert!(e.contains(no_plate), "{e}"); + assert!(s.model.state().sheets.is_empty()); +} + +/// The flat pattern's cut-outs that are no whole circle (slots), each by the middle of its arcs' +/// centres. +fn flat_slots(s: &mut Session) -> Vec<[f64; 2]> { + let shapes = flat(s)["cutout_shapes"].as_array().cloned().unwrap_or_default(); + let centre = |segs: &Vec| { + let c: Vec<[f64; 2]> = segs.iter().filter_map(|g| Some([g["arc"]["center"][0].as_f64()?, g["arc"]["center"][1].as_f64()?])).collect(); + let n = c.len().max(1) as f64; + [c.iter().map(|p| p[0]).sum::() / n, c.iter().map(|p| p[1]).sum::() / n] + }; + shapes.iter().filter_map(|x| x["segments"].as_array()).map(centre).collect() +} + +/// A U channel 40 long (along z) and `t` thick: a web 100 wide on y = 0 and two legs 30 high, +/// outside lengths to the sharp corners. Returns a point on each outside corner (the bends). +fn channel(s: &mut Session, t: f64) -> Vec<[f64; 3]> { + section(s, &[(0.0, 30.0), (0.0, 0.0), (100.0, 0.0), (100.0, 30.0)], t, 40.0) +} + +/// The channel folded with two 90° bends, less `cut` mm² of its faces: the web and the legs +/// between their setbacks R + T, and two quarter rounds on the neutral radius R + T/2. +fn channel_volume(t: f64, r: f64, cut: f64) -> f64 { + t * (40.0 * ((100.0 - 2.0 * (r + t)) + 2.0 * (30.0 - (r + t)) + PI * (r + t / 2.0)) - cut) +} + +/// Holes and a slot in the faces of a channel converted by its bend edges are cut-outs of the +/// flat pattern, where the bend allowance puts them: in the fixed face (the web) and in the bent +/// ones (the legs). With bend edges, such faces were refused ("faces with curved edges"). +#[test] +fn holes_and_a_slot_in_a_channels_faces_land_where_the_bends_put_them() { + let (t, k, r) = (2.0, 0.4, 2.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + let bends = channel(&mut s, t); + // Two 6 mm holes in the web, 30 from either outside corner; one in the right leg and a slot + // 6 wide and 20 long in the left one, each centred 18 up the leg's outside. + run(&mut s, "solid.hole", json!({"position": [30, 0, 20], "diameter": 6})); + run(&mut s, "solid.hole", json!({"position": [70, 0, 20], "diameter": 6})); + run(&mut s, "solid.hole", json!({"position": [100, 18, 20], "diameter": 6, "depth": 3})); + run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Slot"})); + run(&mut s, "sketch.slot.overall", json!({"p0": [18, 10], "p1": [18, 30], "width": 6})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Slot", "distance": 3, "operation": "cut"})); + run(&mut s, "sheet.convert", json!({"body": "Section", "face": [50, 0, 5], "bends": bends})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 40.0).abs() < 1e-9), "{rows:?}"); + // T 2, R 2, K 0.4: BA = π/2 (R + K T) = 1.4 π, BD = 2 (R + T) - BA = 8 - 1.4 π = 3.6018; the + // flat is the outside lengths less a BD per bend. + let bd = deduction(FRAC_PI_2, r, t, k); + assert!((bd - (8.0 - 1.4 * PI)).abs() < 1e-12); + let across = 160.0 - 2.0 * bd; + assert!(same_size(flat_size(&mut s), (across, 40.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // On one line across the flat: the right leg's hole, the web's two, 40 apart, and the slot. + // From a leg's to the web's nearer one: 18 + 30 along the outside to the sharp, less the BD. + let (circles, slots) = (flat_circles(&mut s), flat_slots(&mut s)); + assert!(circles.len() == 3 && circles.iter().all(|(radius, _)| (radius - 3.0).abs() < 1e-9) && slots.len() == 1, "{circles:?} {slots:?}"); + let mut centres: Vec<[f64; 2]> = circles.iter().map(|c| c.1).chain(slots).collect(); + centres.sort_by(|a, b| a[0].total_cmp(&b[0])); + let gaps: Vec = centres.windows(2).map(|w| w[1][0] - w[0][0]).collect(); + assert!(centres.iter().all(|c| (c[1] - centres[0][1]).abs() < 1e-6), "{centres:?}"); + assert!(gaps.len() == 3 && gaps.iter().zip([48.0 - bd, 40.0, 48.0 - bd]).all(|(g, want)| (g - want).abs() < 1e-6), "{gaps:?}"); + // Folded, flat and folded again, less three holes of radius 3 and the slot (14 x 6 and its + // round ends). + let cut = 3.0 * PI * 9.0 + 14.0 * 6.0 + PI * 9.0; + let v = volume(&mut s); + let want = channel_volume(t, r, cut); + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); + run(&mut s, "sheet.unfold", json!({})); + let want = t * (across * 40.0 - cut); + assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v); + // The DXF: a CIRCLE per hole, and the slot's round ends as ARCs. + let (_, arcs, holes) = dxf_entities(&mut s); + assert_eq!((arcs, holes), (2, 3)); +} + +/// Round edges of a bent face stay arcs: the legs' free corners rounded by 5, and a notch of +/// radius 4 in one leg's free edge. They were refused too. +#[test] +fn rounded_corners_and_a_notch_on_a_leg_stay_arcs_in_the_flat() { + let (t, k, r) = (2.0, 0.4, 2.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + let bends = channel(&mut s, t); + run(&mut s, "solid.fillet", json!({"edges": [[1, 30, 0], [1, 30, 40], [99, 30, 0], [99, 30, 40]], "radius": 5})); + run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Notch"})); + run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 4})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Notch", "distance": 3, "operation": "cut"})); + run(&mut s, "sheet.convert", json!({"body": "Section", "face": [50, 0, 5], "bends": bends})); + let across = 160.0 - 2.0 * deduction(FRAC_PI_2, r, t, k); + assert!(same_size(flat_size(&mut s), (across, 40.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // Each corner takes (1 - π/4) 5², the notch half a disc of radius 4. + let cut = 4.0 * (1.0 - PI / 4.0) * 25.0 + PI * 16.0 / 2.0; + let v = volume(&mut s); + let want = channel_volume(t, r, cut); + assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}"); + run(&mut s, "sheet.unfold", json!({})); + let want = t * (across * 40.0 - cut); + assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v); + // The DXF: a quarter ARC at each corner and a half one at the notch, no cut-outs. + let (_, arcs, holes) = dxf_entities(&mut s); + assert_eq!((arcs, holes), (5, 0)); +} + +/// A hole that reaches into a bend has no one place in the flat, and a pocket deeper than half +/// the sheet is no engraving: both are refused, by their places. (The web's flat panel starts +/// R + T = 4 from the sharp; the hole reaches from 2 to 8. The pocket is 1.5 deep in the 2 thick +/// web.) +#[test] +fn a_hole_reaching_into_a_bend_and_a_pocket_are_refused_by_their_places() { + let convert = |cut: &[(&str, Value)]| { + let mut s = Session::default(); + rule(&mut s, "Test", 2.0, 0.4, 2.0, 0.5); + let bends = channel(&mut s, 2.0); + for (id, p) in cut { + run(&mut s, id, p.clone()); + } + let e = s.execute("sheet.convert", &json!({"body": "Section", "face": [50, 0, 5], "bends": bends})).err(); + assert!(s.model.state().sheets.is_empty()); + e.map(|e| e.to_string()).unwrap_or_default() + }; + let e = convert(&[("solid.hole", json!({"position": [5, 0, 20], "diameter": 6}))]); + assert!(e.contains("the hole at (5.000, 0.000, 20.000) reaches into a bend"), "{e}"); + let e = convert(&[ + ("sketch.create", json!({"plane": "XZ", "name": "Pocket"})), + ("sketch.rectangle.two_point", json!({"p0": [40, -15], "p1": [60, -25]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"sketch": "Pocket", "distance": 1.5, "operation": "cut"})), + ]); + assert!(e.contains("the pocket at (50.000, 0.000, 20.000) in a face of the sheet ends 1.500 deep, more than half the sheet's 2.000"), "{e}"); +} + +/// The bounding box (low corner, high corner) of a flat pattern pocket's outline. +fn pocket_box(p: &Value) -> ([f64; 2], [f64; 2]) { + let pts: Vec<[f64; 2]> = p["outline"].as_array().into_iter().flatten().filter_map(|q| Some([q[0].as_f64()?, q[1].as_f64()?])).collect(); + let lo = [pts.iter().map(|q| q[0]).fold(f64::MAX, f64::min), pts.iter().map(|q| q[1]).fold(f64::MAX, f64::min)]; + let hi = [pts.iter().map(|q| q[0]).fold(f64::MIN, f64::max), pts.iter().map(|q| q[1]).fold(f64::MIN, f64::max)]; + (lo, hi) +} + +/// A bent part as another system models it, its bends with their radius, with what its faces +/// carry: a channel 80 wide with legs 30 high (outside, to the sharp corners), 2 thick, 60 long, +/// bent R 2 inside and R 4 outside, with a 6 mm hole 18 up its right leg, a label 20 x 10 +/// engraved 0.5 deep into the outside of its web and one 10 x 20 into the inside of its left leg. +/// Converted from the outside of the web by its outer cylinders under a rule of the model's +/// radius, the sheet is the model again: the hole is a cut-out where the bend allowance puts it, +/// and each label is a pocket on its own skin of the sheet. +#[test] +fn a_channel_with_its_bends_modelled_keeps_its_hole_and_engravings_on_either_skin() { + let (t, k, r) = (2.0, 0.4, 2.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + let corners = bent_section(&mut s, &[(0.0, 30.0), (0.0, 0.0), (80.0, 0.0), (80.0, 30.0)], t, r, 60.0); + run(&mut s, "solid.hole", json!({"position": [80, 18, 20], "diameter": 6, "depth": 3})); + // The web's label from its outside (y = 0) in, the leg's from its inside (x = 2) out. + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "WebLabel"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, -35], "p1": [50, -45]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "WebLabel", "distance": 0.5, "operation": "cut"})); + run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 2, "name": "LegLabel"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [12, 10], "p1": [22, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "LegLabel", "distance": 0.5, "direction": "negative", "operation": "cut"})); + // Two flat legs, the web and two quarter rounds on the neutral radius R + T/2, less the hole + // and the labels. + let cut = 9.0 * PI * t + 2.0 * 200.0 * 0.5; + let solid = volume(&mut s); + let model = t * 60.0 * (2.0 * (30.0 - (r + t)) + (80.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - cut; + assert!((solid - model).abs() < 2e-3 * model, "{solid} vs {model}"); + run(&mut s, "sheet.convert", json!({"body": "Section", "face": [65, 0, 10], "bends": [corners[0].0, corners[1].0]})); + let rows = bend_rows(&mut s); + assert!(rows.len() == 2 && rows.iter().all(|row| row.0 == 90 && row.1 == "up" && (row.2 - 60.0).abs() < 1e-6), "{rows:?}"); + // 30 + 80 + 30 less two deductions; the sheet is the solid again. + let bd = deduction(FRAC_PI_2, r, t, k); + let across = 140.0 - 2.0 * bd; + assert!(same_size(flat_size(&mut s), (across, 60.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + let v = volume(&mut s); + assert!((v - solid).abs() < 1e-3 * solid, "{v} vs {solid}"); + // The labels stay in their skins: the web's in its outside, the leg's in its inside. + assert!(!contains(&s, [40.0, 0.25, 40.0]) && contains(&s, [40.0, 0.75, 40.0])); + assert!(!contains(&s, [1.75, 17.0, 20.0]) && contains(&s, [1.25, 17.0, 20.0])); + // The flat: the hole, and a pocket per label, 0.5 deep from the web's skin (z 0) and from the + // other one (z T). Along the outside from the web label's middle (40 in, 40 along) to the + // hole's: 40 to the sharp and 18 up the leg, less a deduction, and 20 back along; to the leg + // label's middle, 40 and 17 the other way. + let circles = flat_circles(&mut s); + assert!(circles.len() == 1 && (circles[0].0 - 3.0).abs() < 1e-9, "{circles:?}"); + let f = flat(&mut s); + let pockets = f["pockets"].as_array().cloned().unwrap_or_default(); + assert_eq!(pockets.len(), 2, "{f}"); + let from = |p: &Value, z0: f64, z1: f64| { + (p["z0_mm"].as_f64().unwrap_or(f64::NAN) - z0).abs() < 1e-9 && (p["z1_mm"].as_f64().unwrap_or(f64::NAN) - z1).abs() < 1e-9 + }; + let web = pockets.iter().find(|p| from(p, 0.0, 0.5)).expect("the web's label, from z 0"); + let leg = pockets.iter().find(|p| from(p, 1.5, 2.0)).expect("the leg's label, from z T"); + let middle = |p: &Value| { + let (lo, hi) = pocket_box(p); + ([(lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0], (hi[0] - lo[0], hi[1] - lo[1])) + }; + let ((w, w_size), (l, l_size), h) = (middle(web), middle(leg), circles[0].1); + assert!(same_size(w_size, (20.0, 10.0), 1e-6) && same_size(l_size, (10.0, 20.0), 1e-6), "{w_size:?} {l_size:?}"); + let (to_hole, to_leg) = ([h[0] - w[0], h[1] - w[1]], [l[0] - w[0], l[1] - w[1]]); + assert!(same_size((to_hole[0].abs(), to_hole[1].abs()), (58.0 - bd, 20.0), 1e-6), "{to_hole:?}"); + assert!(same_size((to_leg[0].abs(), to_leg[1].abs()), (57.0 - bd, 20.0), 1e-6), "{to_leg:?}"); + assert!(to_hole[0] * to_leg[0] + to_hole[1] * to_leg[1] < 0.0, "{to_hole:?} {to_leg:?}"); + // The DXF: the hole a CIRCLE, each label on the pocket layer of its depth. + assert_eq!(dxf_entities(&mut s).2, 1); + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + assert_eq!((lines_on(&dxf, "POCKET_BOTTOM_Z0.000000_TO_0.500000_MM"), lines_on(&dxf, "POCKET_TOP_Z1.500000_TO_2.000000_MM")), (4, 4)); + // Unfolded and folded again it keeps them. + run(&mut s, "sheet.unfold", json!({})); + let want = t * across * 60.0 - cut; + assert!((volume(&mut s) - want).abs() < 1e-4 * want, "{} vs {want}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v); +} + +/// Chamfer bends beside holes: [`chamfered_channel`] with a 6 mm hole in its web 20 from the +/// left sharp corner and one 18 up its right leg, the chamfers picked in their middles. The holes +/// land from the virtual sharps, where the faces were taken out to. +#[test] +fn holes_beside_chamfer_bends_land_from_the_virtual_sharps() { + let (t, k, r) = (2.0, 0.5, 3.0); + let mut s = Session::default(); + rule(&mut s, "Test", t, k, r, 0.5); + chamfered_channel(&mut s); + run(&mut s, "solid.hole", json!({"position": [20, 0, 30], "diameter": 6})); + run(&mut s, "solid.hole", json!({"position": [40, 18, 70], "diameter": 6, "depth": 3})); + run(&mut s, "sheet.convert", json!({"body": "Channel", "face": [20, 0, 80], "bends": [[2, 2, 50], [38, 2, 50]]})); + let bd = deduction(FRAC_PI_2, r, t, k); + let across = 100.0 - 2.0 * bd; + assert!(same_size(flat_size(&mut s), (across, 100.0), 1e-6), "{:?} vs {across}", flat_size(&mut s)); + // From the web's hole: 20 to the right sharp, 18 up the leg, less a deduction; 40 along. + let circles = flat_circles(&mut s); + assert!(circles.len() == 2 && circles.iter().all(|(radius, _)| (radius - 3.0).abs() < 1e-9), "{circles:?}"); + let d = [circles[1].1[0] - circles[0].1[0], circles[1].1[1] - circles[0].1[1]]; + assert!(same_size((d[0].abs(), d[1].abs()), (38.0 - bd, 40.0), 1e-6), "{d:?}"); + // Three flat strips and two quarter rounds on the neutral radius, less the holes. + let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - 2.0 * t * 9.0 * PI; + assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s)); +} diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 55af2a3d..9b685040 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -127,14 +127,37 @@ keeps its envelope and the flat pattern is the outside lengths less a bend deduc 2·(R + T)·tan(θ/2) − θ·(R + K·T). A bend can be picked on either skin of a shell; bends turn either way, follow one another and take any angle (the walls of a tapered tray at 72°). An edge between two faces of the sheet that is no bend is cut open, each side half the rule's -**gap** from the plane halfway between the two, like the corners of a tray. The result is an +**gap** from the plane halfway between the two, like the corners of a tray. A bend drawn as a +chamfer (a flat strip between two faces, the chord of the bend's arc, as models without bend +radii draw one) is picked in the middle of the strip: the two faces then meet at their virtual +sharp in one bend of the rule's radius, and the strip is no face of the sheet. A bend modelled +with its radius (a cylinder between two flat faces tangent to it, as sheet metal parts from +other systems come, or a filleted edge) is picked anywhere on its cylinder, on either skin, and +converts the same way: the sheet is bent there with the rule's radius and K-factor, not the +modelled radius, so the flat pattern is the one the rule makes. The result is an ordinary sheet body: edge flanges, cuts, Unfold, the flat pattern and its exports work on it, and it follows the solid when a parameter changes (the edges and the fixed face are found by -their names). Without bend edges Convert expects a plate, as before; its round walls (holes, slot -ends, rounded corners) stay circles and arcs in the flat pattern and the DXF, also where a STEP -file writes one in pieces. Not converted yet: curved faces (rolled sheet, a filleted edge as a -bend), faces with holes or curved edges in a body with bend edges (cut them after converting) -and bends that close a ring. +their names). Without bend edges Convert expects a plate, as before. Either way round walls +(holes, slot ends, rounded corners, notches) stay circles and arcs in the flat pattern and the +DXF, also where a STEP file writes one in pieces, and the holes and slots of every face of the +sheet are its cut-outs, where the bends put them. An engraving in a face of the sheet (a recess +with a flat floor, at most half the thickness deep) becomes a pocket of it, as a plate's does +(below), on either skin of a shell. Not converted yet: curved faces other than bends (rolled +sheet), with bend edges a hole or an engraving that reaches into a bend or a pocket deeper than +half the thickness (cut them after converting), and bends that close a ring. + +A plate may carry recesses in one of its faces, such as an engraved label or a counterbore: a +recess with a flat floor and walls square to the plate, at most half the plate's thickness +deep. Each becomes a pocket of the sheet (see the pockets below), so the converted part keeps +it: the sheet is as thick as the plate, the flat pattern is the whole plate with the recess +listed under `pockets` (its outline, side and depth), and the DXF draws its outline on the +pocket layer of its depth, apart from the cut contours, for a laser to mark rather than cut. +The face the recesses are in can be the one picked or the other. A recess deeper than half the +thickness, recesses in both faces, and a plate whose recesses are larger than the face around +them (a thinner plate with raised parts) are refused with the reason. A recess lies in an +opening of the face it is cut into, so the faces of a bent part's legs are none: a bent part is +refused as no plate, and converts with its bend edges. A bend across a pocket is refused as for +any pocket, so fold or flange clear of an engraving. ```json {"command":"solid.box","params":{"length":100,"width":60,"height":30,"body_name":"Tray"}}