diff --git a/apps/solidcraft-cli/src/kernel_cases.rs b/apps/solidcraft-cli/src/kernel_cases.rs index d75f83ae06..272c3c99b1 100644 --- a/apps/solidcraft-cli/src/kernel_cases.rs +++ b/apps/solidcraft-cli/src/kernel_cases.rs @@ -114,6 +114,60 @@ fn open_edges(positions: &[solidcraft_engine::geom::Vec3], triangles: &[[u32; 3] } /// An error that means "this kernel can't do it yet", not a wrong answer. +/// Stations along each seam the `continuity` check samples. +const SEAM_SAMPLES: usize = 65; +/// A seam is tangent (G1), and two seams meeting at a vertex turn there, past this angle (°). +const SEAM_ANGLE_DEG: f64 = 1.0; + +/// The `continuity` check's measure of `body`: over the tangent seams lying wholly within +/// `within` of `near`, the worst curvature jump (1/mm) at every station and at the stations at +/// least `clear` (mm, along the seam) from each vertex where two of those seams turn (a corner of +/// the patch's boundary, where its neighbours' own G1 seams end and no surface is G2 to both), +/// with how many seams and stations clear there were. An error where the seams can't be measured. +fn curvature_jumps(body: &solidcraft_engine::kernel::Body, near: [f64; 3], within: f64, clear: f64) -> Result { + use solidcraft_engine::geom::Vec3; + let tol = solidcraft_engine::kernel::ContinuityTolerances { gap_mm: 1e-3, angle_deg: SEAM_ANGLE_DEG, curvature_per_mm: 0.0 }; + let report = solidcraft_engine::kernel::seam_diagnostics(body, &[], SEAM_SAMPLES, tol).map_err(|e| e.to_string())?; + let at = Vec3::new(near[0], near[1], near[2]); + let seams: Vec<_> = report + .seams + .iter() + .filter(|s| s.normal_angle_deg.value <= SEAM_ANGLE_DEG && s.points.len() >= 2 && s.points.iter().all(|p| p.dist(at) <= within)) + .collect(); + // Each seam's end directions, pointing into it. + let ends = |s: &solidcraft_engine::kernel::SeamReport| -> Option<[(Vec3, Vec3); 2]> { + let (p0, p1) = (*s.points.first()?, *s.points.last()?); + let (q0, q1) = (*s.points.get(1)?, *s.points.get(s.points.len() - 2)?); + Some([(p0, (q0 - p0).normalized()?), (p1, (q1 - p1).normalized()?)]) + }; + let all_ends: Vec<(Vec3, Vec3)> = seams.iter().filter_map(|s| ends(s)).flatten().collect(); + let weld = 1e-6 * within.max(1.0); + let cos = SEAM_ANGLE_DEG.to_radians().cos(); + // A vertex turns where another seam leaves it other than straight on. + let turns = |p: Vec3, d: Vec3| all_ends.iter().any(|(q, e)| q.dist(p) <= weld && (*e - d).len() > 1e-9 && -e.dot(d) < cos); + let (mut worst, mut worst_clear, mut stations) = (0.0f64, 0.0f64, 0usize); + for s in &seams { + let Some(e) = ends(s) else { continue }; + let turning: Vec = e.iter().filter(|(p, d)| turns(*p, *d)).map(|(p, _)| *p).collect(); + // Arc length along the seam to each station, from its start. + let mut along = vec![0.0f64; s.points.len()]; + for k in 1..s.points.len() { + along[k] = along[k - 1] + s.points[k].dist(s.points[k - 1]); + } + let length = along.last().copied().unwrap_or(0.0); + for (k, st) in s.stations.iter().enumerate() { + worst = worst.max(st.curvature_jump_per_mm); + let a = along.get(k).copied().unwrap_or(0.0); + let from_turn = turning.iter().map(|p| if p.dist(s.points[0]) <= weld { a } else { length - a }).fold(f64::INFINITY, f64::min); + if from_turn >= clear { + worst_clear = worst_clear.max(st.curvature_jump_per_mm); + stations += 1; + } + } + } + Ok(json!({"seams": seams.len(), "stations_clear": stations, "worst_per_mm": worst, "worst_clear_per_mm": worst_clear})) +} + fn unsupported(e: &str) -> bool { e.contains("not supported yet") } @@ -256,6 +310,24 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>) -> Value { open == 0 && over == 0, ); } + // Curvature continuity across the seams round a point: the worst jump clear of the + // seams' turns must be within the case's bound. + if let Some(cont) = want.get("continuity").filter(|c| !c.is_null()) { + let (near, within, clear, bound) = + (v3(&cont["near"]), cont["within_mm"].as_f64(), cont["clear_mm"].as_f64(), cont["curvature_jump_per_mm"].as_f64()); + match (near, within, clear, bound) { + (Some(near), Some(within), Some(clear), Some(bound)) => match curvature_jumps(&b.body, near, within, clear) { + Ok(got) => { + let ok = got["seams"].as_u64().unwrap_or(0) > 0 + && got["stations_clear"].as_u64().unwrap_or(0) > 0 + && got["worst_clear_per_mm"].as_f64().is_some_and(|w| w <= bound); + c.push("curvature_jump", got, json!({"worst_clear_per_mm": format!("<= {bound}")}), ok); + } + Err(e) => c.push("curvature_jump", json!(format!("{}: {e}", b.name)), json!("measurable"), false), + }, + _ => c.push("curvature_jump", json!("continuity needs near, within_mm, clear_mm and curvature_jump_per_mm"), json!("a bound"), false), + } + } if want_valid { // Closed shells, and every validity check this build has. if want_shells.is_none() { diff --git a/tests/kernel-cases/README.md b/tests/kernel-cases/README.md index 04b6cee873..5de1011e42 100644 --- a/tests/kernel-cases/README.md +++ b/tests/kernel-cases/README.md @@ -88,6 +88,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). | +| `continuity` | `{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. | | `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`. | diff --git a/tests/kernel-cases/ladder/07-fillets/015-corner-g2-patch/expected.json b/tests/kernel-cases/ladder/07-fillets/015-corner-g2-patch/expected.json index a5246f6977..da5c27f960 100644 --- a/tests/kernel-cases/ladder/07-fillets/015-corner-g2-patch/expected.json +++ b/tests/kernel-cases/ladder/07-fillets/015-corner-g2-patch/expected.json @@ -2,7 +2,29 @@ "sources": [], "what": "three r5 rounds at a corner with the g2 corner patch", "source": "invariants", - "method": "the g2 patch replaces the sphere octant; no closed-form volume; one valid body, the same faces as 009", + "method": "the corner set back 1.5 r = 7.5 and filled with one patch G2 to the three rounds and the three faces; no closed-form volume; one valid body, the same faces as 009, within the box; across the patch's seams the curvature jump is at most 1e-3 per mm (the ball's corner, 009, jumps 1/r = 0.2) clear of the six points where a round's section ends on a face, where the round's own G1 seam with the face ends (the patch passes from one curvature to the other within 15% of each side: 1.2 mm covers it)", "body_count": 1, - "faces": 10 + "faces": 10, + "bbox": { + "min": [ + 0, + 0, + 0 + ], + "max": [ + 60, + 40, + 20 + ] + }, + "continuity": { + "near": [ + 60, + 40, + 20 + ], + "within_mm": 15, + "clear_mm": 1.2, + "curvature_jump_per_mm": 0.001 + } } diff --git a/tests/kernel-cases/ladder/golden.json b/tests/kernel-cases/ladder/golden.json index 7f35e0b010..2e17a58170 100644 --- a/tests/kernel-cases/ladder/golden.json +++ b/tests/kernel-cases/ladder/golden.json @@ -1189,8 +1189,16 @@ "status": "pass" }, "ladder/07-fillets/015-corner-g2-patch": { - "seconds": 0.00302384, - "status": "unsupported" + "measured": { + "area_mm2": 8520.38925544218, + "body_count": 1, + "edges": 24, + "faces": 10, + "vertices": 16, + "volume_mm3": 47370.10741440046 + }, + "seconds": 2.184099933, + "status": "pass" }, "ladder/07-fillets/016-corner-three-sequential-r5-r3-r7": { "seconds": 0.045599545, diff --git a/tools/kernel-ladder/cases.py b/tools/kernel-ladder/cases.py index 5150837020..06a5021473 100644 --- a/tools/kernel-ladder/cases.py +++ b/tools/kernel-ladder/cases.py @@ -863,8 +863,14 @@ case(T7, "014-curved-edge-r2", "an r2 round on the top edge of a cylinder r10 h2 {"body_count": 1, "volume_mm3": PI * 100 * 20 - SPANDREL * 4 * 2 * PI * (10 - XBAR * 2), "faces": 4}, "a torus round: Pappus on the removed section (1-pi/4) r^2 at radius 10 - r(10-3pi)/(12-3pi)") case(T7, "015-corner-g2-patch", "three r5 rounds at a corner with the g2 corner patch", - [F(corner, 5, corner="g2")], {"body_count": 1, "faces": 10}, - "the g2 patch replaces the sphere octant; no closed-form volume; one valid body, the same faces as 009", base=B1, source="invariants") + [F(corner, 5, corner="g2")], + {"body_count": 1, "faces": 10, "bbox": {"min": [0, 0, 0], "max": [60, 40, 20]}, + "continuity": {"near": [60, 40, 20], "within_mm": 15, "clear_mm": 1.2, "curvature_jump_per_mm": 0.001}}, + "the corner set back 1.5 r = 7.5 and filled with one patch G2 to the three rounds and the three faces; no closed-form volume; " + "one valid body, the same faces as 009, within the box; across the patch's seams the curvature jump is at most 1e-3 per mm " + "(the ball's corner, 009, jumps 1/r = 0.2) clear of the six points where a round's section ends on a face, where the round's " + "own G1 seam with the face ends (the patch passes from one curvature to the other within 15% of each side: 1.2 mm covers it)", + base=B1, source="invariants") V7 = V5 - 2 * SPANDREL * 25 * 20 - SPANDREL * 9 * 60 + SPANDREL * 1 * 2 * PI * (8 + XBAR * 1) case(T7, "017-part-rounds", "the running part: r5 on the front vertical edges, r3 on the back wall's top front edge, r1 where the boss meets the top", [F([[0, 0, 10], [60, 0, 10]], 5), F([[30, 30, 35]], 3), F([[23, 15, 20]], 1)],