kernel: a patch builds G0 to G4, and Continuity Analysis reads each seam's middle at the order asked

The patch fill meets its neighbours to G4, so Construction::Patch now offers g0 to g4: the
surface.patch command takes g3 and g4 and the Patch dialog's continuity row offers
"Curvature change" and "Smooth curvature change" by itself, from the one table.

The three-round corner test patches G2, G3 and G4 and reads the seams with the Continuity
Analysis too: the middle of each seam (30-70 %) at the order asked; the whole seam G1, since at
the corners the rounds' curvatures differ and no patch can meet both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5Lzv2n8fyKqD61NCHSF
This commit is contained in:
jyviko
2026-10-28 18:02:53 -04:00
committed by Irakli Kourtis
parent 11af682c0f
commit 7d00ad78c4
5 changed files with 55 additions and 16 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("surface_patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("surface_stitch")
+2 -2
View File
@@ -1091,7 +1091,7 @@ fn crown(surface: NurbsSurface, net: &Net, st: &[Station], dev: &Developed, crow
if crown == 0.0 {
return Ok(surface);
}
// The side the neighbours' normals face, by the patch's normals at the G1/G2 stations.
// The side the neighbours' normals face, by the patch's normals at the stations of G1 and up.
let mut side = 0.0;
for (s, q) in st.iter().zip(&dev.d) {
if let (Some(a), Some((n, _, _))) = (s.across, net.frame(*q)) {
@@ -1099,7 +1099,7 @@ fn crown(surface: NurbsSurface, net: &Net, st: &[Station], dev: &Developed, crow
}
}
if side == 0.0 {
return Err(SplineError::Unsupported("crowning a patch with no g1 or g2 edge to say which side is out".into()));
return Err(SplineError::Unsupported("crowning a patch with no g1 or higher edge to say which side is out".into()));
}
let sign = side.signum();
let along = |g: [f64; 2]| net.frame(g).map(|(n, _, _)| n * sign);
+7 -7
View File
@@ -132,9 +132,8 @@ impl Continuity {
/// What builds geometry to a continuity order, and the orders each one builds today.
///
/// HAND-OFF POINT: this table is the only place that says how far a construction goes. When a
/// construction learns a higher order, raise its `highest` in [`Construction::orders`] (for the
/// patch, from the fill's own `supported_orders` once it has one); every command, dialog and
/// kernel entry point reads it from here, so nothing else changes.
/// construction learns a higher order, raise its `highest` in [`Construction::orders`]; every
/// command, dialog and kernel entry point reads it from here, so nothing else changes.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Construction {
/// A patch filling a loop of edges (`surface.patch`).
@@ -154,7 +153,7 @@ impl Construction {
/// The lowest and the highest order this construction builds.
pub fn orders(self) -> (Continuity, Continuity) {
match self {
Construction::Patch => (Continuity::G0, Continuity::G2),
Construction::Patch => (Continuity::G0, Continuity::G4),
Construction::Bridge => (Continuity::G0, Continuity::G3),
Construction::Network => (Continuity::G0, Continuity::G2),
Construction::Match => (Continuity::G1, Construction::Bridge.orders().1),
@@ -224,14 +223,15 @@ mod tests {
fn a_construction_refuses_what_it_does_not_build_and_names_what_it_does() {
assert_eq!(Continuity::words(Continuity::G0, Continuity::G2), "g0, g1 or g2");
assert_eq!(Continuity::words(Continuity::G4, Continuity::G4), "g4");
assert_eq!(Continuity::G2.built_by(Construction::Patch).unwrap(), Continuity::G2);
let e = Continuity::G3.built_by(Construction::Patch).unwrap_err().to_string();
assert_eq!(Continuity::G4.built_by(Construction::Patch).unwrap(), Continuity::G4);
let e = Continuity::G3.built_by(Construction::Network).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("g0, g1 or g2") && e.contains("g3"), "{e}");
let e = Continuity::G0.built_by(Construction::Match).unwrap_err().to_string();
assert!(e.contains("g1, g2 or g3"), "{e}");
assert!(Continuity::G4.built_by(Construction::Bridge).is_err());
assert!(Continuity::G4.built_by(Construction::BlendCurve).is_ok());
assert_eq!(Construction::Patch.offered(), vec![Continuity::G0, Continuity::G1, Continuity::G2]);
assert_eq!(Construction::Patch.offered(), Continuity::ALL.to_vec());
assert_eq!(Construction::Network.offered(), vec![Continuity::G0, Continuity::G1, Continuity::G2]);
assert_eq!(Construction::BlendCurve.offered().first(), Some(&Continuity::G1));
}
}
+44 -5
View File
@@ -533,12 +533,44 @@ fn seam_jumps(open: &Body, fill: &Body) -> Vec<[f64; 4]> {
out
}
/// The Continuity Analysis (`inspect.continuity`) of `fill` sewn into `open`, on the seams at
/// `hole`: per seam, its order over the whole edge, its order over the stations from 30 % to 70 %
/// (the corners left out), and the worst G3 jump there (1/mm²).
fn continuity_analysis(open: &Body, fill: &Body, hole: &[Vec3]) -> Vec<(Option<Continuity>, Option<Continuity>, f64)> {
use crate::{ContinuityTolerances, EdgePick, pick_edges, seam_diagnostics};
let sewn = stitch(&[open, fill], 1e-3).unwrap();
let picks: Vec<EdgePick> = hole.iter().map(|p| EdgePick::Point(*p)).collect();
let edges = pick_edges(&sewn, &picks).unwrap();
let t = ContinuityTolerances::default();
let limits = [t.gap_mm, t.angle_deg, t.curvature_per_mm, t.curvature_change_per_mm2, t.curvature_change_rate_per_mm3];
let report = seam_diagnostics(&sewn, &edges, 33, t).unwrap();
report
.seams
.iter()
.map(|seam| {
let inside: Vec<_> = seam.stations.iter().filter(|s| (0.3..=0.7).contains(&s.edge_fraction)).collect();
let worst = |f: fn(&crate::SeamStation) -> f64| inside.iter().map(|s| f(s)).fold(0.0f64, f64::max);
let metrics = [
worst(|s| s.gap_mm),
worst(|s| s.normal_angle_deg),
worst(|s| s.curvature_jump_per_mm),
worst(|s| s.curvature_change_jump_per_mm2),
worst(|s| s.curvature_change_rate_jump_per_mm3),
];
let met = metrics.iter().zip(limits).take_while(|(w, l)| **w <= *l).count();
(seam.order, met.checked_sub(1).and_then(Continuity::from_order), metrics[3])
})
.collect()
}
/// A box's corner where a round of 8 up the vertical edge meets the rounds of 4 along the top
/// (one blend face rolls the top round over the vertical one); that face taken off, and the hole
/// patched G2 and G3 to the rounds and the top around it. Read off the faces themselves in the
/// middle of each seam: both meet the tangent planes and the curvature; the G3 patch also meets
/// the curvature's rate of change, which the G2 patch leaves many times larger. G3 to an edge
/// between two faces is refused.
/// patched G2, G3 and G4 to the rounds and the top around it. Read off the faces themselves in
/// the middle of each seam: all meet the tangent planes and the curvature; the G3 patch also meets
/// the curvature's rate of change, which the G2 patch leaves many times larger. The Continuity
/// Analysis reads each seam's middle at the order asked, and the whole seam G1: at the corners the
/// rounds' curvatures differ, so no patch can meet both. G3 to an edge between two faces is
/// refused.
#[test]
fn a_corner_of_three_rounds_patched_g3_holds_the_curvature_change_a_g2_patch_does_not() {
let solid = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 30.0)).unwrap();
@@ -554,12 +586,19 @@ fn a_corner_of_three_rounds_patched_g3_holds_the_curvature_change_a_g2_patch_doe
assert!(open.is_surface());
let hole = [Vec3::new(34.828, 34.828, 30.0), Vec3::new(37.657, 37.657, 26.0), Vec3::new(38.828, 32.0, 28.828), Vec3::new(32.0, 38.828, 28.828)];
let mut read = Vec::new();
for g in [Continuity::G2, Continuity::G3] {
for g in [Continuity::G2, Continuity::G3, Continuity::G4] {
let name = format!("three-round corner {}", g.as_str());
let (fill, r) = patch_edges_report(&open, &hole, &spec(g)).unwrap();
report(&name, &r);
let seams = seam_jumps(&open, &fill);
eprintln!("{name}: jumps of the fill's report {:?}; read off the faces, per seam [angle°, H2, H3, H4] {seams:?}", r.jumps);
let analysed = continuity_analysis(&open, &fill, &hole);
eprintln!("{name}: Continuity Analysis, per seam [order over the edge, order 30-70 %, worst G3 jump 30-70 % /mm²] {analysed:?}");
assert_eq!(analysed.len(), 4, "{name}: four seams analysed");
for (whole, middle, _) in &analysed {
assert_eq!(*middle, Some(g), "{name}: the Continuity Analysis reads the order asked in the middle of each seam");
assert_eq!(*whole, Some(Continuity::G1), "{name}: the corners, where the rounds' curvatures differ, hold the tangent plane only");
}
assert_eq!(seams.len(), 4, "{name}: four seams");
for s in &seams {
assert!(s[0] < PATCH_ANGLE_TOL_DEG, "{name}: the tangent planes meet: {s:?}");
+1 -1
View File
@@ -1,5 +1,5 @@
{
"description": "A corner where three rounds meet: a 40 x 40 x 30 box with its vertical edge rounded R8, then its two top edges and the arc between them over that round rounded R4 (one tangent chain). The top round rolls over the vertical one in one blend face at the corner, which meets its neighbours tangent but jumps in curvature. It is taken off (face.delete with heal false) and the hole patched from its four edges with Curvature (g2) and, beside it, with Curvature change (g3). Both meet the rounds and the top in tangent plane and curvature; the g3 patch also meets the curvature's rate of change in the middle of each seam (below 1e-4 /mm^2, against 0.27 to 0.63 for g2). The g3 patch needs surface.patch to take g3.",
"description": "A corner where three rounds meet: a 40 x 40 x 30 box with its vertical edge rounded R8, then its two top edges and the arc between them over that round rounded R4 (one tangent chain). The top round rolls over the vertical one in one blend face at the corner, which meets its neighbours tangent but jumps in curvature. It is taken off (face.delete with heal false) and the hole patched from its four edges with Curvature (g2) and, beside it, with Curvature change (g3). Both meet the rounds and the top in tangent plane and curvature; the g3 patch also meets the curvature's rate of change in the middle of each seam (below 1e-4 /mm^2, against 0.27 to 0.63 for g2).",
"commands": [
{"command": "solid.box", "params": {"corner": [0, 0, 0], "length": 40, "width": 40, "height": 30, "body_name": "Plate"}},
{"command": "solid.fillet", "params": {"body": "Plate", "edges": [[40, 40, 15]], "radius": 8}},