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 <[email protected]>
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 04:45:19 -04:00
1 parent 9089edfb71
commit a2f31f683d
4 files changed
+1242 -4

No files matched your search

+4
View File
@@ -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
+1021 -4
View File
File diff suppressed because it is too large. Load diff
+191
View File
@@ -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<f64> {
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<Value> = (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<Value> = 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<String> = 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();
+26
View File
@@ -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