diff --git a/apps/solidcraft-cli/src/kernel_cases.rs b/apps/solidcraft-cli/src/kernel_cases.rs index 4cf6180804..07dcc4673f 100644 --- a/apps/solidcraft-cli/src/kernel_cases.rs +++ b/apps/solidcraft-cli/src/kernel_cases.rs @@ -871,12 +871,18 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>, validation: solidcraft count(&mut c, "faces", got.faces); count(&mut c, "edges", edges); count(&mut c, "vertices", vertices); - if has("volume_mm3") { - c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel); - } - if has("area_mm2") { - c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel); - } + // A measure is a value within `rel`, or an inclusive range `[min, max]` where analysis only + // bounds it (a cut removes at most its tool's volume). + let measure = |c: &mut Checks, k: &str, g: f64| { + if has(k) { + match want[k].as_array() { + Some(r) => c.push(k, json!(g), want[k].clone(), within(r, g)), + None => c.rel(k, g, want[k].as_f64(), rel), + } + } + }; + measure(&mut c, "volume_mm3", got.volume); + measure(&mut c, "area_mm2", got.area); if has("center_of_mass") { c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs); } @@ -962,6 +968,14 @@ pub fn run(args: &[String]) -> Result<(), String> { if bad > 0 { Err(format!("{bad} kernel case(s) failed")) } else { Ok(()) } } +/// `g` lies in the inclusive range `[min, max]`; a range without both ends holds nothing. +fn within(range: &[Value], g: f64) -> bool { + match (range.first().and_then(Value::as_f64), range.get(1).and_then(Value::as_f64), range.len()) { + (Some(lo), Some(hi), 2) => lo <= g && g <= hi, + _ => false, + } +} + #[cfg(test)] mod tests { use std::f64::consts::PI; @@ -1021,6 +1035,15 @@ mod tests { assert_eq!(surface_word(&face(1, 1, 1, 1)), "bspline"); } + #[test] + fn a_measure_range_holds_its_ends_and_nothing_outside() { + let r = [json!(22500.0), json!(24000.0)]; + assert!(within(&r, 22500.0) && within(&r, 24000.0) && within(&r, 23000.0)); + assert!(!within(&r, 22499.9) && !within(&r, 24000.1)); + assert!(!within(&[json!(1.0)], 1.0), "one end is no range"); + assert!(!within(&[json!(1.0), json!(2.0), json!(3.0)], 1.5), "three ends are no range"); + } + #[test] fn json_parts_match_within_tolerance_and_whole_numbers_exactly() { let got = json!({"bends": [{"angle_deg": 90.00001, "direction": "up", "count": 3}], "extra": 1}); diff --git a/tests/kernel-cases/README.md b/tests/kernel-cases/README.md index 3267772e7a..d894f2308b 100644 --- a/tests/kernel-cases/README.md +++ b/tests/kernel-cases/README.md @@ -106,6 +106,7 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage | `body_count`, `faces`, `edges`, `vertices` | Exact counts, where the geometry fixes them. Faces and edges count whole analytic surfaces and curves once (a full cylinder side is one face). | | `volume_mm3`, `area_mm2`, `center_of_mass`, `bbox` | As in `tests/oracle`: totals over every body, relative tolerance 1e-3 for volume and area, 1e-3 of the box diagonal for positions. | | `faces`, `edges`, `vertices`, `shells`, `body_count` as `[min, max]` | An inclusive range, where the geometry only bounds a count (a modelled thread's faces). | +| `volume_mm3`, `area_mm2` as `[min, max]` | An inclusive range, where analysis only bounds the measure: a cut removes at most its tool's volume, a join adds at most the tool's. `method` derives both ends. | | `continuity` | `{order, edges}`: every seam `edges` names reaches `order` (`g0` … `g4`), as Continuity Analysis (`inspect.continuity`) measures it: from the faces' exact surfaces, at its default 33 stations a seam and its default tolerances (1 µm, 1°, 0.01 / mm, 0.001 / mm², 0.0001 / mm³), never loosened per case. A failure names the worst seam (lowest order; of those, the one furthest past the next order's tolerance), its faces, the order it reaches and that next order's worst value. `edges`: `blend` (default), every seam of a face a fillet or chamfer made (a round, a chamfer face or a corner patch) with the faces either side of the edge it replaced, and every seam of a corner patch with the rounds it joins: what the blend is built to meet (not where a round runs into an end face, nor where two rounds meet without a patch: that seam is the input's, tangent only where the rounded edges were); `corner`, those of them on a corner patch (a blend made from several replaced edges); or a list of points `[[x,y,z], …]`, the edges nearest them, as Continuity Analysis picks them. `exact: true`: every seam reaches `order` and not the next (a case built to an order proves the measurement reads that order, no more and no less). A constant round meets its faces G1 (curvature jumps by 1 / r), a `curvature` round G2, a chamfer G0; a `corner: g2` patch is built G2 to the rounds and faces round it. | | `continuity` (older form) | `{near, within_mm, clear_mm, curvature_jump_per_mm}`: over every tangent seam lying wholly within `within_mm` of `near` (a corner's patch and its neighbours), the curvature jump across it (the faces' normal curvatures square to the seam, from their exact surfaces, at 65 stations a seam) is at most `curvature_jump_per_mm` at every station at least `clear_mm` along the seam from a vertex where two of those seams turn (where a neighbour's own G1 seam ends, which no surface is G2 to both sides of). A rolling ball's corner jumps 1 / r everywhere. | | `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. |