From bc3c9185cdee0a421d53db5949f6744543d43f7c Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:03:27 -0400 Subject: [PATCH] Kernel rig: grade the kinds of a result's surfaces, its B-spline degrees and spans, and its free edges Co-Authored-By: Claude Opus 5.5 --- apps/solidcraft-cli/src/kernel_cases.rs | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/apps/solidcraft-cli/src/kernel_cases.rs b/apps/solidcraft-cli/src/kernel_cases.rs index d5bdde80af..2b6921b883 100644 --- a/apps/solidcraft-cli/src/kernel_cases.rs +++ b/apps/solidcraft-cli/src/kernel_cases.rs @@ -450,6 +450,70 @@ fn output_checks(c: &mut Checks, want: &Value, out: &str, results: &[Value], rel } } +/// The word a `surface_types` check names a face's surface by, as the record holds it: an +/// analytic kind, `bspline` (a B-spline or NURBS surface) or `revolution` (a revolved curve). +fn surface_word(s: &solidcraft_engine::geom::brep::Surface) -> &'static str { + use solidcraft_engine::geom::brep::Surface as S; + match s { + S::Plane { .. } | S::PlaneThrough { .. } => "plane", + S::Cylinder { .. } => "cylinder", + S::Cone { .. } => "cone", + S::Sphere { .. } => "sphere", + S::Torus { .. } => "torus", + S::Spline { .. } | S::RationalSpline { .. } => "bspline", + S::PlacedRevolution { .. } => "revolution", + } +} + +/// A B-spline face's two directions as `[degree, spans]` (spans: the distinct knot intervals of +/// the modelled range), the lower first, so a surface parameterised the other way round reads the +/// same. `None` for a surface that is not a B-spline. +fn spline_shape(s: &solidcraft_engine::geom::brep::Surface) -> Option<[[u64; 2]; 2]> { + use solidcraft_engine::geom::brep::Surface as S; + let (u, v, nu, nv) = match s { + S::Spline { u_knots, v_knots, points } => (u_knots, v_knots, points.len(), points.first().map_or(0, Vec::len)), + S::RationalSpline { u_knots, v_knots, homogeneous } => (u_knots, v_knots, homogeneous.len(), homogeneous.first().map_or(0, Vec::len)), + _ => return None, + }; + let dir = |k: &[f64], n: usize| -> Option<[u64; 2]> { + let p = k.len().checked_sub(n + 1)?; + let inner = k.get(p..=n)?; + let spans = inner.windows(2).filter(|w| w[1] > w[0]).count(); + Some([p as u64, spans as u64]) + }; + let (a, b) = (dir(u, nu)?, dir(v, nv)?); + Some(if a <= b { [a, b] } else { [b, a] }) +} + +/// The `splines` check: every B-spline face of the bodies matches one entry of `want` and every +/// entry one face (`{degree: [p, q], spans?: [m, n]}`, in either order of directions; without +/// `spans` only the degrees are graded). `Ok(got)` with what the faces are, `ok` when they match. +fn splines_match(faces: &[[[u64; 2]; 2]], want: &Value) -> Result<(Value, bool), String> { + let list = want.as_array().ok_or("`splines` must be a list of {degree: [p, q], spans?: [m, n]}")?; + let pair = |v: &Value| -> Option<[u64; 2]> { Some([v.get(0)?.as_u64()?, v.get(1)?.as_u64()?]) }; + let mut left: Vec<[[u64; 2]; 2]> = faces.to_vec(); + let mut ok = true; + for w in list { + let degree = w.get("degree").and_then(pair).ok_or("each `splines` entry needs `degree: [p, q]`")?; + let spans = match w.get("spans").filter(|s| !s.is_null()) { + Some(s) => Some(pair(s).ok_or("`spans` must be [m, n]")?), + None => None, + }; + let fits = |f: &[[u64; 2]; 2]| { + let mine = |d: [u64; 2], s: Option<[u64; 2]>| f[0][0] == d[0] && f[1][0] == d[1] && s.is_none_or(|s| f[0][1] == s[0] && f[1][1] == s[1]); + mine(degree, spans) || mine([degree[1], degree[0]], spans.map(|s| [s[1], s[0]])) + }; + match left.iter().position(fits) { + Some(i) => { + left.remove(i); + } + None => ok = false, + } + } + let got: Vec = faces.iter().map(|f| json!({"degree": [f[0][0], f[1][0]], "spans": [f[0][1], f[1][1]]})).collect(); + Ok((json!(got), ok && left.is_empty())) +} + fn unsupported(e: &str) -> bool { e.contains("not supported yet") } @@ -583,6 +647,17 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value { // body), else one per body. let want_shells = want["shells"].as_u64(); let mut shells = 0u64; + // What the faces lie on and the edges only one face uses, over every body, as the records + // hold them: for `surface_types`, `splines` and `boundary_edges`. + let (mut kinds, mut spline_faces, mut boundary) = (std::collections::BTreeSet::new(), Vec::new(), 0u64); + for b in &world.bodies { + let record = b.body.record(); + for f in &record.faces { + kinds.insert(surface_word(&f.surface)); + spline_faces.extend(spline_shape(&f.surface)); + } + boundary += record.edges.iter().filter(|e| e.uses.len() == 1).count() as u64; + } for b in &world.bodies { let m = match solidcraft_engine::kernel::measure(&b.body) { Ok(m) => m, @@ -705,6 +780,19 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value { c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs); } output_checks(&mut c, &want, &scratch_dir, &answers, rel); + count(&mut c, "boundary_edges", boundary); + if has("surface_types") { + // The set of surface kinds the faces lie on, exactly: none missing, none extra. + let want_kinds: std::collections::BTreeSet<&str> = want["surface_types"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + let ok = want_kinds == kinds.iter().copied().collect(); + c.push("surface_types", json!(kinds), want["surface_types"].clone(), ok); + } + if has("splines") { + match splines_match(&spline_faces, &want["splines"]) { + Ok((got, ok)) => c.push("splines", got, want["splines"].clone(), ok), + Err(e) => c.push("splines", json!(e), want["splines"].clone(), false), + } + } out["status"] = json!(if c.pass { "pass" } else { "fail" }); out["checks"] = json!(c.list); out @@ -784,6 +872,35 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// A bicubic face of 4 × 3 spans and a bilinear one of one span: degrees and spans graded in + /// either order of directions, spans only where given, and every face must be accounted for. + #[test] + fn splines_match_degrees_and_spans_in_either_order() { + use solidcraft_engine::geom::brep::Surface; + let knots = |p: usize, spans: usize| -> Vec { + let mut k = vec![0.0; p + 1]; + k.extend((1..spans).map(|i| i as f64)); + k.extend(vec![spans as f64; p + 1]); + k + }; + let face = |pu: usize, su: usize, pv: usize, sv: usize| Surface::Spline { + u_knots: knots(pu, su), + v_knots: knots(pv, sv), + points: vec![vec![[0.0; 3]; pv + sv]; pu + su], + }; + let faces: Vec<_> = [face(3, 4, 3, 3), face(1, 1, 1, 1)].iter().filter_map(spline_shape).collect(); + assert_eq!(faces[0], [[3, 3], [3, 4]]); + let ok = |w: Value| splines_match(&faces, &w).unwrap().1; + assert!(ok(json!([{"degree": [1, 1], "spans": [1, 1]}, {"degree": [3, 3], "spans": [4, 3]}]))); + assert!(ok(json!([{"degree": [3, 3]}, {"degree": [1, 1]}]))); + assert!(!ok(json!([{"degree": [3, 3], "spans": [4, 4]}, {"degree": [1, 1]}])), "wrong spans"); + assert!(!ok(json!([{"degree": [3, 3]}])), "a face left over"); + assert!(!ok(json!([{"degree": [3, 3]}, {"degree": [1, 1]}, {"degree": [1, 1]}])), "an entry with no face"); + assert!(splines_match(&faces, &json!([{"spans": [1, 1]}])).is_err()); + assert_eq!(spline_shape(&Surface::Plane { origin: [0.0; 3], u_axis: [1.0, 0.0, 0.0], v_axis: [0.0, 1.0, 0.0] }), None); + assert_eq!(surface_word(&face(1, 1, 1, 1)), "bspline"); + } + #[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});