From a2f31f683de8582ef85e0f124a15a22ec61f60a7 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:45:19 +0200 Subject: [PATCH] feat: flanges on flanges follow their flange's end and close at corners (#601) An edge flange on another flange's far edge (a return) runs along that edge from end to end, following the parent's closed or cut-back corner, and returns of one feature close toward each other at convex base corners, mitred the rule's gap apart. Bends stop square where the mitre allows. Fixes #593. Co-authored-by: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> --- ROADMAP.md | 4 + crates/doc/src/sheet.rs | 1025 +++++++++++++++++++++++++- crates/engine/src/cmd/sheet_tests.rs | 191 +++++ docs/book/src/guide/sheet-metal.md | 26 + 4 files changed, 1242 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0ada4897..b089a7a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -338,6 +338,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. tapered or bend-crossing machining refuses atomically instead of restoring material later. Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the flat pattern's coordinates, and follow it through rebuilds and unfold/refold. + A flange on another flange's far edge (a return) follows that flange's closed end and keeps + clear of the flange beside it, and the returns of one feature are mitred toward each other + with the rule's gap where their flanges meet at a corner; returns of separate features, and + flanges on returns, are not closed toward each other. - Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 47cb2342..86b187f1 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -567,6 +567,40 @@ fn convex_overlap(a: &[Vec2], b: &[Vec2]) -> f64 { signed_area(&poly).abs() } +/// How wide a strip two flat pieces may share and still only touch. A flange's bend starts on +/// its parent's far edge at a point snapped to the 1e-9 mm grid, so where that edge runs at a +/// slant to the axes the two share a sliver whose area grows with the edge's length. +const FLAT_SLIVER: f64 = 1e-6; + +/// Whether two convex counter-clockwise flat pieces overlap by more than a sliver along an edge. +fn pieces_overlap(a: &[Vec2], b: &[Vec2]) -> bool { + let perimeter = |p: &[Vec2]| (0..p.len()).filter_map(|i| Some(p.get(i)?.dist(*p.get((i + 1) % p.len())?))).sum::(); + convex_overlap(a, b) > FLAT_SLIVER * perimeter(a).max(perimeter(b)).max(1.0) +} + +/// Whether `p` lies in the triangle (a, b, c), its edges included (within 1e-6 mm). +fn in_triangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2) -> bool { + let side = |u: Vec2, v: Vec2| { + let e = v - u; + e.cross(p - u) / e.len().max(1e-12) + }; + let (x, y, z) = (side(a, b), side(b, c), side(c, a)); + (x >= -1e-6 && y >= -1e-6 && z >= -1e-6) || (x <= 1e-6 && y <= 1e-6 && z <= 1e-6) +} + +/// Two flanges on the far edges of two flanges that meet at a corner of the base (see +/// [`SheetBody::close_return_corners`]): flanges `a` and `b`, which end of each (false = the end +/// at `p`), and where each one's bend ends there (flat coordinates; apart, each on its own parent). +#[derive(Clone, Copy, Debug)] +struct ReturnCorner { + a: usize, + a_end: bool, + b: usize, + b_end: bool, + ca: Vec2, + cb: Vec2, +} + impl SheetBody { /// Bend allowance of a flange. pub fn allowance(&self, f: &SheetFlange) -> f64 { @@ -618,6 +652,13 @@ impl SheetBody { rect(a, b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), a + f.n * f.leg - f.d * (f.slope.0 * f.leg)) } + /// Where a flange panel's far edge (`leg` past the bend zone) starts and ends, along the bend + /// from its start `p`: a closed corner runs the panel's end on or cuts it back there (`ext`, + /// `slope`). + fn far_edge(&self, f: &SheetFlange) -> (f64, f64) { + (-f.ext.0 - f.slope.0 * f.leg, f.len + f.ext.1 + f.slope.1 * f.leg) + } + /// Flat region of a flange's bend zone. pub fn zone(&self, f: &SheetFlange) -> Region2 { let ba = self.allowance(f); @@ -1486,7 +1527,10 @@ impl SheetBody { /// Edge flanges that each have their own height and angle, in one go: every edge is trimmed /// back by its own setback first, then the flanges are added and the corners between them /// are closed. The closing works from the two flanges it joins (each one's own fold and - /// leg), so unequal flanges close like equal ones. + /// leg), so unequal flanges close like equal ones. A flange on another flange's far edge (a + /// return) runs along that edge end to end, following its parent's closed end; two of them + /// whose parents meet at a corner are closed toward each other as well, and one alone at a + /// corner is kept clear of the flange beside its parent (`close_return_corners`). pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec, Vec)> { if edges.is_empty() || edges.len() > 1000 { return Err(DocError::Invalid("pick 1…1000 edges".into())); @@ -1553,8 +1597,19 @@ impl SheetBody { } Some(i) => { let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("flange".into()))?; - let u0 = (line_o - f.p).dot(f.d).clamp(0.0, f.len); - (f.p + f.n * (self.allowance(f) + f.leg) + f.d * u0, f.len) + let tip = f.p + f.n * (self.allowance(f) + f.leg); + if f.shape.is_some() { + let u0 = (line_o - f.p).dot(f.d).clamp(0.0, f.len); + (tip + f.d * u0, f.len) + } else { + // The panel's far edge, end to end: where a closed corner ran the panel on + // or cut it back (slanted or not), the flange on it follows. + let (lo, hi) = self.far_edge(f); + if !(hi - lo > 1e-9) { + return Err(DocError::Invalid("the flange's edge was trimmed away".into())); + } + (tip + f.d * lo, hi - lo) + } } }; let up = top != flip; @@ -1574,10 +1629,351 @@ impl SheetBody { }); made.push(self.flanges.len() - 1); } - let notes = self.close_corners(&made); + let mut notes = self.close_corners(&made); + notes.extend(self.close_return_corners(&made)); Ok((made, notes)) } + /// Flanges on the base that meet at a convex corner of it, at least one of them among `among`: + /// (a, a_end, b, b_end, the corner). Their bend lines cross at the corner and both bends end + /// there, or both the same distance short of it: where a corner relief was cut, or where the + /// base's outline cuts the corner off. + fn base_pairs(&self, among: &[usize]) -> Vec<(usize, bool, usize, bool, Vec2)> { + let limit = std::f64::consts::PI - 1e-6; + // Every bend end on the base: (flange, which end, the point, the way into the bend from it). + let ends: Vec<(usize, bool, Vec2, Vec2)> = self + .flanges + .iter() + .enumerate() + .filter(|(_, f)| f.parent.is_none() && f.shape.is_none() && f.angle.abs() < limit) + .flat_map(|(i, f)| { + let (u0, u1) = self.zone_span(f); + [(i, false, f.p + f.d * u0, f.d), (i, true, f.p + f.d * u1, f.d * -1.0)] + }) + .collect(); + let mut out = Vec::new(); + for (k, &(a, a_end, za, ea)) in ends.iter().enumerate() { + for &(b, b_end, zb, eb) in ends.iter().skip(k + 1) { + if a == b || !(among.contains(&a) || among.contains(&b)) { + continue; + } + let (Some(fa), Some(fb)) = (self.flanges.get(a), self.flanges.get(b)) else { continue }; + let cross = ea.cross(eb); + // Parallel bends don't cross; at an inside corner the flanges turn away from each other. + if cross.abs() < 1e-9 || ea.dot(fb.n) > -1e-9 || eb.dot(fa.n) > -1e-9 { + continue; + } + // The corner c where the bend lines cross: za = c + ea·δa, zb = c + eb·δb. + let r = za - zb; + let (da, db) = (r.cross(eb) / cross, r.cross(ea) / cross); + if !(da > -1e-6 && (da - db).abs() < 1e-6) { + continue; + } + let c = za - ea * da; + if da > 1e-6 { + // Short of the crossing. Where both flanges' own edges run to it, a corner relief + // took the bends' ends away after the flanges were closed there: their corner, + // however deep the relief. Otherwise the base is cut off between the bends, which + // is a corner only while the cut is no deeper than the flanges reach and no other + // flange's bend ends in it; else it is an edge of the base (the top of a trapezoid + // whose sides cross above it) and the flanges on its two sides don't meet. + let whole = |f: &SheetFlange, at_end: bool| (f.p + f.d * if at_end { f.len } else { 0.0 }).dist(c) < 1e-6; + let relieved = whole(fa, a_end) && whole(fb, b_end); + let reach = |f: &SheetFlange| self.allowance(f) + f.leg; + let between = || ends.iter().any(|&(g, _, z, _)| g != a && g != b && in_triangle(z, za, c, zb)); + if !relieved && (da > reach(fa).min(reach(fb)) || between()) { + continue; + } + } + out.push((a, a_end, b, b_end, c)); + } + } + out + } + + /// The flange of `made` on flange `i`'s far edge whose bend ends at that edge's end (the one + /// at `p`, or the other one when `at_end`), with the point where its bend ends there. + fn on_far_end(&self, i: usize, at_end: bool, made: &[usize]) -> Option<(usize, Vec2)> { + let limit = std::f64::consts::PI - 1e-6; + let f = self.flanges.get(i).filter(|f| f.shape.is_none())?; + let (lo, hi) = self.far_edge(f); + let tip = self.allowance(f) + f.leg; + made.iter().find_map(|&j| { + let g = self.flanges.get(j).filter(|g| g.parent == Some(i) && g.shape.is_none() && g.angle.abs() < limit && g.d.dot(f.d) > 1.0 - 1e-9)?; + let c = if at_end { g.p + g.d * g.len } else { g.p }; + let (s, u) = ((c - f.p).dot(f.n), (c - f.p).dot(f.d)); + ((s - tip).abs() < 1e-6 && (u - if at_end { hi } else { lo }).abs() < 1e-6).then_some((j, c)) + }) + } + + /// Finish the new flanges that stand on the far edges of flanges meeting at a convex corner of + /// the base (returns on the walls of a tray), where their bends reach their parents' ends. + /// Two of them meeting at a corner are closed toward each other ([`SheetBody::close_returns`]); + /// as on the base, a flange of an earlier feature is left as it is. One that is alone at a + /// corner, or passes the other, is kept clear of the flange beside its parent + /// ([`SheetBody::clear_of_neighbour`]). Returns a note for every corner left open. + fn close_return_corners(&mut self, made: &[usize]) -> Vec { + let mut parents: Vec = made.iter().filter_map(|&j| self.flanges.get(j)?.parent).collect(); + parents.sort_unstable(); + parents.dedup(); + let mut notes = Vec::new(); + if parents.is_empty() { + return notes; + } + // A point of flange `on`'s panel, in the part's coordinates, for a note. + let at = |s: &SheetBody, on: usize, c: Vec2| { + let w = apply_point(&s.world(Some(on)), v3(c)); + format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z) + }; + for (a, a_end, b, b_end, c) in self.base_pairs(&parents) { + let (x, y) = (self.on_far_end(a, a_end, made), self.on_far_end(b, b_end, made)); + if let (Some((x, ca)), Some((y, cb))) = (x, y) { + match self.close_returns(&ReturnCorner { a: x, a_end, b: y, b_end, ca, cb }) { + Ok(true) => continue, + Ok(false) => {} + Err(why) => { + notes.push(format!("the flanges at {} are left open: {why}", at(self, a, ca))); + continue; + } + } + } + for (on, at_end, own, other) in [(x, a_end, a, b), (y, b_end, b, a)] { + let Some((i, ci)) = on else { continue }; + if let Err(why) = self.clear_of_neighbour(i, at_end, ci, own, other, c) { + notes.push(format!("the flange at {} is not clear of the flange beside it: {why}", at(self, own, ci))); + } + } + } + notes + } + + /// Close two flanges on flanges that meet at a corner (returns on two walls that meet): mitre + /// them on the plane halfway between them, the rule's gap apart. That plane runs through the + /// point where the two flanges' inner start lines cross (seen along their panels' mean normal), + /// along that normal, and bisects the two bend lines; returns lying in one plane get the + /// vertical mitre of a picture frame, whatever their parents' angles. Each panel ends square + /// to the sheet on a line in the flat, slanted as the mitre makes it, so that all of it keeps + /// gap/2 from the plane. A bend can't end at a slant like that (cut that way in the flat, its + /// folded end is not a plane), so each bend stops square where all of it keeps gap/2 from the + /// plane, never past its parent's end; a short stretch of the parent's edge can stay bare there. + /// Returns whether the two met: one turned in and one turned out pass each other untouched. + fn close_returns(&mut self, k: &ReturnCorner) -> std::result::Result { + let (Some(fa), Some(fb)) = (self.flanges.get(k.a).cloned(), self.flanges.get(k.b).cloned()) else { + return Err("a flange is missing".into()); + }; + let (wa, wb) = (self.fold(k.a), self.fold(k.b)); + // Inner faces (toward the bends' axes) and their normals, pointing from the outer face in. + let inner = |f: &SheetFlange| if f.angle >= 0.0 { (self.t, 1.0) } else { (0.0, -1.0) }; + let ((za, sa), (zb, sb)) = (inner(&fa), inner(&fb)); + let (na, nb) = (apply_vector(&wa, Vec3::Z) * sa, apply_vector(&wb, Vec3::Z) * sb); + let Some(up) = (na + nb).normalized() else { return Err("the flanges face each other".into()) }; + let across = |v: Vec3| (v - up * v.dot(up)).normalized(); + let ea = if k.a_end { fa.d * -1.0 } else { fa.d }; + let eb = if k.b_end { fb.d * -1.0 } else { fb.d }; + let (Some(ea3), Some(eb3), Some(ta), Some(tb)) = ( + across(apply_vector(&wa, v3(ea))), + across(apply_vector(&wb, v3(eb))), + across(apply_vector(&wa, v3(fa.n))), + across(apply_vector(&wb, v3(fb.n))), + ) else { + return Err("a flange stands square to the other one".into()); + }; + // They meet when both panels lie toward the other's bend (inward returns overlap) or both + // away from it (outward ones leave a V). One each way: they pass each other, nothing to do. + if ta.dot(eb3) * tb.dot(ea3) < -1e-12 { + return Ok(false); + } + let Some(m) = (ea3 - eb3).normalized() else { return Err("the flanges are parallel".into()) }; + let gap = self.gap; + if !(gap >= MIN_CORNER_GAP) { + return Err(format!("the flange gap {gap:.4} mm is below {MIN_CORNER_GAP} mm")); + } + // Where the inner start lines cross, seen along `up` (the midpoint if they run in line). + let start = |f: &SheetFlange, w: &Mat, c: Vec2, z: f64| apply_point(w, v3(c + f.n * self.allowance(f)) + Vec3::Z * z); + let (pa, pb) = (start(&fa, &wa, k.ca, za), start(&fb, &wb, k.cb, zb)); + let side = up.cross(ea3); + let den = eb3.dot(side); + let x = if den.abs() > 1e-9 { pb + eb3 * ((pa - pb).dot(side) / den) } else { (pa + pb) * 0.5 }; + let mut new = [fa.clone(), fb.clone()]; + for (f, (w, c, e, at_end, sign)) in new.iter_mut().zip([(&wa, k.ca, ea, k.a_end, 1.0), (&wb, k.cb, eb, k.b_end, -1.0)]) { + // Signed distance from the mitre plane, positive on this flange's side. It is affine in + // (u, s, z): u from the bend's end along the bend, s from the bend's start across it. + let dist = |p: Vec3| sign * (p - x).dot(m); + let at = |u: f64, s: f64, z: f64| dist(apply_point(w, v3(c + e * u + f.n * s) + Vec3::Z * z)); + let c0 = at(0.0, 0.0, 0.0); + let (cu, cs, cz) = (at(1.0, 0.0, 0.0) - c0, at(0.0, 1.0, 0.0) - c0, at(0.0, 0.0, 1.0) - c0); + if !(cu > 1e-6) || ![c0, cs, cz].iter().all(|v| v.is_finite()) { + return Err("the flanges don't meet across the corner".into()); + } + let ba = self.allowance(f); + // The bend: along it the distance grows as on the panel; at its end take the least over + // the bend's cross-section. + let u_bend = ((gap / 2.0 - self.zone_least(f, c, &dist)) / cu).max(0.0); + let u_panel = (gap / 2.0 - c0 - (cz * self.t).min(0.0) - cs * ba) / cu; + if !(u_bend.is_finite() && u_panel.is_finite()) { + return Err("the flanges don't meet across the corner".into()); + } + // Panel and bend ending together stay one straight edge. + let u_panel = if (u_panel - u_bend).abs() < 1e-9 { u_bend } else { u_panel }; + let slope = if (cs / cu).abs() < 1e-12 { 0.0 } else { cs / cu }; + if !at_end { + f.p += f.d * u_bend; + } + f.len -= u_bend; + if !(f.len > 1e-6) { + return Err("the gap would cut a bend away".into()); + } + // The panel runs on past its bend's (new) end toward the corner, or is cut back. + if at_end { + f.ext.1 = u_bend - u_panel; + f.slope.1 = slope; + } else { + f.ext.0 = u_bend - u_panel; + f.slope.0 = slope; + } + let bottom = f.len + f.ext.0 + f.ext.1; + let top = bottom + (f.slope.0 + f.slope.1) * f.leg; + if f.leg > 1e-9 && !(bottom > 1e-6 && top > 1e-6) { + return Err("the gap would cut a panel away".into()); + } + } + // The flat pattern must stay one sheet without overlaps. + let [new_a, new_b] = new; + let pieces = |s: &SheetBody, f: &SheetFlange| { + let mut v = vec![corners_of(&s.zone(f))]; + if f.leg > 1e-9 { + v.push(corners_of(&s.panel(f))); + } + v + }; + let mine = [pieces(self, &new_a), pieces(self, &new_b)]; + let overlap = |p: &[Vec], q: &[Vec]| p.iter().any(|a| q.iter().any(|b| pieces_overlap(a, b))); + let [mine_a, mine_b] = &mine; + let clash = overlap(mine_a, mine_b) + || self.flanges.iter().enumerate().any(|(j, g)| { + j != k.a && j != k.b && g.shape.is_none() && { + let other = pieces(self, g); + mine.iter().any(|p| overlap(p, &other)) + } + }); + if clash { + return Err("closed, the flanges would overlap in the flat pattern".into()); + } + if let Some(f) = self.flanges.get_mut(k.a) { + *f = new_a; + } + if let Some(f) = self.flanges.get_mut(k.b) { + *f = new_b; + } + Ok(true) + } + + /// Keep flange `i`, which stands on the far edge of flange `own` and reaches its end at a + /// corner of the base, clear of the flange `other` that `own` meets there (`c`: where their + /// bend lines cross; `ci`: where flange `i`'s bend ends). A wall that leans in over the base + /// also leans in over a return on the wall beside it. `own`'s end keeps half the gap from the + /// plane halfway between the two walls' inner faces, which leaves its nearest edge + /// |nb − na|·gap/2 from `other`'s inner face (na, nb: the inner faces' normals). Flange `i` is + /// cut back until what has passed `own`'s inner face keeps that distance too: its panel, its + /// bend's inner face, and its bend's outer face from the turn where that face crosses the + /// plane (the rest lies in `own`'s thickness, on `own`'s side of the corner as `own`'s end + /// does). Its bend stops square and its panel ends on a line in the flat; it is never run + /// on. Only a flange that turns the way its parent did lies on that side of it. + fn clear_of_neighbour(&mut self, i: usize, at_end: bool, ci: Vec2, own: usize, other: usize, c: Vec2) -> std::result::Result<(), String> { + let (Some(mut f), Some(fa), Some(fb)) = (self.flanges.get(i).cloned(), self.flanges.get(own), self.flanges.get(other)) else { + return Err("a flange is missing".into()); + }; + let up = fa.angle >= 0.0; + if (fb.angle >= 0.0) != up || (f.angle >= 0.0) != up { + return Ok(()); + } + let (z_in, sign) = if up { (self.t, 1.0) } else { (0.0, -1.0) }; + let (wb, w) = (self.fold(other), self.fold(i)); + let (na, nb) = (apply_vector(&self.fold(own), Vec3::Z) * sign, apply_vector(&wb, Vec3::Z) * sign); + let pb = apply_point(&wb, v3(c) + Vec3::Z * z_in); + let keep = (nb - na).len() * self.gap.max(0.0) / 2.0; + // How far a point is clear: affine in (u, s, z), u from the bend's end along the bend, s + // from the bend's start across it. + let clear = |p: Vec3| nb.dot(p - pb) - keep; + let e = if at_end { f.d * -1.0 } else { f.d }; + let at = |u: f64, s: f64, z: f64| clear(apply_point(&w, v3(ci + e * u + f.n * s) + Vec3::Z * z)); + let c0 = at(0.0, 0.0, 0.0); + let (cu, cs, cz) = (at(1.0, 0.0, 0.0) - c0, at(0.0, 1.0, 0.0) - c0, at(0.0, 0.0, 1.0) - c0); + if !(cu > 1e-6) || ![c0, cs, cz].iter().all(|v| v.is_finite()) { + return Ok(()); + } + let ba = self.allowance(&f); + // The bend's outer face is R + T from the axis and starts T outside `own`'s inner face: it + // crosses that plane at the turn φ with (R + T)·(1 − cos φ) = T, or not before its end. + let th = f.angle.abs(); + let from = (f.radius / (f.radius + self.t)).acos().min(th); + let least = self.arc_least(&f, ci, z_in, (0.0, th), &clear).min(self.arc_least(&f, ci, self.t - z_in, (from, th), &clear)); + let u_bend = (-least / cu).max(0.0); + // The panel's end is one line in the flat: through what its start and its far edge need + // (on the face that comes nearer), neither less than nothing. + let worst = c0 + (cz * self.t).min(0.0); + let (u0, u1) = ((-(worst + cs * ba) / cu).max(0.0), (-(worst + cs * (ba + f.leg)) / cu).max(0.0)); + if !(u_bend > 1e-9 || u0 > 1e-9 || u1 > 1e-9) { + return Ok(()); + } + // Panel and bend ending together stay one straight edge. + let u0 = if (u0 - u_bend).abs() < 1e-9 { u_bend } else { u0 }; + let slope = if f.leg > 1e-9 && (u0 - u1).abs() > 1e-12 { (u0 - u1) / f.leg } else { 0.0 }; + if !at_end { + f.p += f.d * u_bend; + } + f.len -= u_bend; + if !(f.len > 1e-6) { + return Err("that would cut its bend away".into()); + } + if at_end { + f.ext.1 = u_bend - u0; + f.slope.1 = slope; + } else { + f.ext.0 = u_bend - u0; + f.slope.0 = slope; + } + let bottom = f.len + f.ext.0 + f.ext.1; + let top = bottom + (f.slope.0 + f.slope.1) * f.leg; + if f.leg > 1e-9 && !(bottom > 1e-6 && top > 1e-6) { + return Err("that would cut its panel away".into()); + } + if let Some(g) = self.flanges.get_mut(i) { + *g = f; + } + Ok(()) + } + + /// The least of `g` (an affine function of a folded point) over the cross-section of flange + /// `f`'s bend at `q0` on its start line: over its two faces, from the bend's start to its end. + fn zone_least(&self, f: &SheetFlange, q0: Vec2, g: &dyn Fn(Vec3) -> f64) -> f64 { + let whole = (0.0, f.angle.abs()); + self.arc_least(f, q0, 0.0, whole, g).min(self.arc_least(f, q0, self.t, whole, g)) + } + + /// The least of `g` (an affine function of a folded point) along the arc that the point `z` + /// up in the sheet at `q0`, on the start line of flange `f`'s bend, runs through while the + /// bend turns it from `turns.0` to `turns.1`. The bend turns the point about the bend axis, + /// so `g` is a + b·cos φ + c·sin φ in the turn φ: its least is at an end of the arc or where + /// that sinusoid bottoms out. + fn arc_least(&self, f: &SheetFlange, q0: Vec2, z: f64, turns: (f64, f64), g: &dyn Fn(Vec3) -> f64) -> f64 { + let parent = f.parent.map_or(IDENTITY, |i| self.fold(i)); + let (axis_o, k, turn) = self.bend_axis(f); + let at = |phi: f64| g(apply_point(&mat_mul(&parent, &rot_about(axis_o, k, turn.signum() * phi)), v3(q0) + Vec3::Z * z)); + let (v0, v90, v180) = (at(0.0), at(std::f64::consts::FRAC_PI_2), at(std::f64::consts::PI)); + let (a, b) = ((v0 + v180) / 2.0, (v0 - v180) / 2.0); + let c = v90 - a; + let mut least = at(turns.0).min(at(turns.1)); + let mut phi = c.atan2(b) + std::f64::consts::PI; + if phi >= std::f64::consts::TAU { + phi -= std::f64::consts::TAU; + } + if phi > turns.0 && phi < turns.1 { + least = least.min(a - b.hypot(c)); + } + least + } + /// Corners where two of the new flanges' bends meet on the base: both bend zones end at the /// corner point. Hems and fold panels are left out, and so is a corner with a flange of an /// earlier feature: that one keeps its length and the new flange ends at its bend, as before. @@ -3937,6 +4333,627 @@ mod tests { holds_the_flat_material(&s, "trapezoid"); } + /// Flanges `height` high at `deg` on the edges `on` of the base `pts` (counter-clockwise; edge k + /// leaves corner k), in one call, bend inside, their corners closed. + fn walls(rv: &RuleValues, pts: &[Vec2], on: &[usize], deg: f64, height: f64) -> SheetBody { + let n = pts.len(); + let each: Vec = on + .iter() + .map(|&k| { + let m = (pts[k] + pts[(k + 1) % n]) * 0.5; + EdgeFlange { pick: Vec3::new(m.x, m.y, rv.thickness), height, angle: deg.to_radians() } + }) + .collect(); + let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(pts).ccw(), holes: vec![] }, rv, &each); + assert!(notes.is_empty(), "{notes:?}"); + s + } + + /// The middle of flange `i`'s far edge, folded, on its inner face (the one toward its bend's + /// axis) or its outer one: a flange picked there turns the way flange `i` turned, or the other. + fn far_edge_pick(s: &SheetBody, i: usize, inner: bool) -> Vec3 { + let f = &s.flanges[i]; + let (lo, hi) = s.far_edge(f); + let q = f.p + f.n * (s.allowance(f) + f.leg) + f.d * ((lo + hi) / 2.0); + let z = if (f.angle >= 0.0) == inner { s.t } else { 0.0 }; + apply_point(&s.world(Some(i)), Vec3::new(q.x, q.y, z)) + } + + /// Returns `height` high at `deg` on the far edges of the flanges `on`, in one call, turning in + /// over the base (bend inside). The new flanges and the notes on corners left open. + fn returns(s: &mut SheetBody, on: &[usize], deg: f64, height: f64) -> (Vec, Vec) { + let picks: Vec = on.iter().map(|&i| far_edge_pick(s, i, true)).collect(); + s.add_flanges_noted(&picks, height, deg.to_radians(), 1.0, "inside", false).unwrap() + } + + /// Folded points of flange `i`'s material: its panel's corners and, with `bend`, its bend's + /// cross-section at both ends (65 points across it), each on both faces. + fn material(s: &SheetBody, i: usize, bend: bool) -> Vec { + let f = &s.flanges[i]; + let mut v = Vec::new(); + if f.leg > 1e-9 { + let w = s.fold(i); + for q in corners_of(&s.panel(f)) { + v.extend([0.0, s.t].map(|z| apply_point(&w, Vec3::new(q.x, q.y, z)))); + } + } + if bend { + let ba = s.allowance(f); + let (u0, u1) = s.zone_span(f); + for u in [u0, u1] { + for k in 0..=64 { + let q = f.p + f.d * u + f.n * (ba * k as f64 / 64.0); + v.extend([0.0, s.t].map(|z| s.map3(Piece::Zone(i), q, z))); + } + } + } + v + } + + /// The least and greatest signed distance of points from the plane through `o` with normal `m`. + fn spread(pts: &[Vec3], o: Vec3, m: Vec3) -> (f64, f64) { + pts.iter().map(|p| (*p - o).dot(m)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d))) + } + + /// Folded, no flange on a wall's far edge reaches into another wall: no point of its bend or + /// its panel within 25 mm of either end (sampled every 0.25 mm, at three heights through the + /// sheet) lies in a flange on the base other than the one it stands on. + fn returns_keep_out_of_the_walls(s: &SheetBody, what: &str) { + let on_base: Vec<(usize, Material)> = + (0..s.flanges.len()).filter(|&i| s.flanges[i].parent.is_none()).map(|i| (i, Material::of(s, i))).collect(); + for (i, f) in s.flanges.iter().enumerate() { + let Some(parent) = f.parent else { continue }; + let ba = s.allowance(f); + let (u0, u1) = s.zone_span(f); + let mut samples = Vec::new(); + for at_end in [false, true] { + for iu in 0..100 { + let du = 0.01 + 0.25 * iu as f64; + let u = if at_end { u1 - du } else { u0 + du }; + for is in 0..24 { + samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0))); + } + } + let (q0, q1, inward) = if at_end { + let b = f.p + f.n * ba + f.d * (f.len + f.ext.1); + (b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), f.d * -1.0) + } else { + let a = f.p + f.n * ba - f.d * f.ext.0; + (a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg), f.d) + }; + let rows = (f.leg / 0.25).ceil().max(1.0) as usize; + for ir in 0..rows { + let on_end = q0 + (q1 - q0) * ((ir as f64 + 0.5) / rows as f64); + for iu in 0..100 { + samples.push((Piece::Panel(Some(i)), on_end + inward * (0.01 + 0.25 * iu as f64))); + } + } + } + for (piece, flat) in samples { + for z in [0.02 * s.t, 0.5 * s.t, 0.98 * s.t] { + let q = s.map3(piece, flat, z); + for (w, m) in on_base.iter().filter(|(w, _)| *w != parent) { + let d = m.depth(q); + assert!( + d.is_none_or(|d| d < 1e-6), + "{what}: flange {i}'s {piece:?} at {flat:?}, height {z}, lies {d:?} mm inside flange {w}" + ); + } + } + } + } + } + + /// The two returns at each corner of a four-sided tray, returns 4…7 on walls 0…3: (a, the end + /// of a, b, the end of b), true for the end past the far end of the bend. + const TRAY_RETURNS: [(usize, bool, usize, bool); 4] = [(4, false, 7, true), (4, true, 5, false), (5, true, 6, false), (6, true, 7, false)]; + + #[test] + fn returns_on_a_tray_are_mitred_the_gap_apart() { + use std::f64::consts::{PI, SQRT_2}; + // The 120 x 90 tray with walls 20 high at 90° and, on their far edges, returns 12 high at + // 90° turning in over the base. Each return used to take its wall's bend length (114 or + // 84) whatever the wall's closed end did, and neighbours ran through each other: the solid + // failed to sew. + let rv = alu(); + let g = rv.gap; + let (mut s, notes) = tray(&rv, 90.0, 20.0); + assert!(notes.is_empty(), "{notes:?}"); + let (made, notes) = returns(&mut s, &[0, 1, 2, 3], 90.0, 12.0); + assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}"); + // By hand (T 2, R 1, K 0.5, g 0.5; e = g/√2). Every bend takes a setback of + // (R + T)·tan 45° = 3 and is π/2·(R + K·T) = π wide in the flat. The wall on y = 0 starts at + // the base's trimmed edge y = 3 and is 20 − 3 − 3 = 14 past its bend once the return's + // setback is off, so its far edge lies at y = 3 − π − 14 in the flat and runs, between its + // closed ends, from x = 2 + e to 118 − e (as before). Folded, its inner face is y = 2 and + // the return's bend turns about y = 3, z = 17: the return lies in 18 ≤ z ≤ 20 from y = 3 to + // y = 12, and its neighbour on x = 0 likewise from x = 3. Their inner start lines y = 3 + // and x = 3 cross at (3, 3), so the mitre is the plane x = y and this return keeps + // x − y ≥ e. Its bend (y ≤ 3) stops square at x = 3 + e, 1 mm in from the wall's end, and + // its panel ends on x = 3 + e + (y − 3). + let e = g / SQRT_2; + let wall = &s.flanges[0]; + let (lo, hi) = s.far_edge(wall); + assert!((wall.leg - 14.0).abs() < 1e-12 && (lo - (e - 1.0)).abs() < 1e-9 && (hi - (115.0 - e)).abs() < 1e-9, "{wall:?}"); + let r = &s.flanges[4]; + assert!(r.p.dist(Vec2::new(3.0 + e, -11.0 - PI)) < 1e-9 && (r.len - (114.0 - 2.0 * e)).abs() < 1e-9, "{r:?}"); + assert!(r.ext.0.abs() < 1e-9 && r.ext.1.abs() < 1e-9 && (r.slope.0 + 1.0).abs() < 1e-9 && (r.slope.1 + 1.0).abs() < 1e-9, "{r:?}"); + let lens: Vec = s.flanges[4..].iter().map(|f| f.len).collect(); + for (got, want) in lens.iter().zip([114.0, 84.0, 114.0, 84.0]) { + assert!((got - (want - 2.0 * e)).abs() < 1e-9, "{lens:?}"); + } + // In the flat the return's bend is π wide and its panel 12 − 3 = 9 past it. + let (y0, y1) = (-11.0 - 2.0 * PI, -20.0 - 2.0 * PI); + let got = corners_of(&s.panel(r)); + assert_eq!(got.len(), 4); + for (x, y) in [(3.0 + e, y0), (117.0 - e, y0), (108.0 - e, y1), (12.0 + e, y1)] { + assert!(got.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9), "({x}, {y}) not in {got:?}"); + } + // Folded, the inner end edges (on z = 18) of the two returns at each corner run parallel, + // the gap apart. + for (a, a_end, b, b_end) in TRAY_RETURNS { + let ((a0, a1), (b0, b1)) = (inner_end_edge(&s, a, a_end), inner_end_edge(&s, b, b_end)); + assert!((a0.z - 18.0).abs() < 1e-9 && (b1.z - 18.0).abs() < 1e-9, "{a0:?} {b1:?}"); + let gap = parallel_gap(a0, a1, b0, b1); + assert!(gap.is_some_and(|d| (d - g).abs() < 1e-9), "{a}/{b}: {gap:?}"); + } + // The part stays inside the tray's 120 x 90 x 20. + let bb = kernel::measure(&s.solid().unwrap()).unwrap().bbox; + assert!(bb.min.len() < 1e-6 && (bb.max - Vec3::new(120.0, 90.0, 20.0)).len() < 1e-6, "{bb:?}"); + // The flat pattern by hand: the base 114 x 84; the walls' bends π·(114 + 84)·2 and panels + // 14·((116 − 2e) + (86 − 2e))·2; the returns' bends π·((114 − 2e) + (84 − 2e))·2 and their + // panels, 9 high and 9 shorter at each end of the far edge, 9·((105 − 2e) + (75 − 2e))·2. + let want = 114.0 * 84.0 + 396.0 * PI + 28.0 * (202.0 - 4.0 * e) + 2.0 * PI * (198.0 - 4.0 * e) + 18.0 * (180.0 - 4.0 * e); + assert!((flat_area(&s) - want).abs() < 1e-6, "{} vs {want}", flat_area(&s)); + // One piece in the flat and one valid shell folded, holding that area times the thickness. + holds_the_flat_material(&s, "returns at 90°"); + } + + /// A tray under rule `rv` on the convex base `pts` (counter-clockwise): walls 20 high at `deg` + /// on every edge and on each a return 12 high at 180° − `deg`, which turns in over the base and + /// lies parallel to it. By hand, for a wall at θ and its return at ρ = 180° − θ (T, R, g as the rule has them): + /// a point of the wall's inner face w up its outer face from the virtual sharp lies + /// −w·cosθ + T·sinθ in from the base's edge. The return's setback leaves the wall's far edge at + /// w = 20 − (R + T)·tan(ρ/2), and its bend turns about an axis R inside that inner face, so the + /// return's panel starts v = −w·cosθ + (T + R)·sinθ in from the edge, and all of its bend lies + /// further out than that. At a base corner of angle α, a point x along an edge from the corner + /// and v in from that edge is x·sin(α/2) − v·cos(α/2) from the vertical plane on the corner's + /// bisector. That plane is the mitre (the two returns lie in one plane and are alike), so each + /// return's bend stops square at x_b = (g/2 + v·cos(α/2)) / sin(α/2) and its panel ends from + /// there on a line that loses cot(α/2) per unit of height. Checks: + /// - every return's bend is its edge less x_b at both corners, its panel starts with the bend + /// (no run-on) and its ends slant by −cot(α/2); + /// - at every corner the two walls' panels and the two returns (bend and panel) keep g/2 from + /// the vertical plane on the corner's bisector, each on its own side, the panels' nearest + /// points exactly g/2; + /// - nothing reaches further out past the base's edges than the walls' tips do; + /// - one piece in the flat, one valid shell folded, holding the flat pattern's material. + fn tray_with_level_returns_closes_every_corner(rv: &RuleValues, pts: &[Vec2], deg: f64, what: &str) -> SheetBody { + let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap); + let n = pts.len(); + let all: Vec = (0..n).collect(); + let mut s = walls(rv, pts, &all, deg, 20.0); + let (made, notes) = returns(&mut s, &all, 180.0 - deg, 12.0); + assert!(notes.is_empty() && made.len() == n, "{what}: {notes:?}"); + let (th, rho) = (deg.to_radians(), (180.0 - deg).to_radians()); + let v = (t + r) * th.sin() - (20.0 - (r + t) * (rho / 2.0).tan()) * th.cos(); + // Half the base's angle at corner c, and the two directions along its edges. + let edges = |c: usize| ((pts[(c + n - 1) % n] - pts[c]).normalized().unwrap(), (pts[(c + 1) % n] - pts[c]).normalized().unwrap()); + let half = |c: usize| edges(c).0.dot(edges(c).1).acos() / 2.0; + let x_b = |c: usize| (g / 2.0 + v * half(c).cos()) / half(c).sin(); + for k in 0..n { + let (ret, next) = (&s.flanges[n + k], (k + 1) % n); + let want = pts[k].dist(pts[next]) - x_b(k) - x_b(next); + assert!((ret.len - want).abs() < 1e-9 && ret.ext.0.abs() < 1e-9 && ret.ext.1.abs() < 1e-9, "{what}: return {k}: {want} vs {ret:?}"); + assert!( + (ret.slope.0 + 1.0 / half(k).tan()).abs() < 1e-9 && (ret.slope.1 + 1.0 / half(next).tan()).abs() < 1e-9, + "{what}: return {k}: {ret:?}" + ); + } + for (c, corner) in pts.iter().enumerate() { + let (to_prev, to_next) = edges(c); + let bis = (to_prev + to_next).normalized().unwrap(); + // The plane's normal points to the side of the edge that leaves the corner (wall c). + let (o, m) = (Vec3::new(corner.x, corner.y, 0.0), Vec3::new(bis.y, -bis.x, 0.0)); + for (wall, sign) in [((c + n - 1) % n, -1.0), (c, 1.0)] { + for (i, bend) in [(wall, false), (wall + n, true)] { + let (all, panel) = (spread(&material(&s, i, bend), o, m * sign).0, spread(&material(&s, i, false), o, m * sign).0); + assert!(all > g / 2.0 - 1e-9 && (panel - g / 2.0).abs() < 1e-9, "{what}: corner {c}, flange {i}: {all} {panel}"); + } + } + } + // A wall leaning out reaches 20·cosθ past its edge at its tip; one leaning in stays inside. + let reach = (20.0 * th.cos()).max(0.0); + for k in 0..n { + let d = (pts[(k + 1) % n] - pts[k]).normalized().unwrap(); + for i in 0..2 * n { + let far = spread(&material(&s, i, true), Vec3::new(pts[k].x, pts[k].y, 0.0), Vec3::new(d.y, -d.x, 0.0)).1; + assert!(far < reach + 1e-6, "{what}: flange {i} reaches {far} past edge {k}"); + } + } + holds_the_flat_material(&s, what); + s + } + + #[test] + fn returns_on_leaning_walls_close_on_each_corners_bisector() { + // Walls at 110° (leaning 20° in) with returns at 70°. Every return used to run its wall's + // bend length from where the wall's far edge started: through the neighbouring wall and + // out past the corner (to x = 120.504 on the tray, whose walls end at x = 118.716). + let tray = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0)]; + let s = tray_with_level_returns_closes_every_corner(&alu(), &tray, 110.0, "tray"); + // In numbers: v = 3·sin 110° + (20 − 3·tan 35°)·cos 70° = 8.941025 and x_b = v + g/√2 + // = 9.294579 at a square corner, so the bends are 120 − 2·x_b and 90 − 2·x_b long. + assert!((s.flanges[4].len - 101.410842).abs() < 1e-6 && (s.flanges[5].len - 71.410842).abs() < 1e-6, "{:?}", s.flanges[4]); + // The part is no larger than its walls: their bends' outer faces, 3·tan 55° − 3 = 1.284444 + // inside the base's edges (measured on the bends' facets, so to 1e-3), and the returns' top + // at 20·sin 110° = 18.793852. + let bb = kernel::measure(&s.solid().unwrap()).unwrap().bbox; + assert!( + (bb.min - Vec3::new(1.284444, 1.284444, 0.0)).len() < 1e-3 && (bb.max - Vec3::new(118.715556, 88.715556, 18.793852)).len() < 1e-3, + "{bb:?}" + ); + // A trapezoid: corners of 77.5° and 102.5°. + let trapezoid = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)]; + let s = tray_with_level_returns_closes_every_corner(&alu(), &trapezoid, 110.0, "trapezoid"); + let bb = kernel::measure(&s.solid().unwrap()).unwrap().bbox; + assert!(bb.min.y > 1.28 && bb.max.y < 88.72 && bb.min.x > 2.41 && bb.max.x < 117.59, "{bb:?}"); + } + + #[test] + fn returns_close_at_any_corner_on_walls_leaning_either_way() { + // Walls leaning out (70°), upright and leaning in (110°), each with level returns, on the + // tray, the trapezoid and parallelograms with corners of 60° and 120°: 9 parts, 36 corners. + let (c, sn) = (60f64.to_radians().cos(), 60f64.to_radians().sin()); + let bases = [ + ("tray", [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0)]), + ("trapezoid", [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)]), + ("parallelogram", [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0 + 90.0 * c, 90.0 * sn), Vec2::new(90.0 * c, 90.0 * sn)]), + ]; + for (name, pts) in bases { + for deg in [70.0, 90.0, 110.0] { + tray_with_level_returns_closes_every_corner(&alu(), &pts, deg, &format!("{name}, walls at {deg}°")); + } + } + } + + #[test] + fn returns_close_at_relieved_corners_as_at_whole_ones() { + use std::f64::consts::{FRAC_PI_2, PI}; + // A round or rectangular corner relief stops the walls' bends half its size short of the + // corner and leaves the walls' closed ends as they are, so the returns on them meet as at + // a whole corner: every check of `tray_with_level_returns_closes_every_corner` holds, and + // the flanges are the very ones the notch gives. So it is with a relief 36 across, which + // stops the bends 18 from the corner: further than the walls reach past their bends in the + // flat (π + 14), where a cut in the base's outline would be an edge of the base. And the + // relief is cut as without returns: at each corner the flat loses what the relief takes. + let tray = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0)]; + let trapezoid = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)]; + let alpha = 90f64.atan2(20.0); + let cases = [ + ("tray", tray, [FRAC_PI_2; 4], 90.0, 8.0), + ("tray", tray, [FRAC_PI_2; 4], 90.0, 36.0), + ("tray", tray, [FRAC_PI_2; 4], 110.0, 8.0), + ("trapezoid", trapezoid, [alpha, alpha, PI - alpha, PI - alpha], 110.0, 8.0), + ]; + for shape in [ReliefShape::Round, ReliefShape::Rectangular] { + for (name, pts, corners, deg, size) in cases { + let what = format!("{shape:?} relief {size} mm across on the {name}, walls at {deg}°"); + let rv = RuleValues { corner_relief_shape: shape, corner_relief: size, ..alu() }; + let notch = tray_with_level_returns_closes_every_corner(&alu(), &pts, deg, &what); + let s = tray_with_level_returns_closes_every_corner(&rv, &pts, deg, &what); + assert_eq!(s.flanges, notch.flanges, "{what}"); + let want: f64 = corners.iter().map(|a| relief_area(shape, size, *a, deg.to_radians() * 2.0)).sum(); + let removed = flat_area(¬ch) - flat_area(&s); + assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}"); + } + } + } + + #[test] + fn returns_at_unequal_angles_meet_on_a_leaning_mitre() { + // The tray's 90° walls with, in one call, returns at 90° on the walls on y = 0 and y = 90 + // and at 70° (rising 20° toward the middle) on the other two: at every corner two returns + // meet that don't lie in one plane. Their mitre runs along the mean of the two panels' + // inner normals, bisects their bend lines seen along it, and passes where their inner + // start lines cross seen along it. + let rv = alu(); + let (t, g) = (rv.thickness, rv.gap); + let (mut s, _) = tray(&rv, 90.0, 20.0); + let each: Vec = + (0..4).map(|i| EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: [90f64, 70.0][i % 2].to_radians() }).collect(); + let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}"); + for (a, a_end, b, b_end) in TRAY_RETURNS { + let (fa, fb) = (&s.flanges[a], &s.flanges[b]); + let (wa, wb) = (s.fold(a), s.fold(b)); + let up = (apply_vector(&wa, Vec3::Z) + apply_vector(&wb, Vec3::Z)).normalized().unwrap(); + let across = |v: Vec3| (v - up * v.dot(up)).normalized().unwrap(); + let ea = across(apply_vector(&wa, v3(if a_end { fa.d * -1.0 } else { fa.d }))); + let eb = across(apply_vector(&wb, v3(if b_end { fb.d * -1.0 } else { fb.d }))); + let m = (ea - eb).normalized().unwrap(); + let pa = apply_point(&wa, v3(fa.p + fa.n * s.allowance(fa)) + Vec3::Z * t); + let pb = apply_point(&wb, v3(fb.p + fb.n * s.allowance(fb)) + Vec3::Z * t); + let side = up.cross(ea); + let o = pb + eb * ((pa - pb).dot(side) / eb.dot(side)); + // Near the corner each return keeps g/2 from that plane, its panel's nearest point at g/2. + for (i, sign) in [(a, 1.0), (b, -1.0)] { + let near = |bend: bool| material(&s, i, bend).into_iter().filter(|p| (*p - o).len() < 30.0).collect::>(); + let (all, panel) = (near(true), near(false)); + assert!(!panel.is_empty() && spread(&all, o, m * sign).0 > g / 2.0 - 1e-9, "{a}/{b}, flange {i}"); + assert!((spread(&panel, o, m * sign).0 - g / 2.0).abs() < 1e-9, "{a}/{b}, flange {i}"); + } + // The 70° return rises out of the 90° one's plane, so the mitre leans. + assert!(m.z.abs() > 1e-3, "{m:?}"); + } + holds_the_flat_material(&s, "returns at 90° and 70°"); + } + + #[test] + fn returns_turned_outward_close_the_v_between_them() { + use std::f64::consts::{PI, SQRT_2}; + // The tray's 90° walls with returns turned away from the base (picked on the walls' outer + // faces): a brim, 12 wide. Left as the walls' ends leave them, the brim's strips stop + // short of each other in a V at every corner. + let rv = alu(); + let g = rv.gap; + let (mut s, _) = tray(&rv, 90.0, 20.0); + let picks: Vec = (0..4).map(|i| far_edge_pick(&s, i, false)).collect(); + let (_, notes) = s.add_flanges_noted(&picks, 12.0, 90f64.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + // By hand, for the strip on the wall on y = 0 (e = g/√2): its bend turns about y = −1, + // z = 17, and it lies in 18 ≤ z ≤ 20 from y = −1 out to y = −10; its neighbour likewise + // from x = −1. The inner start lines cross at (−1, −1), so the mitre is x = y again and + // the strip keeps x − y ≥ e. Its bend spans y from −1 to the wall's inner face y = 2, so + // it may reach x = 2 + e: that is the wall's end, and the bend keeps the wall's whole far + // edge, 116 − 2e. The panel at y = −1 − h keeps x ≥ e − 1 − h: it starts 3 before the bend + // and runs on 1 per unit of height. + let e = g / SQRT_2; + let r = &s.flanges[4]; + assert!(r.angle < 0.0 && r.p.dist(Vec2::new(2.0 + e, -11.0 - PI)) < 1e-9 && (r.len - (116.0 - 2.0 * e)).abs() < 1e-9, "{r:?}"); + assert!( + (r.ext.0 - 3.0).abs() < 1e-9 && (r.ext.1 - 3.0).abs() < 1e-9 && (r.slope.0 - 1.0).abs() < 1e-9 && (r.slope.1 - 1.0).abs() < 1e-9, + "{r:?}" + ); + for (a, a_end, b, b_end) in TRAY_RETURNS { + let ((a0, a1), (b0, b1)) = (inner_end_edge(&s, a, a_end), inner_end_edge(&s, b, b_end)); + assert!((a0.z - 18.0).abs() < 1e-9 && (b1.z - 18.0).abs() < 1e-9, "{a0:?} {b1:?}"); + let gap = parallel_gap(a0, a1, b0, b1); + assert!(gap.is_some_and(|d| (d - g).abs() < 1e-9), "{a}/{b}: {gap:?}"); + } + let bb = kernel::measure(&s.solid().unwrap()).unwrap().bbox; + assert!((bb.min - Vec3::new(-10.0, -10.0, 0.0)).len() < 1e-6 && (bb.max - Vec3::new(130.0, 100.0, 20.0)).len() < 1e-6, "{bb:?}"); + holds_the_flat_material(&s, "a brim"); + } + + #[test] + fn a_lone_return_follows_its_walls_end_and_keeps_clear_of_the_wall_beside_it() { + use std::f64::consts::SQRT_2; + // A wall's closed end is not where its bend ends: at 70° the wall leans out and its end + // runs on past the corner toward its tip, at 110° it is cut back. A return alone on one + // wall used to take the bend's length from where the wall's far edge started. + let rv = alu(); + let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap); + let e = g / SQRT_2; + // By hand, for the wall on y = 0 at θ with a level return (at ρ = 180° − θ) and the wall + // beside it on x = 0 (`closed_corners_end_on_the_bisector_with_the_gap`): the wall's inner + // face ends at x(w) = e + T·sinθ − w·cosθ, w up its outer face from the virtual sharp, and + // the return's setback leaves the wall's far edge at w = 20 − (R + T)·tan(ρ/2). The wall + // beside it has its inner face on x·sinθ + z·cosθ = T, and the first wall's end keeps + // e·sinθ from that plane (|nb − na| = √2·sinθ), which a return on it has to keep too + // with what it has inside the first wall's inner face: x ≥ e + (T − z·cosθ)/sinθ at the + // height z. Walls that stand upright or lean out ask less of it the higher it is, and + // nothing more than of the wall's far edge it starts on: the return spans that edge, + // from x = −3.142098 at 70° (past the corner) and 2.353553 at 90°. Walls that lean in + // ask most at its top, z = 20·sinθ: at 110° the far edge starts at x = 8.354886 and the + // return stops square at e + T/sinθ − 20·cosθ = 9.322312. + for (deg, want) in [(70.0f64, -3.142098), (90.0, 2.353553), (110.0, 9.322312)] { + let (mut s, _) = tray(&rv, deg, 20.0); + let bend = s.flanges[0].len; + let (made, notes) = returns(&mut s, &[0], 180.0 - deg, 12.0); + assert!(notes.is_empty() && made == [4], "{deg}°: {notes:?}"); + let (th, rho) = (deg.to_radians(), (180.0 - deg).to_radians()); + let edge = e + t * th.sin() - (20.0 - (r + t) * (rho / 2.0).tan()) * th.cos(); + let (lo, hi) = s.far_edge(&s.flanges[0]); + assert!((s.flanges[0].p.x + lo - edge).abs() < 2e-9 && (hi - lo - (120.0 - 2.0 * edge)).abs() < 2e-9, "{deg}°: {edge} vs {lo} {hi}"); + let ret = &s.flanges[4]; + assert!((ret.p.x - want).abs() < 1e-6 && (ret.len - (120.0 - 2.0 * want)).abs() < 1e-6, "{deg}°: {want} vs {ret:?}"); + assert!((ret.len - bend).abs() > 1.0 && ret.ext == (0.0, 0.0) && ret.slope == (0.0, 0.0), "{deg}°: {bend} vs {ret:?}"); + // Up to 90° that is the wall's far edge exactly; at 110° it is 0.967426 in from it. + let cut = if deg > 90.0 { 0.967426 } else { 0.0 }; + assert!((ret.p.x - edge - cut).abs() < 1e-6, "{deg}°: {edge} vs {ret:?}"); + returns_keep_out_of_the_walls(&s, &format!("a lone return on a wall at {deg}°")); + holds_the_flat_material(&s, &format!("a lone return on a wall at {deg}°")); + } + // Returns on two walls that don't meet (the tray's bottom and top) are each alone. + let (mut s, _) = tray(&rv, 110.0, 20.0); + let (made, notes) = returns(&mut s, &[0, 2], 70.0, 12.0); + assert!(notes.is_empty() && made == [4, 5], "{notes:?}"); + assert!(s.flanges[4..].iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0) && (f.len - 101.355376).abs() < 1e-6)); + returns_keep_out_of_the_walls(&s, "returns on opposite walls"); + holds_the_flat_material(&s, "returns on opposite walls"); + // A return at 90° on the wall at 110° points down into the tray. Its setback is 3, so the + // wall's far edge is 17 up its outer face and starts at x = e + T·sinθ − 17·cosθ = 8.047281. + // The return's bend turns about an axis at z = 17·sinθ + 3·cosθ = 14.948714. Its outer + // face is highest at the turn 70° (3 above the axis), still in the wall's thickness; it + // crosses the wall's inner face at the turn acos(R/(R + T)) = 70.528779°, at + // z = 14.948714 + 3·cos 0.528779° = 17.948586, and from there on has to keep + // x ≥ e + (T − z·cosθ)/sinθ: the bend stops square at x = 9.014660. The panel's outer + // face starts at z = 20·sinθ + 3·cosθ = 17.767792, where it has to keep x ≥ 8.948856, and + // has fallen to 20·sinθ + 12·cosθ = 14.689611 at its far edge, where 7.828490 would do: + // before the wall's end, so the panel's far edge starts with the wall's, at 8.047281. + // The panel begins 9.014660 − 8.948856 = 0.065804 before its bend's end and gains + // (8.948856 − 8.047281)/9 = 0.100175 per unit of height, the same at both ends. + let (mut s, _) = tray(&rv, 110.0, 20.0); + let (_, notes) = returns(&mut s, &[0], 90.0, 12.0); + assert!(notes.is_empty(), "{notes:?}"); + let ret = &s.flanges[4]; + assert!((ret.p.x - 9.014660).abs() < 1e-6 && (ret.len - (120.0 - 2.0 * 9.014660)).abs() < 2e-6 && (ret.leg - 9.0).abs() < 1e-12, "{ret:?}"); + for (ext, slope) in [(ret.ext.0, ret.slope.0), (ret.ext.1, ret.slope.1)] { + assert!((ext - 0.065804).abs() < 1e-6 && (slope - 0.100175).abs() < 1e-6, "{ret:?}"); + } + let (lo, hi) = s.far_edge(ret); + assert!((ret.p.x + lo - 8.047281).abs() < 1e-6 && (ret.p.x + hi - (120.0 - 8.047281)).abs() < 1e-6, "{lo} {hi}"); + returns_keep_out_of_the_walls(&s, "a return at 90° on a wall at 110°"); + holds_the_flat_material(&s, "a return at 90° on a wall at 110°"); + } + + #[test] + fn returns_keep_out_of_the_walls_beside_them_whatever_the_angles() { + // A wall that leans in over the base also leans in over a return on the wall beside it. + // Returns alone on one wall, and returns on every wall in one call, level and at 90°, on + // walls from 70° to 135°: none reaches into a wall (sampled), and none is said to be left open. + let rv = alu(); + for deg in [70.0f64, 90.0, 100.0, 110.0, 120.0, 135.0] { + for ret in [180.0 - deg, 90.0] { + for on in [&[0usize][..], &[0, 1, 2, 3][..]] { + let what = format!("walls at {deg}°, returns at {ret}° on {on:?}"); + let (mut s, _) = tray(&rv, deg, 20.0); + let (_, notes) = returns(&mut s, on, ret, 12.0); + assert!(notes.is_empty(), "{what}: {notes:?}"); + returns_keep_out_of_the_walls(&s, &what); + holds_the_flat_material(&s, &what); + } + } + } + // Walls at unequal angles, each with a level return, and walls of unequal heights with + // returns at 90°: the returns of one feature, mitred toward each other, stay out of the + // walls as well. + let cases = [ + ([60.0, 90.0, 105.0, 120.0], [20.0; 4], false), + ([110.0, 90.0, 110.0, 90.0], [20.0; 4], false), + ([120.0, 100.0, 70.0, 110.0], [20.0; 4], false), + ([90.0; 4], [20.0, 30.0, 20.0, 30.0], true), + ]; + for (angles, heights, square) in cases { + let what = format!("walls at {angles:?}, {heights:?} high"); + let (mut s, notes) = tray_each(&rv, angles, heights); + assert!(notes.is_empty(), "{what}: {notes:?}"); + let each: Vec = (0..4) + .map(|i| { + let deg: f64 = if square { 90.0 } else { 180.0 - angles[i] }; + EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: deg.to_radians() } + }) + .collect(); + let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{what}: {notes:?}"); + returns_keep_out_of_the_walls(&s, &what); + holds_the_flat_material(&s, &what); + } + } + + #[test] + fn returns_that_cannot_close_stay_open_with_a_note_and_others_are_left_alone() { + // A gap of zero would make the ends touch: the returns stay as their walls' ends leave + // them, with a note for each corner that names the point on the wall's far edge. + let mut rv = alu(); + rv.gap = 0.0; + let (mut s, _) = tray(&rv, 110.0, 20.0); + let (_, notes) = returns(&mut s, &[0, 1, 2, 3], 70.0, 12.0); + assert_eq!(notes.len(), 4, "{notes:?}"); + assert!(notes.iter().all(|n| n.contains("left open") && n.contains("gap")), "{notes:?}"); + // The corner at the origin: the wall on y = 0 starts 3·tan 55° = 4.284 along its edge, and + // its outer face at its far edge (w = 20 − 3·tan 35° = 17.899 up it) is at + // (−w·cos 110°, w·sin 110°) = (6.122, 16.820). + assert!(notes[0].starts_with("the flanges at (4.284, 6.122, 16.820) are left open"), "{notes:?}"); + for i in 4..8 { + let (lo, hi) = s.far_edge(&s.flanges[i - 4]); + let r = &s.flanges[i]; + assert!(r.ext == (0.0, 0.0) && r.slope == (0.0, 0.0) && (r.len - (hi - lo)).abs() < 1e-9, "{r:?}"); + } + // A return turned in next to one turned out: they pass each other, so nothing is closed + // and nothing is said. + let (mut s, _) = tray(&alu(), 90.0, 20.0); + let picks = [far_edge_pick(&s, 0, true), far_edge_pick(&s, 3, false)]; + let (made, notes) = s.add_flanges_noted(&picks, 12.0, 90f64.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && made == [4, 5], "{notes:?}"); + // Each spans its wall's far edge, 116 − 2e on y = 0 and 86 − 2e on x = 0, square-ended. + let e = 0.5 / std::f64::consts::SQRT_2; + assert!(s.flanges[4..].iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0))); + assert!((s.flanges[4].len - (116.0 - 2.0 * e)).abs() < 1e-9 && (s.flanges[5].len - (86.0 - 2.0 * e)).abs() < 1e-9, "{:?}", &s.flanges[4..]); + holds_the_flat_material(&s, "one return in, one out"); + // Returns of separate features are not closed toward each other, like flanges on the + // base: the later one does not reach into the earlier feature, and is only kept clear of + // the walls beside its own (71.355376 = 90 − 2·9.322312 long, see the lone return). Turned + // the same way, the two then run through each other at the corner, as before. + let (mut s, _) = tray(&alu(), 110.0, 20.0); + let (first, notes) = returns(&mut s, &[0], 70.0, 12.0); + assert!(notes.is_empty() && first == [4], "{notes:?}"); + let kept = s.flanges[4].clone(); + let (second, notes) = returns(&mut s, &[1], 70.0, 12.0); + assert!(notes.is_empty() && second == [5], "{notes:?}"); + let later = &s.flanges[5]; + assert!(s.flanges[4] == kept && later.ext == (0.0, 0.0) && later.slope == (0.0, 0.0) && (later.len - 71.355376).abs() < 1e-6, "{later:?}"); + returns_keep_out_of_the_walls(&s, "returns of separate features"); + } + + #[test] + fn returns_close_across_a_cut_off_corner_but_not_across_an_edge_of_the_base() { + use std::f64::consts::SQRT_2; + let rv = alu(); + let e = rv.gap / SQRT_2; + // The tray with its corner at the origin cut off from (10, 0) to (0, 10), and no flange on + // that cut: the walls on y = 0 and x = 0 don't share a corner point, but their bend lines + // y = 3 and x = 3 cross at (3, 3) and both bends stop 4 short of it, at (7, 3) and (3, 7). + // Their returns would run through each other over the cut, so they close as at a whole + // corner, on x = y through (3, 3): each return's bend keeps its wall's end (x = 7 is past + // 3 + e) and its panel runs on from 3 + e, 4 − e before the bend, losing 1 per unit of + // height. At the other ends, whole corners, the bends stop 1 in from the walls' ends at + // 117 − e and 87 − e. + let cut = [Vec2::new(10.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0), Vec2::new(0.0, 10.0)]; + let mut s = walls(&rv, &cut, &[0, 1, 2, 3], 90.0, 20.0); + assert!(s.flanges[0].p.dist(Vec2::new(7.0, 3.0)) < 1e-9 && s.flanges[0].ext.0 == 0.0 && (s.flanges[3].len - 80.0).abs() < 1e-9); + let (_, notes) = returns(&mut s, &[0, 1, 2, 3], 90.0, 12.0); + assert!(notes.is_empty(), "{notes:?}"); + let (bottom, left) = (&s.flanges[4], &s.flanges[7]); + assert!((bottom.p.x - 7.0).abs() < 1e-9 && (bottom.len - (110.0 - e)).abs() < 1e-9, "{bottom:?}"); + assert!((bottom.ext.0 - (4.0 - e)).abs() < 1e-9 && (bottom.slope.0 + 1.0).abs() < 1e-9 && bottom.ext.1.abs() < 1e-9, "{bottom:?}"); + assert!((left.len - (80.0 - e)).abs() < 1e-9 && (left.ext.1 - (4.0 - e)).abs() < 1e-9 && (left.slope.1 + 1.0).abs() < 1e-9, "{left:?}"); + for (a, a_end, b, b_end) in TRAY_RETURNS { + let ((a0, a1), (b0, b1)) = (inner_end_edge(&s, a, a_end), inner_end_edge(&s, b, b_end)); + let gap = parallel_gap(a0, a1, b0, b1); + assert!(gap.is_some_and(|d| (d - rv.gap).abs() < 1e-9), "{a}/{b}: {gap:?}"); + } + holds_the_flat_material(&s, "a cut-off corner"); + // The same cut, 16 wide, with a wall of its own: now it is an edge of the base. The walls + // on y = 0 and x = 0 still stop the same distance short of where their bend lines cross, + // but the cut's bend ends between them, so each closes with the wall on the cut (two + // corners of 135°) and not with the other. `tray_with_level_returns_closes_every_corner` + // checks every return against the corners of its own edge. + let walled = [Vec2::new(16.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0), Vec2::new(0.0, 16.0)]; + let s = tray_with_level_returns_closes_every_corner(&rv, &walled, 90.0, "a walled cut"); + assert!(s.flanges[5].ext.0.abs() < 1e-9 && (s.flanges[5].slope.0 + (SQRT_2 - 1.0)).abs() < 1e-9, "{:?}", s.flanges[5]); + // With returns on those two walls only, the cut's wall stands between them: they don't + // close with each other through it. Each is alone at both its corners and spans its wall's + // far edge, square-ended: on the edges of 104 and 74, from 2 + e short of the whole corner + // to (g/2 + 2·cos 67.5°)/sin 67.5° = 1.099025 short of the corner of 135°. + let mut s = walls(&rv, &walled, &[0, 1, 2, 3, 4], 90.0, 20.0); + let (made, notes) = returns(&mut s, &[0, 3], 90.0, 12.0); + assert!(notes.is_empty() && made == [5, 6], "{made:?} {notes:?}"); + let half = 67.5f64.to_radians(); + let short = (rv.gap / 2.0 + 2.0 * half.cos()) / half.sin(); + for (i, edge) in [(5, 104.0), (6, 74.0)] { + let f = &s.flanges[i]; + assert!(f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0) && (f.len - (edge - (2.0 + e) - short)).abs() < 1e-9, "{f:?}"); + } + returns_keep_out_of_the_walls(&s, "beside a walled cut"); + holds_the_flat_material(&s, "beside a walled cut"); + // A trapezoid with walls on its bottom and its two sides only. The sides' bend lines cross + // above the open top edge, both bends stop 26.04 short of that point, and nothing lies + // between them: but that is further than the walls reach (π + 14 past their bends), so the + // top is an edge of the base, and the sides' returns end with their walls there. + let open = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(75.0, 90.0), Vec2::new(45.0, 90.0)]; + let mut s = walls(&rv, &open, &[0, 1, 3], 90.0, 20.0); + let (_, notes) = returns(&mut s, &[0, 1, 2], 90.0, 12.0); + assert!(notes.is_empty(), "{notes:?}"); + // Flanges 0…2 are the walls on the bottom, the right side and the left side, 3…5 their + // returns: the right side's ends at the top with its far end, the left side's with its start. + let (right, left) = (&s.flanges[4], &s.flanges[5]); + assert!(right.ext.1 == 0.0 && right.slope.1 == 0.0 && left.ext.0 == 0.0 && left.slope.0 == 0.0, "{right:?} {left:?}"); + assert!(right.slope.0 < -0.1 && left.slope.1 < -0.1 && (right.len - left.len).abs() < 1e-9, "{right:?} {left:?}"); + holds_the_flat_material(&s, "an open top"); + } + /// The angles of the sweep below. const SWEEP: [f64; 7] = [45.0, 60.0, 75.0, 90.0, 105.0, 120.0, 135.0]; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 3369efc5..fc4e9177 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2435,6 +2435,197 @@ fn per_edge_values_are_expressions_and_feature_inputs() { assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); } +/// The bends' lengths in the flat pattern, in the order the bends were made. +fn bend_lengths(s: &mut Session) -> Vec { + flat(s)["bends"].as_array().cloned().unwrap_or_default().iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect() +} + +/// A tray on a 120 wide, 90 deep base whose top edge is `top` wide (120: a rectangle), in 2 mm +/// aluminium (R 1, K 0.5) with the flange gap `gap`: walls 20 high at `deg` in one feature and, +/// in the next feature, a return 12 high at 180° − `deg` on the far edge of each of the walls +/// `returns` (0…3: bottom, right, top, left; none: no feature), picked on the walls' inner faces, +/// so that the returns turn in over the base and lie level. +fn tray_with_returns(top: f64, deg: f64, gap: &str, returns: &[usize]) -> Session { + let mut s = Session::default(); + run( + &mut s, + "sheet.manage_rules", + json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": gap, "active": true}), + ); + let inset = (120.0 - top) / 2.0; + let pts = [(0.0, 0.0), (120.0, 0.0), (120.0 - inset, 90.0), (inset, 90.0)]; + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.line", json!({"points": pts.map(|p| [p.0, p.1]), "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Base"})); + // A wall's inner face 20 up its outer face from the base's edge: that far in from the edge + // and that high (see the doc tests). + let th = deg.to_radians(); + let (into, up) = (-20.0 * th.cos() + 2.0 * th.sin(), 20.0 * th.sin() + 2.0 * th.cos()); + let pick = |k: usize, into: f64, z: f64| { + let (a, b) = (pts[k], pts[(k + 1) % 4]); + let l = (b.0 - a.0).hypot(b.1 - a.1); + json!([(a.0 + b.0) / 2.0 - (b.1 - a.1) / l * into, (a.1 + b.1) / 2.0 + (b.0 - a.0) / l * into, z]) + }; + let edges: Vec = (0..4).map(|k| pick(k, 0.0, 2.0)).collect(); + run(&mut s, "sheet.flange", json!({"edges": edges, "height": 20, "angle": format!("{deg} deg")})); + if !returns.is_empty() { + let edges: Vec = returns.iter().map(|&k| pick(k, into, up)).collect(); + run(&mut s, "sheet.flange", json!({"edges": edges, "height": 12, "angle": format!("{} deg", 180.0 - deg)})); + } + s +} + +#[test] +fn returns_on_a_tray_close_at_its_corners() { + use std::f64::consts::SQRT_2; + // Walls at 90° with returns at 90°. The returns' feature used to fail here ("sew: nonmanifold + // edge contact"): each return took its wall's bend length and ran through its neighbours. + let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + // By hand (the derivation is with the doc tests; e = g/√2). The part fills 120 x 90 x 20. + let e = 0.5 / SQRT_2; + let b = bbox(&mut s); + for (k, want) in [120.0, 90.0, 20.0].into_iter().enumerate() { + let (lo, hi) = (b["min"][k].as_f64().unwrap_or(f64::NAN), b["max"][k].as_f64().unwrap_or(f64::NAN)); + assert!(lo.abs() < 1e-6 && (hi - want).abs() < 1e-6, "{b}"); + } + // The walls' bends are the base's trimmed edges; the returns' bends stop g/√2 short of the + // mitre x = y through (3, 3) at both ends. + let f = flat(&mut s); + let lengths = bend_lengths(&mut s); + assert_eq!(lengths.len(), 8, "{f}"); + for (got, want) in lengths.iter().zip([114.0, 84.0, 114.0, 84.0, 114.0 - 2.0 * e, 84.0 - 2.0 * e, 114.0 - 2.0 * e, 84.0 - 2.0 * e]) { + assert!((got - want).abs() < 1e-6, "{lengths:?}"); + } + // The flat pattern is one outline through the corners of the return's panel on the wall on + // y = 0: 9 high past two bend zones π wide and the wall's 14, mitred at 45° at both ends. + let outline = outline_points(&f); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); + let (y0, y1) = (-11.0 - 2.0 * PI, -20.0 - 2.0 * PI); + for (x, y) in [(3.0 + e, y0), (117.0 - e, y0), (108.0 - e, y1), (12.0 + e, y1)] { + assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "({x}, {y}) not in {outline:?}"); + } + // It holds the base, the walls' bends and panels and the returns' bends and panels, nothing + // more or less, and is 20 + 2π wider than the base on every side. + let area = 114.0 * 84.0 + 396.0 * PI + 28.0 * (202.0 - 4.0 * e) + 2.0 * PI * (198.0 - 4.0 * e) + 18.0 * (180.0 - 4.0 * e); + assert!(rel(polygon_area(&outline), area) < 1e-9, "{} vs {area}", polygon_area(&outline)); + for (k, want) in [160.0 + 4.0 * PI, 130.0 + 4.0 * PI, 2.0].into_iter().enumerate() { + assert!((f["flat_size_mm"][k].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{}: {want}", f["flat_size_mm"]); + } + // Folded, the part holds that material (K = 0.5 keeps the volume). + let v = volume(&mut s); + assert!(rel(v, area * 2.0) < 1e-3, "{v} vs {}", area * 2.0); + // The DXF: the outline in straight lines, and eight bend lines. + let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + assert_eq!(dxf.matches("0\nLINE\n").count(), outline.len() + 8, "{} outline points", outline.len()); + // Unfold and refold keep it. + run(&mut s, "sheet.unfold", json!({})); + assert!(same_points(&outline_points(&flat(&mut s)), &outline)); + run(&mut s, "sheet.refold", json!({})); + assert!(rel(volume(&mut s), v) < 1e-9); + // Reopened, it is the same part. + let mut again = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!(no_errors_or_warnings(&again), "{:?}", again.model.results); + assert!(rel(volume(&mut again), v) < 1e-9); + assert!(same_points(&outline_points(&flat(&mut again)), &outline)); +} + +#[test] +fn returns_on_leaning_walls_stay_inside_the_walls() { + // A trapezoid (corners of 77.5° and 102.5°) with walls at 110° and level returns at 70°. Each + // return used to run its wall's bend length from where the wall's far edge started, out + // through the neighbouring wall: the part reached x = 120.588 where its walls end at 117.587. + let mut s = tray_with_returns(80.0, 110.0, "0.5 mm", &[0, 1, 2, 3]); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + // The returns lie inside the walls and no higher than their tips: the part's box is the + // walls' own. + let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(80.0, 110.0, "0.5 mm", &[]))); + for end in ["min", "max"] { + for k in 0..3 { + let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN)); + assert!((a - b).abs() < 1e-6, "{with} vs {without}"); + } + } + // By hand (the doc tests): a return's panel starts v = 3·sin 110° + (20 − 3·tan 35°)·cos 70° + // in from its wall's edge, and at a base corner of angle α its bend stops + // x_b = (g/2 + v·cos(α/2)) / sin(α/2) along the edge from the corner. + let th = 110f64.to_radians(); + let v = 3.0 * th.sin() - (20.0 - 3.0 * 35f64.to_radians().tan()) * th.cos(); + let x_b = |alpha: f64| (0.25 + v * (alpha / 2.0).cos()) / (alpha / 2.0).sin(); + let (acute, side) = ((90.0f64 / 20.0).atan(), 20f64.hypot(90.0)); + let want = [120.0 - 2.0 * x_b(acute), side - x_b(acute) - x_b(PI - acute), 80.0 - 2.0 * x_b(PI - acute), side - x_b(acute) - x_b(PI - acute)]; + let lengths = bend_lengths(&mut s); + assert_eq!(lengths.len(), 8, "{lengths:?}"); + for (got, want) in lengths[4..].iter().zip(want) { + assert!((got - want).abs() < 1e-6, "{lengths:?} vs {want:?}"); + } + // One outline in the flat, and the folded part holds its material. + let f = flat(&mut s); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); + let (outline, vol) = (outline_points(&f), volume(&mut s)); + assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0); + run(&mut s, "sheet.unfold", json!({})); + assert!(same_points(&outline_points(&flat(&mut s)), &outline)); + run(&mut s, "sheet.refold", json!({})); + assert!(rel(volume(&mut s), vol) < 1e-9); +} + +#[test] +fn a_return_alone_on_a_leaning_wall_keeps_out_of_the_walls_beside_it() { + use std::f64::consts::SQRT_2; + // The tray with walls at 110° and a level return on the wall on y = 0 only. It used to take + // that wall's bend length (120 − 2·3·tan 55° = 111.431) from where the wall's far edge starts + // and came out through the wall on x = 120: the part reached x = 120.504 where its walls end + // at 118.716. + let mut s = tray_with_returns(120.0, 110.0, "0.5 mm", &[0]); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let (with, without) = (bbox(&mut s), bbox(&mut tray_with_returns(120.0, 110.0, "0.5 mm", &[]))); + for end in ["min", "max"] { + for k in 0..3 { + let (a, b) = (with[end][k].as_f64().unwrap_or(f64::NAN), without[end][k].as_f64().unwrap_or(f64::NAN)); + assert!((a - b).abs() < 1e-6, "{with} vs {without}"); + } + } + // By hand (the doc tests): the walls beside it lean in over it and come nearest at its top, + // 20·sin 110° up, where their inner faces are 2/sin 110° + 20·cos 70° in from the base's edges. + // The return stops square g/√2 short of them at both ends: 120 − 2·9.322312 = 101.355376. + let th = 110f64.to_radians(); + let want = 120.0 - 2.0 * (0.5 / SQRT_2 + 2.0 / th.sin() - 20.0 * th.cos()); + let lengths = bend_lengths(&mut s); + assert!(lengths.len() == 5 && (lengths[4] - want).abs() < 1e-6 && (want - 101.355376).abs() < 1e-6, "{lengths:?} vs {want}"); + // One outline in the flat, and the folded part holds its material. + let f = flat(&mut s); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); + let (outline, vol) = (outline_points(&f), volume(&mut s)); + assert!(rel(vol, polygon_area(&outline) * 2.0) < 1e-3, "{vol} vs {}", polygon_area(&outline) * 2.0); +} + +#[test] +fn returns_follow_the_rules_gap_and_stay_open_with_a_warning_when_it_is_zero() { + use std::f64::consts::SQRT_2; + // The mitre keeps the rule's gap: at 90° the returns' bends are 114 − 2·g/√2 and 84 − 2·g/√2. + let mut s = tray_with_returns(120.0, 90.0, "0.5 mm", &[0, 1, 2, 3]); + for g in [0.5, 1.0, 0.2] { + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "gap": format!("{g} mm")})); + assert!(no_errors_or_warnings(&s), "{g}: {:?}", s.model.results); + let lengths = bend_lengths(&mut s); + assert_eq!(lengths.len(), 8, "{g}: {lengths:?}"); + for (got, want) in lengths[4..].iter().zip([114.0, 84.0, 114.0, 84.0]) { + assert!((got - (want - 2.0 * g / SQRT_2)).abs() < 1e-6, "{g}: {lengths:?}"); + } + } + // A gap of zero can't be kept: the walls' corners and the returns' corners are left open, + // each feature with its warning, and each return spans its wall's far edge (here the wall's + // bend, 120 − 2·3·tan 55°). + let mut s = tray_with_returns(120.0, 110.0, "0 mm", &[0, 1, 2, 3]); + let warnings: Vec = s.model.results.iter().filter_map(|r| r.warning.clone()).collect(); + assert!(s.model.results.iter().all(|r| r.error.is_none()) && warnings.len() == 2, "{:?}", s.model.results); + assert!(warnings.iter().all(|w| w.matches("left open").count() == 4), "{warnings:?}"); + let lengths = bend_lengths(&mut s); + assert!((lengths[4] - lengths[0]).abs() < 1e-9 && (lengths[0] - (120.0 - 6.0 * 55f64.to_radians().tan())).abs() < 1e-6, "{lengths:?}"); +} + #[test] fn bad_per_edge_input_is_refused_and_plain_edges_stay_plain() { let mut s = Session::default(); diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 1ca81011..54121504 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -61,6 +61,32 @@ per edge with an angle and a height box; an empty box takes the value above. "height":38.6,"angle":"75 deg"}} ``` +A flange can also stand on the far edge of another flange: a return, such as a lip turned in +along the top of a tray's walls. It runs along that edge from end to end, so it follows the +closed end of the flange it stands on, slanted or not. Two returns of one feature whose flanges +meet at a corner of the base are closed toward each other as well, with the rule's gap. They +are mitred either side of a plane that runs along the mean of the two panels' normals, bisects +their bend lines and passes where the lines their panels start on cross. Returns that lie in +one plane (level returns on a tray's walls, whatever the walls' angle) then meet on the corner's +bisector, like the sides of a picture frame. Each panel ends on a slanted line in the flat. +Each bend stops square where all of it keeps half the gap from that plane, at the latest at +the end of the flange it stands on, which can leave a short stretch of that flange's edge bare. +Returns turned opposite ways pass each other. A return that meets no other at a corner ends +with the flange it stands on; where the flange beside that one leans in over it, the return is +cut back until it keeps as clear of that flange as the end of the flange it stands on does. +Returns that can't be closed stay open with a warning, as on the base. Returns of separate +features don't close toward each other either: make the returns round a corner in one feature. +A corner relief changes none of this: it leaves the flanges' closed ends as they are, so the +returns on them close over a relieved corner as over a whole one. + +```json +{"command":"sheet.flange","params":{"edges":[[60,2,20],[118,45,20],[60,88,20],[2,45,20]], + "height":12,"angle":"90 deg"}} +``` + +(The four returns of a 120 x 90 tray in 2 mm sheet with 90° walls 20 high: each point is the +middle of a wall's far edge on its inner face, so the returns turn in over the base.) + For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose `format: "json"` for structured data. It includes the sheet rule, thickness, bend angles, radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use