Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 03:26:20 -04:00
1 parent 7f79443b46
commit 07da7f8065
27 files changed
+3513 -11

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
//! Tests of boundary patches: the bridge this builds is measured back off the face it made — not
//! from the construction — and everything it cannot hold is an error rather than a quiet
//! approximation.
use super::*;
use crate::body::from_p3;
use truck_modeling::ParametricSurface;
/// The angle between two vectors, in degrees.
fn angle_deg(a: Vec3, b: Vec3) -> f64 {
(a.normalized().unwrap().dot(b.normalized().unwrap())).clamp(-1.0, 1.0).acos().to_degrees()
}
/// The geometric shape of a surface curve from its first three derivatives along it: the unit
/// tangent, the curvature vector and the arclength derivative of that curvature vector — the same
/// reading `bridge` states its conditions with.
fn shape_of(d1: Vec3, d2: Vec3, d3: Vec3) -> (Vec3, Vec3, Vec3) {
let sigma = d1.len();
let t = d1 * (1.0 / sigma);
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
let nn = d2 - t * d2.dot(t);
let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp);
let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2));
(t, nn * (1.0 / (sigma * sigma)), kappa * (1.0 / (sigma.powi(5))))
}
/// Across a boundary: five samples a step `h` apart, `into` the patch from the parameter `(u, v)`,
/// read back as the position, the unit tangent, the curvature vector and the change of curvature
/// along that inward direction.
fn across(body: &Body, uv: (f64, f64), into: (f64, f64), h: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(uv.0 + k as f64 * h * into.0, uv.1 + k as f64 * h * into.1))).collect();
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
let (t, bend, change) = shape_of(d1, d2, d3);
(f0, t, bend, change)
}
/// The stations' parameters: `n` of them, evenly spread over the patch.
fn stations(n: usize) -> Vec<f64> {
(0..n).map(|i| i as f64 / (n - 1) as f64).collect()
}
/// The normal angle a side may be off by.
const ANGLE_TOL: f64 = 0.01;
/// How much of the supporting wall's own curvature a side must repeat, relative to it.
const CURV_TOL: f64 = 1e-3;
/// The normal part of the change of curvature a G3 side may leave over, relative to 1/r² (the
/// finite differences that read the third derivative off the face amplify round-off by 1/h³, so
/// this is a noise floor rather than a geometric tolerance).
const CHANGE_TOL: f64 = 1e-4;
/// Measure one side of a built patch at every station of `frames`, and check what its order asks
/// for: the boundary curve itself always, the tangent plane from G1 on, the curvature vector from
/// G2 on and its change along the leave direction at G3.
fn check_side(body: &Body, frames: &[Frame], order: Continuity, at: impl Fn(usize) -> ((f64, f64), (f64, f64)), r: f64) {
for (i, f) in frames.iter().enumerate() {
let (uv, into) = at(i);
let (p, t, bend, change) = across(body, uv, into, 1e-3);
let gap = p.dist(f.p);
assert!(gap < 1e-9, "station {i} sits on its boundary: {p:?} vs {:?} ({gap:.2e} mm)", f.p);
if order == Continuity::G0 {
continue;
}
assert!(angle_deg(t, f.leave) < ANGLE_TOL, "station {i} leaves along the tangent: {t:?} vs {:?}", f.leave);
if order == Continuity::G1 {
continue;
}
if f.bend.len() > 1e-9 {
assert!(bend.dist(f.bend) / f.bend.len() < CURV_TOL, "station {i}: curvature {bend:?} vs the wall's {:?}", f.bend);
if order == Continuity::G3 {
let normal = f.bend * (1.0 / f.bend.len());
assert!(change.dot(normal).abs() < CHANGE_TOL / (r * r), "station {i}: the change of curvature has a normal part {change:?}");
}
} else {
assert!(bend.len() < CURV_TOL / r, "station {i}: the wall is straight here, the patch curves by {bend:?}");
if order == Continuity::G3 {
assert!(change.len() < CHANGE_TOL / (r * r), "station {i}: a straight cross-section changes curvature by {change:?}");
}
}
}
}
/// Two walls 20 mm apart, each leaving towards the other, both walls of the same cylinder of radius
/// `r` (the valley `bridge` is tested with): the two sides of a two-sided patch.
fn two_walls(n: usize, r: f64) -> (Vec<Frame>, Vec<Frame>) {
let mut a = Vec::new();
let mut b = Vec::new();
for t in stations(n) {
let y = 20.0 * t;
a.push(Frame {
p: Vec3::new(0.0, y, 0.0),
leave: Vec3::new(0.0, 1.0, 0.0),
bend: Vec3::new(0.0, 0.0, 1.0 / r),
bend_change: Vec3::new(0.0, -1.0 / (r * r), 0.0),
});
b.push(Frame {
p: Vec3::new(0.0, y + 20.0, 0.0),
leave: Vec3::new(0.0, -1.0, 0.0),
bend: Vec3::new(0.0, 0.0, 1.0 / r),
bend_change: Vec3::new(0.0, 1.0 / (r * r), 0.0),
});
}
(a, b)
}
#[test]
fn a_two_sided_patch_is_a_bridge_that_holds_both_ends() {
let n = 9;
let r = 10.0;
let (a, b) = two_walls(n, r);
let boundaries = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
let body = boundary_patch(&boundaries, 0.0, n).unwrap();
let s = stations(n);
check_side(&body, &a, Continuity::G2, |i| ((s[i], 0.0), (0.0, 1.0)), r);
check_side(&body, &b, Continuity::G2, |i| ((s[i], 1.0), (0.0, -1.0)), r);
}
/// Four chained sides round a 20 × 20 square in the XY plane, each leaving into the square: the
/// loop a patch through three or four boundaries would have to close.
fn square_loop(n: usize) -> Vec<Boundary> {
let corners = [(0.0_f64, 0.0_f64), (20.0, 0.0), (20.0, 20.0), (0.0, 20.0)];
let leaves = [Vec3::new(0.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0), Vec3::new(0.0, -1.0, 0.0), Vec3::new(1.0, 0.0, 0.0)];
(0..4)
.map(|side| {
let (a, b) = (corners[side], corners[(side + 1) % 4]);
let frames = stations(n)
.into_iter()
.map(|t| Frame {
p: Vec3::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, 0.0),
leave: leaves[side],
bend: Vec3::ZERO,
bend_change: Vec3::ZERO,
})
.collect();
Boundary { frames, order: Continuity::G2 }
})
.collect()
}
#[test]
fn a_two_sided_patch_meets_its_walls_up_to_the_curvature() {
let n = 11;
let r = 10.0;
let (a, b) = two_walls(n, r);
// G0, G1 and G2 are what this patch states: position, tangent plane and curvature across each
// boundary. (A G3 bridge's third-order rows are `bridge`'s own subject, measured in its tests.)
for order in [Continuity::G0, Continuity::G1, Continuity::G2] {
let boundaries = vec![Boundary { frames: a.clone(), order }, Boundary { frames: b.clone(), order }];
let body = boundary_patch(&boundaries, 0.0, n).unwrap();
let s = stations(n);
if order == Continuity::G0 {
for (i, f) in a.iter().enumerate() {
let (p, _, _, _) = across(&body, (s[i], 0.0), (0.0, 1.0), 1e-3);
assert!(p.dist(f.p) < 1e-9, "station {i}: {p:?} vs {:?}", f.p);
}
continue;
}
check_side(&body, &a, order, |i| ((s[i], 0.0), (0.0, 1.0)), r);
check_side(&body, &b, order, |i| ((s[i], 1.0), (0.0, -1.0)), r);
}
}
#[test]
fn a_crown_shapes_the_middle_without_touching_the_boundary_rows() {
let n = 9;
let r = 10.0;
let (a, b) = two_walls(n, r);
let boundaries = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
let s = stations(n);
let mut middles = Vec::new();
for crown in [-0.5, 0.0, 1.0, 2.0] {
let body = boundary_patch(&boundaries, crown, n).unwrap();
let grid = crate::bridge::sample_grid(&body, 9, 9).unwrap();
middles.push(grid[4][4].z);
// The interior is the cross-section between the two walls: its middle sits in the gap, not
// outside it, and it stays finite everywhere on the grid.
for row in &grid {
for p in row {
assert!(p.is_finite() && (-2.0..=42.0).contains(&p.y), "the interior stays between the walls: {p:?}");
}
}
assert!((18.0..=22.0).contains(&grid[4][4].y), "the middle of the patch sits midway in the 20 mm band: {:?}", grid[4][4]);
// The conditions are untouched: the same boundary points, tangent and curvature.
check_side(&body, &a, Continuity::G2, |i| ((s[i], 0.0), (0.0, 1.0)), r);
check_side(&body, &b, Continuity::G2, |i| ((s[i], 1.0), (0.0, -1.0)), r);
}
let steps: Vec<f64> = middles.windows(2).map(|w| w[1] - w[0]).collect();
assert!(steps.iter().all(|d| d.abs() > 1e-9), "a fuller crown moves the middle: {middles:?}");
}
#[test]
fn the_patch_refuses_what_it_cannot_hold() {
let n = 9;
let (a, b) = two_walls(n, 10.0);
let two = vec![Boundary { frames: a.clone(), order: Continuity::G2 }, Boundary { frames: b.clone(), order: Continuity::G2 }];
// Not 2 to 4 boundaries.
assert!(boundary_patch(&[], 0.0, n).is_err());
assert!(boundary_patch(&two[..1], 0.0, n).is_err());
let five = vec![two[0].clone(), two[1].clone(), two[0].clone(), two[1].clone(), two[0].clone()];
assert!(boundary_patch(&five, 0.0, n).is_err());
// Three or four sides are refused: a tensor-product patch cannot hold two sides' data at a
// corner, which needs a Gregory-type corner (issue #763). The message says so.
let square = square_loop(n);
let e = boundary_patch(&square, 0.0, n).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "4 sides: {e}");
// Three sides that do close: a triangle, whose last side runs from the second one's end back to
// the first one's start.
let triangle: Vec<Boundary> = vec![
square[0].clone(),
square[1].clone(),
Boundary {
frames: stations(n)
.into_iter()
.map(|t| Frame {
p: Vec3::new(20.0 * (1.0 - t), 20.0 * (1.0 - t), 0.0),
leave: Vec3::new(-std::f64::consts::FRAC_1_SQRT_2, -std::f64::consts::FRAC_1_SQRT_2, 0.0),
bend: Vec3::ZERO,
bend_change: Vec3::ZERO,
})
.collect(),
order: Continuity::G2,
},
];
let e = boundary_patch(&triangle, 0.0, n).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "3 sides: {e}");
// A loop whose sides do not even meet is refused earlier and for its own reason.
let broken = square_loop(n);
let mut moved = broken[1].frames.clone();
moved[0] = Frame { p: Vec3::new(30.0, 0.0, 0.0), ..moved[0] };
let broken = vec![broken[0].clone(), Boundary { frames: moved, order: Continuity::G2 }, broken[2].clone()];
let e = boundary_patch(&broken, 0.0, n).unwrap_err().to_string();
assert!(e.contains("run on into one another"), "{e}");
// The stations and the crown.
let short = vec![Boundary { frames: a[..n - 1].to_vec(), order: Continuity::G2 }, two[1].clone()];
assert!(boundary_patch(&short, 0.0, n).is_err(), "a boundary with the wrong number of stations");
assert!(boundary_patch(&two, 0.0, n - 1).is_err());
assert!(boundary_patch(&two, 0.0, 1).is_err());
for crown in [-1.5, 3.5, f64::NAN, f64::INFINITY] {
assert!(boundary_patch(&two, crown, n).is_err(), "crown {crown}");
}
// A station that is not finite, and a station with no leave direction.
for field in 0..4 {
let mut bad = two.clone();
let mut frames = bad[0].frames.clone();
frames[3] = match field {
0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
1 => Frame { leave: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
2 => Frame { bend: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frames[3] },
_ => Frame { bend_change: Vec3::new(f64::NAN, 0.0, 0.0), ..frames[3] },
};
if let Some(b) = bad.first_mut() {
b.frames = frames;
}
assert!(boundary_patch(&bad, 0.0, n).is_err(), "a station that is not finite: field {field}");
}
let mut flat = two.clone();
if let Some(b) = flat.first_mut() {
b.frames = vec![Frame { p: Vec3::new(0.0, 1.0, 0.0), leave: Vec3::ZERO, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n];
}
assert!(boundary_patch(&flat, 0.0, n).is_err(), "a station with no leave direction");
let point = vec![
Boundary {
frames: vec![Frame { p: Vec3::new(1.0, 1.0, 1.0), leave: Vec3::X, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n],
order: Continuity::G1,
},
Boundary {
frames: vec![Frame { p: Vec3::new(1.0, 1.0, 1.0), leave: Vec3::X, bend: Vec3::ZERO, bend_change: Vec3::ZERO }; n],
order: Continuity::G1,
},
];
assert!(boundary_patch(&point, 0.0, n).is_err(), "a boundary with no length");
}