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"}}