Kernel rig: a flat_pattern field grades a sheet metal case's flat pattern

From the script's last sheet.flat_pattern result: the outline's size, its area less the
cut-outs', and the bend lines, each matched to its own one of the result's either way round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 17:48:54 -04:00
committed by Irakli Kourtis
parent 1ad9660452
commit 014a9eced5
2 changed files with 83 additions and 1 deletions
+81
View File
@@ -583,6 +583,61 @@ fn case_name(dir: &str) -> (String, String) {
(name(p.parent()), name(Some(p)))
}
/// The area a polygon of `[x, y]` points encloses (shoelace), unsigned.
fn polygon_area(points: &Value) -> f64 {
let pts: Vec<(f64, f64)> = points.as_array().into_iter().flatten().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect();
pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum::<f64>().abs() / 2.0
}
/// The `flat_pattern` check, against the script's last `sheet.flat_pattern` result: `size_mm`
/// ([w, h] of the outline), `area_mm2` (the outline loops' area less the cut-outs') and
/// `bend_lines` ([[x, y], [x, y]] each, the bends' centre lines in flat coordinates, every one
/// matched to its own one of the result's, either way round, and as many of them).
fn flat_pattern_checks(c: &mut Checks, want: &Value, results: &[Value], rel: f64, abs: f64) {
let Some(got) = results.iter().rev().find(|r| r.get("flat_size_mm").is_some()) else {
c.push("flat_pattern", json!("no sheet.flat_pattern result"), want.clone(), false);
return;
};
if let Some(w) = want.get("size_mm").and_then(Value::as_array) {
let g = &got["flat_size_mm"];
let ok = w.len() == 2
&& (0..2).all(|k| match (g.get(k).and_then(Value::as_f64), w.get(k).and_then(Value::as_f64)) {
(Some(g), Some(w)) => (g - w).abs() <= abs,
_ => false,
});
c.push("flat_pattern.size_mm", json!([g.get(0), g.get(1)]), json!(w), ok);
}
if let Some(w) = want.get("area_mm2").and_then(Value::as_f64) {
let loops = |k: &str| got[k].as_array().into_iter().flatten().map(polygon_area).sum::<f64>();
c.rel("flat_pattern.area_mm2", loops("outline") - loops("cutouts"), Some(w), rel);
}
if let Some(w) = want.get("bend_lines").and_then(Value::as_array) {
let line = |v: &Value| -> Option<[f64; 4]> {
let (a, b) = (v.get(0)?, v.get(1)?);
Some([a.get(0)?.as_f64()?, a.get(1)?.as_f64()?, b.get(0)?.as_f64()?, b.get(1)?.as_f64()?])
};
let mut left: Vec<[f64; 4]> = got["bends"]
.as_array()
.into_iter()
.flatten()
.filter_map(|b| line(&json!([b["start"], b["end"]])))
.collect();
let got_lines = json!(left.iter().map(|l| [[l[0], l[1]], [l[2], l[3]]]).collect::<Vec<_>>());
let near = |p: [f64; 4], q: [f64; 4]| (0..4).all(|k| (p[k] - q[k]).abs() <= abs);
let mut ok = left.len() == w.len();
for l in w.iter().map(line) {
let found = l.and_then(|l| left.iter().position(|g| near(*g, l) || near(*g, [l[2], l[3], l[0], l[1]])));
match found {
Some(i) => {
left.swap_remove(i);
}
None => ok = false,
}
}
c.push("flat_pattern.bend_lines", got_lines, json!(w), ok);
}
}
fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value {
let (area, name) = case_name(dir);
let mut out = json!({"case": name, "area": area, "kernel": kernel()});
@@ -852,6 +907,9 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value {
if has("construction") {
check_construction(&s, &want["construction"], pos_tol, &mut c);
}
if has("flat_pattern") {
flat_pattern_checks(&mut c, &want["flat_pattern"], &answers, rel, abs);
}
out["status"] = json!(if c.pass { "pass" } else { "fail" });
out["checks"] = json!(c.list);
out
@@ -1111,4 +1169,27 @@ mod tests {
assert!(script(&a, "/scratch", &r).unwrap_err().contains("deeper than"));
let _ = std::fs::remove_dir_all(&root);
}
/// The flat pattern is graded on the last `sheet.flat_pattern` result: its size, its outline's
/// area less the cut-outs', and its bend lines matched either way round, one for one.
#[test]
fn flat_pattern_grades_size_area_and_bend_lines() {
let flat = json!({"flat_size_mm": [10.0, 4.0, 2.0],
"outline": [[[0.0, 0.0], [10.0, 0.0], [10.0, 4.0], [0.0, 4.0]]], "cutouts": [[[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0]]],
"bends": [{"start": [5.0, 0.0], "end": [5.0, 4.0]}]});
let results = [json!({"volume_mm3": 1.0}), flat];
let grade = |want: Value| {
let mut c = Checks { list: Vec::new(), pass: true };
flat_pattern_checks(&mut c, &want, &results, 1e-3, 1e-3);
c.pass
};
assert!(grade(json!({"size_mm": [10.0, 4.0], "area_mm2": 39.0, "bend_lines": [[[5.0, 4.0], [5.0, 0.0]]]})));
assert!(!grade(json!({"area_mm2": 40.0})), "the cut-out is not material");
assert!(!grade(json!({"size_mm": [10.0, 5.0]})));
assert!(!grade(json!({"bend_lines": [[[5.0, 0.0], [5.0, 4.0]], [[5.0, 0.0], [5.0, 4.0]]]})), "one bend line matches one only");
assert!(!grade(json!({"bend_lines": [[[6.0, 0.0], [6.0, 4.0]]]})));
let mut c = Checks { list: Vec::new(), pass: true };
flat_pattern_checks(&mut c, &json!({"area_mm2": 1.0}), &results[..1], 1e-3, 1e-3);
assert!(!c.pass, "no flat pattern was made");
}
}
+2 -1
View File
@@ -105,6 +105,7 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage
| `surface_types` | The set of kinds the faces' surfaces are, over every body, as the records hold them: `plane`, `cylinder`, `cone`, `sphere`, `torus`, `bspline` (a B-spline or NURBS surface) or `revolution` (a revolved curve). Exact: none missing, none extra. |
| `splines` | `[{degree: [p, q], spans?: [m, n]}, …]`: one entry per B-spline face, each matching one face (directions in either order); `spans`, the distinct knot intervals each way, only where the construction fixes them. |
| `boundary_edges` | The edges only one face uses (a sheet's free boundary), over every body, as the records hold them; exact or `[min, max]`. |
| `flat_pattern` | A sheet metal case's flat pattern, from the script's last `sheet.flat_pattern` result: `size_mm` ([w, h] of the outline), `area_mm2` (the outline's area less the cut-outs', relative tolerance as volume) and `bend_lines` (each `[[x, y], [x, y]]`, a bend's centre line in flat coordinates, matched one for one, either way round, and as many as the result has), positions within the box-diagonal tolerance. |
| `watertight` | `true`: each body's display mesh (chord tolerance `tolerance.mesh_mm`, default 0.01 mm) has no open and no over-shared edges, with vertices closer than `tolerance.weld_mm` (default 1e-4 mm) taken as one. |
| `timeout_s` | A longer timeout for this case than the run's (at most 1800 s), for cases that are slow by nature. A case that only needs it on a loaded machine shouldn't set it. |
| `tolerance` | Optional overrides: `rel`, `abs_mm`, `mesh_mm`, `weld_mm`. |
@@ -188,7 +189,7 @@ check on any change to `crates/engine/src/cmd/`, a recipe or `EXEMPT`, even when
`ladder/` orders kernel operations by complexity instead of by where they went wrong: tiers from
primitives through degenerate booleans, analytic and spline profiles, holes and taps, threads,
fillets, chamfers, shells, patterns, to whole parts. Later tiers build on earlier tiers' shapes (a
fillets, chamfers, shells, patterns, whole parts, and sheet metal. Later tiers build on earlier tiers' shapes (a
running part grows one tier at a time through `base`), so the last case carries everything.
- `ladder/<NN-tier>/<NNN-name>/`: cases in the format above, each a test with its own answer.