feat: close sheet flange corners at any angle (#406)

Edge flanges meeting at a corner of the base were closed only at exactly
90°; at any other angle they stayed open (a V at 72°) and the rule's gap
was not used.

The flanges of one feature now close at any convex base corner and any
bend angle: each panel end is cut square to the sheet on a line in the
flat (slanted off 90°, stored as ext plus a new per-end slope) so the
edges nearest each other, on the inner faces, run parallel the rule's gap
apart about the plane bisecting the inner faces. The construction reads
the two flanges' own angles, so it holds for unequal ones as well. At 90°
it is the extension the old code gave (reference case 69-sm-tray is
unchanged). A corner that can't close (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 that says where and why.

A flange of a later feature leaves the corner as it was (reference case
75-sm-bracket-holes: the earlier flange keeps its length).

Tested at 72° and 90° against a hand derivation, on a trapezoid, and
every 5° from 45° to 135° on a rectangle and a trapezoid (gap exact, one
flat region without hairline edges, one valid shell holding the flat
material).

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-12 18:48:19 -04:00
1 parent 0bb5b7eb09
commit 0154572580
6 files changed
+511 -34

No files matched your search

+2 -1
View File
@@ -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)
+68
View File
@@ -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::<f64>().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);
}