Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)
Thanks @midasdf.
This commit is contained in:
1 parent
7f79443b46
commit
07da7f8065
27 files changed
+3513
-11
No files matched your search
@@ -0,0 +1,123 @@
|
||||
//! Boundary patches: one face through the boundary curves of a sketch, each side meeting the
|
||||
//! surface it was taken from at a requested continuity (G0 up to G3).
|
||||
//!
|
||||
//! Two boundaries are the two sides of a [`crate::bridge`]: the patch is the Hermite surface that
|
||||
//! leaves each boundary along the direction its frames state and repeats as much of that support
|
||||
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
|
||||
//! asks for, with `crown` shaping the middle without touching a boundary condition.
|
||||
//!
|
||||
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
|
||||
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
|
||||
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
|
||||
//! carrying every side, say). Where they do not — the general case — the requested order would
|
||||
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
|
||||
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
|
||||
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::bridge::{Continuity, Frame, bridge};
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
/// The most stations a boundary of a patch may carry.
|
||||
const MAX_SAMPLES: usize = 512;
|
||||
|
||||
/// One boundary of a patch: the stations along it, and how much of the surface it was taken from
|
||||
/// the patch repeats there.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Boundary {
|
||||
/// The stations along the boundary, in the order they run: the end of one boundary meets the
|
||||
/// start of the next, and the last meets the first.
|
||||
pub frames: Vec<Frame>,
|
||||
/// How much of the neighbouring surface the patch repeats at this boundary.
|
||||
pub order: Continuity,
|
||||
}
|
||||
|
||||
/// A two-sided patch: one face that meets boundary `i` at the continuity its `order` asks for (G0 up
|
||||
/// to G3), with `samples` stations every boundary must carry and `crown` shaping the middle of the
|
||||
/// patch without touching any boundary condition (0 the plain blend, up to 3 fuller, down to -0.9
|
||||
/// flatter). Three or four boundaries are refused: see the module docs.
|
||||
pub fn boundary_patch(boundaries: &[Boundary], crown: f64, samples: usize) -> Result<Body> {
|
||||
check_input(boundaries, crown, samples)?;
|
||||
if boundaries.len() > 2 {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"not supported yet: a patch through {} boundaries — a three- or four-sided patch needs a Gregory-type corner (issue #763); \
|
||||
give two boundaries, which is the bridge this builds exactly",
|
||||
boundaries.len()
|
||||
)));
|
||||
}
|
||||
let (Some(a), Some(b)) = (boundaries.first(), boundaries.get(1)) else {
|
||||
return Err(KernelError::Invalid("a boundary patch joins 2 boundaries: one of them is missing".into()));
|
||||
};
|
||||
bridge(&a.frames, &b.frames, [a.order, b.order], [crown, crown])
|
||||
}
|
||||
|
||||
/// Everything a patch refuses, before any geometry is built.
|
||||
fn check_input(boundaries: &[Boundary], crown: f64, samples: usize) -> Result<()> {
|
||||
let n = boundaries.len();
|
||||
if !(2..=4).contains(&n) {
|
||||
return Err(KernelError::Invalid(format!("a boundary patch joins 2 to 4 boundaries, {n} given")));
|
||||
}
|
||||
if !(2..=MAX_SAMPLES).contains(&samples) {
|
||||
return Err(KernelError::Invalid(format!("a boundary patch needs 2 to {MAX_SAMPLES} stations per boundary, {samples} given")));
|
||||
}
|
||||
if !crown.is_finite() || !(-0.9..=3.0).contains(&crown) {
|
||||
return Err(KernelError::Invalid("a boundary patch's crown must be between -0.9 and 3".into()));
|
||||
}
|
||||
for (i, b) in boundaries.iter().enumerate() {
|
||||
if b.frames.len() != samples {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"boundary {i} carries {} stations, not the {samples} every boundary of a patch needs",
|
||||
b.frames.len()
|
||||
)));
|
||||
}
|
||||
for f in &b.frames {
|
||||
if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has a station that is not finite")));
|
||||
}
|
||||
if f.leave.normalized().is_none() {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has a station with no leave direction")));
|
||||
}
|
||||
}
|
||||
if boundary_length(&b.frames) <= 1e-9 {
|
||||
return Err(KernelError::Invalid(format!("boundary {i} has no length: a patch needs a curve, not a single point")));
|
||||
}
|
||||
}
|
||||
// Two boundaries are the two sides of a bridge, which do not meet; three or four would run round
|
||||
// a loop, so each one would have to end where the next one begins.
|
||||
if n >= 3 {
|
||||
let mut scale: f64 = 1.0;
|
||||
for b in boundaries {
|
||||
scale = scale.max(boundary_length(&b.frames));
|
||||
}
|
||||
let tol = scale * 1e-4 + 1e-9;
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (boundaries.get(i), boundaries.get((i + 1) % n)) else { continue };
|
||||
let (Some(end), Some(start)) = (a.frames.last(), b.frames.first()) else { continue };
|
||||
let gap = end.p.dist(start.p);
|
||||
if gap > tol {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"boundary {i} ends and boundary {} starts {gap:.6} mm apart: the boundaries of a loop must run on into one another",
|
||||
(i + 1) % n
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The length of a run of stations.
|
||||
fn boundary_length(frames: &[Frame]) -> f64 {
|
||||
let mut length: f64 = 0.0;
|
||||
for w in frames.windows(2) {
|
||||
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
|
||||
length += a.p.dist(b.p);
|
||||
}
|
||||
}
|
||||
length
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "boundary_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,7 @@ mod boolean_diagnostics;
|
||||
mod boolean_guard;
|
||||
mod boolean_record;
|
||||
mod bore_guard;
|
||||
mod boundary;
|
||||
mod bridge;
|
||||
mod build;
|
||||
mod clearance;
|
||||
@@ -36,6 +37,7 @@ mod emboss;
|
||||
mod face_params;
|
||||
#[cfg(test)]
|
||||
mod face_source_tests;
|
||||
mod fair;
|
||||
mod fill;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
@@ -66,6 +68,7 @@ mod issue_871_tests;
|
||||
mod join_between_caps_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod matchsurf;
|
||||
mod measure;
|
||||
mod mesh_convert;
|
||||
mod meshbody;
|
||||
@@ -125,6 +128,7 @@ pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
|
||||
@@ -132,11 +136,16 @@ pub use build::{
|
||||
};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
pub use fair::{
|
||||
FairCurve, FairNet, FairOptions, FairReport, MAX_FAIR_ITERATIONS, MAX_FAIR_POINTS, fair_bspline_curve, fair_bspline_surface, fair_curve,
|
||||
fair_faces, fair_net, fair_nurbs_curve, fair_nurbs_surface, fair_surface,
|
||||
};
|
||||
pub use fill::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, patch_edges_crowned, patch_edges_report, patch_edges_with};
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use matchsurf::{match_surface, seam_continuity};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Surface matching: rebuild a surface body's face so that one boundary of it meets a picked
|
||||
//! edge chain of another body up to a requested continuity (G1, G2 or G3), while the boundary
|
||||
//! opposite it does not move.
|
||||
//!
|
||||
//! A match is the bridge core ([`crate::bridge`]) pointed at a different pair of boundaries. One
|
||||
//! end reads its position, tangent, curvature and change of curvature from the *target's* face
|
||||
//! along the picked chain — that is the end that moves. The other end reads them from the body's
|
||||
//! own surface along the boundary opposite it, built through the original boundary's own points:
|
||||
//! a match may shape the middle, never that seam. In between the cross-sections are the bridge's
|
||||
//! cubic Hermites, whose crown is free, so the crown whose surface stays closest to the original
|
||||
//! one — sampled on a grid and measured against it — is the one kept: a match moves the seam and
|
||||
//! as little else as the conditions allow.
|
||||
//!
|
||||
//! Which boundary of the face moves is read off the geometry: the one nearest the picked chain.
|
||||
//! The order a match may ask for is read off the target the same way: a chain that several faces
|
||||
//! of the target meet, with shapes that part company, is a seam the target itself does not hold
|
||||
//! beyond that order, and no surface can be told to carry a condition the target has not got.
|
||||
|
||||
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::bridge::{self, Continuity, Frame};
|
||||
use crate::record::Rebuild;
|
||||
use crate::surfaces::surface_body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Stations a match reads along the seam and along the boundary it keeps.
|
||||
const STATIONS: usize = 33;
|
||||
|
||||
/// Samples each way of the grid the rebuilt surface is compared with the original on.
|
||||
const GRID: usize = 7;
|
||||
|
||||
/// The crowns the match tries at each end: [`crate::bridge::bridge`]'s free knob, which shapes
|
||||
/// the middle without touching either end's conditions.
|
||||
const CROWNS: [f64; 5] = [-0.5, 0.0, 0.5, 1.0, 2.0];
|
||||
|
||||
/// Points a caller may pick one face with.
|
||||
const MAX_PICKS: usize = 64;
|
||||
|
||||
/// Samples per edge of a chain, for lengths and distances.
|
||||
const PER_EDGE: usize = 32;
|
||||
|
||||
/// A seam is credited with G1 when its normals agree this closely (degrees), with G2 when the
|
||||
/// two curvature vectors agree within this fraction of the larger of them, and with G3 when the
|
||||
/// change of curvature agrees within this fraction of the curvature over the body's size.
|
||||
const G1_DEG: f64 = 0.05;
|
||||
const G2_REL: f64 = 5e-3;
|
||||
const G3_REL: f64 = 5e-2;
|
||||
|
||||
/// Two faces of the target count as meeting each other at the picked chain when their normals
|
||||
/// agree this closely, their curvature within this fraction of the larger, and their change of
|
||||
/// curvature within this fraction of the curvature over the body's size.
|
||||
const SUPPLY_DEG: f64 = 0.05;
|
||||
const SUPPLY_REL: f64 = 2e-2;
|
||||
const SUPPLY_BEND_REL: f64 = 1e-1;
|
||||
|
||||
/// Rebuild the face of `body` picked by `faces` (points on it) so that its boundary nearest the
|
||||
/// chain of `target`'s edges picked by `target_pick` takes that chain's place and repeats the
|
||||
/// target's own shape along it up to `order` — position, tangent, curvature vector, and the
|
||||
/// change of that curvature vector along the direction the match leaves in. The boundary
|
||||
/// opposite keeps its place and the original surface's own frame, and the middle is shaped to
|
||||
/// stay as close to the original surface as the conditions allow.
|
||||
pub fn match_surface(body: &Body, faces: &[Vec3], target: &Body, target_pick: &[Vec3], order: Continuity) -> Result<Body> {
|
||||
body.require_brep("match")?;
|
||||
target.require_brep("match")?;
|
||||
if !body.is_surface() {
|
||||
return Err(KernelError::Invalid("match rebuilds a surface body: this one is closed".into()));
|
||||
}
|
||||
let face = face_near(body, faces)?;
|
||||
let chain = bridge::chain(target, target_pick)?;
|
||||
let near = nearest_side(&face, &chain)?;
|
||||
let far = near.opposite();
|
||||
let far_edges = side_edges(&face, far, body.size() * 1e-6 + 1e-9)?;
|
||||
let (mid_near, mid_far) = (chain_mid(&chain)?, Side::at(&face.surface(), &ranges(&face)?, far, 0.5));
|
||||
let leave = (mid_far - mid_near)
|
||||
.normalized()
|
||||
.filter(|l| l.is_finite())
|
||||
.ok_or_else(|| KernelError::Invalid("the far boundary sits on the picked chain: there is nothing to match".into()))?;
|
||||
// Which face of the target the match continues from, and how much of the requested order the
|
||||
// target itself holds along that chain.
|
||||
let candidates = support_faces(target, &chain, leave)?;
|
||||
let supply = supply_order(&candidates, &chain, leave, target.size())?;
|
||||
if order.conditions() > supply.conditions() {
|
||||
return Err(KernelError::Invalid(format!(
|
||||
"the target's own surfaces part company at the picked edges: they hold only {} there, so a seam cannot hold {}",
|
||||
supply.as_str(),
|
||||
order.as_str()
|
||||
)));
|
||||
}
|
||||
let Some((target_face, _)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
// The stations of the seam: what the target does there along the direction the match leaves
|
||||
// in, and what the far boundary does coming back the other way.
|
||||
let mut near_frames = bridge::frames(target_face, &chain, leave, STATIONS)?;
|
||||
let far_frames = bridge::frames(&face, &far_edges, leave * -1.0, STATIONS)?;
|
||||
// The two chains are sampled from their own starts: turn one round when that pairs the
|
||||
// stations better (a match is built station to station).
|
||||
let direct: f64 = near_frames.iter().zip(&far_frames).map(|(a, b)| a.p.dist(b.p)).sum();
|
||||
let reversed: f64 = near_frames.iter().zip(far_frames.iter().rev()).map(|(a, b)| a.p.dist(b.p)).sum();
|
||||
if reversed < direct {
|
||||
near_frames.reverse();
|
||||
}
|
||||
guard("surface match", || {
|
||||
// The crown is the free knob: keep the one whose surface stays closest to the original.
|
||||
let mut best: Option<(f64, mt::Face)> = None;
|
||||
for crown_near in CROWNS {
|
||||
for crown_far in CROWNS {
|
||||
let made = bridge::bridge(&near_frames, &far_frames, [order, Continuity::G1], [crown_near, crown_far])?;
|
||||
let Some(made) = made.solid.face_iter().next().cloned() else {
|
||||
return Err(KernelError::Failed("match: the bridge made no face".into()));
|
||||
};
|
||||
let stray = deviation(&made, &face)?;
|
||||
if best.as_ref().is_none_or(|(b, _)| stray < *b) {
|
||||
best = Some((stray, made));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, made)) = best else {
|
||||
return Err(KernelError::Failed("match: no crown built a surface".into()));
|
||||
};
|
||||
let mut kept = made;
|
||||
// The rebuilt face takes the target's own sense at the seam: the two surfaces then meet
|
||||
// the same way round, which is what a stitch, a thicken or a display of the result reads.
|
||||
let seam = chain_mid(&chain)?;
|
||||
if let (Some((made_n, _)), Some((target_n, _))) = (normal_near(&kept, seam), normal_near(target_face, seam))
|
||||
&& made_n.dot(target_n) < 0.0
|
||||
{
|
||||
kept.invert();
|
||||
}
|
||||
let mut rec = Rebuild::default();
|
||||
rec.face(&kept, &face);
|
||||
Ok(crate::record::rebuilt_body(surface_body(vec![kept]), &[&body.solid, &target.solid], &rec))
|
||||
})
|
||||
}
|
||||
|
||||
/// How well the rebuilt surface `made` meets `target` along the chain picked by `target_pick`:
|
||||
/// the worst distance from the seam to that chain, the worst angle between the two surfaces'
|
||||
/// normals across it (degrees), and the highest continuity the seam holds
|
||||
/// ([`Continuity::G0`] when even the normals disagree).
|
||||
pub fn seam_continuity(made: &Body, target: &Body, target_pick: &[Vec3]) -> Result<(f64, f64, Continuity)> {
|
||||
made.require_brep("match")?;
|
||||
target.require_brep("match")?;
|
||||
let chain = bridge::chain(target, target_pick)?;
|
||||
let poly = chain_polyline(&chain)?;
|
||||
// The face whose boundary runs along the chain: the one nearest it, so that a body that has
|
||||
// not been matched yet — where the seam is still some way off — can be measured too.
|
||||
let mid = chain_mid(&chain)?;
|
||||
let face = made
|
||||
.solid
|
||||
.face_iter()
|
||||
.filter_map(|f| face_distance(f, mid).map(|d| (d, f)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, f)| f.clone())
|
||||
.ok_or_else(|| KernelError::Failed("match: the body has no faces".into()))?;
|
||||
let near = nearest_side(&face, &chain)?;
|
||||
let far = near.opposite();
|
||||
let ranges = ranges(&face)?;
|
||||
let mid_far = Side::at(&face.surface(), &ranges, far, 0.5);
|
||||
let leave = (mid_far - chain_mid(&chain)?)
|
||||
.normalized()
|
||||
.ok_or_else(|| KernelError::Invalid("the seam and the far boundary coincide: there is nothing to measure".into()))?;
|
||||
let candidates = support_faces(target, &chain, leave)?;
|
||||
let Some((target_face, _)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
let frames = bridge::frames(target_face, &chain, leave, STATIONS)?;
|
||||
let size = target.size();
|
||||
guard("surface match", || {
|
||||
let surf = face.surface();
|
||||
let (mut gap, mut deg, mut curv, mut bend) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
|
||||
for i in 0..STATIONS {
|
||||
let t = i as f64 / (STATIONS - 1) as f64;
|
||||
let p = Side::at(&surf, &ranges, near, t);
|
||||
gap = gap.max(chain_distance(&poly, p));
|
||||
// What the target says there, and what the rebuilt surface actually does there.
|
||||
let Some(frame) = frames.iter().min_by(|a, b| a.p.dist(p).total_cmp(&b.p.dist(p))) else {
|
||||
return Err(KernelError::Failed("match: the target has no frames".into()));
|
||||
};
|
||||
let (_, t_made, bend_made, change_made) = seam_shape(&surf, &ranges, near, t)?;
|
||||
if let Some((n_made, _)) = normal_near(&face, p)
|
||||
&& let Some((n_target, _)) = normal_near(target_face, p)
|
||||
{
|
||||
deg = deg.max(angle_deg(n_made, n_target));
|
||||
}
|
||||
let scale = frame.bend.len().max(bend_made.len()).max(1.0 / size);
|
||||
curv = curv.max((bend_made - frame.bend).len() / scale);
|
||||
// The change of curvature is what a surface can hold: only its normal part counts,
|
||||
// and it turns round with the direction of travel.
|
||||
let change = if t_made.dot(frame.leave) < 0.0 { change_made * -1.0 } else { change_made };
|
||||
let scale3 = frame.bend_change.len().max(change.len()).max(scale / size);
|
||||
bend = bend.max((change - frame.bend_change).len() / scale3);
|
||||
}
|
||||
let mut order = Continuity::G0;
|
||||
if deg <= G1_DEG {
|
||||
order = Continuity::G1;
|
||||
if curv <= G2_REL {
|
||||
order = Continuity::G2;
|
||||
if bend <= G3_REL {
|
||||
order = Continuity::G3;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((gap, deg, order))
|
||||
})
|
||||
}
|
||||
|
||||
/// Which side of a face's parameter rectangle a boundary runs along: the match reads its face as
|
||||
/// a tensor patch, so its four sides are its own parameter lines.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Side {
|
||||
/// The `u = u0` side (its points run along `v`).
|
||||
U0,
|
||||
/// The `u = u1` side.
|
||||
U1,
|
||||
/// The `v = v0` side (its points run along `u`).
|
||||
V0,
|
||||
/// The `v = v1` side.
|
||||
V1,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The four sides, in a fixed order.
|
||||
const ALL: [Side; 4] = [Side::U0, Side::U1, Side::V0, Side::V1];
|
||||
|
||||
/// The side opposite this one: what a match keeps when this one moves.
|
||||
fn opposite(self) -> Side {
|
||||
match self {
|
||||
Side::U0 => Side::U1,
|
||||
Side::U1 => Side::U0,
|
||||
Side::V0 => Side::V1,
|
||||
Side::V1 => Side::V0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this side pins `u` (its points run along `v`).
|
||||
fn is_u(self) -> bool {
|
||||
matches!(self, Side::U0 | Side::U1)
|
||||
}
|
||||
|
||||
/// The value this side pins, and the range the side runs over.
|
||||
fn fixed_and_along(self, u: (f64, f64), v: (f64, f64)) -> (f64, (f64, f64)) {
|
||||
match self {
|
||||
Side::U0 => (u.0, v),
|
||||
Side::U1 => (u.1, v),
|
||||
Side::V0 => (v.0, u),
|
||||
Side::V1 => (v.1, u),
|
||||
}
|
||||
}
|
||||
|
||||
/// The point at the normalized station `t` along the side.
|
||||
fn at(surf: &mt::Surface, ranges: &[(f64, f64); 2], side: Side, t: f64) -> Vec3 {
|
||||
let (u, v) = (ranges[0], ranges[1]);
|
||||
let (fixed, along) = side.fixed_and_along(u, v);
|
||||
let t = along.0 + (along.1 - along.0) * t.clamp(0.0, 1.0);
|
||||
if side.is_u() { from_p3(surf.subs(fixed, t)) } else { from_p3(surf.subs(t, fixed)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounded parameter ranges of a face's surface.
|
||||
fn ranges(face: &mt::Face) -> Result<[(f64, f64); 2]> {
|
||||
let surf = face.surface();
|
||||
let (Some(u), Some(v)) = surf.try_range_tuple() else {
|
||||
return Err(KernelError::Failed("match: the face's surface is unbounded".into()));
|
||||
};
|
||||
Ok([u, v])
|
||||
}
|
||||
|
||||
/// How far `p` is from the face's own surface.
|
||||
fn face_distance(face: &mt::Face, p: Vec3) -> Option<f64> {
|
||||
let surf = face.surface();
|
||||
let (u, v) = surf.search_nearest_parameter(p3(p), None, 100)?;
|
||||
Some(from_p3(surf.subs(u, v)).dist(p))
|
||||
}
|
||||
|
||||
/// The surface's unit normal at the point nearest `p`, and how far `p` is from that point.
|
||||
fn normal_near(face: &mt::Face, p: Vec3) -> Option<(Vec3, f64)> {
|
||||
let surf = face.surface();
|
||||
let (u, v) = surf.search_nearest_parameter(p3(p), None, 100)?;
|
||||
let n = surf.normal(u, v);
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||||
Some((n, from_p3(surf.subs(u, v)).dist(p)))
|
||||
}
|
||||
|
||||
/// The face of `body` the picks lie on: they must all pick the same one. A pick is a point the
|
||||
/// caller clicked, so the face nearest it within the body's own pick tolerance is the face it
|
||||
/// means (`crate::surfaces::edge_near` reads points the same way).
|
||||
fn face_near(body: &Body, picks: &[Vec3]) -> Result<mt::Face> {
|
||||
if picks.is_empty() || picks.len() > MAX_PICKS || picks.iter().any(|p| !p.is_finite()) {
|
||||
return Err(KernelError::Invalid(format!("pick 1 to {MAX_PICKS} points on the face to match")));
|
||||
}
|
||||
let tol = body.size() * 0.05 + 1e-6;
|
||||
let mut found: Option<mt::Face> = None;
|
||||
for p in picks {
|
||||
let hit = body
|
||||
.solid
|
||||
.face_iter()
|
||||
.filter_map(|f| face_distance(f, *p).map(|d| (d, f)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.filter(|(d, _)| *d <= tol)
|
||||
.map(|(_, f)| f.clone())
|
||||
.ok_or_else(|| KernelError::Invalid(format!("no face of the body passes through {p:?}")))?;
|
||||
if found.as_ref().is_some_and(|f| f.id() != hit.id()) {
|
||||
return Err(KernelError::Invalid("the picks are on different faces: match one face".into()));
|
||||
}
|
||||
found = Some(hit);
|
||||
}
|
||||
found.ok_or_else(|| KernelError::Invalid("pick a point on the face to match".into()))
|
||||
}
|
||||
|
||||
/// A chain of edges as a polyline (each edge sampled), for lengths and distances.
|
||||
fn chain_polyline(chain: &[mt::Edge]) -> Result<Vec<Vec3>> {
|
||||
if chain.is_empty() {
|
||||
return Err(KernelError::Invalid("the chain has no edges".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(chain.len() * (PER_EDGE + 1));
|
||||
for e in chain {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
if !(t1 > t0) || ![t0, t1].into_iter().all(f64::is_finite) {
|
||||
return Err(KernelError::Failed("match: an edge has no parameter range".into()));
|
||||
}
|
||||
for k in 0..=PER_EDGE {
|
||||
out.push(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / PER_EDGE as f64)));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The point at the middle of a chain, by length.
|
||||
fn chain_mid(chain: &[mt::Edge]) -> Result<Vec3> {
|
||||
let poly = chain_polyline(chain)?;
|
||||
let mut cum = Vec::with_capacity(poly.len());
|
||||
cum.push(0.0);
|
||||
for w in poly.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let Some(last) = cum.last().copied() else { break };
|
||||
cum.push(last + a.dist(*b));
|
||||
}
|
||||
let total = cum.last().copied().unwrap_or(0.0);
|
||||
if !(total > 1e-9) || !total.is_finite() {
|
||||
return Err(KernelError::Invalid("the picked chain has no length".into()));
|
||||
}
|
||||
let half = total * 0.5;
|
||||
let hi = cum.partition_point(|x| *x < half).clamp(1, poly.len().saturating_sub(1));
|
||||
let (Some(a), Some(b)) = (poly.get(hi - 1), poly.get(hi)) else {
|
||||
return Err(KernelError::Failed("match: chain".into()));
|
||||
};
|
||||
let (Some(&lo), Some(&up)) = (cum.get(hi - 1), cum.get(hi)) else {
|
||||
return Err(KernelError::Failed("match: chain".into()));
|
||||
};
|
||||
let span = up - lo;
|
||||
let t = if span > 1e-12 { (half - lo) / span } else { 0.0 };
|
||||
Ok(*a + (*b - *a) * t)
|
||||
}
|
||||
|
||||
/// How far `p` is from a polyline.
|
||||
fn chain_distance(poly: &[Vec3], p: Vec3) -> f64 {
|
||||
let mut best = f64::INFINITY;
|
||||
for w in poly.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
let d = b.dist(*a);
|
||||
let t = if d > 1e-12 { ((p - *a).dot(*b - *a) / (d * d)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
best = best.min(p.dist(*a + (*b - *a) * t));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The side of the face's own parameter rectangle the picked chain sits nearest.
|
||||
fn nearest_side(face: &mt::Face, chain: &[mt::Edge]) -> Result<Side> {
|
||||
let surf = face.surface();
|
||||
let ranges = ranges(face)?;
|
||||
let poly = chain_polyline(chain)?;
|
||||
/// Samples of each side used to tell which one the chain runs along.
|
||||
const SAMPLES: usize = 17;
|
||||
let mut best: Option<(f64, Side)> = None;
|
||||
for side in Side::ALL {
|
||||
let mut total = 0.0;
|
||||
for k in 0..SAMPLES {
|
||||
let t = k as f64 / (SAMPLES - 1) as f64;
|
||||
total += chain_distance(&poly, Side::at(&surf, &ranges, side, t));
|
||||
}
|
||||
let mean = total / SAMPLES as f64;
|
||||
if best.is_none_or(|(b, _)| mean < b) {
|
||||
best = Some((mean, side));
|
||||
}
|
||||
}
|
||||
best.map(|(_, s)| s).ok_or_else(|| KernelError::Failed("match: the face has no sides".into()))
|
||||
}
|
||||
|
||||
/// The face's own edges that run along a side of its parameter rectangle, chained head to tail.
|
||||
/// A match keeps the far side's own edges, so they must be a chain that runs on.
|
||||
fn side_edges(face: &mt::Face, side: Side, tol: f64) -> Result<Vec<mt::Edge>> {
|
||||
let surf = face.surface();
|
||||
let ranges = ranges(face)?;
|
||||
let (fixed, along) = side.fixed_and_along(ranges[0], ranges[1]);
|
||||
let band = (along.1 - along.0).abs().max(1e-12) * 1e-3;
|
||||
let mut pool: Vec<mt::Edge> = Vec::new();
|
||||
for e in face.edge_iter() {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let p = from_p3(c.subs(0.5 * (t0 + t1)));
|
||||
let Some((u, v)) = surf.search_nearest_parameter(p3(p), None, 100) else { continue };
|
||||
if from_p3(surf.subs(u, v)).dist(p) > tol {
|
||||
continue;
|
||||
}
|
||||
let got = if side.is_u() { u } else { v };
|
||||
if (got - fixed).abs() <= band {
|
||||
pool.push(e.clone());
|
||||
}
|
||||
}
|
||||
if pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the face has no edges along the boundary a match would keep".into()));
|
||||
}
|
||||
let first = pool.remove(0);
|
||||
let mut chain = vec![first];
|
||||
let mut end = match chain.first() {
|
||||
Some(e) => e.back().clone(),
|
||||
None => return Err(KernelError::Failed("match: chain".into())),
|
||||
};
|
||||
while let Some(k) = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()) {
|
||||
let e = pool.remove(k);
|
||||
let e = if e.front().id() == end.id() { e } else { e.inverse() };
|
||||
end = e.back().clone();
|
||||
chain.push(e);
|
||||
}
|
||||
let mut start = match chain.first() {
|
||||
Some(e) => e.front().clone(),
|
||||
None => return Err(KernelError::Failed("match: chain".into())),
|
||||
};
|
||||
while let Some(k) = pool.iter().position(|e| e.front().id() == start.id() || e.back().id() == start.id()) {
|
||||
let e = pool.remove(k);
|
||||
let e = if e.back().id() == start.id() { e } else { e.inverse() };
|
||||
start = e.front().clone();
|
||||
chain.insert(0, e);
|
||||
}
|
||||
if !pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the face's edges along that boundary do not run on from one another".into()));
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// The faces of `target` that meet the chain and whose tangent plane holds the leave direction,
|
||||
/// best first: the face a match continues from, and the ones it might have been read from.
|
||||
fn support_faces(target: &Body, chain: &[mt::Edge], leave: Vec3) -> Result<Vec<(mt::Face, f64)>> {
|
||||
let mid = chain_mid(chain)?;
|
||||
let tol = target.size() * 1e-6 + 1e-9;
|
||||
let mut out: Vec<(mt::Face, f64)> = Vec::new();
|
||||
for face in target.solid.face_iter() {
|
||||
if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) {
|
||||
continue;
|
||||
}
|
||||
let Some((n, d)) = normal_near(face, mid) else { continue };
|
||||
if d > tol {
|
||||
continue;
|
||||
}
|
||||
let score = (leave - n * leave.dot(n)).len();
|
||||
if score.is_finite() && score >= 0.2 {
|
||||
out.push((face.clone(), score));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(KernelError::Invalid(
|
||||
"no face of the target holds the picked edges in the direction the match leaves in: pick the chain of a face the surface can continue"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
out.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The highest order the target itself holds along the chain: every face that meets those edges
|
||||
/// and whose tangent plane holds the leave direction reads the chain's shape from its own
|
||||
/// surface, and where two of them part company the target's own geometry jumps at the seam.
|
||||
fn supply_order(candidates: &[(mt::Face, f64)], chain: &[mt::Edge], leave: Vec3, size: f64) -> Result<Continuity> {
|
||||
let Some((first, best)) = candidates.first() else {
|
||||
return Err(KernelError::Invalid("no face of the target holds the picked edges".into()));
|
||||
};
|
||||
let keep: Vec<&mt::Face> = candidates.iter().filter(|(_, s)| *s >= 0.5 * best).map(|(f, _)| f).collect();
|
||||
let mut order = Continuity::G3;
|
||||
if keep.len() < 2 {
|
||||
return Ok(order);
|
||||
}
|
||||
let primary: Vec<Frame> = bridge::frames(first, chain, leave, STATIONS)?;
|
||||
for other in keep.iter().skip(1) {
|
||||
// A face that cannot be read along the whole chain says nothing here: the face the match
|
||||
// is built on is the one that must be able to supply it.
|
||||
let Ok(other_frames) = bridge::frames(other, chain, leave, STATIONS) else { continue };
|
||||
let mut worst_deg = 0.0f64;
|
||||
let mut worst = 0.0f64;
|
||||
let mut worst_change = 0.0f64;
|
||||
for (a, b) in primary.iter().zip(&other_frames) {
|
||||
if let (Some((na, _)), Some((nb, _))) = (normal_near(first, a.p), normal_near(other, b.p)) {
|
||||
worst_deg = worst_deg.max(angle_deg(na, nb));
|
||||
}
|
||||
let scale = a.bend.len().max(b.bend.len()).max(1.0 / size);
|
||||
worst = worst.max((a.bend - b.bend).len() / scale);
|
||||
let scale3 = a.bend_change.len().max(b.bend_change.len()).max(scale / size);
|
||||
worst_change = worst_change.max((a.bend_change - b.bend_change).len() / scale3);
|
||||
}
|
||||
if worst_deg > SUPPLY_DEG {
|
||||
order = Continuity::G0;
|
||||
}
|
||||
if worst > SUPPLY_REL && order.conditions() > 1 {
|
||||
order = Continuity::G1;
|
||||
}
|
||||
if worst_change > SUPPLY_BEND_REL && order.conditions() > 2 {
|
||||
order = Continuity::G2;
|
||||
}
|
||||
}
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
/// The acute angle between two directions, in degrees. For two surfaces' normals this is the
|
||||
/// angle between their tangent planes: a sheet has no inside, so which way a normal points says
|
||||
/// nothing about whether the surfaces meet smoothly.
|
||||
fn angle_deg(a: Vec3, b: Vec3) -> f64 {
|
||||
let d = a.dot(b) / (a.len() * b.len());
|
||||
if !d.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
d.abs().clamp(0.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
/// The geometric shape of a surface curve at a point from its first three derivatives along the
|
||||
/// curve: the same statement [`crate::bridge`] makes of a support surface — the unit tangent, the
|
||||
/// curvature vector, and the arclength derivative of that curvature vector.
|
||||
fn shape(d1: Vec3, d2: Vec3, d3: Vec3) -> Result<(Vec3, Vec3, Vec3)> {
|
||||
let sigma = d1.len();
|
||||
if !(sigma > 1e-12) || !sigma.is_finite() {
|
||||
return Err(KernelError::Failed("match: the seam's cross-section does not move".into()));
|
||||
}
|
||||
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));
|
||||
let change = kappa * (1.0 / (sigma.powi(5)));
|
||||
if ![t, nn, change].into_iter().all(Vec3::is_finite) {
|
||||
return Err(KernelError::Failed("match: the seam's shape is not finite".into()));
|
||||
}
|
||||
Ok((t, nn * (1.0 / (sigma * sigma)), change))
|
||||
}
|
||||
|
||||
/// The shape of a face's surface along its cross direction at the station `t`, read inwards from
|
||||
/// the boundary `near` over five one-sided samples: the point, the unit tangent, the curvature
|
||||
/// vector and the change of that curvature. The same finite difference [`crate::bridge::frames`]
|
||||
/// reads a support surface with, so that a rebuilt seam can be compared with what it matched.
|
||||
fn seam_shape(surf: &mt::Surface, ranges: &[(f64, f64); 2], near: Side, t: f64) -> Result<(Vec3, Vec3, Vec3, Vec3)> {
|
||||
let (station, cross, at_start) = if near.is_u() {
|
||||
(ranges[1].0 + (ranges[1].1 - ranges[1].0) * t, ranges[0], near == Side::U0)
|
||||
} else {
|
||||
(ranges[0].0 + (ranges[0].1 - ranges[0].0) * t, ranges[1], near == Side::V0)
|
||||
};
|
||||
let span = (cross.1 - cross.0).abs().max(1e-12);
|
||||
let h = span * 1e-3;
|
||||
let c0 = if at_start { cross.0 } else { cross.1 };
|
||||
let sgn = if at_start { 1.0 } else { -1.0 };
|
||||
let at = |x: f64| if near.is_u() { from_p3(surf.subs(x, station)) } else { from_p3(surf.subs(station, x)) };
|
||||
let q: Vec<Vec3> = (0..5).map(|k| at(c0 + sgn * k as f64 * h)).collect();
|
||||
let (Some(&q0), Some(&q1), Some(&q2), Some(&q3), Some(&q4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else {
|
||||
return Err(KernelError::Failed("match: seam samples".into()));
|
||||
};
|
||||
let d1 = (q0 * -25.0 + q1 * 48.0 + q2 * -36.0 + q3 * 16.0 + q4 * -3.0) * (sgn / (12.0 * h));
|
||||
let d2 = (q0 * 35.0 + q1 * -104.0 + q2 * 114.0 + q3 * -56.0 + q4 * 11.0) * (1.0 / (12.0 * h * h));
|
||||
let d3 = (q0 * -5.0 + q1 * 18.0 + q2 * -24.0 + q3 * 14.0 + q4 * -3.0) * (sgn / (2.0 * h * h * h));
|
||||
let (t, bend, change) = shape(d1, d2, d3)?;
|
||||
Ok((q0, t, bend, change))
|
||||
}
|
||||
|
||||
/// How far the rebuilt surface strays from the original one: the root mean square of the
|
||||
/// distances from a grid of its samples to the original surface.
|
||||
fn deviation(made: &mt::Face, original: &mt::Face) -> Result<f64> {
|
||||
let (surf, os) = (made.surface(), original.surface());
|
||||
let ranges = ranges(made)?;
|
||||
let mut sum = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
for i in 0..GRID {
|
||||
for j in 0..GRID {
|
||||
let u = ranges[0].0 + (ranges[0].1 - ranges[0].0) * i as f64 / (GRID - 1) as f64;
|
||||
let v = ranges[1].0 + (ranges[1].1 - ranges[1].0) * j as f64 / (GRID - 1) as f64;
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
let Some((a, b)) = os.search_nearest_parameter(p3(q), None, 100) else { continue };
|
||||
let d = from_p3(os.subs(a, b)).dist(q);
|
||||
if d.is_finite() {
|
||||
sum += d * d;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(KernelError::Failed("match: the original surface cannot be sampled".into()));
|
||||
}
|
||||
Ok((sum / n as f64).sqrt())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "matchsurf_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Tests of surface matching: a sheet is rebuilt to meet a cylinder's wall up to G1, G2 and G3,
|
||||
//! its seam, its far boundary and its interior are measured, and what cannot be matched is
|
||||
//! refused.
|
||||
|
||||
use super::*;
|
||||
use crate::bridge::{Continuity, Frame};
|
||||
use crate::build::SEAM_ANGLE;
|
||||
use crate::{cylinder, stitch};
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
/// The radius of the cylinder a sheet is matched to, the radius the sheet starts at, its height,
|
||||
/// and how far round the cylinder the sheet runs.
|
||||
const R_TARGET: f64 = 10.0;
|
||||
const R_SHEET: f64 = 11.0;
|
||||
const HEIGHT: f64 = 30.0;
|
||||
const SPAN: f64 = 0.6;
|
||||
|
||||
/// A point on the vertical line at radius `r`, angle `a`, height `z` (Z up).
|
||||
fn at(a: f64, r: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(r * a.cos(), r * a.sin(), z)
|
||||
}
|
||||
|
||||
/// The outward radial and the circumferential unit directions at the angle `a`.
|
||||
fn radial(a: f64) -> Vec3 {
|
||||
Vec3::new(a.cos(), a.sin(), 0.0)
|
||||
}
|
||||
|
||||
fn circ(a: f64) -> Vec3 {
|
||||
Vec3::new(-a.sin(), a.cos(), 0.0)
|
||||
}
|
||||
|
||||
/// A cylindrical sheet of radius `r` between the angles `a0` and `a1`, `stations` high: the body
|
||||
/// a match rebuilds. Its near boundary is the vertical line at `a0`, its far boundary at `a1`.
|
||||
fn cylinder_sheet(r: f64, a0: f64, a1: f64, stations: usize) -> Body {
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
for i in 0..stations {
|
||||
let z = HEIGHT * i as f64 / (stations - 1) as f64;
|
||||
a.push(Frame { p: at(a0, r, z), leave: circ(a0), bend: radial(a0) * (-1.0 / r), bend_change: Vec3::ZERO });
|
||||
b.push(Frame { p: at(a1, r, z), leave: circ(a1) * -1.0, bend: radial(a1) * (-1.0 / r), bend_change: Vec3::ZERO });
|
||||
}
|
||||
bridge::bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap()
|
||||
}
|
||||
|
||||
/// The stock cylinder, and two picks on the vertical seam line of its wall.
|
||||
fn cylinder_and_picks() -> (Body, [Vec3; 2]) {
|
||||
let c = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), R_TARGET, HEIGHT).unwrap();
|
||||
(c, [at(SEAM_ANGLE, R_TARGET, 6.0), at(SEAM_ANGLE, R_TARGET, 24.0)])
|
||||
}
|
||||
|
||||
/// The sheet of the usual shape, and a pick on its near boundary.
|
||||
fn sheet_and_pick() -> (Body, [Vec3; 1]) {
|
||||
(cylinder_sheet(R_SHEET, SEAM_ANGLE, SEAM_ANGLE + SPAN, 9), [at(SEAM_ANGLE, R_SHEET, HEIGHT * 0.5)])
|
||||
}
|
||||
|
||||
/// The middle of the edge of `body`'s first face nearest `p`.
|
||||
fn edge_at(body: &Body, p: Vec3) -> mt::Edge {
|
||||
let face = body.solid.face_iter().next().unwrap();
|
||||
let mut best: Option<(f64, mt::Edge)> = None;
|
||||
for e in face.edge_iter() {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let m = from_p3(c.subs(0.5 * (t0 + t1)));
|
||||
let d = m.dist(p);
|
||||
if best.as_ref().is_none_or(|(b, _)| d < *b) {
|
||||
best = Some((d, e.clone()));
|
||||
}
|
||||
}
|
||||
best.unwrap().1
|
||||
}
|
||||
|
||||
/// How far the rebuilt face's far boundary lies from the original one, in mm: the far row of the
|
||||
/// rebuilt surface sampled at its own stations, against the original boundary edge's own points.
|
||||
fn far_drift(made: &Body, sheet: &Body, far_mid: Vec3) -> f64 {
|
||||
let face = made.solid.face_iter().next().unwrap();
|
||||
let surf = face.surface();
|
||||
let (Some((u0, u1)), Some((_, v1))) = surf.try_range_tuple() else { panic!("unbounded") };
|
||||
let edge = edge_at(sheet, far_mid);
|
||||
let poly = chain_polyline(&[edge]).unwrap();
|
||||
let mut worst = 0.0f64;
|
||||
for k in 0..=8 {
|
||||
let p = from_p3(surf.subs(u0 + (u1 - u0) * k as f64 / 8.0, v1));
|
||||
worst = worst.max(chain_distance(&poly, p));
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// What the rebuilt sheet does at the normalized station `t` of its matching boundary: the
|
||||
/// point, the unit tangent, the curvature vector and the change of that curvature, read off the
|
||||
/// finished surface (never from the construction).
|
||||
fn seam_at(body: &Body, t: f64) -> (Vec3, Vec3, Vec3, Vec3) {
|
||||
let face = body.solid.face_iter().next().unwrap();
|
||||
let ranges = super::ranges(face).unwrap();
|
||||
super::seam_shape(&face.surface(), &ranges, Side::V0, t).unwrap()
|
||||
}
|
||||
|
||||
/// The stations a seam is measured at, as fractions of its length.
|
||||
fn stations() -> Vec<f64> {
|
||||
(0..9).map(|k| k as f64 / 8.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g2_match_meets_a_cylinder_with_its_own_curvature() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G2).unwrap();
|
||||
// The rebuilt boundary has moved onto the picked line: the seam is on the cylinder's wall.
|
||||
assert!(seam_at(&made, 0.5).0.dist(at(SEAM_ANGLE, R_TARGET, HEIGHT * 0.5)) < 1e-6, "the seam is on the picked line");
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
println!("g2: gap {gap:e} mm, normal {deg:e} deg, order {order:?}");
|
||||
assert!(gap < 1e-6, "position gap {gap:e}");
|
||||
assert!(deg < 0.01, "normal angle {deg}");
|
||||
assert!(order.conditions() >= Continuity::G2.conditions(), "{order:?}");
|
||||
// The curvature the cylinder bends with along the seam: matched to a thousandth of itself.
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let mut worst = 0.0f64;
|
||||
for t in stations() {
|
||||
worst = worst.max(seam_at(&made, t).2.dist(want) * R_TARGET);
|
||||
}
|
||||
println!("g2: worst curvature jump {worst:e} of the cylinder's own");
|
||||
assert!(worst < 1e-3, "curvature jump {worst:e}");
|
||||
// The far boundary did not move: it is built through the original boundary's own points and
|
||||
// stays on the original edge between them to round-off.
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
let drift = far_drift(&made, &sheet, far);
|
||||
println!("g2: far boundary drift {drift:e} mm");
|
||||
assert!(drift < 1e-9, "the far boundary drifted {drift:e} mm");
|
||||
// The interior stays near the original: the crowning that leaves it closest.
|
||||
let (face, original) = (made.solid.face_iter().next().unwrap().clone(), sheet.solid.face_iter().next().unwrap().clone());
|
||||
let stray = deviation(&face, &original).unwrap();
|
||||
println!("g2: interior stray {stray:.6} mm rms");
|
||||
// The seam moved 1 mm, so the face cannot be closer than that at the seam itself: over the
|
||||
// whole face the root mean square distance is well under half of it.
|
||||
assert!(stray < 0.6, "the interior strays {stray} mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g1_match_holds_the_tangent_and_leaves_the_curvature_free() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G1).unwrap();
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let worst = stations().into_iter().map(|t| seam_at(&made, t).2.dist(want) * R_TARGET).fold(0.0f64, f64::max);
|
||||
println!("g1: gap {gap:e} mm, normal {deg:e} deg, order {order:?}, curvature jump {worst:e}");
|
||||
assert!(gap < 1e-6 && deg < 0.01, "position {gap:e}, normal {deg}");
|
||||
assert_eq!(order, Continuity::G1, "a tangent match leaves the curvature free");
|
||||
assert!(worst > 1e-3, "a G1 match need not hold the curvature: {worst:e}");
|
||||
// The far boundary is kept whatever the order.
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
assert!(far_drift(&made, &sheet, far) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_g3_match_holds_the_change_of_curvature() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
let made = match_surface(&sheet, &pick, &target, &picks, Continuity::G3).unwrap();
|
||||
let (gap, deg, order) = seam_continuity(&made, &target, &picks).unwrap();
|
||||
let want = radial(SEAM_ANGLE) * (-1.0 / R_TARGET);
|
||||
let (mut bend, mut change) = (0.0f64, 0.0f64);
|
||||
for t in stations() {
|
||||
let (_, _, b, c) = seam_at(&made, t);
|
||||
bend = bend.max(b.dist(want) * R_TARGET);
|
||||
// A cylinder's curvature does not change in its own surface: the normal part of the
|
||||
// change of curvature across the seam must be zero too.
|
||||
change = change.max(c.dot(radial(SEAM_ANGLE)).abs() * R_TARGET * R_TARGET);
|
||||
}
|
||||
println!("g3: gap {gap:e} mm, normal {deg:e} deg, order {order:?}, curvature jump {bend:e}, change {change:e}");
|
||||
assert!(gap < 1e-6 && deg < 0.01, "position {gap:e}, normal {deg}");
|
||||
assert_eq!(order, Continuity::G3, "the seam holds the change of curvature");
|
||||
assert!(bend < 1e-3, "curvature jump {bend:e}");
|
||||
assert!(change < 1e-3, "change of curvature {change:e}");
|
||||
let far = at(SEAM_ANGLE + SPAN, R_SHEET, HEIGHT * 0.5);
|
||||
assert!(far_drift(&made, &sheet, far) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_refuses_what_it_cannot_hold() {
|
||||
let (target, picks) = cylinder_and_picks();
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
// No pick on the body's face, no pick on the target's edges, and picks that are not numbers.
|
||||
assert!(match_surface(&sheet, &[], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &[Vec3::new(0.0, 0.0, 100.0)], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &[Vec3::new(f64::NAN, 0.0, 0.0)], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &vec![Vec3::ZERO; MAX_PICKS + 1], &target, &picks, Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &pick, &target, &[], Continuity::G1).is_err());
|
||||
// A pick that is not on the target at all, and one in the middle of its wall: no chain there.
|
||||
assert!(match_surface(&sheet, &pick, &target, &[Vec3::new(100.0, 0.0, 15.0)], Continuity::G1).is_err());
|
||||
assert!(match_surface(&sheet, &pick, &target, &[Vec3::new(10.0, 0.0, 15.0)], Continuity::G1).is_err());
|
||||
// A body that is not a surface: the stock cylinder itself.
|
||||
assert!(match_surface(&target, &[at(SEAM_ANGLE, R_TARGET, 15.0)], &target, &picks, Continuity::G1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_target_that_jumps_in_curvature_supplies_only_g1() {
|
||||
// Two sheets of the target meet along a line with the same tangent plane but different
|
||||
// curvature: a cylinder's wall and the plane tangent to it there. The target itself does not
|
||||
// hold the curvature along that line, so no match may be told to.
|
||||
let wall = cylinder_sheet(R_TARGET, SEAM_ANGLE, SEAM_ANGLE + 0.5, 5);
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
for i in 0..5 {
|
||||
let z = HEIGHT * i as f64 / 4.0;
|
||||
let (p, q) = (at(SEAM_ANGLE, R_TARGET, z), at(SEAM_ANGLE, R_TARGET, z) + circ(SEAM_ANGLE) * 8.0);
|
||||
a.push(Frame { p, leave: circ(SEAM_ANGLE), bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
b.push(Frame { p: q, leave: circ(SEAM_ANGLE) * -1.0, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
}
|
||||
let plane = bridge::bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
|
||||
let joined = stitch(&[&wall, &plane], 1e-6).unwrap();
|
||||
assert_eq!(joined.solid.face_iter().count(), 2, "the two sheets sewed into one body");
|
||||
let picks = [at(SEAM_ANGLE, R_TARGET, 6.0), at(SEAM_ANGLE, R_TARGET, 24.0)];
|
||||
let (sheet, pick) = sheet_and_pick();
|
||||
// The seam is smooth enough to match, so G1 goes through; the curvature jumps there, so G2
|
||||
// and G3 do not.
|
||||
assert!(match_surface(&sheet, &pick, &joined, &picks, Continuity::G1).is_ok());
|
||||
let err = match_surface(&sheet, &pick, &joined, &picks, Continuity::G2).unwrap_err().to_string();
|
||||
println!("refused g2: {err}");
|
||||
assert!(err.contains("only g1"), "{err}");
|
||||
assert!(match_surface(&sheet, &pick, &joined, &picks, Continuity::G3).is_err());
|
||||
// The plain cylinder, whose two wall faces are the same surface, supplies G3.
|
||||
let (target, plain_picks) = cylinder_and_picks();
|
||||
assert!(match_surface(&sheet, &pick, &target, &plain_picks, Continuity::G3).is_ok());
|
||||
}
|
||||
Reference in new issue
Block a user