diff --git a/ROADMAP.md b/ROADMAP.md index fe544244..1a646754 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 | +| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index d0ab8e89..b4be6867 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -430,7 +430,14 @@ pub(super) fn sheet_eval( } let picks = sheet_picks(sh, f, edges, warning); let n0 = sh.flanges.len(); - sh.add_flanges(&picks, h, a, rr, position, *flip)?; + let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?; + if !notes.is_empty() { + let notes = notes.join("; "); + *warning = Some(match warning.take() { + Some(w) => format!("{w}; {notes}"), + None => notes, + }); + } tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 31a2ac1c..21ece1e1 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -273,9 +273,14 @@ pub struct SheetFlange { pub radius: f64, /// Flat length of the panel past the bend zone. pub leg: f64, - /// The panel runs on past the bend's ends by these (toward a neighbouring flange). + /// The panel runs on past the bend's ends by these (toward a neighbouring flange), measured + /// along the bend line; negative values cut the panel back. #[serde(default)] pub ext: (f64, f64), + /// How much further each end runs on per unit of panel height: a closed corner between + /// flanges that aren't at 90° ends on a slanted line in the flat. + #[serde(default, skip_serializing_if = "is_zero_pair")] + pub slope: (f64, f64), /// A fold's panel: the part of the sheet past the bend (flat coordinates, counter-clockwise) /// instead of a rectangle `leg` long. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -333,6 +338,10 @@ struct Cell { holes: Vec>, } +fn is_zero_pair(v: &(f64, f64)) -> bool { + v.0 == 0.0 && v.1 == 0.0 +} + fn rev_edge(e: EdgeSpec) -> EdgeSpec { match e { EdgeSpec::Line { a, b } => EdgeSpec::Line { a: b, b: a }, @@ -417,6 +426,40 @@ fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec { out } +/// The smallest gap closed corners are given: closer than this the flanges' ends would touch. +const MIN_CORNER_GAP: f64 = 0.01; + +/// Two bends on the base meeting at a corner (see [`SheetBody::corners`]): flanges `a` and `b`, +/// which end of each (false = the end at `p`), and the corner point (flat coordinates). +#[derive(Clone, Copy, Debug)] +struct Corner { + a: usize, + a_end: bool, + b: usize, + b_end: bool, + c: Vec2, +} + +/// The corners of a straight-edged region (counter-clockwise). +fn corners_of(r: &Region2) -> Vec { + dedup(&r.outer.ccw().polyline(1e-3)) +} + +/// The area two convex counter-clockwise polygons share. +fn convex_overlap(a: &[Vec2], b: &[Vec2]) -> f64 { + let mut poly = a.to_vec(); + let m = b.len(); + for i in 0..m { + let (Some(&p), Some(&q)) = (b.get(i), b.get((i + 1) % m)) else { continue }; + let d = q - p; + poly = clip(&poly, p, Vec2::new(-d.y, d.x)); + if poly.len() < 3 { + return 0.0; + } + } + signed_area(&poly).abs() +} + impl SheetBody { /// Bend allowance of a flange. pub fn allowance(&self, f: &SheetFlange) -> f64 { @@ -465,7 +508,7 @@ impl SheetBody { let ba = self.allowance(f); let a = f.p + f.n * ba - f.d * f.ext.0; let b = f.p + f.n * ba + f.d * (f.len + f.ext.1); - rect(a, b, b + f.n * f.leg, a + f.n * f.leg) + 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)) } /// Flat region of a flange's bend zone. @@ -1185,8 +1228,23 @@ impl SheetBody { } /// Edge flanges on several edges at once: every edge is trimmed back first, so flanges on - /// adjacent edges stop at each other's bends (the corners stay open). + /// adjacent edges stop at each other's bends; the corners between them are then closed with + /// the rule's gap (`close_corners`). pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result> { + self.add_flanges_noted(picks, height, angle, radius, position, flip).map(|(made, _)| made) + } + + /// [`SheetBody::add_flanges`], also returning notes on corners that could not be closed + /// (they are left open, as the bends leave them). + pub fn add_flanges_noted( + &mut self, + picks: &[Vec3], + height: f64, + angle: f64, + radius: f64, + position: &str, + flip: bool, + ) -> Result<(Vec, Vec)> { if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) { return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into())); } @@ -1262,43 +1320,146 @@ impl SheetBody { shape: None, tag: String::new(), ext: (0.0, 0.0), + slope: (0.0, 0.0), }); made.push(self.flanges.len() - 1); } - self.close_corners(&made); - Ok(made) + let notes = self.close_corners(&made); + Ok((made, notes)) } - /// Where two new 90° flanges on the base meet at a corner, run their panels on toward each - /// other until the gap between their inner corners is the rule's gap. - fn close_corners(&mut self, made: &[usize]) { - let ends: Vec<(usize, bool, Vec2)> = made + /// 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. + fn corners(&self, made: &[usize]) -> Vec { + let limit = std::f64::consts::PI - 1e-6; + let ends: Vec<(usize, bool, Vec2)> = self + .flanges .iter() - .filter_map(|&i| self.flanges.get(i).map(|f| (i, f))) - .filter(|(_, f)| f.parent.is_none() && (f.angle.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-9) - .flat_map(|(i, f)| [(i, false, f.p), (i, true, f.p + f.d * f.len)]) + .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), (i, true, f.p + f.d * u1)] + }) .collect(); - for &(i, at_end, c) in &ends { - let Some(f) = self.flanges.get(i).cloned() else { continue }; - let other = ends.iter().find(|&&(j, _, c2)| j != i && c2.dist(c) < 1e-6).and_then(|&(j, _, _)| self.flanges.get(j).cloned()); - let Some(g) = other else { continue }; - if g.n.dot(f.n).abs() > 1e-6 || (g.angle > 0.0) != (f.angle > 0.0) { - continue; - } - // The neighbour's setback: its bend starts that far in from the corner of the outer faces. - let setback = (g.radius + self.t) * (g.angle.abs() / 2.0).tan(); - let ext = setback - (self.t + self.gap / std::f64::consts::SQRT_2); - if ext <= 1e-9 { - continue; - } - if let Some(x) = self.flanges.get_mut(i) { - if at_end { - x.ext.1 = ext; - } else { - x.ext.0 = ext; + let mut out = Vec::new(); + for (k, &(a, a_end, c)) in ends.iter().enumerate() { + for &(b, b_end, c2) in ends.iter().skip(k + 1) { + if a != b && c.dist(c2) < 1e-6 && made.contains(&a) && made.contains(&b) { + out.push(Corner { a, a_end, b, b_end, c }); } } } + out + } + + /// Close the corners the new flanges make. Returns a note for every corner left open. + fn close_corners(&mut self, made: &[usize]) -> Vec { + let corners = self.corners(made); + let mut notes = Vec::new(); + let at = |s: &SheetBody, c: Vec2| { + let w = apply_point(&s.frame_mat(), v3(c)); + format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z) + }; + for k in &corners { + if let Err(why) = self.close_corner(k) { + notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))); + } + } + notes + } + + /// Run two flanges' panels on toward each other (or cut them back) until their ends meet the + /// plane halfway between the flanges with the rule's gap between them. The ends stay square + /// to the sheet and follow lines in the flat, slanted when the flanges aren't at 90°, so that + /// the edges nearest each other (on the inner faces) are `gap` apart along their length. Any + /// angles, also unequal ones, at any convex corner of the base. + fn close_corner(&mut self, k: &Corner) -> std::result::Result<(), String> { + 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 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 }; + // At an inside corner of the base the flanges turn away from each other. + if ea.dot(fb.n) > -1e-9 || eb.dot(fa.n) > -1e-9 { + return Ok(()); + } + if fa.leg <= 1e-9 || fb.leg <= 1e-9 { + // No panel past a bend: the bends meet at the corner, there is nothing to close. + return Ok(()); + } + if (fa.angle >= 0.0) != (fb.angle >= 0.0) { + return Err("the flanges bend opposite ways".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")); + } + let (wa, wb) = (self.fold(k.a), self.fold(k.b)); + // The inner faces (toward the bends' axes), with normals pointing from the outer face in. + let (z_in, sign) = if fa.angle >= 0.0 { (self.t, 1.0) } else { (0.0, -1.0) }; + let na = apply_vector(&wa, Vec3::Z) * sign; + let nb = apply_vector(&wb, Vec3::Z) * sign; + let pa = apply_point(&wa, v3(k.c) + Vec3::Z * z_in); + let pb = apply_point(&wb, v3(k.c) + Vec3::Z * z_in); + let m = (nb - na).len(); + if !(m > 1e-6) { + return Err("the flanges are parallel".into()); + } + // Signed distance from the plane bisecting the inner faces, positive on flange a's side. + let bisector = |p: Vec3| (nb.dot(p - pb) - na.dot(p - pa)) / m; + // A flange's end: its panel keeps u ≥ u_end(s) (u from the corner along its bend, s from + // the bend's start across it), so that every point of the end face is gap/2 or more from + // the bisecting plane. The distance is affine in (u, s, z), so the end is a line in the + // flat: returns u_end at the panel's bend edge and how much further the panel runs on per + // unit of height. + let end = |f: &SheetFlange, w: &Mat, e: Vec2, sign: f64| -> std::result::Result<(f64, f64), String> { + let at = |u: f64, s: f64, z: f64| sign * bisector(apply_point(w, v3(k.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(|x| x.is_finite()) { + return Err("the flanges don't meet across the corner".into()); + } + let ba = self.allowance(f); + // At 90° the end is square to the bend: keep the panel an exact rectangle. + let slope = if (cs / cu).abs() < 1e-12 { 0.0 } else { cs / cu }; + Ok(((gap / 2.0 - c0 - (cz * self.t).min(0.0) - cs * ba) / cu, slope)) + }; + let mut new = [fa.clone(), fb.clone()]; + for (f, (e, at_end, sign)) in new.iter_mut().zip([(ea, k.a_end, 1.0), (eb, k.b_end, -1.0)]) { + let w = if sign > 0.0 { &wa } else { &wb }; + let (u_end, slope) = end(f, w, e, sign)?; + let tc = (k.c - f.p).dot(f.d); + if at_end { + f.ext.1 = tc - u_end - f.len; + f.slope.1 = slope; + } else { + f.ext.0 = -(tc + u_end); + 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 !(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 (qa, qb) = (corners_of(&self.panel(&new_a)), corners_of(&self.panel(&new_b))); + if convex_overlap(&qa, &qb) > 1e-9 + || convex_overlap(&qa, &corners_of(&self.zone(&fb))) > 1e-9 + || convex_overlap(&qb, &corners_of(&self.zone(&fa))) > 1e-9 + { + 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(()) } /// A flat hem: a 180° bend with a tiny radius at the panel edge, `length` to its outer face. @@ -1326,6 +1487,7 @@ impl SheetBody { radius: gap, leg, ext: (0.0, 0.0), + slope: (0.0, 0.0), shape: None, tag: String::new(), }); @@ -1414,7 +1576,20 @@ impl SheetBody { n = n * -1.0; } let l = a2.dot(n); - let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() }; + let probe = SheetFlange { + parent: panel, + p: a2, + d, + len: 1.0, + n, + angle, + radius, + leg: 0.0, + ext: (0.0, 0.0), + slope: (0.0, 0.0), + shape: None, + tag: String::new(), + }; let ba = self.allowance(&probe); let s0 = match position { "centerline" | "center" | "centre" => l - ba / 2.0, @@ -1484,6 +1659,7 @@ impl SheetBody { radius, leg, ext: (0.0, 0.0), + slope: (0.0, 0.0), shape: Some(ccw(moving)), tag: String::new(), }); @@ -1625,6 +1801,7 @@ impl SheetBody { radius, leg, ext: (0.0, 0.0), + slope: (0.0, 0.0), shape: Some(ccw(moving)), tag: String::new(), }); @@ -1986,6 +2163,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt radius: r, leg: leg.max(0.0), ext: (0.0, 0.0), + slope: (0.0, 0.0), shape: None, tag: String::new(), }; @@ -2283,4 +2461,217 @@ mod tests { assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3); } } + + /// 2 mm aluminium, R 1, K 0.5 (folding keeps the volume), flanges 0.5 mm apart at corners. + fn alu() -> RuleValues { + RuleValues { + thickness: 2.0, + k_factor: 0.5, + bend_radius: 1.0, + relief_width: 2.0, + relief_depth: 1.0, + corner_relief: 8.0, + hem_gap: 0.01, + gap: 0.5, + } + } + + /// A 120 x 90 tray: flanges `height` high at `deg` on all four edges (bottom, right, top, left). + fn tray(rv: &RuleValues, deg: f64, height: f64) -> (SheetBody, Vec) { + let r = rect(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 mut s = base_flange("Body1", "AL2", rv, Plane::XY, r); + let t = rv.thickness; + let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)]; + let (_, notes) = s.add_flanges_noted(&picks, height, deg.to_radians(), rv.bend_radius, "inside", false).unwrap(); + (s, notes) + } + + fn flat_area(s: &SheetBody) -> f64 { + s.flat_regions().unwrap().iter().map(Region2::area).sum() + } + + /// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel). + fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option { + let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?); + (u.cross(v).len() < 1e-9).then(|| (b0 - a0).cross(u).len()) + } + + #[test] + fn closed_corners_end_on_the_bisector_with_the_gap() { + let (t, r, g, h) = (2.0, 1.0, 0.5, 38.6); + for deg in [90.0f64, 72.0] { + let (s, notes) = tray(&alu(), deg, h); + assert!(notes.is_empty(), "{notes:?}"); + let th = deg.to_radians(); + let s0 = (r + t) * (th / 2.0).tan(); + let ba = th * (r + 0.5 * t); + let leg = h - s0; + // By hand, for the flange on y = 0 and its mirror image on x = 0: a point of the flange w up + // its outer face from the virtual sharp (the original edge), h along the outward normal + // (−T ≤ h ≤ 0) is (x, −w·cosθ − h·sinθ, w·sinθ − h·cosθ). Keeping x − y ≥ g/√2 on the + // inner face (h = −T) gives the end x(w) = g/√2 + T·sinθ − w·cosθ; the panel spans w = s0 … h. + let x_end = |w: f64| g / std::f64::consts::SQRT_2 + t * th.sin() - w * th.cos(); + let (y0, y1) = (s0 - ba, s0 - ba - leg); + let want = [(x_end(s0), y0), (120.0 - x_end(s0), y0), (120.0 - x_end(h), y1), (x_end(h), y1)]; + let got = corners_of(&s.panel(&s.flanges[0])); + assert_eq!(got.len(), 4); + for (x, y) in want { + assert!(got.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9), "{deg}°: ({x}, {y}) not in {got:?}"); + } + if deg == 90.0 { + // The rule 90° corners always had: the panel runs on s0 − (T + g/√2) past its bend. + assert!((s.flanges[0].ext.0 - (s0 - t - g / std::f64::consts::SQRT_2)).abs() < 1e-12); + assert_eq!(s.flanges[0].slope, (0.0, 0.0)); + } else { + assert!((s.flanges[0].slope.0 - th.cos()).abs() < 1e-12, "{:?}", s.flanges[0].slope); + } + // Folded, the inner edges of the two ends (bottom flange 0, left flange 3) run parallel, + // `gap` apart. + let (fa, fb) = (s.fold(0), s.fold(3)); + let pa = |x: f64, y: f64| apply_point(&fa, Vec3::new(x, y, t)); + let pb = |x: f64, y: f64| apply_point(&fb, Vec3::new(x, y, t)); + let gap = parallel_gap(pa(x_end(s0), y0), pa(x_end(h), y1), pb(y0, x_end(s0)), pb(y1, x_end(h))); + assert!(gap.is_some_and(|d| (d - g).abs() < 1e-9), "{deg}°: {gap:?}"); + // The flat pattern is one piece and the folded part holds the same material (K = 0.5). + let regions = s.flat_regions().unwrap(); + assert_eq!(regions.len(), 1); + let b = s.solid().unwrap(); + assert!(b.validity().is_empty() && b.shell_count() == 1, "{deg}°: {:?}", b.validity()); + let v = vol(&b); + assert!((v - flat_area(&s) * t).abs() < 1e-3 * v, "{deg}°: {v} vs {}", flat_area(&s) * t); + } + } + + #[test] + fn a_flange_of_a_later_feature_leaves_the_corner_as_it_was() { + // The convention of reference case 75-sm-bracket-holes: the earlier flange keeps its length + // and the later one ends at its bend. Only flanges made together close toward each other. + let rv = alu(); + let base = rect(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 mut s = base_flange("Body1", "AL2", &rv, Plane::XY, base); + s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap(); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && s.corners(&made).is_empty(), "{notes:?}"); + assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0))); + assert!((s.flanges[0].len - 120.0).abs() < 1e-9); + assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1); + } + + #[test] + fn corners_that_are_not_square_close_with_the_gap() { + // A trapezoid: two acute and two obtuse corners. + let pts = [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 base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }; + let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base); + let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)]; + let (made, notes) = s.add_flanges_noted(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let corners = s.corners(&made); + assert_eq!(corners.len(), 4); + // Each end's inner edge, folded. + let edge = |i: usize, at_end: bool| { + let f = &s.flanges[i]; + let ba = s.allowance(f); + let (q0, q1) = 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)) + } 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)) + }; + let w = s.fold(i); + (apply_point(&w, Vec3::new(q0.x, q0.y, 2.0)), apply_point(&w, Vec3::new(q1.x, q1.y, 2.0))) + }; + for k in &corners { + let ((a0, a1), (b0, b1)) = (edge(k.a, k.a_end), edge(k.b, k.b_end)); + let gap = parallel_gap(a0, a1, b0, b1); + assert!(gap.is_some_and(|d| (d - 0.5).abs() < 1e-9), "{k:?}: {gap:?}"); + } + let b = s.solid().unwrap(); + assert!(b.validity().is_empty() && b.shell_count() == 1, "{:?}", b.validity()); + assert!((vol(&b) - flat_area(&s) * 2.0).abs() < 1e-3 * vol(&b)); + } + + #[test] + fn corners_that_cannot_close_stay_open_with_a_note() { + // A gap of zero would make the ends touch. + let mut rv = alu(); + rv.gap = 0.0; + let (s, notes) = tray(&rv, 72.0, 38.6); + assert_eq!(notes.len(), 4, "{notes:?}"); + assert!(notes.iter().all(|n| n.contains("left open") && n.contains("gap")), "{notes:?}"); + assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0))); + assert!(s.solid().is_ok()); + // Flanges bending opposite ways (the second edge picked on the bottom face). + let base = rect(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 mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base); + let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 0.0)]; + let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.len() == 1 && notes[0].contains("opposite"), "{notes:?}"); + assert!(s.solid().is_ok()); + } + + /// Folded, the inner edge of the end of flange `i`'s panel (`at_end`: the end past the far end + /// of its bend). + fn inner_end_edge(s: &SheetBody, i: usize, at_end: bool) -> (Vec3, Vec3) { + let f = &s.flanges[i]; + let ba = s.allowance(f); + let (q0, q1) = 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)) + } 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)) + }; + let (w, z) = (s.fold(i), if f.angle >= 0.0 { s.t } else { 0.0 }); + (apply_point(&w, Vec3::new(q0.x, q0.y, z)), apply_point(&w, Vec3::new(q1.x, q1.y, z))) + } + + /// At each of `corners` the two panel ends' inner edges run parallel, `gap` apart. + fn ends_are_gap_apart(s: &SheetBody, corners: &[Corner], gap: f64, what: &str) { + for k in corners { + let ((a0, a1), (b0, b1)) = (inner_end_edge(s, k.a, k.a_end), inner_end_edge(s, k.b, k.b_end)); + let got = parallel_gap(a0, a1, b0, b1); + assert!(got.is_some_and(|d| (d - gap).abs() < 1e-9), "{what}: {k:?}: {got:?}"); + } + } + + /// The flat pattern is one piece without hairline edges, and the folded part is one valid shell + /// holding the same material (K = 0.5 keeps the volume). + fn holds_the_flat_material(s: &SheetBody, what: &str) { + let regions = s.flat_regions().unwrap_or_else(|e| panic!("{what}: no flat pattern: {e}")); + assert_eq!(regions.len(), 1, "{what}"); + let shortest = regions[0].outer.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min); + assert!(regions[0].holes.is_empty() && shortest > 1e-3, "{what}: shortest outline edge {shortest:e}"); + assert_eq!(s.outline().unwrap_or_else(|e| panic!("{what}: no outline: {e}")).len(), 1, "{what}"); + let b = s.solid().unwrap_or_else(|e| panic!("{what}: no solid: {e}")); + assert!(b.validity().is_empty() && b.shell_count() == 1, "{what}: {:?}", b.validity()); + let v = vol(&b); + assert!((v - flat_area(s) * s.t).abs() < 1e-3 * v, "{what}: {v} vs {}", flat_area(s) * s.t); + } + + #[test] + fn closed_corners_keep_their_flat_pattern_at_every_angle() { + // 45° to 135° in steps of 5° on the tray and on a trapezoid. Which angles lose the flat + // pattern to a rounding slip in the planar arrangement is a matter of luck (50°, 60°, 65°, + // 75°, 105° and 110° did on the tray), so every one is built. + 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 tray_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(0.0, 45.0, 2.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 trapezoid_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)]; + for (name, pts, picks) in [("tray", tray, tray_picks), ("trapezoid", trapezoid, trapezoid_picks)] { + for k in 9..=27 { + let deg = k as f64 * 5.0; + let what = format!("{name} at {deg}°"); + let base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }; + let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base); + let (made, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{what}: {notes:?}"); + let corners = s.corners(&made); + assert_eq!(corners.len(), 4, "{what}"); + ends_are_gap_apart(&s, &corners, 0.5, &what); + holds_the_flat_material(&s, &what); + } + } + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 20c478a3..57b2bbe0 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -34,7 +34,8 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("convert_sheet") .params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"), 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); active?: bool; delete?: bool", + "name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \ + gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool", ), CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"), CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern) diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index bf1eafb3..501d971e 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -1753,3 +1753,71 @@ fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() { } } } + +/// A 120 x 90 tray in 2 mm aluminium (R 1, K 0.5): four 38.6 mm flanges at `deg` in one feature. +fn alu_tray(s: &mut Session, deg: f64, rule: Value) { + let mut r = json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}); + if let (Some(r), Some(extra)) = (r.as_object_mut(), rule.as_object()) { + r.extend(extra.clone()); + } + run(s, "sheet.manage_rules", r); + base(s, 120.0, 90.0); + run(s, "sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 38.6, "angle": format!("{deg} deg")})); +} + +#[test] +fn closed_corners_reach_the_flat_pattern_and_the_dxf() { + use std::f64::consts::SQRT_2; + // 60° and 105° are angles whose flat pattern was lost to a rounding slip in the planar arrangement. + for deg in [60.0f64, 72.0, 90.0, 105.0] { + let mut s = Session::default(); + alu_tray(&mut s, deg, json!({})); + assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{deg}: {:?}", s.model.results); + // The end of the flange on y = 0 at the corner at the origin, by hand (see the doc tests): + // x(w) = g/√2 + T·sinθ − w·cosθ at flat y = s0 − BA − (w − s0), for w = s0 and w = 38.6. + let th = deg.to_radians(); + let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); + let x_end = |w: f64| 0.5 / SQRT_2 + 2.0 * th.sin() - w * th.cos(); + let f = flat(&mut s); + let outline: Vec<(f64, f64)> = f["outline"][0] + .as_array() + .map(|l| l.iter().map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN))).collect()) + .unwrap_or_default(); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{deg}: {f}"); + for (x, y) in [(x_end(s0), s0 - ba), (x_end(38.6), s0 - ba - (38.6 - s0))] { + assert!(outline.iter().any(|p| (p.0 - x).abs() < 1e-6 && (p.1 - y).abs() < 1e-6), "{deg}: ({x}, {y}) not in {outline:?}"); + } + // The folded part holds the flat pattern's material (K = 0.5 keeps the volume). + let n = outline.len(); + let area = (0..n).map(|i| outline[i].0 * outline[(i + 1) % n].1 - outline[(i + 1) % n].0 * outline[i].1).sum::().abs() / 2.0; + let v = volume(&mut s); + assert!((v - area * 2.0).abs() < 1e-3 * v, "{deg}: {v} vs {}", area * 2.0); + // The DXF: the closed outline in straight lines, four 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(), n + 4, "{deg}: {n} outline points"); + // Unfold and refold keep it. + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{deg}"); + // A cut made folded still goes through with the corners closed: a Ø20 hole in the base. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true})); + let want = PI * 100.0 * 2.0; + assert!((v - volume(&mut s) - want).abs() < 1e-3 * want, "{deg}: {v} - {} vs {want}", volume(&mut s)); + assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1), "{deg}"); + } +} + +#[test] +fn corners_left_open_are_reported_on_the_flange() { + let mut s = Session::default(); + alu_tray(&mut s, 72.0, json!({"gap": "0 mm"})); + let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); + assert_eq!(w.matches("left open").count(), 4, "{w}"); + // Open corners still fold, unfold and take cuts. + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sheet.refold", json!({})); + assert!(volume(&mut s) > 0.0); +} diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 631e04ba..fb01530e 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -14,6 +14,16 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests check flat lengths against hand-computed bend allowances. +Where the edge flanges of one feature meet at a corner of the base, their panels are closed +toward each other at any bend angle: each end is cut square to the sheet, on a line in the flat +chosen so that the edges nearest each other (on the inner faces) run parallel, the rule's +**gap** apart, either side of the plane halfway between the flanges. Flanges at 72° get slanted +ends in the flat; corners that aren't square close the same way, and at 90° it is the corner +the rule always gave. A corner that can't be closed (a gap under 0.01 mm, flanges bending +opposite ways, ends that would overlap in the flat pattern) stays open, and the flange feature +reports a warning saying where. A flange added by a later feature leaves the corner as it was: +the earlier flange keeps its length and the new one ends at its bend. + 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