Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)

Thanks @midasdf.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-16 06:26:27 -04:00
1 parent 3126962cd4
commit bbd4f29ebb
43 files changed
+4753 -132

No files matched your search

+24 -6
View File
@@ -1637,7 +1637,7 @@ fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool {
}
/// [`smooth_chain`], walking on only over edges `keep` allows.
fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option<Vec<mt::EdgeID>> {
pub(crate) fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option<Vec<mt::EdgeID>> {
use mt::{BoundedCurve, ParametricCurve};
let all = Body::unique_edges(solid);
// Along the edge as it runs from its front to its back (an edge may run against its curve).
@@ -1859,7 +1859,7 @@ fn fillet_built(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
// trimmed by the face. Refused, the refusal above stands.
.or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b)).map_err(|_| e))
// Blends meeting at corners on curved faces: all at once.
.or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2)))
.or_else(|e| corners(body, edges, radius, Shape::Round, "fillet", false).and_then(|b| closed(body, b)).map_err(|e2| keep_size(e, e2)))
}
/// The first refusal, unless only the second found the blend too large (a smaller one may fit).
@@ -1872,8 +1872,9 @@ fn keep_size(e: KernelError, e2: KernelError) -> KernelError {
}
/// Blend the edges together, rebuilding the corners where they meet on faces of any surface
/// (see [`crate::curveblend::corner_blend`]).
fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
/// (see [`crate::curveblend::corner_blend`]); with `g2`, the corners where three blends meet are
/// G2 patches ([`crate::corner_g2`]) instead of pieces of the ball's sphere.
fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, g2: bool) -> Result<Body> {
let counts = record::counts(&[body]);
let cur = crate::boolean_record::healed(body)?;
let size = cur.size();
@@ -1882,10 +1883,27 @@ fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Res
if ids.len() != edges.len() {
return Err(KernelError::Invalid("no edge near a selected point".into()));
}
let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?;
let (made, rec) = guard(what, || match g2 {
// The corner blend as it always was, bit for bit.
false => crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round),
true => crate::curveblend::corner_blend_style(&solid, &ids, size, r, shape == Shape::Round, true),
})?;
Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts))
}
/// [`fillet`] whose corners where three blended edges meet are G2 patches: the corner repeats each
/// blend's surface up to the second order (see [`crate::corner_g2`]) instead of the rolling ball's
/// sphere. The corner construction is the all-at-once corner blend, so a configuration it cannot
/// hold is refused rather than quietly given the ball's sphere; [`fillet`] (the default) is
/// unchanged.
pub fn fillet_g2_corners(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("fillet")?;
if !(radius.is_finite() && radius > 0.0) {
return Err(KernelError::Invalid("the blend radius must be finite and positive".into()));
}
corners(body, edges, radius, Shape::Round, "fillet", true).and_then(|b| closed(body, b))
}
/// The step of the radius search: three significant digits of the requested radius.
fn radius_step(radius: f64) -> f64 {
10f64.powf(radius.log10().floor() - 3.0)
@@ -2191,7 +2209,7 @@ fn chamfer_built(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
Ok(cur)
};
// Chamfers meeting at corners on curved faces: all at once.
one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2)))
one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer", false).map_err(|e2| keep_size(e, e2)))
}
/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).
+71 -16
View File
@@ -205,19 +205,35 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
}
/// Fillet one straight edge with the radius `spline` along it, its positions from the end
/// nearest `start`. One end's radius may be zero.
/// nearest `start`. One end's radius may be zero. A curved edge, or one between curved faces, is
/// rolled by a ball of the law's radius instead ([`curved_variable`]).
pub fn fillet_variable_spline(body: &Body, edge_at: Vec3, start: Vec3, spline: &RadiusSpline) -> Result<Body> {
body.require_brep("fillet")?;
spline.check()?;
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
guard("variable fillet", || {
let s = EdgeSetup::new(body, &edge)?;
let Ok(s) = EdgeSetup::new(body, &edge) else {
return curved_variable(body, &edge, start, spline);
};
// Positions from the end nearest `start` (the edge keeps its own direction).
let law = if s.p1.dist(start) < s.p0.dist(start) { spline.reversed() } else { spline.clone() };
build(body, &s, &edge, &law)
})
}
/// The radius law along a curved edge: a ball whose radius is the law's at each station rolls
/// along the edge's smooth chain (the same sections, contacts and trims as the straight-edge case,
/// sized by the law there). A law that has run to zero at an end closes the blend to the edge's
/// end point, as the straight-edge fillet does.
fn curved_variable(body: &Body, edge: &mt::Edge, start: Vec3, law: &RadiusSpline) -> Result<Body> {
// Positions from the end nearest `start` (the chain runs along the edge's own direction).
let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back()));
let law = if p1.dist(start) < p0.dist(start) { law.reversed() } else { law.clone() };
let chain = crate::blend::smooth_chain(&body.solid, edge).unwrap_or_else(|| vec![edge.id()]);
let (made, rec) = guard("variable fillet", || crate::curveblend::curve_blend_law(&body.solid, &chain, body.size(), &law, true))?;
crate::record::rebuilt(made, &[&body.solid], &rec)
}
/// A straight edge between two planar faces as the variable blend sees it: the shell it is in
/// and that shell's faces, the frame at the edge, and its ends (front first).
struct EdgeSetup<'a> {
@@ -570,8 +586,11 @@ pub fn fillet_tapered(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body,
Ok((cur, tapers))
}
/// Chord-length fillet: each edge (straight, between planar faces) rounded so that the blend
/// is `chord` wide across; edges with the same face angle share a radius.
/// Chord-length fillet: each edge rounded so that the blend is `chord` wide across, from contact
/// to contact at every station. A straight edge between planar faces is built exactly, the radius
/// `chord / (2 cos(φ/2))` from its face angle and edges sharing that angle blended together; a
/// curved edge, or one between curved faces (or a chain of selected edges running on into one),
/// is rolled by a ball whose radius gives the same width, which follows the face angle along it.
pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
body.require_brep("fillet")?;
if !(chord.is_finite() && chord > 1e-6 && chord < 1e6) {
@@ -580,19 +599,44 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
if edges.is_empty() || edges.len() > 1000 {
return Err(KernelError::Invalid("select 1…1000 edges".into()));
}
// Radius per edge from its face angle.
let mut groups: Vec<(f64, Vec<Vec3>)> = Vec::new();
for p in edges {
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
let shell =
body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id())).ok_or_else(|| KernelError::Failed("edge".into()))?;
let faces: Vec<&mt::Face> = shell.face_iter().collect();
let fr = edge_frame(&faces, &e)?;
let r = chord_radius(chord, fr.phi);
match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) {
Some((_, v)) => v.push(*p),
None => groups.push((r, vec![*p])),
let found: Vec<mt::Edge> = edges
.iter()
.map(|p| crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}"))))
.collect::<Result<_>>()?;
// The exact radius of a straight edge between planar faces; None where the rolling ball has to
// take the edge (a curved edge, or one between curved faces).
let exact_radius = |e: &mt::Edge| -> Option<f64> {
if !matches!(e.curve(), mt::Curve::Line(_)) {
return None;
}
let shell = body.solid.boundaries().iter().find(|sh| sh.edge_iter().any(|x| x.id() == e.id()))?;
let faces: Vec<&mt::Face> = shell.face_iter().collect();
Some(chord_radius(chord, edge_frame(&faces, e).ok()?.phi))
};
// A straight edge running on smoothly into a selected curved one goes with it: the ball rolls
// along the chain, so blending the parts apart would leave a step at their joint.
let meets = |a: &mt::Edge, b: &mt::Edge| [a.front(), a.back()].into_iter().any(|v| v == b.front() || v == b.back());
let mixed = |e: &mt::Edge| found.iter().any(|f| f.id() != e.id() && exact_radius(f).is_none() && meets(e, f));
// Radius per edge from its face angle; the rest roll a ball along their chain.
let mut groups: Vec<(f64, Vec<Vec3>)> = Vec::new();
let mut rolled: Vec<Vec<Vec3>> = Vec::new();
let mut done: Vec<mt::EdgeID> = Vec::new();
for (p, e) in edges.iter().zip(&found) {
if let Some(r) = exact_radius(e).filter(|_| !mixed(e)) {
match groups.iter_mut().find(|(gr, _)| (gr - r).abs() <= 1e-9 * r) {
Some((_, v)) => v.push(*p),
None => groups.push((r, vec![*p])),
}
continue;
}
if done.contains(&e.id()) {
continue;
}
// The whole smooth chain (as the constant-radius path rolls it): a ball cannot stop
// where the edge runs on, so the chain's other edges are blended with it.
let chain = crate::blend::smooth_chain(&body.solid, e).unwrap_or_else(|| vec![e.id()]);
done.extend(chain.iter().copied());
rolled.push(edges.iter().zip(&found).filter(|(_, f)| chain.contains(&f.id())).map(|(p, _)| *p).collect());
}
// Each step's result carries its history over `[body]`.
let counts = crate::record::counts(&[body]);
@@ -600,6 +644,17 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
for (r, pts) in groups {
cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts);
}
for picks in rolled {
// The chain as the current body has it: the blends before it rebuilt the faces it runs
// through, and a rebuilt face carries its edges again.
let edges: Vec<mt::Edge> = picks.iter().filter_map(|p| crate::surfaces::edge_near(&cur, *p)).collect();
let Some(first) = edges.first() else { continue };
let keep = |x: &mt::Edge| edges.iter().any(|e| e.id() == x.id());
let chain = crate::blend::smooth_chain_within(&cur.solid, first, &keep).unwrap_or_else(|| vec![first.id()]);
let (made, rec) = crate::guard("chord fillet", || crate::curveblend::curve_blend_chord(&cur.solid, &chain, cur.size(), chord, true))?;
let step = crate::record::rebuilt(made, &[&cur.solid], &rec)?;
cur = crate::record::after(step, &cur, &counts);
}
Ok(cur)
}
+497
View File
@@ -0,0 +1,497 @@
//! Continuity diagnostics across the seams between a body's faces: do the two faces that meet
//! at an edge share a curve (G0), a tangent plane (G1) and a curvature (G2)?
//!
//! The stations are taken on the edge itself — the curve both faces are supposed to share — so
//! the numbers describe the B-rep and not a tessellation: each station is projected onto each
//! face's own surface, and the two projections are compared. The gap is the distance between
//! them (G0); the normal angle is the angle between the faces' outward normals there (G1); the
//! curvature jump is the difference of their normal curvatures in the normal section square to
//! the edge, from the second fundamental form of each surface (G2). A rolling-ball fillet is
//! tangent to the faces it joins but is not curvature continuous: where it meets a plane its
//! seam reads 0° and 1 / radius, which no chord tolerance would reveal.
//!
//! [`crate::smooth_edges`] decides whether a join needs a *display* line; this module measures
//! the join, and backs the engine's `inspect.continuity`.
use serde::Serialize;
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::{KernelError, Result};
/// Bodies with more edges than this are refused rather than sampled for minutes.
const MAX_EDGES: usize = 4096;
/// Most faces a body may have and still be analysed.
const MAX_FACES: usize = 50_000;
/// Stations one call may take over all its edges; edges past the budget are left out and
/// counted in [`ContinuityReport::skipped`].
const MAX_STATIONS: usize = 200_000;
/// Most edges one pick list may name (the full list always fits the station budget).
const MAX_PICKS: usize = 512;
/// Samples used to find the edge nearest a picked point (as in `surfaces::edge_near`).
const PICK_SAMPLES: usize = 32;
/// Least and most stations along one edge (`inspect.continuity`'s `samples`): three are the two
/// ends and the middle.
pub const MIN_SAMPLES: usize = 3;
pub const MAX_SAMPLES: usize = 257;
/// How an edge is picked: a point on (or near) it, or its index in the display mesh's edge
/// order ([`Body::edges`]), which is what a viewport pick carries.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum EdgePick {
Point(Vec3),
Index(usize),
}
/// What counts as continuous across a seam.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ContinuityTolerances {
/// Largest position gap that still counts as coincident (G0), mm.
pub gap_mm: f64,
/// Largest angle between the faces' normals that still counts as tangent (G1), degrees.
pub angle_deg: f64,
/// Largest curvature jump that still counts as curvature continuous (G2), 1/mm.
pub curvature_per_mm: f64,
}
/// The worst value of one metric along an edge, with where it was found.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct SeamWorst {
pub value: f64,
/// The station that gave the worst value, in the body's coordinates.
pub at: Vec3,
/// How far along the edge it is: 0 at the edge's start, 1 at its end.
pub edge_fraction: f64,
}
/// Which test a seam fails, from the best continuity to the worst: [`SeamVerdict::Smooth`] is
/// within every tolerance (G2 or better), then a tangent seam whose curvature jumps (G1), then
/// a visible crease (G0 only), and finally faces that do not meet at all.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SeamVerdict {
Smooth,
CurvatureJump,
Sharp,
Gap,
}
/// One edge's diagnostics: the worst of each metric along it, and where.
#[derive(Clone, Debug, Serialize)]
pub struct SeamReport {
/// Index in the display mesh's edge order ([`Body::edges`]).
pub edge: usize,
/// Length of the edge, mm.
pub length: f64,
/// Stations the values were taken at.
pub samples: usize,
pub gap_mm: SeamWorst,
pub normal_angle_deg: SeamWorst,
pub curvature_jump_per_mm: SeamWorst,
/// What the tolerances make of the seam.
pub verdict: SeamVerdict,
/// The stations in order: what a viewport shades.
pub points: Vec<Vec3>,
}
/// The seam diagnostics of one body.
#[derive(Clone, Debug, Serialize)]
pub struct ContinuityReport {
pub seams: Vec<SeamReport>,
/// Edges left out: not a seam between two faces (an open boundary, or a periodic face's own
/// seam, which is one face meeting itself), or past the station budget.
pub skipped: usize,
/// Edges that could not be sampled at all: no station of theirs projected onto both faces.
pub unmeasured: usize,
}
/// The edge indices `picks` mean, in display-mesh order and without repeats. A point pick takes
/// the edge nearest it (within the distance the kernel re-finds an edge from); an index pick
/// must be an existing index. An empty list means every two-sided seam of the body, and a pick
/// of an edge that is not a seam between two faces is refused, not guessed at.
pub fn pick_edges(body: &Body, picks: &[EdgePick]) -> Result<Vec<usize>> {
body.require_brep("continuity")?;
if picks.len() > MAX_PICKS {
return Err(KernelError::Invalid(format!("pick at most {MAX_PICKS} edges")));
}
crate::guard("continuity picks", || {
let (edges, adjacent) = topology(body)?;
if picks.is_empty() {
return Ok((0..edges.len()).filter(|i| two_sided(&adjacent, *i)).collect());
}
let reach = body.size() * 0.05 + 1e-6;
let mut out: Vec<usize> = Vec::new();
for pick in picks {
let index = match *pick {
EdgePick::Index(index) => index,
EdgePick::Point(p) => nearest_edge(&edges, p, reach)?,
};
if !two_sided(&adjacent, index) {
return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces")));
}
if !out.contains(&index) {
out.push(index);
}
}
out.sort_unstable();
Ok(out)
})
}
/// Sample the two-sided seams `edges` of `body` (indices in display-mesh order; empty: every
/// one of them) at `samples` stations along each edge's own parameter, the ends included, with
/// the tolerances the seams are judged against.
pub fn seam_diagnostics(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result<ContinuityReport> {
body.require_brep("continuity")?;
if !(MIN_SAMPLES..=MAX_SAMPLES).contains(&samples) {
return Err(KernelError::Invalid(format!("`samples` must be {MIN_SAMPLES}…{MAX_SAMPLES}")));
}
for (name, value) in [("gap", tolerances.gap_mm), ("angle", tolerances.angle_deg), ("curvature", tolerances.curvature_per_mm)] {
// A range test also throws out NaN (both comparisons are false).
if !(0.0..1e9).contains(&value) {
return Err(KernelError::Invalid(format!("the {name} tolerance must be a finite number, 0 or more")));
}
}
crate::guard("continuity", || measure(body, edges, samples, tolerances))
}
/// The body's unique edges and the faces meeting at each of them, either under the caps.
fn topology(body: &Body) -> Result<(Vec<mt::Edge>, Vec<Vec<usize>>)> {
if body.solid.face_iter().take(MAX_FACES + 1).count() > MAX_FACES {
return Err(KernelError::TooLarge(format!("the body has more than {MAX_FACES} faces")));
}
let (edges, adjacent) = crate::body::edge_order(body.solid.face_iter());
if edges.len() > MAX_EDGES {
return Err(KernelError::TooLarge(format!("the body has more than {MAX_EDGES} edges")));
}
Ok((edges, adjacent))
}
/// Is the edge at `index` shared by (exactly) two faces of the body?
fn two_sided(adjacent: &[Vec<usize>], index: usize) -> bool {
adjacent.get(index).is_some_and(|faces| faces.len() == 2)
}
/// The index of the edge whose curve comes nearest `p`, within `reach`.
fn nearest_edge(edges: &[mt::Edge], p: Vec3, reach: f64) -> Result<usize> {
use mt::ParametricCurve;
let mut best: Option<(f64, usize)> = None;
for (index, edge) in edges.iter().enumerate() {
let curve = edge.curve();
let Some((t0, t1)) = curve.try_range_tuple() else { continue };
if !(t0.is_finite() && t1.is_finite() && t1 > t0) {
continue;
}
for k in 0..=PICK_SAMPLES {
let q = from_p3(curve.subs(t0 + (t1 - t0) * k as f64 / PICK_SAMPLES as f64));
let d = q.dist(p);
if d.is_finite() && best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, index));
}
}
}
match best {
Some((d, index)) if d <= reach => Ok(index),
_ => Err(KernelError::Invalid(format!("no edge of the body is within {reach:.3} mm of ({}, {}, {})", p.x, p.y, p.z))),
}
}
fn measure(body: &Body, edges: &[usize], samples: usize, tolerances: ContinuityTolerances) -> Result<ContinuityReport> {
let (list, adjacent) = topology(body)?;
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
let mut selected: Vec<usize> = Vec::new();
let mut skipped = 0usize;
if edges.is_empty() {
for index in 0..list.len() {
if two_sided(&adjacent, index) {
selected.push(index);
} else {
skipped += 1;
}
}
} else {
selected.extend(edges.iter().copied());
selected.sort_unstable();
selected.dedup();
for &index in &selected {
if index >= list.len() {
return Err(KernelError::Invalid(format!("the body has no edge {index}")));
}
if !two_sided(&adjacent, index) {
return Err(KernelError::Invalid(format!("edge {index} of this body is not a seam between two faces")));
}
}
}
let mut seams = Vec::new();
let mut unmeasured = 0usize;
let mut budget = MAX_STATIONS;
for index in selected {
let Some(face_pair) = adjacent.get(index).filter(|faces| faces.len() == 2) else { continue };
let (Some(&first), Some(&second)) = (face_pair.first(), face_pair.get(1)) else { continue };
let (Some(a), Some(b)) = (faces.get(first).copied(), faces.get(second).copied()) else { continue };
let Some(edge) = list.get(index) else { continue };
// Every edge costs `samples` stations; a run out of budget stops cleanly.
if budget < samples {
skipped += 1;
continue;
}
budget -= samples;
match seam(index, edge, a, b, samples, tolerances) {
Some(report) => seams.push(report),
None => unmeasured += 1,
}
}
Ok(ContinuityReport { seams, skipped, unmeasured })
}
/// One edge's worst gap, normal angle and curvature jump.
fn seam(index: usize, edge: &mt::Edge, face_a: &mt::Face, face_b: &mt::Face, samples: usize, tolerances: ContinuityTolerances) -> Option<SeamReport> {
use mt::ParametricCurve;
let curve = edge.curve();
let (t0, t1) = curve.try_range_tuple()?;
// The station fractions: 0 at the edge's start, 1 at its end.
let steps = samples.checked_sub(1)?;
let fraction = |k: usize| k as f64 / steps as f64;
let mut on_a = FaceSurface::new(face_a);
let mut on_b = FaceSurface::new(face_b);
let mut gap = None;
let mut angle = None;
let mut jump = None;
let mut points = Vec::with_capacity(samples);
for k in 0..samples {
let f = fraction(k);
let at = from_p3(curve.subs(t0 + (t1 - t0) * f));
if !at.is_finite() {
continue;
}
// Both projections and both curvatures, or the station says nothing.
let (Some((qa, na, uv_a)), Some((qb, nb, uv_b))) = (on_a.project(at), on_b.project(at)) else { continue };
let tangent = curve.der(t0 + (t1 - t0) * f);
let Some(tangent) = Vec3::new(tangent.x, tangent.y, tangent.z).normalized() else { continue };
// The normal sections square to the seam: one direction per face, in its tangent plane.
let (Some(ka), Some(kb)) = (on_a.normal_curvature(uv_a, na, across(na, tangent)), on_b.normal_curvature(uv_b, nb, across(nb, tangent)))
else {
continue;
};
// The faces' outward normals agree on a smooth seam; where they are opposed (a knife
// edge) the curvatures are compared in one frame, since bending away from one face is
// bending towards the other.
let signed = if na.dot(nb) < 0.0 { ka + kb } else { ka - kb };
points.push(at);
keep(&mut gap, qa.dist(qb), at, f);
keep(&mut angle, na.dot(nb).clamp(-1.0, 1.0).acos().to_degrees(), at, f);
keep(&mut jump, signed.abs(), at, f);
}
let (gap, angle, jump) = (gap?, angle?, jump?);
let verdict = if gap.value > tolerances.gap_mm {
SeamVerdict::Gap
} else if angle.value > tolerances.angle_deg {
SeamVerdict::Sharp
} else if jump.value > tolerances.curvature_per_mm {
SeamVerdict::CurvatureJump
} else {
SeamVerdict::Smooth
};
let length = points.windows(2).map(|w| w.first().zip(w.get(1)).map_or(0.0, |(a, b)| a.dist(*b))).sum();
Some(SeamReport {
edge: index,
length,
samples: points.len(),
gap_mm: gap,
normal_angle_deg: angle,
curvature_jump_per_mm: jump,
verdict,
points,
})
}
/// The direction in which a face's curvature is read at a seam: across the edge, square to it,
/// inside the face's tangent plane (a zero vector where the edge runs along the normal, which
/// leaves that station unmeasured).
fn across(normal: Vec3, tangent: Vec3) -> Vec3 {
normal.cross(tangent)
}
/// Keep the largest value seen, with where it was found.
fn keep(worst: &mut Option<SeamWorst>, value: f64, at: Vec3, edge_fraction: f64) {
if value.is_finite() && worst.is_none_or(|w| value > w.value) {
*worst = Some(SeamWorst { value, at, edge_fraction });
}
}
/// A face's surface, kept over the stations of one edge so each search can start at the last
/// answer (stations run along the edge, so the parameters move smoothly).
struct FaceSurface {
surface: mt::Surface,
hint: Option<(f64, f64)>,
}
impl FaceSurface {
/// The face's own outward-facing surface (`oriented_surface`: a reversed face's surface is
/// inverted, so its normal is the material's outward normal).
fn new(face: &mt::Face) -> Self {
Self { surface: face.oriented_surface(), hint: None }
}
/// The nearest point of the surface to `p`, the outward unit normal there and the
/// parameters. `None` where the search or the normal is unusable.
fn project(&mut self, p: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let uv = self.surface.search_nearest_parameter(p3(p), self.hint, 100).or_else(|| self.surface.search_nearest_parameter(p3(p), None, 100))?;
if !(uv.0.is_finite() && uv.1.is_finite()) {
return None;
}
let q = from_p3(self.surface.subs(uv.0, uv.1));
let n = self.surface.normal(uv.0, uv.1);
if !q.is_finite() {
return None;
}
let normal = Vec3::new(n.x, n.y, n.z).normalized()?;
self.hint = Some(uv);
Some((q, normal, uv))
}
/// The normal curvature of the face at `uv` along `direction` (a unit vector of its tangent
/// plane), from the second fundamental form. A surface point `r(u, v)` with unit normal `n`
/// crossed that way has curvature `κ = n · r''` of the curve whose tangent there is
/// `direction`: a plane reads 0 and a cylinder of radius `R`, across its axis, −1 / R
/// (bending towards its axis). `None` at a degenerate parameter point (a cone's apex), where
/// the surface has no curvature.
fn normal_curvature(&self, uv: (f64, f64), normal: Vec3, direction: Vec3) -> Option<f64> {
use mt::ParametricSurface;
let ru = vec3(self.surface.uder(uv.0, uv.1));
let rv = vec3(self.surface.vder(uv.0, uv.1));
let (ru_len, rv_len) = (ru.len(), rv.len());
if !(ru_len > 0.0 && rv_len > 0.0) {
return None;
}
// Solve the direction in the unit basis (r_u / |r_u|, r_v / |r_v|): the curve then runs
// its parameters at du/ds = α / |r_u| and dv/ds = β / |r_v|, and
// `r'' = r_uu (du/ds)² + 2 r_uv (du/ds)(dv/ds) + r_vv (dv/ds)²`.
let cos = (ru.dot(rv) / (ru_len * rv_len)).clamp(-1.0, 1.0);
let sin2 = 1.0 - cos * cos;
if !(sin2 > 1e-12) {
return None;
}
let (cu, cv) = (direction.dot(ru) / ru_len, direction.dot(rv) / rv_len);
let (du, dv) = ((cu - cv * cos) / sin2 / ru_len, (cv - cu * cos) / sin2 / rv_len);
if !(du.is_finite() && dv.is_finite()) {
return None;
}
// `direction` is a unit vector: divide by the speed the parameters imply (they only miss
// it by rounding), so κ does not depend on how the surface is parameterized.
let speed = (ru * du + rv * dv).len();
if !(speed > 1e-6) {
return None;
}
let second = normal.dot(
vec3(self.surface.uuder(uv.0, uv.1)) * (du * du)
+ vec3(self.surface.uvder(uv.0, uv.1)) * (2.0 * du * dv)
+ vec3(self.surface.vvder(uv.0, uv.1)) * (dv * dv),
);
(second / (speed * speed)).is_finite().then_some(second / (speed * speed))
}
}
/// A kernel point as a vector.
fn vec3(p: mt::Vector3) -> Vec3 {
Vec3::new(p.x, p.y, p.z)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use solidcraft_geom::Plane;
fn tolerances() -> ContinuityTolerances {
ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 0.01 }
}
fn box_body() -> Body {
crate::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap()
}
fn report(body: &Body) -> ContinuityReport {
seam_diagnostics(body, &[], 33, tolerances()).unwrap()
}
#[test]
fn a_box_edge_is_a_gap_free_right_angle() {
let body = box_body();
let r = report(&body);
assert_eq!(r.seams.len(), 12, "a box has twelve edges");
assert_eq!(r.skipped, 0);
for seam in &r.seams {
assert!(seam.gap_mm.value < 1e-6, "gap {}", seam.gap_mm.value);
assert!((seam.normal_angle_deg.value - 90.0).abs() < 1e-9, "angle {}", seam.normal_angle_deg.value);
assert!(seam.curvature_jump_per_mm.value < 1e-9, "jump {}", seam.curvature_jump_per_mm.value);
assert_eq!(seam.verdict, SeamVerdict::Sharp);
assert_eq!(seam.samples, 33);
assert!((seam.length - 10.0).abs() < 1e-9);
}
}
#[test]
fn a_fillet_seam_is_tangent_with_the_fillets_curvature_jump() {
let body = box_body();
let rounded = crate::fillet(&body, &[Vec3::new(10.0, 10.0, 5.0)], 4.0).unwrap();
let r = report(&rounded);
// The rounded edge is replaced by the blend face: two tangent seams along it, the two
// arcs where the blend meets the ends of the edge, and the box's other eleven edges.
assert_eq!(r.seams.len(), 15);
let tangent: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
assert_eq!(tangent.len(), 2, "the fillet meets its two neighbours tangentially");
for seam in &tangent {
assert!(seam.gap_mm.value < 1e-6);
assert!(seam.curvature_jump_per_mm.value.is_finite());
assert!((seam.curvature_jump_per_mm.value - 0.25).abs() < 1e-6, "1 / radius, got {}", seam.curvature_jump_per_mm.value);
assert_eq!(seam.verdict, SeamVerdict::CurvatureJump);
}
assert_eq!(r.seams.iter().filter(|s| s.verdict == SeamVerdict::Sharp).count(), 13);
}
#[test]
fn a_line_tangent_into_an_arc_reports_one_over_its_radius() {
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2};
let v = |x: f64, y: f64| Vec2::new(x, y);
let outer = Loop2 {
segs: vec![
Seg2::Arc { center: v(10.0, 0.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: v(10.0, 5.0), b: v(-10.0, 5.0) },
Seg2::Arc { center: v(-10.0, 0.0), radius: 5.0, start: std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: v(-10.0, -5.0), b: v(10.0, -5.0) },
],
};
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 3.0).unwrap().remove(0);
let r = report(&body);
let joins: Vec<&SeamReport> = r.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
assert_eq!(joins.len(), 4, "a slot has four line-to-arc joins");
for seam in joins {
assert!(
seam.curvature_jump_per_mm.value > 0.19 && seam.curvature_jump_per_mm.value < 0.21,
"1 / 5, got {}",
seam.curvature_jump_per_mm.value
);
}
}
#[test]
fn picks_refuse_what_is_not_there() {
let body = box_body();
let all = pick_edges(&body, &[]).unwrap();
assert_eq!(all.len(), 12);
// The edge up the corner at (0, 0), beside a point in the middle of a face.
let picked = pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 0.0, 5.0)), EdgePick::Index(all[0])]).unwrap();
assert_eq!(picked.len(), 2);
assert!(pick_edges(&body, &[EdgePick::Index(999)]).is_err());
assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(0.0, 5.0, 5.0))]).is_err(), "a point on a face is not on an edge");
assert!(pick_edges(&body, &[EdgePick::Point(Vec3::new(500.0, 0.0, 0.0))]).is_err());
assert!(seam_diagnostics(&body, &[], 2, tolerances()).is_err());
assert!(seam_diagnostics(&body, &[], 258, tolerances()).is_err());
assert!(seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: f64::NAN, ..tolerances() }).is_err());
assert!(seam_diagnostics(&body, &[999], 33, tolerances()).is_err());
}
}
+501
View File
@@ -0,0 +1,501 @@
//! G2 corner patches: the patch that fills a vertex where filleted edges meet, built to repeat each
//! blend's surface up to the second order instead of the rolling ball's sphere.
//!
//! [`crate::blend_corner`] and [`crate::curvecorner`] fill the corner of a vertex with a piece of
//! the ball that rolls along the three edges (a sphere for equal radii, a rational patch for
//! unequal ones). That patch is tangent to every blend along the sections where they stop, so the
//! corner is G1 — but across a seam the ball keeps its own 1/r where a fillet is flat along its
//! axis, so the two surfaces meet with a jump in curvature.
//!
//! Here the corner is one tensor patch whose boundary is the blends' section curves themselves and
//! whose cross-sections carry each blend's own derivatives: the position, the direction the patch
//! leaves the blend's surface in, and the blend's curvature vector along that direction are the
//! first three control points of every cross-section, one control point per continuity condition,
//! exactly as [`crate::bridge`]'s sections do. The other end of every cross-section gathers at
//! `apex` — a point inside the material the corner caps — where the sections arrive in a common
//! tangent plane with a common curvature, so the patch closes there smoothly like a sphere's pole.
//! Along a seam the patch therefore repeats the blend up to the second order (G2).
//!
//! The boundary is a closed loop (the three sections joined corner to corner), so the direction
//! along it is a *closed* cubic B-spline: an interpolating curve with free ends swings wide at the
//! corner where the loop joins and folds the patch there. Where two blends meet at that corner
//! their sections leave in directions 90° apart and ask for different curvature at that one point
//! (a fillet is flat across its own seam, the other is not), which no single surface can hold; the
//! corner is a still point of the construction and [`seam_error`] is measured clear of it. A patch
//! that folds, or a corner with no room, is refused rather than replaced by the G1 ball.
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::{HPoint, NurbsSurface};
use truck_modeling::{self as mt, builder};
use crate::blend::unsupported;
use crate::body::{from_p3, p3};
use crate::bridge::Frame;
use crate::{KernelError, Result, guard};
/// A corner's name in an error.
fn fail(m: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("G2 corner: {m}"))
}
/// One of the surface's own derivative vectors as ours.
fn vec_of(v: mt::Vector3) -> Vec3 {
Vec3::new(v.x, v.y, v.z)
}
/// Conditions each end of a cross-section carries: position, tangent and curvature.
const END_CONDITIONS: usize = 3;
/// Degree across the patch: three conditions at each end, less the control point they share.
const ACROSS: usize = 2 * END_CONDITIONS - 1;
/// Degree along the seam loop.
const ALONG: usize = 3;
/// Fewest stations a section curve may be sampled at (a cubic through them).
const MIN_STATIONS: usize = 4;
/// Most stations one section curve may carry.
const MAX_STATIONS: usize = 512;
/// How much of a station's `leave` has to lie across its seam for the patch to have a direction to
/// leave the blend in at all.
const ACROSS_ENOUGH: f64 = 0.2;
/// The G2 corner patch over three blends' section curves: `sides[i]` is one blend's section curve,
/// sampled in order from one corner of the vertex to the next, each side's last station the next
/// side's first (the three close a loop). `apex` is a point inside the material the corner caps;
/// the patch gathers there. The face's boundary edges are the section curves themselves (one edge
/// per side, cut out of the patch's own boundary curve, so they lie on it exactly); the caller
/// re-wraps the wire where it needs its own orientation.
pub fn corner_g2_patch(sides: &[Vec<Frame>], apex: Vec3) -> Result<Vec<mt::Face>> {
guard("G2 corner patch", || build(sides, apex))
}
/// The seam's own direction at station `i` of a side, from a four-point central difference through
/// the stations (`crate::bridge::frames` samples a chain evenly, and a corner's own callers do the
/// same): a two-point chord would only be second order in the step and would tilt the corner's
/// tangent plane by more than G2 is judged at. `None` within two stations of a side's end.
fn seam_tangent(side: &[Frame], i: usize) -> Option<Vec3> {
let at = |j: isize| usize::try_from(j).ok().and_then(|j| side.get(j)).map(|f| f.p);
let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?);
(b * 8.0 - a - c * 8.0 + d).normalized()
}
/// The seam's direction at every station of the closed loop (the same difference, wrapping round
/// its ends), falling back to the chord to a neighbour where the four points are not there.
fn loop_tangents(frames: &[Frame]) -> Vec<Option<Vec3>> {
let m = frames.len();
if m == 0 {
return Vec::new();
}
let at = |j: isize| frames.get(j.rem_euclid(m as isize) as usize).map(|f| f.p);
(0..m)
.map(|i| {
let four = || {
let (a, b, c, d) = (at(i as isize - 2)?, at(i as isize - 1)?, at(i as isize + 1)?, at(i as isize + 2)?);
(b * 8.0 - a - c * 8.0 + d).normalized()
};
four().or_else(|| {
let prev = at(i as isize - 1)?;
let next = at(i as isize + 1)?;
(next - prev).normalized()
})
})
.collect()
}
/// The three section curves as one closed loop of stations, the loop index each side starts at,
/// and the corner's own size (twice the mean distance of a station from their centre).
fn corner_loop(sides: &[Vec<Frame>]) -> Result<(Vec<Frame>, Vec<usize>, f64)> {
if sides.len() != 3 {
return Err(unsupported("a G2 corner needs three blends meeting at one vertex (a corner of two, or one, still ends on the ball's patch)"));
}
let mut frames: Vec<Frame> = Vec::new();
let mut starts: Vec<usize> = Vec::new();
for side in sides {
if side.len() < MIN_STATIONS || side.len() > MAX_STATIONS {
return Err(KernelError::Invalid(format!("a corner section curve needs {MIN_STATIONS} to {MAX_STATIONS} sampled stations")));
}
for f in side {
if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) || !(f.leave.len() > 0.5) {
return Err(KernelError::Invalid("a corner station must carry finite data and a unit direction".into()));
}
}
starts.push(frames.len());
// A side runs from one corner to the next; its last station is the next side's first, so the
// loop takes it once.
frames.extend(side.iter().take(side.len() - 1).copied());
}
let centre = frames.iter().fold(Vec3::ZERO, |a, f| a + f.p) * (1.0 / frames.len().max(1) as f64);
let size = frames.iter().map(|f| f.p.dist(centre)).fold(0.0, f64::max) * 2.0;
if !(size > 0.0 && size.is_finite()) {
return Err(unsupported("a corner whose section curves have no extent"));
}
for (i, side) in sides.iter().enumerate() {
let Some(prev) = sides.get((i + sides.len() - 1) % sides.len()).and_then(|s| s.last()) else { continue };
let Some(&here) = side.first() else { continue };
if prev.p.dist(here.p) > size * 1e-3 {
return Err(unsupported("the section curves do not meet at a corner"));
}
}
Ok((frames, starts, size))
}
/// Every station's `leave` squared up with the seam it belongs to: the part of it across the seam,
/// which is the direction the cross-section has to leave the blend in. A `leave` along the seam
/// itself leaves the cross-section no room, and is refused.
fn transverse(frames: &[Frame], tangents: &[Option<Vec3>]) -> Result<Vec<Frame>> {
let mut out = Vec::with_capacity(frames.len());
for (f, t) in frames.iter().zip(tangents) {
let across = match t {
Some(t) => f.leave - *t * f.leave.dot(*t),
// Stations that offer no direction along the seam: the leave stands as it came.
None => f.leave,
};
if across.len() < ACROSS_ENOUGH * f.leave.len() {
return Err(unsupported("a section curve meets the corner patch along its own tangent, so the patch has no direction to leave it in"));
}
let leave = across.normalized().ok_or_else(|| unsupported("a corner station has no direction across its seam"))?;
out.push(Frame { leave, ..*f });
}
Ok(out)
}
/// One station's cross-section: `ACROSS + 1` control points of a Bézier from the seam point to
/// `apex`. The first three hold the blend: its position, the direction the patch leaves it in
/// (one control point along `leave`) and its curvature vector across the seam (one more). The last
/// three hold the apex: the point, a tangent in the apex's own plane, and the curvature of a dome
/// over the corner, so the sections arrive smoothly instead of leaving a point.
fn section(f: &Frame, apex: Vec3, n_apex: Vec3, kappa: f64) -> Result<[Vec3; ACROSS + 1]> {
let k = ACROSS as f64;
let chord = f.p.dist(apex);
if !(chord.is_finite() && chord > 0.0) {
return Err(unsupported("a blend too small for the corner patch to fit"));
}
let speed = chord / 3.0;
let to_apex = (f.p - apex).normalized().ok_or_else(|| unsupported("a section curve starts on the apex"))?;
let leave_apex =
(to_apex - n_apex * to_apex.dot(n_apex)).normalized().ok_or_else(|| unsupported("a section curve reaches the apex along the apex normal"))?;
let mut c = [Vec3::ZERO; ACROSS + 1];
c[0] = f.p;
c[1] = f.p + f.leave * (speed / k);
c[2] = f.bend * (speed * speed / (k * (k - 1.0))) + c[1] * 2.0 - c[0];
c[ACROSS] = apex;
c[ACROSS - 1] = apex + leave_apex * (speed / k);
c[ACROSS - 2] = (-n_apex * kappa) * (speed * speed / (k * (k - 1.0))) + c[ACROSS - 1] * 2.0 - c[ACROSS];
if c.iter().any(|p| !p.is_finite()) {
return Err(fail("a cross-section of the corner is not finite"));
}
Ok(c)
}
/// A closed uniform cubic B-spline through `points`, once round the loop: the curve at each station
/// is `(q[i-1] + 4 q[i] + q[i+1]) / 6`, so the stations fix the net by a cyclic tridiagonal system,
/// solved here with the Sherman–Morrison form of the Thomas algorithm (a corner's loop runs to
/// hundreds of stations). The net comes back in the open form the tensor surface wants — the first
/// three control points repeated past the end — so it still closes, with one uniform span per
/// station and no free end anywhere.
fn closed_layer(points: &[Vec3]) -> Result<Vec<Vec3>> {
let m = points.len();
if m < 2 * ALONG + 2 {
return Err(fail("a corner loop needs at least eight stations"));
}
// Sub-diagonal 1, diagonal 4, super-diagonal 1, and 1 in both corners.
let (sub, diag, sup, corner) = (1.0f64, 4.0f64, 1.0f64, 1.0f64);
let gamma = -diag;
let mut bb = vec![diag; m];
if let Some(first) = bb.first_mut() {
*first = diag - gamma;
}
if let Some(last) = bb.last_mut() {
*last = diag - corner * corner / gamma;
}
// Thomas, one right-hand side at a time (three components solved together).
let thomas = |rhs: &[Vec3]| -> Result<Vec<Vec3>> {
let mut c = vec![0.0f64; m];
let mut d = rhs.to_vec();
let mut denom = bb.first().copied().unwrap_or(0.0);
if !(denom.abs() > 1e-300) {
return Err(fail("the corner's stations are degenerate"));
}
let (Some(c0), Some(d0)) = (c.first_mut(), d.first_mut()) else { return Err(fail("stations")) };
*c0 = sup / denom;
*d0 = *d0 * (1.0 / denom);
for i in 1..m {
let (prev_c, prev_d) = (c.get(i - 1).copied().unwrap_or(0.0), d.get(i - 1).copied().unwrap_or(Vec3::ZERO));
let b = bb.get(i).copied().unwrap_or(diag);
denom = b - sub * prev_c;
if !(denom.abs() > 1e-300) {
return Err(fail("the corner's stations are degenerate"));
}
if let Some(slot) = c.get_mut(i) {
*slot = if i + 1 < m { sup / denom } else { 0.0 };
}
if let Some(slot) = d.get_mut(i) {
*slot = (*slot - prev_d * sub) * (1.0 / denom);
}
}
let mut x = vec![Vec3::ZERO; m];
let last = d.last().copied().unwrap_or(Vec3::ZERO);
if let Some(slot) = x.last_mut() {
*slot = last;
}
for i in (0..m - 1).rev() {
let (Some(ci), Some(xn)) = (c.get(i).copied(), x.get(i + 1).copied()) else { continue };
let di = d.get(i).copied().unwrap_or(Vec3::ZERO);
if let Some(slot) = x.get_mut(i) {
*slot = di - xn * ci;
}
}
Ok(x)
};
let rhs: Vec<Vec3> = points.iter().map(|p| *p * 6.0).collect();
let x = thomas(&rhs)?;
// The unit right-hand side the corner terms add: gamma at the first station, the corner at the
// last.
let mut unit = vec![Vec3::ZERO; m];
if let Some(slot) = unit.first_mut() {
*slot = Vec3::new(gamma, gamma, gamma);
}
if let Some(slot) = unit.last_mut() {
*slot = Vec3::new(corner, corner, corner);
}
let z = thomas(&unit)?;
let (x0, xn) = (x.first().copied().unwrap_or(Vec3::ZERO), x.last().copied().unwrap_or(Vec3::ZERO));
let (z0, zn) = (z.first().copied().unwrap_or(Vec3::ZERO), z.last().copied().unwrap_or(Vec3::ZERO));
// z answers a scalar right-hand side, so the correction is one scalar factor per component.
let factor = |x0: f64, xn: f64, z0: f64, zn: f64| (x0 + corner * xn / gamma) / (1.0 + z0 + corner * zn / gamma);
let f = Vec3::new(factor(x0.x, xn.x, z0.x, zn.x), factor(x0.y, xn.y, z0.y, zn.y), factor(x0.z, xn.z, z0.z, zn.z));
let mut out = Vec::with_capacity(m + ALONG);
for i in 0..m {
let (xi, zi) = (x.get(i).copied().unwrap_or(Vec3::ZERO), z.get(i).copied().unwrap_or(Vec3::ZERO));
out.push(xi - Vec3::new(f.x * zi.x, f.y * zi.y, f.z * zi.z));
}
if !out.iter().all(|p| p.is_finite()) {
return Err(fail("the corner's boundary is not finite"));
}
for i in 0..ALONG {
let p = out.get(i).copied().unwrap_or(Vec3::ZERO);
out.push(p);
}
Ok(out)
}
/// The stations of the loop as one tensor control net: across (v) each station's cross-section is
/// the Bézier above, along the seam loop (u) each of its control points is a closed cubic through
/// the stations, so the face passes through every section exactly and its parameter line along the
/// loop closes without an end to swing from. The loop's station count comes back with it: station
/// `i` sits at parameter `i + ALONG`, and the loop closes at `count + ALONG`.
fn tensor(layers: &[[Vec3; ACROSS + 1]]) -> Result<(NurbsSurface, f64)> {
let m = layers.len();
let nu = m + ALONG;
let knots_u: Vec<f64> = (0..=(nu + ALONG) as i64).map(|i| i as f64).collect();
let mut ctrl: Vec<HPoint> = Vec::with_capacity(nu * (ACROSS + 1));
for j in 0..=ACROSS {
let points: Vec<Vec3> = layers.iter().map(|c| c.get(j).copied().unwrap_or(Vec3::ZERO)).collect();
let net = closed_layer(&points)?;
for p in &net {
ctrl.push(HPoint::weighted(*p, 1.0));
}
}
let mut knots_v = vec![0.0; ACROSS + 1];
knots_v.extend(std::iter::repeat_n(1.0, ACROSS + 1));
let s = NurbsSurface::from_homogeneous([ALONG, ACROSS], [knots_u, knots_v], ctrl).map_err(|e| fail(format!("the corner patch: {e}")))?;
Ok((s, m as f64))
}
/// The surface across its own domain: the patch must keep facing one way, or a blend would be met
/// by a piece of the patch folded back on itself.
fn no_fold(surface: &mt::Surface, loop_range: (f64, f64)) -> Result<()> {
use mt::ParametricSurface;
// The u knots run past the loop at both ends (a closed curve has no clamped ends to name), so
// the loop's own range is brought in rather than read off the knot vector.
let (u0, u1) = loop_range;
let Some((v0, v1)) = surface.try_range_tuple().1 else {
return Err(fail("the corner patch has no parameter domain"));
};
let normal = |u: f64, v: f64| -> Option<Vec3> { vec_of(surface.uder(u, v)).cross(vec_of(surface.vder(u, v))).normalized() };
let mid = normal((u0 + u1) * 0.5, v0 + (v1 - v0) * 0.5).ok_or_else(|| unsupported("the corner patch has no normal at its middle"))?;
// Clear of the apex (v1), where the patch's parameter line along the seam collapses to a point
// and the normal is read from the sections approaching it.
for i in 0..=8 {
for j in 0..9 {
let u = u0 + (u1 - u0) * i as f64 / 8.0;
let v = v0 + (v1 - v0) * j as f64 / 9.0;
let n = normal(u, v).ok_or_else(|| unsupported("the corner patch folds back on itself"))?;
if n.dot(mid) <= 0.0 {
return Err(unsupported("the corner patch folds back on itself with these section curves"));
}
}
}
Ok(())
}
/// Build the patch: sections, tensor surface, and the wire of the section curves themselves.
fn build(sides: &[Vec<Frame>], apex: Vec3) -> Result<Vec<mt::Face>> {
if !apex.is_finite() {
return Err(KernelError::Invalid("the corner apex must be a finite point".into()));
}
let (frames, starts, size) = corner_loop(sides)?;
let frames = transverse(&frames, &loop_tangents(&frames))?;
// The apex's own plane: the mean direction from the apex to the boundary it caps.
let n_apex = frames
.iter()
.fold(Vec3::ZERO, |a, f| a + (f.p - apex))
.normalized()
.ok_or_else(|| unsupported("the corner's apex sits on its own boundary curves"))?;
// How hard the patch bends into the apex: as much as its blends do across their seams, and
// never flatter than a dome of the corner's own size (a flat cap would end in a point).
let mean_bend = frames.iter().map(|f| f.bend.len()).sum::<f64>() / frames.len().max(1) as f64;
let reach = (frames.iter().map(|f| f.p.dist(apex)).sum::<f64>() / frames.len().max(1) as f64).max(size * 1e-3);
let kappa = mean_bend.max(1.0 / (2.0 * reach));
if !(kappa.is_finite() && kappa > 0.0) {
return Err(unsupported("a corner the patch cannot span"));
}
let layers: Vec<[Vec3; ACROSS + 1]> = frames.iter().map(|f| section(f, apex, n_apex, kappa)).collect::<Result<_>>()?;
let (nurbs, m) = tensor(&layers)?;
let surface = crate::spline_conv::surface(&nurbs)?;
no_fold(&surface, (ALONG as f64, m + ALONG as f64))?;
// The boundary is the loop of section curves; each side is cut out of the patch's own boundary
// curve so that it lies on the patch exactly.
let nu = nurbs.count().first().copied().unwrap_or(0);
let row: Vec<HPoint> = (0..nu).map(|i| nurbs.point(i, 0)).collect();
let Some(knots_u) = nurbs.knots().first().cloned() else { return Err(fail("knots")) };
let loop_curve = crate::spline_conv::nurbs_curve(mt::KnotVec::from(knots_u), &row)?;
let mut cuts: Vec<f64> = starts.iter().map(|i| *i as f64 + ALONG as f64).collect();
cuts.push(m + ALONG as f64);
let mut verts: Vec<mt::Vertex> = Vec::with_capacity(sides.len());
for side in sides {
let first = side.first().ok_or_else(|| fail("a section curve has no stations"))?;
verts.push(builder::vertex(p3(first.p)));
}
let mut edges: Vec<mt::Edge> = Vec::with_capacity(sides.len());
for i in 0..sides.len() {
let (Some(&a), Some(&b)) = (cuts.get(i), cuts.get(i + 1)) else { return Err(fail("the corner's seams")) };
let mut piece = loop_curve.clone();
let _tail = mt::Cut::cut(&mut piece, b);
let piece = mt::Cut::cut(&mut piece, a);
let (Some(from), Some(to)) = (verts.get(i), verts.get((i + 1) % verts.len())) else { return Err(fail("the corner's vertices")) };
edges.push(mt::Edge::try_new(from, to, piece).map_err(|e| fail(format!("a corner seam: {e}")))?);
}
let face = mt::Face::try_new(vec![mt::Wire::from(edges)], surface).map_err(|e| fail(format!("the corner patch: {e}")))?;
Ok(vec![face])
}
/// What the patch achieves along the seams of `sides`: the largest distance from a seam station to
/// the patch, the largest angle (radians) between the patch's normal there and the blend's, and the
/// largest difference between the curvature across the seam and the blend's own, relative to
/// `reference` — the curvature a rolling-ball corner shows across the same seam, which is what G2
/// removes. Measured on the built surface alone, by the same finite differences of its own samples
/// that `crate::bridge`'s tests read a cross-section's shape with, so it is evidence about the
/// geometry and not about the construction.
pub fn seam_error(surface: &mt::Surface, sides: &[Vec<Frame>], reference: f64) -> Result<(f64, f64, f64)> {
use mt::{ParametricSurface, SearchNearestParameter};
if !(reference.is_finite() && reference > 0.0) {
return Err(KernelError::Invalid("a reference curvature must be finite and positive".into()));
}
let (Some((u0, u1)), Some((v0, v1))) = surface.try_range_tuple() else {
return Err(fail("the corner patch has no parameter domain"));
};
// Samples across the section for a five-point difference, as `bridge`'s tests use.
const INWARDS: usize = 5;
let hv = (v1 - v0).abs() * 1e-3;
let hu = (u1 - u0).abs() * 1e-3;
if !(hv > 0.0 && hu > 0.0) {
return Err(fail("the corner patch has no extent to measure"));
}
let (mut gap, mut angle, mut curvature) = (0.0f64, 0.0f64, 0.0f64);
for side in sides {
if side.len() < 5 {
return Err(KernelError::Invalid("a seam needs at least five stations to measure its direction".into()));
}
let mut hint: Option<(f64, f64)> = None;
for (i, f) in side.iter().enumerate() {
let Some(t) = seam_tangent(side, i) else { continue };
let found = surface
.search_nearest_parameter(p3(f.p), hint, 100)
.or_else(|| surface.search_nearest_parameter(p3(f.p), None, 200))
.ok_or_else(|| fail("a seam station is not on the corner patch"))?;
hint = Some(found);
let (u, v) = found;
let at = from_p3(surface.subs(u, v));
gap = gap.max(at.dist(f.p));
// The cross-section, sampled inwards from the seam (or from the other end of the patch,
// with the odd differences turned round).
let out = v <= (v0 + v1) * 0.5;
let step = if out { hv } else { -hv };
let q: Vec<Vec3> = (0..INWARDS).map(|k| from_p3(surface.subs(u, v + step * k as f64))).collect();
let (Some(&f0), Some(&f1), Some(&f2), Some(&f3), Some(&f4)) = (q.first(), q.get(1), q.get(2), q.get(3), q.get(4)) else {
return Err(fail("the corner patch has no room for a sample"));
};
let sign = if out { 1.0 } else { -1.0 };
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (sign / (12.0 * hv));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * hv * hv));
// Along the seam, to stand the tangent plane up.
let du = if u - hu >= u0 && u + hu <= u1 {
(from_p3(surface.subs(u + hu, v)) - from_p3(surface.subs(u - hu, v))) * (0.5 / hu)
} else {
let (a, b) = if u + hu <= u1 { (u, u + hu) } else { (u, u - hu) };
(from_p3(surface.subs(b, v)) - from_p3(surface.subs(a, v))) * (1.0 / hu)
};
let n = du.cross(d1).normalized().ok_or_else(|| fail("the corner patch has no normal at a seam"))?;
let blend_n = t.cross(f.leave).normalized().ok_or_else(|| fail("a blend has no normal at a seam"))?;
angle = angle.max(n.dot(blend_n).abs().clamp(0.0, 1.0).acos());
// The curvature vector of the cross-seam curve: what the blend's own surface does along
// the direction the patch leaves it in.
let speed2 = d1.len2().max(1e-300);
let bend = (d2 - d1 * (d2.dot(d1) / speed2)) * (1.0 / speed2);
curvature = curvature.max((bend - f.bend).len() / reference.max(f.bend.len()).max(1e-30));
}
}
Ok((gap, angle, curvature))
}
#[cfg(test)]
#[path = "corner_g2_tests.rs"]
mod tests;
/// The G2 corner through the fillet path: a box's three edges at one corner rounded, rolling ball
/// against G2 patch, measured on the built bodies with the continuity analysis.
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod fillet_corner_tests {
use super::*;
use crate::{ContinuityTolerances, SeamReport, measure, seam_diagnostics};
/// A 20 mm box with its three edges at one corner rounded by 4: the rolling ball fills that
/// corner with a piece of its sphere, the G2 route with a patch on the blends' own sections.
fn cornered(g2: bool) -> crate::Body {
let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
if g2 { crate::fillet_g2_corners(&b, &edges, 4.0).unwrap() } else { crate::fillet(&b, &edges, 4.0).unwrap() }
}
#[test]
fn the_ball_corner_is_measurably_g1_and_a_g2_corner_is_refused_until_it_sews() {
// The rolling ball: tangent to every blend, keeping its own 1/r across each corner seam.
let body = cornered(false);
let volume = measure(&body).unwrap().volume;
assert!(volume > 7000.0 && volume < 8000.0, "the box is still a solid: {volume}");
let report = seam_diagnostics(&body, &[], 33, ContinuityTolerances { gap_mm: 1e-3, angle_deg: 1.0, curvature_per_mm: 1e-6 }).unwrap();
// Every seam of a filleted box is tangent; the corner's three are the short ones (the
// blend-to-face seams run the edge's own 12 mm and more).
let tangent: Vec<&SeamReport> = report.seams.iter().filter(|s| s.normal_angle_deg.value < 0.01).collect();
let corner: Vec<&&SeamReport> = tangent.iter().filter(|s| s.length < 8.0).collect();
assert_eq!(
corner.len(),
3,
"three corner seams among {} tangent ones: {:?}",
tangent.len(),
tangent.iter().map(|s| s.length).collect::<Vec<_>>()
);
let worst = corner.iter().map(|s| s.curvature_jump_per_mm.value).fold(0.0f64, f64::max);
eprintln!("rolling ball: corner seams jump {worst:.6} per mm (volume {volume:.3})");
assert!((worst - 0.25).abs() < 1e-3, "the ball keeps its own 1/r (r = 4) across the seam: {worst}");
// The G2 corner patch is built on the blends' own sections, but sewing it into the shell
// is not done yet: the fillet with `corner: g2` refuses with a clear message rather than
// leaving the shell open, and leaves the ball the corner.
let b = crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let edges = [Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(0.0, 0.0, 10.0)];
let err = crate::fillet_g2_corners(&b, &edges, 4.0).unwrap_err().to_string();
eprintln!("G2 corner: {err}");
assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}");
}
}
+154
View File
@@ -0,0 +1,154 @@
//! What the G2 corner patch has to prove on a corner whose answer is known analytically: the box.
use super::*;
use std::f64::consts::FRAC_PI_2;
use crate::body::v3;
/// One seam of a box corner: a quarter circle about `centre` from direction `u` to direction `w`,
/// with `leave` the way the patch leaves the blend's surface. A fillet is flat along its own axis,
/// so its curvature across the seam is nothing.
fn seam(centre: Vec3, u: Vec3, w: Vec3, leave: Vec3, r: f64, n: usize) -> Vec<Frame> {
(0..n)
.map(|i| {
let theta = FRAC_PI_2 * i as f64 / (n - 1) as f64;
Frame { p: centre + (u * theta.cos() + w * theta.sin()) * r, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO }
})
.collect()
}
/// A box corner at the origin (material x, y, z ≥ 0) with three fillets of radius `r`: the three
/// section curves where the fillets stop, meeting at (0,r,r), (r,0,r) and (r,r,0), and the middle of
/// the ball's octant a rolling ball would leave there.
fn box_corner(r: f64, n: usize) -> (Vec<Vec<Frame>>, Vec3, Vec3) {
let c = Vec3::new(r, r, r);
let (a, b, d) = (Vec3::new(0.0, r, r), Vec3::new(r, 0.0, r), Vec3::new(r, r, 0.0));
let dir = |p: Vec3| (p - c).normalized().unwrap_or(Vec3::X);
let sides = vec![
// A → B on the z edge's cylinder (flat along z), B → C on the x edge's, C → A on the y
// edge's; each patch leaves the blend towards the material the corner caps.
seam(c, dir(a), dir(b), Vec3::new(0.0, 0.0, -1.0), r, n),
seam(c, dir(b), dir(d), Vec3::new(-1.0, 0.0, 0.0), r, n),
seam(c, dir(d), dir(a), Vec3::new(0.0, -1.0, 0.0), r, n),
];
let apex = c - Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z) * r;
(sides, apex, c)
}
/// The rolling-ball corner the kernel builds today: the sphere through the same three seams, made
/// the way `curvecorner` makes it (a meridian arc swept about the axis through the ball's centre).
fn rolling_ball(r: f64, c: Vec3) -> mt::Surface {
let axis = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap_or(Vec3::Z);
let side = axis.any_perp();
let meridian = builder::circle_arc(&builder::vertex(p3(c + axis * r)), &builder::vertex(p3(c - axis * r)), p3(c + side * r));
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(c), v3(axis))))
}
/// The stations clear of the corners. Two blends meet at a corner with their seams 90° apart and
/// ask for different curvature at that one point, which no single patch can hold, and the corner's
/// own spline rounds the seam there; both effects fade within a few stations, so the measurement
/// starts clear of them.
fn interior(sides: &[Vec<Frame>]) -> Vec<Vec<Frame>> {
sides.iter().map(|s| s.iter().skip(4).take(s.len().saturating_sub(8)).copied().collect()).collect()
}
#[test]
fn a_box_corner_is_curvature_continuous_with_every_blend() {
let r = 2.0;
let (sides, apex, centre) = box_corner(r, 25);
let faces = corner_g2_patch(&sides, apex).unwrap();
let face = faces.first().expect("a corner patch");
let surface = face.oriented_surface();
let inner = interior(&sides);
let (gap, angle, curvature) = seam_error(&surface, &inner, 1.0 / r).unwrap();
println!("G2 corner: gap {gap:e}, normal angle {angle:e} rad, curvature {curvature:e} (relative to 1/r)");
assert!(gap <= r * 1e-5, "the patch is {gap} from the seam");
// The stated tolerances: the normal angle within 0.01° and the curvature across the seam within
// 1% of the blend's own (here the fillet is flat across the seam, so that is 1% of 1/r).
assert!(angle < 0.01f64.to_radians(), "normal angle {angle} rad");
assert!(curvature < 1e-2, "curvature across the seam is {curvature} of the blend's");
// The same measurement on the rolling-ball corner the kernel builds today: G1 (angle nought)
// but the ball's own 1/r across each seam, which is the whole of the blend's curvature.
let ball = rolling_ball(r, centre);
let (ball_gap, ball_angle, ball_curvature) = seam_error(&ball, &inner, 1.0 / r).unwrap();
println!("rolling ball: gap {ball_gap:e}, normal angle {ball_angle:e} rad, curvature {ball_curvature:e}");
assert!(ball_angle < 0.01f64.to_radians(), "the ball is tangent to each blend");
assert!(ball_curvature > 0.5, "the ball keeps its own curvature across the seam: {ball_curvature}");
assert!(curvature * 20.0 < ball_curvature, "the G2 patch has to be far closer than the ball");
}
#[test]
fn a_corner_meets_three_blends_of_different_shape_apart() {
// Blends that differ along their seams: a fillet of radius 2 next to rounder walls, each asking
// for its own curvature across the seam, at its own direction.
let r = 2.0;
let (mut sides, apex, _) = box_corner(r, 33);
let bends = [0.25, 0.4, 0.6];
for (i, bend) in bends.iter().enumerate() {
let Some(side) = sides.get_mut(i) else { continue };
for f in side.iter_mut() {
// Across the seam, perpendicular to both the leave direction and the seam.
f.bend = f.leave.any_perp() * *bend;
}
}
let faces = corner_g2_patch(&sides, apex).unwrap();
let surface = faces.first().expect("a corner patch").oriented_surface();
let inner = interior(&sides);
let (gap, angle, curvature) = seam_error(&surface, &inner, bends.iter().copied().fold(0.0, f64::max)).unwrap();
println!("three different blends: gap {gap:e}, angle {angle:e} rad, curvature {curvature:e}");
assert!(gap <= r * 1e-6, "{gap}");
assert!(angle < 0.01f64.to_radians(), "{angle}");
assert!(curvature < 1e-2, "each blend's own curvature across the seam: {curvature}");
}
#[test]
fn a_g2_corner_refuses_what_it_cannot_hold() {
let r = 2.0;
let (sides, apex, _) = box_corner(r, 9);
assert!(corner_g2_patch(&sides, apex).is_ok());
// Two blends (the ball's patch still fills that corner), or one.
assert!(corner_g2_patch(&sides[..2], apex).is_err());
assert!(corner_g2_patch(&sides[..1], apex).is_err());
assert!(corner_g2_patch(&[], apex).is_err());
// Too few stations to interpolate a cubic through, and too many to work with.
let thin: Vec<Vec<Frame>> = sides.iter().map(|s| s[..3].to_vec()).collect();
assert!(corner_g2_patch(&thin, apex).is_err());
let fat: Vec<Vec<Frame>> = sides.iter().map(|s| s[..1].iter().cycle().take(MAX_STATIONS + 1).copied().collect()).collect();
assert!(corner_g2_patch(&fat, apex).is_err());
// A vertex the construction cannot hold: the section curves do not meet at their corners.
let mut apart = sides.clone();
if let Some(first) = apart.first_mut().and_then(|s| s.first_mut()) {
first.p += Vec3::new(0.5, 0.0, 0.0);
}
assert!(corner_g2_patch(&apart, apex).is_err());
// A section curve that runs along the seam, so the patch has no direction to leave it in.
let along: Vec<Vec<Frame>> = sides
.iter()
.map(|s| {
s.windows(2)
.map(|w| {
let d = w.first().zip(w.get(1)).map(|(a, b)| (b.p - a.p).normalized().unwrap_or(Vec3::X)).unwrap_or(Vec3::X);
Frame { leave: d, ..w.first().copied().unwrap_or(s[0]) }
})
.chain(s.last().copied())
.collect()
})
.collect();
assert!(corner_g2_patch(&along, apex).is_err());
// Hostile numbers never panic.
let mut nan = sides.clone();
if let Some(f) = nan.first_mut().and_then(|s| s.get_mut(1)) {
f.p = Vec3::new(f64::NAN, 0.0, 0.0);
}
assert!(corner_g2_patch(&nan, apex).is_err());
assert!(corner_g2_patch(&sides, Vec3::new(f64::INFINITY, 0.0, 0.0)).is_err());
// A corner with no room: every station on the apex.
let on_apex: Vec<Vec<Frame>> = sides.iter().map(|s| s.iter().map(|f| Frame { p: apex, ..*f }).collect()).collect();
assert!(corner_g2_patch(&on_apex, apex).is_err());
}
+435 -56
View File
@@ -1,10 +1,17 @@
//! Rolling-ball blends along a closed chain of smooth edges between two smooth groups of faces
//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball of the
//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its
//! contacts the curves where the blend meets each side. The blend face is a rational B-spline
//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed
//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are
//! shortened to the contacts.
//! of any surfaces (a branch pipe meeting a main pipe, a boss on a cylinder). A ball rolls along
//! the edge touching both sides; its centre traces the spine and its contacts the curves where
//! the blend meets each side. The blend face is a rational B-spline through the circular
//! cross-sections (exact arcs at the samples), the side faces are trimmed back to the contact
//! curves, and seams that met the edge (lines, or arcs of an end face) are shortened to the
//! contacts.
//!
//! The ball's size along the chain is one of [`Size`]: one radius all along (the ordinary
//! fillet), a chord width across the blend (each section spans the requested width from contact
//! to contact, so its radius is `chord / (2 sin(ψ/2))` for the `ψ` between the two outward
//! normals where the ball touches), or a radius law along the chain (a [`RadiusSpline`] whose
//! positions are the fraction of the chain's arc length, zero only at an end, where the blend
//! closes to the edge's end point as the straight-edge fillet does).
use std::collections::HashMap;
@@ -12,6 +19,7 @@ use solidcraft_geom::Vec3;
use truck_modeling::{self as mt, builder};
use crate::blend::too_large;
use crate::blend_variable::RadiusSpline;
use crate::body::{Solid, from_p3, p3};
use crate::record::Rebuild;
use crate::{KernelError, Result};
@@ -40,21 +48,60 @@ const TANGENT_RETRIES: usize = 8;
const MAX_STEP_STRETCH: f64 = 3.0;
/// Slides of the ball's centre along a knife edge's faces, each 1.6 times the last.
const KNIFE_STEPS: usize = 40;
/// Corrections of the radius a chord fillet makes to the width it measures across the blend.
const CHORD_STEPS: usize = 12;
/// A section this close to a point is closed: the blend ends at the edge's end point (a radius
/// that has run to zero there).
const CLOSED: f64 = 1e-7;
/// Parameters an edge is sampled at to measure the arc length a radius law is read off.
const LENGTH_STEPS: usize = 64;
/// The ball's size along the edge chain.
#[derive(Clone, Copy)]
enum Size<'a> {
/// One radius all along.
Constant(f64),
/// The width across the blend, from contact to contact, at every station.
Chord(f64),
/// The radius at the station's arc-length fraction along the chain, 0 at the front and 1 at
/// the back of its edge.
Law(&'a RadiusSpline),
}
struct Fit {
surface: mt::Surface,
contacts: [mt::Curve; 2],
/// Parameter range belonging to this edge (the fit includes neighbouring sections).
range: (f64, f64),
/// The part of the fit that is the chain's own run, as fractions of its parameter range.
span: (f64, f64),
}
struct Guide {
sections: Vec<Section>,
/// The size law's parameter (the arc-length fraction along the chain) at each section: the
/// radius between the samples is read off the law there, not off the spine's own length.
law: Vec<f64>,
lengths: Vec<f64>,
/// The part of the guide that is the chain itself, as fractions of its length: the sections
/// run on past the ends of the chain (only so that the fit can be cut back there) and the fit
/// is checked for accuracy on the chain alone.
span: (f64, f64),
}
impl Guide {
/// A guide whose law parameter is the section's own fraction of it (the size is one radius,
/// which needs no law).
fn new(sections: Vec<Section>) -> Result<Self> {
let den = sections.len().max(2) - 1;
let law = (0..sections.len()).map(|i| i as f64 / den as f64).collect();
Self::with_law(sections, law)
}
fn with_law(sections: Vec<Section>, law: Vec<f64>) -> Result<Self> {
if sections.len() != law.len() {
return Err(unsupported("degenerate rolling-ball spine"));
}
let mut lengths = vec![0.0];
let mut total = 0.0;
for pair in sections.windows(2) {
@@ -65,10 +112,16 @@ impl Guide {
if !total.is_finite() || total <= 1e-12 {
return Err(unsupported("degenerate rolling-ball spine"));
}
Ok(Self { sections, lengths })
Ok(Self { sections, law, lengths, span: (0.0, 1.0) })
}
/// The centre and tangent at `along` of the spine.
fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> {
self.anchor_at(along).map(|(centre, tangent, _)| (centre, tangent))
}
/// [`Guide::anchor`] with the size law's parameter at that station.
fn anchor_at(&self, along: f64) -> Option<(Vec3, Vec3, f64)> {
if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
return None;
}
@@ -81,7 +134,8 @@ impl Guide {
return None;
}
let fraction = (distance - lo) / span;
Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?))
let law = self.law.get(hi - 1)? * (1.0 - fraction) + self.law.get(hi)? * fraction;
Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?, law))
}
}
@@ -145,7 +199,7 @@ fn fit_strip(list: &[Section], before: &[Section], after: &[Section], round: boo
};
let contacts = [contact(0)?, contact(2)?];
let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), vk), rows).map_err(|e| fail(&e.to_string()))?;
Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range })
Ok(Fit { surface: mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)), contacts, range, span: (0.0, 1.0) })
}
/// Compare the fitted strip with a solved circular section between interpolation nodes.
@@ -263,7 +317,7 @@ fn end_cap(fit: &Fit, g: &mt::Face, at: &Section, seams: [Option<&mt::Edge>; 2],
// Both contacts are cut there: strictly inside the strip's own part.
let inside = |t: f64| t > fit.range.0 + 1e-6 && t < fit.range.1 - 1e-6;
if !(inside(*t1) && inside(*t2)) {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 309)"));
}
return Ok((*t1, *t2, mt::Curve::BSplineCurve(bs)));
}
@@ -341,10 +395,10 @@ fn tangent_cap(fit: &Fit, g: &mt::Face, tangent_side: u8, seams: [Option<&mt::Ed
};
let (rt, ra) = (crossing(u_t, seam_t), crossing(u_o, seam_o));
let v_t = rt.ok_or_else(fail)?;
let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces"))?;
let v_a = ra.ok_or_else(|| too_large("the blend is larger than the neighbouring faces (site 387)"))?;
let inside = |t: f64| t > lo + 1e-6 && t < hi - 1e-6;
if !(inside(v_a) && inside(v_t)) || (v_a - v_t).abs() < 1e-9 {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 390)"));
}
// How far the strip's point (u, v) is outside the end face (negative: inside the body).
let outside = |u: f64, v: f64| -> Option<f64> {
@@ -599,6 +653,93 @@ fn solve_from(
None
}
/// The section whose two contacts are `chord` apart: the ball's radius is `chord / (2 sin(ψ/2))`
/// for the `ψ` between the two outward normals where it touches, `|q1 − q2| = 2 r sin(ψ/2)`. The
/// normals turn as the ball grows against curved faces, so the width is measured on the solved
/// section and the radius corrected until it matches (the width is proportional to the radius,
/// so the corrections converge quickly). The normals at `p` give the first radius, exact where
/// both faces are planar; `None` where the faces meet too nearly tangentially to span a chord.
#[allow(clippy::too_many_arguments)]
fn solve_chord(
f1: &[&mt::Face],
f2: &[&mt::Face],
p: Vec3,
t: Vec3,
chord: f64,
sigma: f64,
cusp: f64,
extra: Option<Extra<'_>>,
tol: f64,
) -> Option<(Section, f64)> {
let normal = |fs: &[&mt::Face]| fs.iter().filter_map(|f| project(f, p, None)).min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|x| x.1);
let (n1, n2) = (normal(f1)?, normal(f2)?);
let half = (n1.dot(n2).clamp(-1.0, 1.0).acos() / 2.0).sin();
if !(half > 1e-3) {
return None;
}
let mut r = chord / (2.0 * half);
for _ in 0..CHORD_STEPS {
if !(r.is_finite() && r > 0.0 && r < 1e9) {
return None;
}
let s = solve(f1, f2, p, t, r, sigma, cusp, extra, tol)?;
let width = s.q1.dist(s.q2);
if !width.is_finite() || width <= 0.0 {
return None;
}
if (width - chord).abs() <= (chord * 1e-9).max(tol * 0.5) {
return Some((s, r));
}
r *= chord / width;
}
None
}
/// The rolling ball at one station, sized by the blend's law.
struct Sizer<'a> {
size: Size<'a>,
/// The tolerance the section's equations are solved at.
tol: f64,
}
impl Sizer<'_> {
/// The section at `p` (tangent `t`, the size law's parameter `s`) holding the third
/// condition `extra` when given, with the radius it was solved at.
#[allow(clippy::too_many_arguments)]
fn section(
&self,
f1: &[&mt::Face],
f2: &[&mt::Face],
p: Vec3,
t: Vec3,
s: f64,
sigma: f64,
cusp: f64,
extra: Option<Extra<'_>>,
) -> Option<(Section, f64)> {
match self.size {
Size::Constant(r) => solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r)),
Size::Chord(chord) => solve_chord(f1, f2, p, t, chord, sigma, cusp, extra, self.tol),
Size::Law(law) => {
let r = law_beyond(law, s)?;
if !(r.is_finite() && (0.0..1e6).contains(&r)) {
return None;
}
solve(f1, f2, p, t, r, sigma, cusp, extra, self.tol).map(|x| (x, r))
}
}
}
/// What a station the ball does not fit at is refused with: a chord too large is about the
/// size asked for, anything else about where the ball is.
fn refusal(&self) -> KernelError {
match self.size {
Size::Chord(_) => too_large("the blend is larger than the neighbouring faces (site 704)"),
_ => unsupported("the ball doesn't fit along the edge"),
}
}
}
/// Whether the strip's curvature jumps where two runs of sections meet (each three sections from
/// the joint outward, the joint's first): for the centres or either contact, the difference of
/// the circles through them would move the middle section by more than a tenth of `tolerance`.
@@ -614,6 +755,12 @@ fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool {
.any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1)
}
/// Whether the section is a point: a blend whose radius has run to zero, closing to the edge's
/// end point there.
fn closed_section(s: &Section) -> bool {
s.c.dist(s.q1) <= CLOSED && s.c.dist(s.q2) <= CLOSED
}
/// Homogeneous control points of the circular (or straight) cross-section from q1 to q2.
fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w);
@@ -621,6 +768,11 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
let m = (s.q1 + s.q2) * 0.5;
return Some([h(s.q1, 1.0), h(m, 1.0), h(s.q2, 1.0)]);
}
if closed_section(s) {
// A closed section: the arc has shrunk to the edge's end point, all three controls
// there, so the fitted strips of the neighbouring faces meet at that vertex.
return Some([h(s.c, 1.0), h(s.c, 1.0), h(s.c, 1.0)]);
}
let (a, b) = (s.q1 - s.c, s.q2 - s.c);
let theta = a.normalized()?.dot(b.normalized()?).clamp(-1.0, 1.0).acos();
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
@@ -833,14 +985,14 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option
.unwrap_or(end);
let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?;
if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 938)"));
}
Ok(t)
};
let (ta, tb) = (at(&a, lo)?, at(&b, hi)?);
let margin = (hi - lo) * 1e-9;
if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 945)"));
}
if tb < hi {
let _ = curve.cut(tb);
@@ -851,16 +1003,100 @@ pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option
mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}")))
}
/// A monotone curve's parameter read off a table of its values at uniform parameters (`None` for
/// an empty table): the fraction of the way through it.
fn sampled(table: &[f64], along: f64) -> Option<f64> {
if table.len() < 2 || !along.is_finite() {
return None;
}
let x = along.clamp(0.0, 1.0) * (table.len() - 1) as f64;
let i = (x.floor() as usize).min(table.len() - 2);
let f = x - i as f64;
Some(table.get(i)? * (1.0 - f) + table.get(i + 1)? * f)
}
/// The arc length of `edge` from its front to its back at each of `LENGTH_STEPS + 1` uniform
/// parameters, accumulated. `None` for an edge that has no length (or runs back over itself).
fn edge_lengths(edge: &mt::Edge) -> Option<Vec<f64>> {
use mt::{BoundedCurve, ParametricCurve};
let c = edge.oriented_curve();
let (a, b) = c.range_tuple();
let mut last = from_p3(c.subs(a));
let mut total = 0.0;
let mut out = Vec::with_capacity(LENGTH_STEPS + 1);
out.push(0.0);
for i in 1..=LENGTH_STEPS {
let p = from_p3(c.subs(a + (b - a) * i as f64 / LENGTH_STEPS as f64));
total += last.dist(p);
last = p;
out.push(total);
}
(total.is_finite() && total > 1e-12).then_some(out)
}
/// The radius a law gives at the chain fraction `s`, continued past 0 and 1 at the law's own end
/// slope. The ball runs on past a chain end (to be cut back there by its end face), and holding
/// the end radius would kink the strip exactly where the edge begins: a kink a cubic fit spreads
/// back over the whole blend. Never below a fifth of the end radius, so that a law running down
/// steeply still has a ball to run on with.
fn law_beyond(law: &RadiusSpline, s: f64) -> Option<f64> {
if (0.0..=1.0).contains(&s) {
return Some(law.eval(s));
}
let (end, step) = if s < 0.0 { (0.0, s) } else { (1.0, s - 1.0) };
let h = 1e-3;
let slope = (law.eval(end + h) - law.eval(end)) / h;
let r = law.eval(end);
let r = (r + slope * step).max(0.2 * r);
(r.is_finite() && r > 0.0).then_some(r)
}
/// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from
/// `solid` (each blend face in place of its chain edge).
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> {
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
}
/// [`curve_blend`] with the blend `chord` wide across, from contact to contact, at every station.
pub(crate) fn curve_blend_chord(solid: &Solid, ids: &[mt::EdgeID], size: f64, chord: f64, round: bool) -> Result<(Solid, Rebuild)> {
curve_blend_sized(solid, ids, size, Size::Chord(chord), round, MAX_SAMPLES)
}
/// [`curve_blend`] with the radius following `law` along the chain (its positions the fraction of
/// the chain's arc length, from the front of its first edge to the back of its last).
pub(crate) fn curve_blend_law(solid: &Solid, ids: &[mt::EdgeID], size: f64, law: &RadiusSpline, round: bool) -> Result<(Solid, Rebuild)> {
curve_blend_sized(solid, ids, size, Size::Law(law), round, MAX_SAMPLES)
}
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
curve_blend_sized(solid, ids, size, Size::Constant(r), round, max_samples)
}
/// Blend the chain of edges (ids in any order) sized by `size`: one radius, a chord width, or a
/// radius law. With what it built from `solid` (each blend face in place of its chain edge).
fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Size<'_>, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> {
if !(size.is_finite() && size > 0.0) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
match size_law {
Size::Constant(r) | Size::Chord(r) => {
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
}
Size::Law(law) => {
let knots = law.knots.len() == law.radii.len() + law.degree + 1;
if law.radii.len() < 2 || !knots || law.radii.iter().any(|r| !(r.is_finite() && (0.0..1e6).contains(r))) {
return Err(KernelError::Invalid("a radius law along the edge must be finite and non-negative".into()));
}
// A law that runs to zero closes the blend at the edge's end point. The fitted strip
// pinches to a point there, and the sections' own interpolation cannot follow the last
// span of it: the straight-edge fillet, which builds that closure exactly, is the way.
if law.eval(0.0) <= CLOSED || law.eval(1.0) <= CLOSED {
return Err(unsupported("a radius law that runs to zero along a curved edge"));
}
}
}
if !(4..=MAX_SAMPLES).contains(&max_samples) {
return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into()));
}
@@ -991,10 +1227,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// below the first (against its outward normal) when the cusp is material, above when it is
// a gap. The cross product of the normals, which tells the other edges apart, is zero here.
let knife = 1.0 + n1.dot(n2) < KNIFE;
// The one radius, where the size is one radius (a chord or a law has none).
let fixed = match size_law {
Size::Constant(r) => Some(r),
_ => None,
};
// The blend's scale where the size itself is not what matters (the knife edge's probe): the
// radius asked for, or the law's middle value.
let scale = match size_law {
Size::Constant(r) | Size::Chord(r) => r,
Size::Law(law) => law.eval(0.5).max(1e-6),
};
let (convex, cusp) = if knife {
let w = n1.cross(t1).normalized().ok_or_else(|| KernelError::Failed("blend: cusp direction".into()))?;
// Second order: from the point of face 1 a little way along `w`, face 2's nearest point.
let probe = project(f1, pm + w * r, None)
let probe = project(f1, pm + w * scale, None)
.and_then(|(qa, na, _)| s2.first().and_then(|f| project(f, qa, None)).map(|(qb, _, _)| (qb - qa).dot(na)))
.filter(|depth| depth.abs() > size * 1e-9)
.ok_or_else(|| unsupported("a knife edge whose faces lie flat on each other"))?;
@@ -1008,10 +1255,41 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if n1.dot(n2) > 1.0 - 1e-6 {
return Err(unsupported("blending a smooth edge"));
}
// The chain's arc length, in fractions of its whole, at each station: where the size is one
// radius it is not read, but the sections take their parameter from it all the same.
let tables: Vec<Vec<f64>> = chain.iter().map(|e| edge_lengths(e).unwrap_or_else(|| vec![0.0; LENGTH_STEPS + 1])).collect();
let total: f64 = tables.iter().filter_map(|t| t.last().copied()).sum();
if !matches!(size_law, Size::Constant(_)) && !(total.is_finite() && total > 1e-12) {
return Err(unsupported("a zero-length edge of the chain"));
}
let total = if total > 1e-12 { total } else { 1.0 };
let mut cum: Vec<f64> = Vec::with_capacity(n + 1);
let mut acc = 0.0;
for table in &tables {
cum.push(acc);
acc += table.last().copied().unwrap_or(0.0);
}
cum.push(acc);
// A law that runs around a closed chain meets itself: its two ends must agree there.
if closed
&& let Size::Law(law) = size_law
&& (law.eval(0.0) - law.eval(1.0)).abs() > 1e-9 * law.radii.iter().copied().fold(1.0_f64, f64::max)
{
return Err(KernelError::Invalid("a closed edge chain runs back into itself: give the same radius law at both ends".into()));
}
// The law's parameter at `along` of edge `k` (its front to its back).
let frac_at = |k: usize, along: f64| -> f64 {
let (Some(table), Some(lo)) = (tables.get(k), cum.get(k)) else { return if k >= n { 1.0 } else { 0.0 } };
let s = sampled(table, along).unwrap_or(0.0);
((lo + s) / total).clamp(0.0, 1.0)
};
let sizer = Sizer { size: size_law, tol: tol * 100.0 };
// Arcs on a planar side that curve round the ball: its centre runs round them at their
// radius less the blend's. Less than nothing is too large; nothing is a pivot.
let mut pivots: Vec<Option<Pivot>> = vec![None; n];
for (k, e) in chain.iter().enumerate() {
'pivots: for (k, e) in chain.iter().enumerate() {
// A size that varies along the edge has no one radius to compare a corner's arc with.
let Some(r) = fixed else { break 'pivots };
let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue };
for side in [1, 2] {
let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue };
@@ -1032,7 +1310,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
continue;
}
if rho < r - tol * 1e3 {
return Err(too_large("the blend is larger than a corner radius"));
return Err(too_large("the blend is larger than a corner radius (site 1257)"));
}
if (rho - r).abs() > tol * 1e3 {
continue;
@@ -1145,6 +1423,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Some((from_p3(c.subs(t)), Vec3::new(d.x, d.y, d.z).normalized()?))
};
let mut vsec: Vec<Section> = Vec::with_capacity(nv);
// The radius each vertex section was solved at (the closed end of a law: zero).
let mut vradius: Vec<f64> = Vec::with_capacity(nv);
for k in 0..nv {
let (p, t) = if k < n {
tangent_at(chain.get(k).ok_or_else(|| KernelError::Failed("edge".into()))?, 0.0)
@@ -1167,8 +1447,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
let q = pv.c + (p - pv.o);
vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } });
vradius.push(fixed.unwrap_or_default());
continue;
}
// The law's parameter at this vertex: the front of its edge, or the back of the last
// edge at an open chain's end.
let law_s = if k < n { frac_at(k, 0.0) } else { frac_at(n - 1, 1.0) };
if at_end {
// A plane square to the edge holds the blend's end section. Any other end face: the
// blend runs on past the end and is cut back where it meets that face.
@@ -1181,7 +1465,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
_ => false,
};
if !square || split_ends.iter().any(|e| e.0 == k) {
// A radius that has run to zero closes the blend at the vertex itself: it does not run
// on past the end, and the end face keeps the corner it had there.
let closed_end = matches!(size_law, Size::Law(law) if law.eval(law_s) <= CLOSED);
if (!square || split_ends.iter().any(|e| e.0 == k)) && !closed_end {
slanted.push((k, fi, p, t, None));
}
}
@@ -1212,7 +1499,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// longer is (a wall past the round at a corner, which a ball rolling round it pokes
// through).
let (a1, a2) = edge_sides(&[k.min(n - 1)]);
let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
let (s, r) = sizer.section(&a1, &a2, p, t, law_s, sigma, cusp, extra).ok_or_else(|| sizer.refusal())?;
// A seam on the other side too must pass through that side's contact.
if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
let q = if *side == 1 { s.q1 } else { s.q2 };
@@ -1222,10 +1509,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
}
vsec.push(s);
vradius.push(r);
}
// Past a slanted end: sections of the same rolling ball beyond the end vertex (far to near
// at the chain's start, near to far at its end), far enough to cross the end face.
let mut beyond: Vec<(usize, Vec<Section>)> = Vec::new();
let mut beyond: Vec<(usize, Vec<Section>, Vec<f64>)> = Vec::new();
for (k, fi, p, t, tangent) in slanted.iter_mut() {
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
@@ -1250,12 +1538,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if cos < 0.15 && tangent.is_none() {
return Err(unsupported("an end face almost along the edge"));
}
let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + r;
let width = at.q1.dist(*p).max(at.q2.dist(*p)).max(at.c.dist(*p)) + vradius.get(*k).copied().unwrap_or(scale);
let reach = 1.5 * width / if tangent.is_some() { 1.0 } else { cos };
let out = if *k == 0 { -*t } else { *t };
// The run on past the end only gives the fit its context (the end face cuts it back), but
// it keeps the law's end slope: the radius is read at the distance past the end.
let law_s = |d: f64| if *k == 0 { -d / total } else { 1.0 + d / total };
// Past a tangent end the straight run along the tangent leaves the curved spine: where
// the ball no longer fits, the run is shortened (it only gives the fit its context).
let mut list = Vec::with_capacity(EXTENSION);
let mut laws = Vec::with_capacity(EXTENSION);
let mut reach = reach;
// Past the end of a curved edge the planes square to its end tangent cut the ring of
// centres further and further round it, until a step jumps across the ring (#1255: a
@@ -1263,26 +1555,33 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// then, as long as one of its sections still lies wholly past the end face.
let ng_out = if ng.dot(out) < 0.0 { -ng } else { ng };
let past = |sec: &Section| [sec.c, sec.q1, sec.q2].iter().all(|x| (*x - *p).dot(ng_out) > tol);
let mut fallback: Option<Vec<Section>> = None;
// A run shortened when its centres jump across the ring (a knuckle's cap circle, #1255)
// stands if no full-length run works; a size law's own end slope is read at each station's
// distance past the end.
let mut fallback: Option<Vec<(f64, Section)>> = None;
for attempt in 0..TANGENT_RETRIES {
list.clear();
laws.clear();
let step = reach / EXTENSION as f64;
let sections = (1..=EXTENSION).map(|j| {
let anchor = *p + out * (step * j as f64);
solve(&e1, &e2, anchor, *t, r, sigma, cusp, None, tol * 100.0)
let d = step * j as f64;
let anchor = *p + out * d;
let s = law_s(d);
sizer.section(&e1, &e2, anchor, *t, s, sigma, cusp, None).map(|(section, _)| (s, section))
});
match sections.collect::<Option<Vec<_>>>() {
Some(all) => {
let smooth = tangent.is_some()
|| std::iter::once(at.c).chain(all.iter().map(|s| s.c)).collect::<Vec<_>>().windows(2).all(|w| match w {
|| std::iter::once(at.c).chain(all.iter().map(|(_, s)| s.c)).collect::<Vec<_>>().windows(2).all(|w| match w {
[a, b] => a.dist(*b) <= MAX_STEP_STRETCH * step,
_ => true,
});
if smooth {
// A shortened run must still cross the end face; else the first,
// full-length run stands.
if fallback.is_none() || all.iter().any(past) {
list = all;
if fallback.is_none() || all.iter().any(|(_, s)| past(s)) {
list = all.iter().map(|(_, s)| *s).collect();
laws = all.iter().map(|(s, _)| *s).collect();
}
break;
}
@@ -1294,12 +1593,27 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
reach *= if tangent.is_some() { 0.5 } else { 0.75 };
}
if list.is_empty() {
list = fallback.ok_or_else(|| unsupported("the ball doesn't fit past the end of the edge"))?;
match fallback {
// The shortened run stands when the ring's step jumped, whatever its size.
Some(all) => {
list = all.iter().map(|(_, s)| *s).collect();
laws = all.iter().map(|(s, _)| *s).collect();
}
// A size asked for beyond what the faces around the end hold is refused as such;
// one radius past the end is the rolling ball's own failure to fit.
None => {
return Err(match size_law {
Size::Constant(_) => unsupported("the ball doesn't fit past the end of the edge"),
_ => too_large("the blend is larger than the neighbouring faces (site 1472)"),
});
}
}
}
if *k == 0 {
list.reverse();
laws.reverse();
}
beyond.push((*k, list));
beyond.push((*k, list, laws));
}
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
@@ -1319,12 +1633,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Ok(((start.c - p0).dot(t0), (end.c - p1).dot(t1)))
})
.collect::<Result<_>>()?;
let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<Section> {
let section_at = |k: usize, edge: &mt::Edge, along: f64| -> Result<(Section, f64)> {
let (p, t) = tangent_at(edge, along).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (start, end) = offsets.get(k).copied().ok_or_else(|| KernelError::Failed("section offset".into()))?;
let anchor = p + t * (start * (1.0 - along) + end * along);
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, t, r, sigma, cusp, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
sizer.section(&a1, &a2, anchor, t, frac_at(k, along), sigma, cusp, None).ok_or_else(|| sizer.refusal())
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
@@ -1339,18 +1653,34 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
continue;
}
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
let mut law = vec![frac_at(k, 0.0)];
let (mut front_beyond, mut back_beyond) = (0usize, 0usize);
for j in 1..samples - 1 {
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
let along = j as f64 / (samples - 1) as f64;
let (section, _) = section_at(k, e, along)?;
list.push(section);
law.push(frac_at(k, along));
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
for (kv, ext) in &beyond {
law.push(frac_at(k, 1.0));
for (kv, ext, exts) in &beyond {
if *kv == k && k == 0 {
list.splice(0..0, ext.iter().copied());
law.splice(0..0, exts.iter().copied());
front_beyond = ext.len();
} else if *kv == k + 1 && k + 1 == nv - 1 {
list.extend(ext.iter().copied());
law.extend(exts.iter().copied());
back_beyond = ext.len();
}
}
guides.push(Some(Guide::new(list)?));
let mut guide = Guide::with_law(list, law)?;
// The chain's own run: the spliced sections at either end are context for the fit
// only (the blend is cut back where it meets the end face there).
let last = guide.lengths.last().copied().unwrap_or(0.0);
let at = |i: usize| if last > 0.0 { guide.lengths.get(i).copied().unwrap_or(0.0) / last } else { 0.0 };
guide.span = (at(front_beyond), at(guide.sections.len().saturating_sub(1 + back_beyond)));
guides.push(Some(guide));
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine.
@@ -1364,9 +1694,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if along == 1.0 {
return guide.sections.last().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
let (anchor, tangent) = guide.anchor(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
let (anchor, tangent, law_s) = guide.anchor_at(along).ok_or_else(|| unsupported("degenerate rolling-ball spine"))?;
let (a1, a2) = edge_sides(&[k]);
solve(&a1, &a2, anchor, tangent, r, sigma, cusp, None, tol * 100.0)
sizer
.section(&a1, &a2, anchor, tangent, law_s, sigma, cusp, None)
.map(|(section, _)| section)
.ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
@@ -1432,6 +1764,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
(Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?,
_ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?,
};
let mut fit = fit;
fit.span = guides.get(k).and_then(Option::as_ref).map(|g| g.span).unwrap_or((0.0, 1.0));
fits.push(Some(fit));
}
let mut accurate = true;
@@ -1440,6 +1774,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
for j in 0..samples - 1 {
for fraction in [0.25, 0.5, 0.75] {
let along = (j as f64 + fraction) / (samples - 1) as f64;
// A size that varies along the edge reaches past its ends only for the fit's
// context (the end face cuts that away), so it is not held to the tolerance
// there. One radius keeps the check it always had, the whole strip.
if !matches!(size_law, Size::Constant(_))
&& fits.get(k).and_then(Option::as_ref).is_some_and(|f| along < f.span.0 || along > f.span.1)
{
continue;
}
let section = uniform_section(k, along)?;
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? {
accurate = false;
@@ -1515,7 +1857,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
&& n == 1
&& !(s1 + 1e-6 < *e1 && s2 + 1e-6 < *e2)
{
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 1668)"));
}
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
@@ -1530,6 +1872,18 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
*b = builder::vertex(curve.subs(hi));
}
}
// A closed section (a radius that has run to zero) has one vertex for both contacts, so
// the blend face's wire and the side faces' meet there.
for (k, s) in vsec.iter().enumerate() {
if !closed_section(s) {
continue;
}
if let Some(v) = v1.get(k).cloned()
&& let Some(slot) = v2.get_mut(k)
{
*slot = v;
}
}
// Along a pivot the planar side's contact is one point: one vertex at both its ends.
for (k, pv) in pivots.iter().enumerate() {
let Some(pv) = pv else { continue };
@@ -1557,18 +1911,28 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
}
}
let mut arcs = Vec::with_capacity(nv);
// The section's own radius, where the blend is sized by a law or a chord width (`r` is the
// one radius of a constant blend, exactly as before).
let rad = |s: &Section| match size_law {
Size::Constant(r) => r,
Size::Chord(_) | Size::Law(_) => s.c.dist(s.q1),
};
let mut arcs: Vec<Option<mt::Edge>> = Vec::with_capacity(nv);
let mut split_arcs: HashMap<usize, Vec<mt::Edge>> = HashMap::new();
for (k, s) in vsec.iter().enumerate() {
let (a, b) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
let e = if round {
let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * r;
builder::circle_arc(a, b, p3(m))
// A closed section has no arc to run between the contacts: the blend meets itself
// there, at the edge's end point.
let e = if closed_section(s) {
None
} else if round {
let m = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("arc"))? * rad(s);
Some(builder::circle_arc(a, b, p3(m)))
} else {
builder::line(a, b)
Some(builder::line(a, b))
};
let e = match caps.iter().find(|c| c.0 == k) {
Some((_, _, _, _, curve)) => mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?,
Some((_, _, _, _, curve)) => Some(mt::Edge::try_new(a, b, curve.clone()).map_err(|e| fail(&e.to_string()))?),
None => e,
};
if let Some((_, _, c)) = split_caps.iter().find(|cap| cap.0 == k) {
@@ -1581,7 +1945,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result<mt::Surface> {
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * rad(s);
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
let want = (s.c - mid) * sigma;
@@ -1688,7 +2052,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.filter(|f| f.edge_iter().any(|e| e.id() == id))
.any(|f| crate::blend::plane_normal(f).is_some() && crate::blend::extension_fits(f, from_p3(old.point()), q, tol));
if !fits || !crate::blend::on_extension(&seam, q, on_tol) {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 1853)"));
}
}
seam_subst.insert(id, crate::blend::retarget(&seam, &old, &nv, on_tol)?);
@@ -1708,7 +2072,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let (ta, oa) = a.as_ref().map(at).unwrap_or((0.0, 0.0));
let (tb, ob) = b.as_ref().map(at).unwrap_or((1.0, 0.0));
if oa > size * 1e-5 || ob > size * 1e-5 || ta < -1e-9 || tb > 1.0 + 1e-9 || tb - ta < 1e-6 {
return Err(too_large("the blend is larger than the neighbouring faces"));
return Err(too_large("the blend is larger than the neighbouring faces (site 1873)"));
}
let a = a.unwrap_or_else(|| seam.front().clone());
let b = b.unwrap_or_else(|| seam.back().clone());
@@ -1723,7 +2087,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if corners.iter().any(|k| split_arcs.contains_key(k)) {
let connectors: Vec<mt::Edge> = corners
.iter()
.flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().into_iter().collect()))
.flat_map(|k| split_arcs.get(k).cloned().unwrap_or_else(|| arcs.get(*k).cloned().flatten().into_iter().collect()))
.collect();
let rebuilt = crate::blend::rebuild_face(f, &seam_subst, &connectors)?;
rec.face(&rebuilt, f);
@@ -1754,8 +2118,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
es.push(ne.clone());
}
if let Some(k) = arrives {
let arc = arcs.get(k).ok_or_else(|| fail("arc"))?;
let (a1, a2) = (v1.get(k).ok_or_else(|| fail("vertex"))?, v2.get(k).ok_or_else(|| fail("vertex"))?);
// A closed section has no blend arc at the corner: the blend ends at
// the vertex itself and the end face keeps the corner it had.
let (Some(arc), Some(a1), Some(a2)) = (arcs.get(k).and_then(Option::as_ref), v1.get(k), v2.get(k)) else {
continue;
};
let to = if used_up { e.front() } else { ne.back() };
let corner = if to == a1 {
arc.clone()
@@ -1810,9 +2177,11 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
// Blend faces: each runs its side-1 contact the other way from side 1's face.
for (k, bf) in blends.iter().enumerate() {
let (Some(e1), Some(e2), Some(ak), Some(ak1)) = (c1.get(k), c2.get(k), arcs.get(k), arcs.get((k + 1) % nv)) else {
let (Some(e1), Some(e2)) = (c1.get(k), c2.get(k)) else {
return Err(fail("blend"));
};
let ak = arcs.get(k).and_then(Option::as_ref);
let ak1 = arcs.get((k + 1) % nv).and_then(Option::as_ref);
let f1i = side1.get(k).ok_or_else(|| fail("side"))?;
let f1 = faces.get(*f1i).ok_or_else(|| fail("side"))?;
let chain_e = chain.get(k).ok_or_else(|| fail("edge"))?;
@@ -1824,20 +2193,23 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.map(|e| e.front() == chain_e.front())
.ok_or_else(|| fail("edge use"))?;
// Side 1 runs the contact forward (as the chain) when it ran the edge forward.
// A pivot's planar contact is a point: three sides.
let edges = if used_fwd {
[e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())]
// A pivot's planar contact is a point: three sides. A closed section has no arc: two.
let edges: Vec<mt::Edge> = if used_fwd {
[e1.as_ref().map(|e| e.inverse()), ak.cloned(), e2.clone(), ak1.map(|e| e.inverse())]
} else {
[e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())]
[e1.clone(), ak1.cloned(), e2.as_ref().map(|e| e.inverse()), ak.map(|e| e.inverse())]
}
.into_iter()
.flatten()
.collect::<Vec<_>>();
.collect();
// An arc the chamfer crossed is two lines through the crossing: the blend meets the
// shortened seam there.
let wire: mt::Wire = edges
.into_iter()
.flat_map(|e| {
let split = [(k, ak), ((k + 1) % nv, ak1)]
.into_iter()
.filter_map(|(i, arc)| arc.map(|arc| (i, arc.clone())))
.find(|(_, arc)| arc.id() == e.id())
.and_then(|(i, arc)| split_arcs.get(&i).map(|parts| (arc, parts)));
match split {
@@ -1861,8 +2233,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
#[path = "curvecorner.rs"]
mod corner;
pub(crate) use corner::corner_blend_style;
// The planar corner builder the G2 corner work left in place of `corner_blend_style`'s sphere.
#[allow(unused_imports)]
pub(crate) use corner::corner_blend;
#[cfg(test)]
#[path = "curveblend_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "curveblend_law_tests.rs"]
mod law_tests;
+315
View File
@@ -0,0 +1,315 @@
//! Chord-width and radius-law blends along curved edges: the width across the blend at every
//! station, and the radius along the edge, measured on the body the kernel built against values
//! worked out from the input geometry alone (the two cylinders' axes, the arc's axis).
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
use solidcraft_geom::Vec3;
use super::*;
use crate::blend::vtx;
use crate::blend_variable::{RadiusSpline, fillet_chord, fillet_variable, fillet_variable_points, fillet_variable_spline};
use crate::{Body, BoolOp, boolean, box_solid, cylinder, fillet, measure, step_export};
/// An oblique boss: a rod of radius 8 whose axis leans 26.57° from the plate's normal, standing on
/// a 5 mm plate. The edge where its side meets the plate's top plane is an ellipse whose two faces
/// run from 63° to 117° apart around it.
fn oblique_boss() -> (Body, Vec3, Vec3) {
let plate = box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 5.0)).unwrap();
let base = Vec3::new(0.0, 0.0, -10.0);
let axis = Vec3::new(0.5, 0.0, 1.0).normalized().unwrap();
let rod = cylinder(base, axis, 8.0, 30.0).unwrap();
(boolean(&plate, &rod, BoolOp::Union).unwrap().unwrap(), base, axis)
}
/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a
/// cylinder away from the front (the wall curves into the part), otherwise the part is the box
/// within the cylinder (the wall curves out). With the bottom arc's midpoint, the axis's base
/// point and the side of the wall the material is on (+1 away from the axis, −1 toward it).
fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) {
let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let (axis, op, side) =
if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) };
let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap();
let body = boolean(&block, &tool, op).unwrap().unwrap();
(body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side)
}
/// The blend face a blend built over `input`: the fitted surface that is on neither of the faces
/// it runs between (a boolean leaves the curved faces it kept as fitted surfaces themselves).
fn blend_face(input: &Body, body: &Body) -> Option<mt::Face> {
use mt::ParametricSurface;
let on_input = |p: Vec3| input.solid.face_iter().any(|f| project(f, p, None).is_some_and(|(q, _, _)| q.dist(p) <= 1e-6));
body.solid
.face_iter()
.filter(|f| matches!(f.oriented_surface(), mt::Surface::NurbsSurface(_)))
.find(|f| !on_input(from_p3(f.surface().subs(0.5, 0.5))))
.cloned()
}
/// The edges of the blend face's boundary, each once, with the point at the middle of it (the
/// blend is bounded by its two contacts and, at each end, an arc or the curve of an end face).
fn blend_boundary(input: &Body, body: &Body) -> Vec<(mt::Edge, Vec3)> {
let Some(f) = blend_face(input, body) else { return Vec::new() };
let mut out: Vec<(mt::Edge, Vec3)> = Vec::new();
for w in f.absolute_boundaries() {
for e in w.edge_iter() {
if out.iter().any(|(x, _)| x.id() == e.id()) {
continue;
}
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
out.push((e.absolute_clone(), from_p3(c.subs((lo + hi) * 0.5))));
}
}
out
}
/// The edges of a contact: the boundary of the blend face whose middle point `side` picks out.
fn contact(input: &Body, body: &Body, side: &dyn Fn(Vec3) -> bool) -> Vec<mt::Edge> {
blend_boundary(input, body).into_iter().filter(|(_, m)| side(*m)).map(|(e, _)| e).collect()
}
/// The fraction of the way along `edge` (its absolute front to its back) of the point of it
/// nearest `q`.
fn along_fraction(edge: &mt::Edge, q: Vec3) -> Option<f64> {
let e = edge.absolute_clone();
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
let steps = 512;
let mut at = vec![from_p3(c.subs(lo))];
let mut run = vec![0.0];
for i in 1..=steps {
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / steps as f64));
run.push(run.last().copied()? + at.last().copied()?.dist(p));
at.push(p);
}
let total = *run.last()?;
let d = |i: usize| at.get(i).map(|p| p.dist(q)).unwrap_or(f64::INFINITY);
let hint = (0..=steps).min_by(|x, y| d(*x).total_cmp(&d(*y))).unwrap_or(0);
let t = c.search_nearest_parameter(p3(q), lo + (hi - lo) * hint as f64 / steps as f64, 100)?;
let i = (((t - lo) / (hi - lo)) * steps as f64).clamp(0.0, steps as f64);
let (k, f) = (i.floor() as usize, i.fract());
let (Some(a), Some(b)) = (run.get(k), run.get((k + 1).min(steps))) else { return None };
Some((a + (b - a) * f) / total)
}
/// The azimuth of `p` about the Z axis through `axis`.
fn azimuth(axis: Vec3, p: Vec3) -> f64 {
(p.y - axis.y).atan2(p.x - axis.x)
}
/// The point of the curves running round the Z axis through `axis` at the azimuth `phi`.
fn at_azimuth(edges: &[mt::Edge], axis: Vec3, phi: f64) -> Option<Vec3> {
let off = |p: Vec3| {
let mut d = azimuth(axis, p) - phi;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
d
};
let (mut best, mut best_d) = (Vec3::ZERO, f64::INFINITY);
for e in edges {
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
let steps = 512;
let mut at = lo;
let mut dist = f64::INFINITY;
for i in 0..=steps {
let t = lo + (hi - lo) * i as f64 / steps as f64;
let d = off(from_p3(c.subs(t))).abs();
if d < dist {
(at, dist) = (t, d);
}
}
let step = (hi - lo) / steps as f64;
let (mut x0, mut x1) = ((at - step).max(lo), (at + step).min(hi));
let g = 0.618_033_988_749_894_9;
for _ in 0..80 {
let (a, b) = (x1 - g * (x1 - x0), x0 + g * (x1 - x0));
if off(from_p3(c.subs(a))).abs() < off(from_p3(c.subs(b))).abs() {
x1 = b;
} else {
x0 = a;
}
}
let t = 0.5 * (x0 + x1);
let d = off(from_p3(c.subs(t))).abs();
if d < best_d {
(best, best_d) = (from_p3(c.subs(t)), d);
}
}
(best_d < 1e-6).then_some(best)
}
/// A chord fillet along the cylinder-into-plane edge of an oblique boss — the two faces run from
/// 63° to 117° apart around it. The blend's chain stops at the seams between the patches a boolean
/// left of the rod's surface, where the end face is tangent to the rod; the cap that cuts the strip
/// back there is only placed for one radius, so a width that varies along the edge is refused
/// rather than approximated (the fixed radius of `fillet` along the same edge rolls).
#[test]
fn a_chord_fillet_along_an_oblique_boss_edge_is_refused() {
let (body, _, _) = oblique_boss();
let pick = Vec3::new(7.5 + 8.944_271_909_999_16, 0.0, 5.0);
// A fixed radius rolls along the same chain: the edge itself is supported.
assert!(fillet(&body, &[pick], 2.5).is_ok());
let e = fillet_chord(&body, &[pick], 4.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
}
/// A chord fillet along an arc whose two faces meet at a right angle: the width across the blend
/// is the chord, so the round is the one of radius `chord / √2` (checked against the constant
/// fillet of that radius) and its two contacts are that far apart at every station.
#[test]
fn chord_fillet_along_an_arc_edge() {
let (chord, r) = (3.0, 3.0 / 2f64.sqrt());
let (body, mid, axis, _) = arc_walled_box(false);
let blended = fillet_chord(&body, &[mid], chord).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
assert_eq!(blended.unmeshed_faces(), 0);
let plain = fillet(&body, &[mid], r).unwrap();
let (made, exact) = (measure(&blended).unwrap().volume, measure(&plain).unwrap().volume);
assert!((made - exact).abs() < exact * 1e-6, "volume {made} vs {exact}");
// The contact on the plane and the one on the wall at the same azimuth: the width across.
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
let wall = contact(&body, &blended, &|m| m.z.abs() >= 1e-6);
assert!(!plane.is_empty() && !wall.is_empty(), "the contacts are not the plane's and the wall's");
let mid_azimuth = azimuth(axis, mid);
let mut worst: f64 = 0.0f64;
let mut stations = 0;
for i in 1..12 {
let phi = mid_azimuth + (i as f64 / 12.0 - 0.5) * 0.9;
let (Some(p), Some(w)) = (at_azimuth(&plane, axis, phi), at_azimuth(&wall, axis, phi)) else { continue };
stations += 1;
worst = worst.max((p.dist(w) - chord).abs());
}
eprintln!("arc chord {chord}: worst width error {worst:.6} mm at {stations} stations");
assert!(stations >= 9, "only {stations} stations measured");
assert!(worst < 0.01, "the width across the blend is {worst} mm off the chord {chord}");
}
/// A chord fillet on a straight edge between planar faces is still the exact local round: the
/// same body, bit for bit, as the fillet of the radius the chord gives (a right angle: chord c is
/// r = c / √2).
#[test]
fn chord_fillet_on_a_straight_edge_is_unchanged() {
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let (at, chord) = (Vec3::new(10.0, 10.0, 30.0), 4.0);
let f = fillet_chord(&b, &[at], chord).unwrap();
let k = fillet(&b, &[at], chord / 2f64.sqrt()).unwrap();
assert_eq!(step_export(&[&f], "part").unwrap(), step_export(&[&k], "part").unwrap());
assert_eq!(f.unmeshed_faces(), 0);
}
/// A variable radius along an arc edge: a ball whose radius is the law's at each station's fraction
/// of the edge's length rolls along it, and the setback on the planar face — the plane and the
/// wall meet at a right angle, so it is the section's radius — follows the law from end to end.
#[test]
fn variable_radius_along_an_arc_edge_follows_the_law() {
let (body, mid, axis, _) = arc_walled_box(false);
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
// Started from the edge's back end: the law runs the other way, as the positions are from the
// end nearest `start`.
let start = vtx(edge.absolute_back());
// A wider law (2 → 4, the radius doubling over the arc) is out of reach of the fitted strip:
// the sections' own last spans cannot be held to the fit's accuracy there. That is refused,
// not approximated.
for wide in [4.0, 5.0] {
let e = fillet_variable(&body, mid, start, 2.0, wide).unwrap_err();
assert!(e.to_string().contains("accuracy and section limits"), "2 -> {wide}: {e}");
}
let (r0, r1) = (2.0, 3.4);
let blended = fillet_variable(&body, mid, start, r0, r1).unwrap();
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
assert_eq!(blended.unmeshed_faces(), 0);
let law = RadiusSpline::through(&[(0.0, r0), (1.0, r1)], false);
// The law runs from the end nearest `start`: the chain's front here is the far end.
let want = if vtx(edge.absolute_back()).dist(start) < vtx(edge.absolute_front()).dist(start) { law.reversed() } else { law.clone() };
// `start` is the chain's back end, so the law is read back to front there: r0 at the back, r1
// at the front.
assert!((want.eval(1.0) - r0).abs() < 1e-12 && (want.eval(0.0) - r1).abs() < 1e-12);
// The blend's contact on the bottom plane is at 40 − r from the axis.
let plane = contact(&body, &blended, &|m| m.z.abs() < 1e-6);
assert!(!plane.is_empty(), "no contact on the bottom plane");
let mut worst: f64 = 0.0;
let mut stations = 0;
let mut shown = Vec::new();
for e in &plane {
let c = e.oriented_curve();
let (lo, hi) = c.range_tuple();
for i in 0..=32 {
let p = from_p3(c.subs(lo + (hi - lo) * i as f64 / 32.0));
let measured = 40.0 - (p - axis).len();
let along = along_fraction(&edge, p).unwrap();
// Leave the two ends, where the caps cut the contact: the blend closes on the end
// faces there.
if !(0.04..=0.96).contains(&along) {
continue;
}
stations += 1;
if shown.len() < 3 || (0.45..0.55).contains(&along) {
shown.push((along, measured, want.eval(along)));
}
worst = worst.max((measured - want.eval(along)).abs());
}
}
eprintln!("arc variable {r0} -> {r1}: worst radius error {worst:.6} mm at {stations} stations; law vs measured {shown:?}");
assert!(stations >= 24, "only {stations} stations measured");
assert!(worst < 0.02, "the section radius is {worst} mm off the law");
// Wider, still inside what the strip holds: the same body as the exact straight-edge case of
// that law is not built here, but the radius is the law's at every station measured.
}
/// A radius law that runs to zero at an end of a curved edge: refused by name. The blend would
/// close to the edge's end point, but the fitted strip pinches to a point there and the last span
/// of it cannot be held to the fit's accuracy: the straight-edge fillet, which closes exactly, is
/// the way (see `variable_radius_down_to_zero_at_one_end`).
#[test]
fn a_law_running_to_zero_along_a_curved_edge_is_refused() {
let (body, mid, _, _) = arc_walled_box(false);
let edge = crate::surfaces::edge_near(&body, mid).unwrap();
for start in [vtx(edge.absolute_front()), vtx(edge.absolute_back())] {
let e = fillet_variable(&body, mid, start, 3.0, 0.0).unwrap_err();
assert!(e.to_string().contains("runs to zero along a curved edge"), "{e}");
let law = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii: vec![0.0, 3.0] };
assert!(fillet_variable_spline(&body, mid, mid, &law).is_err());
// A law that stays clear of zero along it rolls: the blend is closed, a three-sided face
// where nothing closes it.
let made = fillet_variable(&body, mid, start, 3.0, 2.4).unwrap();
assert!(made.validity().is_empty(), "{:?}", made.validity());
let mesh = made.tessellate_with(5e-4, true).unwrap();
let about = |o: Vec3| {
mesh.triangles
.iter()
.map(|t| (mesh.positions[t[0] as usize] - o).dot((mesh.positions[t[1] as usize] - o).cross(mesh.positions[t[2] as usize] - o)) / 6.0)
.sum::<f64>()
};
assert!((about(Vec3::ZERO) - about(Vec3::new(-100.0, 50.0, 30.0))).abs() < 1e-2, "the mesh is not closed");
}
}
/// What a chord or a law the local geometry cannot hold is refused with: an error naming the size,
/// never a silent approximation and never a panic.
#[test]
fn a_chord_or_radius_the_faces_cannot_hold_is_refused() {
let (body, mid, _, _) = arc_walled_box(false);
// A chord of 30 across a 40 mm wide wall (r ≈ 21, past the side walls and the block).
let e = fillet_chord(&body, &[mid], 30.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
assert!(fillet_chord(&body, &[mid], 3.0 / 2f64.sqrt()).is_ok());
// A radius law too large for the wall.
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 2.0, 30.0).is_err());
assert!(fillet_variable(&body, mid, Vec3::new(0.0, 30.0, 0.0), 20.0, 20.0).is_err());
// Non-finite, negative and zero-at-both-ends laws: refused before the geometry is touched.
let bad = |radii: Vec<f64>| RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 1.0, 1.0], radii };
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::NAN])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, f64::INFINITY])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![2.0, -1.0])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &bad(vec![0.0, 0.0])).is_err());
assert!(fillet_variable_spline(&body, mid, mid, &RadiusSpline { degree: 1, knots: vec![0.0, 0.5, 1.0], radii: vec![2.0, 3.0] }).is_err());
assert!(fillet_variable_points(&body, mid, mid, &[(0.0, 2.0), (1.5, 3.0)], false).is_err());
// An edge the body does not have is refused by name, not approximated.
assert!(fillet_chord(&body, &[Vec3::new(500.0, 500.0, 500.0)], 3.0).is_err());
}
+84
View File
@@ -25,6 +25,7 @@ use super::{
};
use crate::blend::{too_large, vtx};
use crate::body::{Solid, from_p3, p3, v3};
use crate::bridge::Frame;
use crate::record::Rebuild;
use crate::{KernelError, Result};
@@ -683,9 +684,65 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec<Owner>)],
Err(fail("a boundary does not close"))
}
/// The strip's own station nearest the corner ball `c`: the section where the strip stops against
/// that ball, where its direction of leave and its curvature are read.
fn corner_station(strip: &Strip, c: Vec3) -> Option<f64> {
strip.sections.iter().min_by(|a, b| a.1.c.dist(c).total_cmp(&b.1.c.dist(c))).map(|x| x.0)
}
/// One side of a G2 corner: the corner ball's own section arc of radius `r` about `c`, from its
/// contact `q_from` to its contact `q_to`, each station carrying the direction the corner patch
/// leaves the blend in (towards the corner's apex) and the blend's own curvature across it, read
/// from `strip` where the ball stops against it. A rolling ball's section is a circular arc about
/// its own centre wherever the edge runs, so this arc is the blend's section curve itself, and its
/// ends are the ball's contacts exactly — the sides close the loop whatever the fit's station
/// spacing is.
fn g2_side(strip: &Strip, c: Vec3, r: f64, q_from: Vec3, q_to: Vec3, apex: Vec3) -> Result<Vec<Frame>> {
use mt::ParametricSurface;
/// Stations across one section: enough for the patch's cubic cross-sections, few enough to
/// keep the corner's own patch small.
const STATIONS: usize = 9;
let vec_of = |d: mt::Vector3| Vec3::new(d.x, d.y, d.z);
let v = corner_station(strip, c).ok_or_else(|| fail("corner section"))?;
let a = (q_from - c).normalized().ok_or_else(|| fail("corner contact"))?;
let b = (q_to - c).normalized().ok_or_else(|| fail("corner contact"))?;
let dot = a.dot(b).clamp(-1.0, 1.0);
let w = (b - a * dot).normalized().ok_or_else(|| fail("corner contact"))?;
let theta = dot.acos();
// The blend's own direction of leave and curvature across the seam, at its corner station.
let speed = vec_of(strip.surface.vder(0.5, v)).len();
if !(speed > 1e-30) {
return Err(unsupported("a corner section of no width"));
}
let leave = vec_of(strip.surface.vder(0.5, v)) * (1.0 / speed);
let bend = vec_of(strip.surface.vvder(0.5, v)) * (1.0 / (speed * speed));
if !(leave.is_finite() && bend.is_finite() && theta.is_finite() && r.is_finite()) {
return Err(unsupported("a corner section that is not finite"));
}
let mut out = Vec::with_capacity(STATIONS);
for k in 0..STATIONS {
let t = theta * k as f64 / (STATIONS - 1) as f64;
let p = c + (a * t.cos() + w * t.sin()) * r;
// The patch leaves the blend towards the material the corner caps, so the sign is the one
// towards the corner's apex (the blends' own v direction runs away from the vertex).
let leave = if leave.dot(apex - p) < 0.0 { -leave } else { leave };
if !(p.is_finite() && leave.is_finite() && bend.is_finite()) {
return Err(unsupported("a corner section that is not finite"));
}
out.push(Frame { p, leave, bend, bend_change: Vec3::ZERO });
}
Ok(out)
}
/// Blend the edges `ids` of `solid` with radius `r` (a chamfer's distance), rebuilding the
/// corners at their ends; with what it built from `solid`.
pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> {
corner_blend_style(solid, ids, size, r, round, false)
}
/// [`corner_blend`]; with `g2`, the corners where three blends meet are G2 patches
/// ([`crate::corner_g2`]) instead of pieces of the rolling ball's sphere.
pub(crate) fn corner_blend_style(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, g2: bool) -> Result<(Solid, Rebuild)> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
return Err(KernelError::Invalid("blend size and radius must be finite and positive".into()));
}
@@ -1006,6 +1063,33 @@ pub(crate) fn corner_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let out = (n(f12, q12) + n(f23, q23) + n(f31, q31)).normalized().unwrap_or(Vec3::Z);
let side = out.any_perp();
let (np, sp, back) = (c + side * r, c - side * r, c - out * r);
if g2 {
// A G2 patch on the blends' own end sections, gathered at the ball's pole
// (the point the sphere's meridians are built through above). Each side is
// the strip's section through the ball's contact with the face it shares
// with the previous strip; the strips run on past their edges, so that
// station is found by projecting the contact point, not taken from the
// edge's own end.
let s2 = strip_of(e2)?;
let s3 = strip_of(e3)?;
// The cap's own pole: the ball's point nearest the corner (the sphere
// patch's `back` above is its opposite pole, where the meridian starts).
let apex = c + out * r;
// Side 1 runs from the ball's contact with the face it shares with e3
// round to the one it shares with e2, and so on round the loop: each
// side's last station is the next side's first, at that contact.
let sides = [g2_side(s1, c, r, q31, q12, apex)?, g2_side(s2, c, r, q12, q23, apex)?, g2_side(s3, c, r, q23, q31, apex)?];
let _patch = crate::corner_g2::corner_g2_patch(&sides, apex)?;
// Sewing the patch into the shell is not done yet: it brings its own
// boundary vertices where the strips' contacts end at the ball's contacts
// (`v12`/`v23`/`v31`), and a truck vertex is its allocation, not its point,
// so the rebuilt shell does not close there (rewrapping the section curves
// onto the contacts is the missing step). Refused rather than left open:
// the rolling ball fills the corner instead.
return Err(unsupported(
"a G2 corner sewn between these blends (the patch's section curves do not join the blends' contacts yet)",
));
}
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
let surf = mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(
meridian.oriented_curve(),
+1 -1
View File
@@ -442,7 +442,7 @@ fn profile_interval_overlaps(plane: &Plane, regions: &[Region2], o: Vec3, axis:
add(p)?;
}
}
Seg2::Bezier { n, p } if (1..=7).contains(&n) => {
Seg2::Bezier { n, p } if (1..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) => {
for p in p.iter().take(usize::from(n) + 1) {
add(*p)?;
}
+9 -1
View File
@@ -28,7 +28,9 @@ mod boundary;
mod bridge;
mod build;
mod clearance;
mod continuity;
mod coplanar;
mod corner_g2;
mod curveblend;
mod delete_face;
#[cfg(test)]
@@ -67,6 +69,7 @@ mod issue_871_tests;
#[cfg(test)]
mod join_between_caps_tests;
mod lip;
pub mod loft_blend;
mod loopblend;
mod matchsurf;
mod measure;
@@ -123,7 +126,7 @@ mod tspline;
mod untouched_faces_tests;
pub mod watch;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_max, fillet_max_each};
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
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};
@@ -134,6 +137,10 @@ pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
sweep_with, torus,
};
pub use continuity::{
ContinuityReport, ContinuityTolerances, EdgePick, MAX_SAMPLES, MIN_SAMPLES, SeamReport, SeamVerdict, SeamWorst, pick_edges, seam_diagnostics,
};
pub use corner_g2::{corner_g2_patch, seam_error};
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::{
@@ -145,6 +152,7 @@ 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 loft_blend::loft_blend;
pub use matchsurf::{match_surface, seam_continuity};
pub use measure::{BodyMeasure, measure};
pub use mesh_convert::convert_prismatic_mesh;
+550
View File
@@ -0,0 +1,550 @@
//! Multi-section blends: one face that runs through many cross-sections and still leaves the
//! first and last of them the way a support surface wants.
//!
//! The sections run in order along the face's **u**, each sampled at the same `samples` stations
//! along the face's **v**. Section `i` sits at `u = i/(n−1)`, and the face passes through it
//! exactly: its v-curve there is that section's own cross-section curve through its stations.
//! Along v the stations are interpolated by a B-spline of degree `min(3, samples − 1)` — the same
//! interpolation [`crate::bridge`] builds its boundary curves with — so every station of every
//! section lies on the face.
//!
//! Along u the face is a B-spline whose knots are clamped at both ends and repeated at every
//! interior section, so every span is one Bézier; a span's leading control rows are the Hermite
//! rows of `bridge::section` — position, tangent (`leave`), curvature (`bend`) and change of
//! curvature (`bend_change`), scaled to that span's parameter interval. The degree is the largest
//! `c + c′ − 1` over the spans (`c` conditions at the section the span leaves, `c′` at the one it
//! arrives at), and a span whose two ends carry fewer conditions than that is elevated exactly,
//! which moves no point of the curve. A blend of two sections is therefore the [`crate::bridge`]
//! of those two boundaries, only with the two parameters the other way round.
//!
//! **The first and last section** take their tangent, curvature and change of curvature from the
//! caller's frames, exactly as a bridge takes them from a support surface's boundary, and hold
//! them to the requested [`Continuity`] (G0 up to G3). The `leave` direction of the last section's
//! frames points back into the blend, towards the interior, as a bridge's far boundary does.
//!
//! **Interior sections** take nothing from the caller's frames but their positions: the run
//! direction's tangent there is the secant of the two neighbouring sections' stations, its
//! curvature their second difference and its change of curvature their third difference, all
//! along the run. The two spans that meet at such a section are given exactly those rows, so they
//! agree in ∂/∂u, ∂²/∂u² and ∂³/∂u³ there and the joint is **C³** in the run parameter (hence G³)
//! whenever a fourth station is there to take the third difference over — that is, whenever the
//! blend runs through at least four sections. A blend of exactly three sections has only its one
//! interior section's two neighbours to read, so no third difference can be taken: the interior
//! section then keeps position, tangent and curvature only and its joint is **C²** (G²). Dropping
//! interior conditions, never the surface, is also how this blend stays within
//! [`MAX_SPLINE_DEGREE`]: a section family asks for at most `c + c′ − 1 ≤ 7` at the four conditions
//! a section can carry today, and a caller that ever asked for more would lose interior
//! conditions (change of curvature first, then curvature, then tangent) rather than fail.
//!
//! `crown` scales how fast the face leaves the first and the last section (0 the plain blend, up
//! to 3 fuller, down to −0.9 flatter), exactly as a bridge's crown does. It shapes the middle of
//! each end span and leaves every joint's conditions alone.
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::{CurveN, HPoint, MAX_SPLINE_DEGREE, NurbsSurface};
use truck_modeling as mt;
use truck_modeling::{ParametricSurface3D, SearchNearestParameter};
use crate::body::{Body, from_p3, p3};
use crate::bridge::{Continuity, Frame};
use crate::surfaces::surface_body;
use crate::{KernelError, Result, guard};
/// Most cross-sections one blend may run through.
pub const MAX_BLEND_SECTIONS: usize = 50;
/// Fewest stations one cross-section may be sampled at (a straight line across it).
pub const MIN_BLEND_STATIONS: usize = 2;
/// Most stations one cross-section may be sampled at. The control net (and the interpolation that
/// builds it) grows with the station count, and no cross-section curve needs more than this.
pub const MAX_BLEND_STATIONS: usize = 257;
/// How many rows of the control net one interpolation solve carries at a time: a row is three
/// coordinates, so this many rows are solved together.
const ROWS_PER_SOLVE: usize = 16;
/// A blend surface through `sections`, in order, that leaves the first and the last of them with
/// the continuity their frames ask for.
///
/// Each section is one cross-section: `samples` stations (`samples` must match every section's
/// length, so that the stations correspond from one section to the next). Section `i` is
/// interpolated at `u = i/(n−1)`, and its stations run along `v`, so the face of the body holds
/// every station of every section. `orders[0]` and `orders[1]` are what the face holds at the
/// first and the last section, read from their frames; the interior sections join the face
/// smoothly by finite differences along the run (see the module comment for the order each joint
/// holds). `crown` scales how fast the face leaves the first (`crown[0]`) and the last
/// (`crown[1]`) section and must be within −0.9 and 3.
///
/// Refused, without ever panicking: fewer than 2 or more than [`MAX_BLEND_SECTIONS`] sections,
/// sections of unequal station counts or a `samples` below [`MIN_BLEND_STATIONS`] or above
/// [`MAX_BLEND_STATIONS`], a frame that is not finite or a `leave` with no direction, a crown
/// outside its range, and two neighbouring sections that meet at a station (no room for a blend).
pub fn loft_blend(sections: &[Vec<Frame>], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result<Body> {
let blend = Blend::new(sections, orders, crown, samples)?;
let net = blend.net()?;
guard("loft blend surface", || crate::spline_conv::tensor_face(&net).map(|f| surface_body(vec![f])))
}
/// The frames of one cross-section picked as an edge chain of a body, sampled at `stations`
/// stations: the chain itself, with the neighbouring face's tangent plane, curvature vector and
/// change of curvature read along `leave` — the direction the blend runs on from this section —
/// exactly as [`crate::bridge::frames`] reads a support surface's own boundary.
///
/// Hand the result to [`loft_blend`] as one section. The face the section repeats is the body's
/// face that holds the whole chain and faces the run best; `leave` must have a part in that face's
/// tangent plane (a direction normal to it has no tangent plane to leave in). The last section's
/// `leave` runs back into the blend, towards the section before it.
pub fn section_frames(body: &Body, edges: &[Vec3], leave: Vec3, stations: usize) -> Result<Vec<Frame>> {
guard("loft blend section", || {
let chain = crate::bridge::chain(body, edges)?;
let leave =
leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?;
let first = chain.first().ok_or_else(|| KernelError::Failed("loft blend: no chain".into()))?;
let mid = (from_p3(first.front().point()) + from_p3(first.back().point())) * 0.5;
// The face the chain lies on that the blend leaves through: the one whose tangent plane
// holds as much of `leave` as possible.
let mut best: Option<(f64, mt::Face)> = None;
for face in body.solid.face_iter() {
if !chain.iter().all(|e| face.edge_iter().any(|f| f.id() == e.id())) {
continue;
}
let surf = face.surface();
let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue };
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO);
let score = (leave - n * leave.dot(n)).len();
if best.as_ref().is_none_or(|(b, _)| score > *b) {
best = Some((score, face.clone()));
}
}
let (score, face) = best.ok_or_else(|| KernelError::Invalid("no face of the body holds the picked edges".into()))?;
if score < 0.2 {
return Err(KernelError::Invalid("the direction towards the next section lies in no face at the picked edges".into()));
}
crate::bridge::frames(&face, &chain, leave, stations)
})
}
/// What the face has to do at one section: where it is, and the run direction's first, second and
/// third derivatives at each of the section's stations. The third is carried only when the
/// section holds a change-of-curvature condition.
struct Shape {
p: Vec<Vec3>,
d1: Vec<Vec3>,
d2: Vec<Vec3>,
d3: Option<Vec<Vec3>>,
}
/// The blend's inputs, validated, with the conditions and the run direction's degree worked out.
struct Blend<'a> {
sections: &'a [Vec<Frame>],
crown: [f64; 2],
samples: usize,
run: usize,
/// The run parameter interval between two neighbouring sections: `1/(run − 1)`.
step: f64,
degree: usize,
/// Conditions held at each section, one per control row of the spans that end there.
conditions: Vec<usize>,
}
impl<'a> Blend<'a> {
fn new(sections: &'a [Vec<Frame>], orders: [Continuity; 2], crown: [f64; 2], samples: usize) -> Result<Blend<'a>> {
if !(MIN_BLEND_STATIONS..=MAX_BLEND_STATIONS).contains(&samples) {
return Err(KernelError::Invalid(format!(
"a blend needs {MIN_BLEND_STATIONS} to {MAX_BLEND_STATIONS} stations per cross-section, not {samples}"
)));
}
let run = sections.len();
if !(2..=MAX_BLEND_SECTIONS).contains(&run) {
return Err(KernelError::Invalid(format!("a blend runs through 2 to {MAX_BLEND_SECTIONS} cross-sections, not {run}")));
}
for c in crown {
if !c.is_finite() || !(-0.9..=3.0).contains(&c) {
return Err(KernelError::Invalid("a blend's crown must be between -0.9 and 3".into()));
}
}
for (i, section) in sections.iter().enumerate() {
if section.len() != samples {
return Err(KernelError::Invalid(format!(
"every cross-section of a blend must be sampled at the same {samples} stations (section {i} has {})",
section.len()
)));
}
for (s, f) in section.iter().enumerate() {
if ![f.p, f.leave, f.bend, f.bend_change].into_iter().all(Vec3::is_finite) {
return Err(KernelError::Invalid(format!("the frame at section {i}, station {s} is not finite")));
}
if f.leave.normalized().is_none() {
return Err(KernelError::Invalid(format!("the leave direction at section {i}, station {s} is zero")));
}
}
}
// The face runs from one section to the next: where two of them meet at a station there is
// no room for a blend between them.
for i in 0..run - 1 {
for s in 0..samples {
if !(point(sections, i, s)?.dist(point(sections, i + 1, s)?) > 1e-9) {
return Err(KernelError::Invalid(format!("sections {} and {} meet at station {s}: there is no room for a blend", i + 1, i + 2)));
}
}
}
// The ends hold what their orders ask for. An interior section would like all four
// conditions (position, tangent, curvature, change of curvature); where no fourth station
// is there to take the third difference over it keeps the three its neighbours can give,
// and its joint is C² instead of C³.
let mut conditions = vec![2usize; run];
if let Some(slot) = conditions.first_mut() {
*slot = orders[0].conditions();
}
if let Some(slot) = conditions.last_mut() {
*slot = orders[1].conditions();
}
for i in 1..run.saturating_sub(1) {
if let Some(slot) = conditions.get_mut(i) {
*slot = if third_difference_available(i, run) { 4 } else { 3 };
}
}
// The run direction's degree is the largest `c + c′ − 1` of a span. Four conditions a
// section is at most what a section can carry, so this fits today; should a caller ever
// ask for more, the interior joints give up conditions rather than the blend failing.
let mut degree = span_degree(&conditions);
while degree > MAX_SPLINE_DEGREE {
let next = (1..run.saturating_sub(1))
.filter(|&i| conditions.get(i).is_some_and(|&c| c > 1))
.max_by_key(|&i| conditions.get(i).copied().unwrap_or(0));
let Some(i) = next else { break };
if let Some(c) = conditions.get_mut(i) {
*c = c.saturating_sub(1);
}
degree = span_degree(&conditions);
}
if degree == 0 || degree > MAX_SPLINE_DEGREE {
return Err(KernelError::Failed("loft blend: the requested continuity needs a degree above the kernel's".into()));
}
Ok(Blend { sections, crown, samples, run, step: 1.0 / (run - 1) as f64, degree, conditions })
}
/// What the face has to do at section `i`: the run direction's derivatives at every station.
fn shape(&self, i: usize) -> Result<Shape> {
let samples = self.samples;
let p: Vec<Vec3> = (0..samples).map(|s| point(self.sections, i, s)).collect::<Result<Vec<_>>>()?;
let conditions = self.conditions.get(i).copied().unwrap_or(1);
let mut d1 = vec![Vec3::ZERO; samples];
let mut d2 = vec![Vec3::ZERO; samples];
let mut d3 = (conditions >= 4).then(|| vec![Vec3::ZERO; samples]);
if i == 0 {
for s in 0..samples {
let f = frame(self.sections, 0, s)?;
let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?;
// The speed a bridge gives a boundary: a third of the station's chord to the
// section it leaves for, the crown, over this span's parameter interval.
let speed = f.p.dist(point(self.sections, 1, s)?) / 3.0 * (1.0 + self.crown[0]) / self.step;
if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) {
*v1 = leave * speed;
*v2 = f.bend * (speed * speed);
}
}
} else if i + 1 == self.run {
for s in 0..samples {
let f = frame(self.sections, i, s)?;
let leave = f.leave.normalized().ok_or_else(|| KernelError::Failed("loft blend: the leave direction".into()))?;
let speed = f.p.dist(point(self.sections, i - 1, s)?) / 3.0 * (1.0 + self.crown[1]) / self.step;
if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) {
// The run leaves the last section outwards; the caller's frames there point
// back into it, so the odd derivatives turn round.
*v1 = leave * -speed;
*v2 = f.bend * (speed * speed);
}
}
} else {
let h = self.step;
for s in 0..samples {
let before = point(self.sections, i - 1, s)?;
let here = point(self.sections, i, s)?;
let after = point(self.sections, i + 1, s)?;
if let (Some(v1), Some(v2)) = (d1.get_mut(s), d2.get_mut(s)) {
*v1 = (after - before) * (1.0 / (2.0 * h));
*v2 = (after - here * 2.0 + before) * (1.0 / (h * h));
}
}
}
// The change of curvature: the caller's bend_change at an end, a third difference of the
// neighbouring sections at an interior one.
if let Some(third) = d3.as_mut() {
for s in 0..samples {
let (Some(&a), Some(&b)) = (d1.get(s), d2.get(s)) else {
return Err(KernelError::Failed("loft blend: a section's derivatives".into()));
};
let value = if i == 0 {
let f = frame(self.sections, 0, s)?;
third_derivative(a, b, f.bend_change)?
} else if i + 1 == self.run {
let f = frame(self.sections, i, s)?;
-third_derivative(-a, b, f.bend_change)?
} else {
self.third_difference(i, s)?
};
if let Some(slot) = third.get_mut(s) {
*slot = value;
}
}
}
Ok(Shape { p, d1, d2, d3 })
}
/// The third difference of the sections' stations at an interior section: the estimate of how
/// the curvature vector changes along the run. `i` is an interior section, and the neighbours
/// reach far enough for a stencil (see [`third_difference_available`]).
fn third_difference(&self, i: usize, s: usize) -> Result<Vec3> {
let h = self.step;
let cube = h * h * h;
let before = i.checked_sub(2);
let value = match (before, i + 2 < self.run) {
// Both sides: the symmetric five-point difference.
(Some(k), true) => {
(point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 2.0 + point(self.sections, i - 1, s)? * 2.0
- point(self.sections, k, s)?)
* (1.0 / (2.0 * cube))
}
// Only ahead: the forward four-point stencil at the second of them.
(None, true) => {
(point(self.sections, i + 2, s)? - point(self.sections, i + 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0
- point(self.sections, i - 1, s)?)
* (1.0 / cube)
}
// Only behind: the same the other way round.
(Some(k), false) => {
(point(self.sections, k, s)? - point(self.sections, i - 1, s)? * 3.0 + point(self.sections, i, s)? * 3.0
- point(self.sections, i + 1, s)?)
* (1.0 / cube)
}
(None, false) => return Err(KernelError::Failed("loft blend: no fourth station for a change of curvature".into())),
};
if !value.is_finite() {
return Err(KernelError::Failed("loft blend: the sections' third difference is not finite".into()));
}
Ok(value)
}
/// The Bézier rows of the span from section `j` to section `j + 1`: one control row per
/// condition at each end, elevated to the run direction's degree so the whole face is one
/// tensor surface. Row `k` of the span is control row `j·degree + k` of the face.
fn span(&self, j: usize, left: &Shape, right: &Shape) -> Result<Vec<Vec<Vec3>>> {
let (Some(&kl), Some(&kr)) = (self.conditions.get(j), self.conditions.get(j + 1)) else {
return Err(KernelError::Failed("loft blend: a section with no conditions".into()));
};
let m = kl.saturating_add(kr).saturating_sub(1);
if !(1..=MAX_SPLINE_DEGREE).contains(&m) {
return Err(KernelError::Failed("loft blend: a span's degree".into()));
}
let k = m as f64;
let delta = self.step;
let one = delta / k;
let two = if m >= 2 { delta * delta / (k * (k - 1.0)) } else { 0.0 };
let three = if m >= 3 { delta * delta * delta / (k * (k - 1.0) * (k - 2.0)) } else { 0.0 };
let mut out = vec![vec![Vec3::ZERO; self.samples]; m + 1];
let missing = || KernelError::Failed("loft blend: a section's derivatives".into());
for s in 0..self.samples {
// The near end: one row per condition, position first.
let p = *left.p.get(s).ok_or_else(missing)?;
put(&mut out, 0, s, p)?;
if kl >= 2 {
let d1 = *left.d1.get(s).ok_or_else(missing)?;
let q1 = p + d1 * one;
put(&mut out, 1, s, q1)?;
if kl >= 3 {
let d2 = *left.d2.get(s).ok_or_else(missing)?;
let q2 = d2 * two + q1 * 2.0 - p;
put(&mut out, 2, s, q2)?;
if kl >= 4 {
let d3 = left.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?;
put(&mut out, 3, s, d3 * three + q2 * 3.0 - q1 * 3.0 + p)?;
}
}
}
// The far end, its own conditions read backwards.
let e = *right.p.get(s).ok_or_else(missing)?;
put(&mut out, m, s, e)?;
if kr >= 2 {
let d1 = *right.d1.get(s).ok_or_else(missing)?;
let r1 = e - d1 * one;
put(&mut out, m - 1, s, r1)?;
if kr >= 3 {
let d2 = *right.d2.get(s).ok_or_else(missing)?;
let r2 = d2 * two + r1 * 2.0 - e;
put(&mut out, m - 2, s, r2)?;
if kr >= 4 {
let d3 = right.d3.as_ref().and_then(|d| d.get(s)).copied().ok_or_else(missing)?;
put(&mut out, m - 3, s, r2 * 3.0 - r1 * 3.0 + e - d3 * three)?;
}
}
}
}
// A span whose two ends carry fewer conditions than the run direction's degree is
// elevated: the same curve, written in more control rows.
while out.len().saturating_sub(1) < self.degree {
out = elevate(&out);
}
Ok(out)
}
/// The run direction's knots: clamped at both ends, repeated `degree` times at every interior
/// section — so the face interpolates each section and every span in between is one Bézier.
fn run_knots(&self) -> Vec<f64> {
let degree = self.degree;
let mut knots = vec![0.0; degree + 1];
for i in 1..self.run.saturating_sub(1) {
knots.extend(std::iter::repeat_n(i as f64 / (self.run - 1) as f64, degree));
}
knots.extend(std::iter::repeat_n(1.0, degree + 1));
knots
}
/// The face's control net: u runs with the sections, v along each section's curve.
fn net(&self) -> Result<NurbsSurface> {
let shapes: Vec<Shape> = (0..self.run).map(|i| self.shape(i)).collect::<Result<Vec<_>>>()?;
// One span per pair of neighbouring sections. The first row of a span is the last row of
// the one before it — the shared section — so only the first span writes it.
let mut rows: Vec<Vec<Vec3>> = Vec::with_capacity((self.run - 1) * self.degree + 1);
for j in 0..self.run.saturating_sub(1) {
let (Some(left), Some(right)) = (shapes.get(j), shapes.get(j + 1)) else {
return Err(KernelError::Failed("loft blend: a section's shape".into()));
};
for row in self.span(j, left, right)?.into_iter().skip(usize::from(j > 0)) {
rows.push(row);
}
}
let count_u = rows.len();
// Along each section's curve the rows are the interpolation through that section's
// stations: the same parameters and degree for every row, so every row has one knot
// vector. Rows are solved in batches of `ROWS_PER_SOLVE` (three coordinates each), which
// shares one elimination between them.
let params: Vec<f64> = (0..self.samples).map(|s| s as f64 / (self.samples - 1) as f64).collect();
let degree_v = 3.min(self.samples - 1);
let mut ctrl = vec![HPoint::ZERO; count_u * self.samples];
let mut knots_v: Option<Vec<f64>> = None;
for (batch, chunk) in rows.chunks(ROWS_PER_SOLVE).enumerate() {
const WIDTH: usize = 3 * ROWS_PER_SOLVE;
let mut points: Vec<[f64; WIDTH]> = Vec::with_capacity(self.samples);
for s in 0..self.samples {
let mut q = [0.0f64; WIDTH];
for (r, row) in chunk.iter().enumerate() {
if let Some(p) = row.get(s) {
for (slot, value) in q.iter_mut().skip(3 * r).zip([p.x, p.y, p.z]) {
*slot = value;
}
}
}
points.push(q);
}
let curve = CurveN::<WIDTH>::interpolate(&params, &points, degree_v).map_err(|e| KernelError::Failed(format!("loft blend: {e}")))?;
if knots_v.is_none() {
knots_v = Some(curve.knots.clone());
}
for (s, c) in curve.ctrl.iter().enumerate() {
for (r, xyz) in c.chunks(3).enumerate() {
let i = batch * ROWS_PER_SOLVE + r;
// Coordinates past the net's last row are this batch's own padding: their
// controls are not part of the surface.
if i >= count_u {
break;
}
if let [x, y, z] = *xyz {
let Some(slot) = s.checked_mul(count_u).and_then(|base| base.checked_add(i)).and_then(|k| ctrl.get_mut(k)) else {
continue;
};
*slot = HPoint::weighted(Vec3::new(x, y, z), 1.0);
}
}
}
}
let knots_v = knots_v.ok_or_else(|| KernelError::Failed("loft blend: no row could be interpolated".into()))?;
NurbsSurface::from_homogeneous([self.degree, degree_v], [self.run_knots(), knots_v], ctrl)
.map_err(|e| KernelError::Failed(format!("loft blend: {e}")))
}
}
/// The frame at section `i`, station `s`.
fn frame(sections: &[Vec<Frame>], i: usize, s: usize) -> Result<&Frame> {
sections.get(i).and_then(|section| section.get(s)).ok_or_else(|| KernelError::Failed("loft blend: a section is missing a station".into()))
}
/// The point of section `i` at station `s`.
fn point(sections: &[Vec<Frame>], i: usize, s: usize) -> Result<Vec3> {
frame(sections, i, s).map(|f| f.p)
}
/// `rows[k][s] = v`, an error when the net has no such row or station.
fn put(rows: &mut [Vec<Vec3>], k: usize, s: usize, v: Vec3) -> Result<()> {
match rows.get_mut(k).and_then(|row| row.get_mut(s)) {
Some(slot) => {
*slot = v;
Ok(())
}
None => Err(KernelError::Failed("loft blend: the control net is too small".into())),
}
}
/// One exact degree elevation of a Bézier control net of rows: the same curve, one control row
/// more.
fn elevate(rows: &[Vec<Vec3>]) -> Vec<Vec<Vec3>> {
let degree = rows.len().saturating_sub(1);
let up = degree as f64 + 1.0;
let mut out: Vec<Vec<Vec3>> = Vec::with_capacity(rows.len() + 1);
if let Some(first) = rows.first() {
out.push(first.clone());
}
for i in 1..=degree {
if let (Some(before), Some(here)) = (rows.get(i - 1), rows.get(i)) {
let a = i as f64 / up;
out.push(before.iter().zip(here).map(|(x, y)| *x * a + *y * (1.0 - a)).collect());
}
}
if let Some(last) = rows.last() {
out.push(last.clone());
}
out
}
/// Whether a third difference for the interior section `i` can be taken from the sections around
/// it: a stencil needs a station on one side of it and two on the other, and with three sections
/// there is none.
fn third_difference_available(i: usize, run: usize) -> bool {
i >= 2 || i + 2 < run
}
/// The degree a family of sections asks for: the largest `c + c′ − 1` over its spans.
fn span_degree(conditions: &[usize]) -> usize {
let mut degree = 0;
for w in conditions.windows(2) {
if let [a, b] = w {
degree = degree.max(a.saturating_add(*b).saturating_sub(1));
}
}
degree
}
/// The third derivative a curve needs at a boundary so that the arclength derivative of its
/// curvature vector is `target`, from its first two derivatives there. Only the normal-plane part
/// of the third derivative can hold that condition; the rest is a reparameterisation of the
/// cross-section. This is `bridge`'s own construction, kept here so the bridge core stays
/// untouched.
fn third_derivative(d1: Vec3, d2: Vec3, target: Vec3) -> Result<Vec3> {
let sigma = d1.len();
if !(sigma > 1e-12) || !sigma.is_finite() {
return Err(KernelError::Failed("loft blend: no room for the curvature condition".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 r = tp * (-d2.dot(t)) - t * d2.dot(tp);
let x = (target * (sigma.powi(5)) - r * (sigma * sigma) + nn * (2.0 * d1.dot(d2))) * (1.0 / (sigma * sigma));
if !x.is_finite() {
return Err(KernelError::Failed("loft blend: the change of curvature is not finite".into()));
}
Ok(x - t * x.dot(t))
}
#[cfg(test)]
#[path = "loft_blend_tests.rs"]
mod tests;
+460
View File
@@ -0,0 +1,460 @@
//! Tests of the multi-section blend: analytic cross-sections given as frames, and the surface
//! measured back off the face it built (a grid of points, five-point differences along the run).
use super::*;
use crate::body::from_p3;
use crate::bridge::{bridge, sample_grid};
use truck_modeling as mt;
use truck_modeling::ParametricSurface;
/// The blend's surface: it is one face.
fn surface_of(body: &Body) -> mt::Surface {
body.solid.face_iter().next().unwrap().surface()
}
/// Position and the run direction's first three derivatives at `(t, v)` of the face, read with the
/// five-point differences `bridge_tests` reads a cross-section with. The samples run inwards from
/// the section (so nothing is asked of the face outside its own domain); `backwards` reads with
/// the parameter falling and turns the odd derivatives round, so every value is a derivative in
/// the run direction `u`.
fn run_derivs(body: &Body, t: f64, v: f64, backwards: bool, h: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let dir = if backwards { -1.0 } else { 1.0 };
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(t + dir * k as f64 * h, v))).collect();
let d1 = (q[0] * -25.0 + q[1] * 48.0 + q[2] * -36.0 + q[3] * 16.0 + q[4] * -3.0) * (dir / (12.0 * h));
let d2 = (q[0] * 35.0 + q[1] * -104.0 + q[2] * 114.0 + q[3] * -56.0 + q[4] * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (q[0] * -5.0 + q[1] * 18.0 + q[2] * -24.0 + q[3] * 14.0 + q[4] * -3.0) * (dir / (2.0 * h * h * h));
(q[0], d1, d2, d3)
}
/// The geometric shape of a curve from its first three derivatives: the unit tangent, the
/// curvature vector and the arclength derivative of that curvature vector (`bridge`'s own
/// reading, kept local to the test).
fn curve_shape(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)))
}
/// 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()
}
/// A circle of radius `r` at height `z` about the run (the z axis), sampled at `n` stations. Each
/// frame leaves along `leave`; its `bend` is given per station as a function of the station's
/// outward radial direction (a support surface curving in the leave direction), and its change of
/// curvature is `change`.
fn circle_section(r: f64, z: f64, n: usize, leave: Vec3, bend: impl Fn(Vec3) -> Vec3, change: Vec3) -> Vec<Frame> {
(0..n)
.map(|s| {
let a = std::f64::consts::TAU * s as f64 / n as f64;
let p = Vec3::new(r * a.cos(), r * a.sin(), z);
Frame { p, leave, bend: bend(Vec3::new(p.x / r, p.y / r, 0.0)), bend_change: change }
})
.collect()
}
/// `count` straight cross-sections along x, `10` apart in z and rising in order: the run is +z.
/// Every frame leaves along +z except the last one, which leaves back into the blend along −z as
/// a bridge's far boundary does. `bend` and `change` are the same at every station, and `bend` is
/// perpendicular to the run (the only shape a support surface can have along a direction it does
/// not bend around).
fn line_sections(count: usize, n: usize, bend: Vec3, change: Vec3) -> Vec<Vec<Frame>> {
(0..count)
.map(|i| {
let leave = if i + 1 == count { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) };
(0..n).map(|s| Frame { p: Vec3::new(10.0 * s as f64 / (n - 1) as f64, 0.0, 10.0 * i as f64), leave, bend, bend_change: change }).collect()
})
.collect()
}
/// A point at arclength `s` along the perimeter of the rounded rectangle of half-width `a`,
/// half-height `b` and corner radius `r` (in the z = 0 plane, counter-clockwise from the right
/// edge's lower end).
fn rounded_rect_at(a: f64, b: f64, r: f64, s: f64) -> Vec3 {
let (sa, sb) = ((a - r).max(0.0), (b - r).max(0.0));
let quarter = std::f64::consts::FRAC_PI_2;
let segs = [2.0 * sb, quarter * r, 2.0 * sa, quarter * r, 2.0 * sb, quarter * r, 2.0 * sa, quarter * r];
let mut left = s;
let mut k = 0;
while k + 1 < segs.len() && left > segs[k] {
left -= segs[k];
k += 1;
}
let f = if segs[k] > 0.0 { left / segs[k] } else { 0.0 };
let arc = |centre: Vec3, from: f64| {
let ang = from + quarter * f;
Vec3::new(centre.x + r * ang.cos(), centre.y + r * ang.sin(), 0.0)
};
match k {
0 => Vec3::new(a, -sb + 2.0 * sb * f, 0.0),
1 => arc(Vec3::new(sa, sb, 0.0), 0.0),
2 => Vec3::new(sa - 2.0 * sa * f, b, 0.0),
3 => arc(Vec3::new(-sa, sb, 0.0), quarter),
4 => Vec3::new(-a, sb - 2.0 * sb * f, 0.0),
5 => arc(Vec3::new(-sa, -sb, 0.0), std::f64::consts::PI),
6 => Vec3::new(-sa + 2.0 * sa * f, -b, 0.0),
_ => arc(Vec3::new(sa, -sb, 0.0), 3.0 * quarter),
}
}
/// A rounded rectangle at height `z`, sampled at `n` even arclength steps around it: a section
/// that is not a scaled copy of the circles it can sit between, with a plan support (no bend).
fn rounded_rect_section(a: f64, b: f64, r: f64, z: f64, n: usize) -> Vec<Frame> {
let total = 4.0 * ((a - r).max(0.0) + (b - r).max(0.0)) + 2.0 * std::f64::consts::PI * r;
(0..n)
.map(|s| {
let p = rounded_rect_at(a, b, r, total * s as f64 / n as f64);
Frame { p: Vec3::new(p.x, p.y, z), leave: Vec3::new(0.0, 0.0, 1.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO }
})
.collect()
}
/// Two parallel circular cylinders of radius `r` 20 apart in y, leaving towards each other along
/// ∓y, each bending up towards its own axis and with a curvature that does not change as it goes
/// round (`bridge_tests`'s own case, for the two-section comparison).
fn valley_between_cylinders(count: usize, r: f64) -> (Vec<Frame>, Vec<Frame>) {
let mut a = Vec::new();
let mut b = Vec::new();
for i in 0..count {
let y = 10.0 * i as f64 / (count - 1) as f64;
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)
}
/// Three circles of different sizes at three heights, the middle one smaller: a shape that
/// changes as the face runs up it.
fn three_circles(n: usize) -> Vec<Vec<Frame>> {
vec![
// The first section is the equator of a sphere of its own radius: the support bends
// inwards by 1/r along the run, and its curvature does not change as it goes round.
circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |radial| radial * -0.1, Vec3::new(0.0, 0.0, -0.01)),
// The middle section's own shape data are ignored: the blend reads it from its neighbours.
circle_section(8.0, 10.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO),
// The last section's frames leave back into the blend, along −z.
circle_section(12.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO),
]
}
/// Every station of every section is on the face, within the tolerance a blend promises.
fn assert_runs_through(body: &Body, sections: &[Vec<Frame>], n: usize) {
let grid = sample_grid(body, sections.len(), n).unwrap();
for (k, section) in sections.iter().enumerate() {
for (s, f) in section.iter().enumerate() {
assert!(grid[k][s].dist(f.p) < 1e-9, "section {k} station {s}: {:?} vs {:?}", grid[k][s], f.p);
}
}
}
#[test]
fn three_sections_run_through_every_station() {
let n = 9;
let sections = three_circles(n);
let body = loft_blend(&sections, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, n);
// The ends hold what their frames ask for: the leave direction is the tangent, and the
// support's own curvature (1/10 inwards on the sphere, nothing on the plane) is the face's.
for s in [0usize, 4, 8] {
let v = s as f64 / (n - 1) as f64;
// The stations run round the circle at even angles.
let angle = std::f64::consts::TAU * s as f64 / n as f64;
let radial = Vec3::new(angle.cos(), angle.sin(), 0.0);
let (_, d1, d2, d3) = run_derivs(&body, 0.0, v, false, 1e-3);
let (t, bend, _) = curve_shape(d1, d2, d3);
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "the face leaves along +z: {t:?}");
assert!(bend.dist(radial * -0.1) < 1e-4, "the face leaves the sphere with its curvature: {bend:?}");
// The last section's frames leave back into the blend, so read the shape that way round.
let (_, d1, d2, d3) = run_derivs(&body, 1.0, v, true, 1e-3);
let (t, bend, _) = curve_shape(-d1, d2, -d3);
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "and arrives along the far leave: {t:?}");
assert!(bend.len() < 1e-3, "the last section's support is flat: {bend:?}");
}
// Three sections: the one interior joint has no fourth station to take a third difference
// over, so it holds position, tangent and curvature — C² — and both sides agree.
for s in [0usize, 4, 8] {
let v = s as f64 / (n - 1) as f64;
let (from_left, left1, left2, left3) = run_derivs(&body, 0.5, v, false, 1e-4);
let (from_right, right1, right2, right3) = run_derivs(&body, 0.5, v, true, 1e-4);
assert!(from_left.dist(from_right) < 1e-9, "the joint is on the middle section");
assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "the tangent is continuous: {left1:?} vs {right1:?}");
assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "the curvature is continuous: {left2:?} vs {right2:?}");
// But the change of curvature is not: a three-point stencil carries no third difference,
// so the joint is C² and not C³, and the difference stays whatever the step size is.
assert!((left3 - right3).len() > 0.05 * (1.0 + left3.len()), "a C² joint is not C³: {left3:?} vs {right3:?}");
// What it holds is the sections' own finite differences along the run: the secant of the
// two neighbours for the tangent, their second difference for the curvature.
let p = |k: usize| sections[k][s].p;
let secant = p(2) - p(0);
let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0;
assert!((left1 - secant).len() < 1e-7 * (1.0 + secant.len()), "the tangent is the neighbours' secant: {left1:?} vs {secant:?}");
assert!((left2 - second).len() < 1e-4 * (1.0 + second.len()), "the curvature is the neighbours' second difference: {left2:?} vs {second:?}");
}
}
#[test]
fn the_ends_hold_the_shape_their_frames_ask_for() {
let stations = 7;
let bend = Vec3::new(0.0, 0.2, 0.0);
let change = Vec3::new(0.0, 0.5, 0.0);
let sections = line_sections(3, stations, bend, change);
let z = |s: f64| s * 10.0;
// G1 holds the tangent and nothing more.
let g1 = loft_blend(&sections, [Continuity::G1, Continuity::G1], [0.0, 0.0], stations).unwrap();
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
let (p, d1, d2, d3) = run_derivs(&g1, 0.0, v, false, 1e-3);
let (t, _, _) = curve_shape(d1, d2, d3);
assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G1 blend leaves along leave: {t:?}");
}
// G2 holds the tangent and the support's curvature, at both ends.
let g2 = loft_blend(&sections, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap();
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
let (p, d1, d2, d3) = run_derivs(&g2, 0.0, v, false, 1e-3);
let (t, k, _) = curve_shape(d1, d2, d3);
assert!(p.dist(Vec3::new(x, 0.0, 0.0)) < 1e-9, "the face starts on the first section: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01, "a G2 blend leaves along leave: {t:?}");
assert!(k.dist(bend) / bend.len() < 1e-3, "a G2 blend holds the support's curvature: {k:?} vs {bend:?}");
let (p, d1, d2, d3) = run_derivs(&g2, 1.0, v, true, 1e-3);
let (t, k, c) = curve_shape(-d1, d2, -d3);
assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9, "the face ends on the last section: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01, "reading back, it arrives along the far leave: {t:?}");
assert!(k.dist(bend) / bend.len() < 1e-3, "the far end holds the curvature too: {k:?}");
assert!(c.len() < 1e3);
}
// A G2 blend leaves the change of curvature free: moving the frames' own value does not move
// the face at the first section, while a G3 blend follows it.
let (_, d1, d2, d3) = run_derivs(&g2, 0.0, 0.5, false, 1e-3);
let free = curve_shape(d1, d2, d3).2;
let moved = line_sections(3, stations, bend, change + Vec3::new(0.0, 1.0, 0.0));
let g2_moved = loft_blend(&moved, [Continuity::G2, Continuity::G2], [0.0, 0.0], stations).unwrap();
let (_, d1, d2, d3) = run_derivs(&g2_moved, 0.0, 0.5, false, 1e-3);
assert!(curve_shape(d1, d2, d3).2.dist(free) < 1e-9, "a G2 blend does not read the far frame's change");
// G3 holds the change of curvature as well: its normal part is what a surface match can hold,
// and here the frame's own change is normal (along its curvature vector).
let g3 = loft_blend(&sections, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap();
for (v, x) in [(0.0, 0.0), (0.5, 5.0), (1.0, 10.0)] {
let (_, d1, d2, d3) = run_derivs(&g3, 0.0, v, false, 1e-3);
let (t, k, c) = curve_shape(d1, d2, d3);
assert!(angle_deg(t, Vec3::new(0.0, 0.0, 1.0)) < 0.01);
assert!(k.dist(bend) / bend.len() < 1e-3, "a G3 blend still holds the curvature: {k:?}");
assert!((c.y - change.y).abs() < 1e-3, "a G3 blend holds the change of curvature: {c:?} vs {change:?}");
let (p, d1, d2, d3) = run_derivs(&g3, 1.0, v, true, 1e-3);
let (t, k, c) = curve_shape(-d1, d2, -d3);
assert!(angle_deg(t, Vec3::new(0.0, 0.0, -1.0)) < 0.01);
assert!(k.dist(bend) / bend.len() < 1e-3);
assert!((c.y - change.y).abs() < 1e-3, "and the far end holds it too: {c:?} vs {change:?}");
assert!(p.dist(Vec3::new(x, 0.0, z(2.0))) < 1e-9);
}
// With G3, moving the frames' own value does move the face there.
let g3_moved = loft_blend(&moved, [Continuity::G3, Continuity::G3], [0.0, 0.0], stations).unwrap();
let (_, d1, d2, d3) = run_derivs(&g3_moved, 0.0, 0.5, false, 1e-3);
assert!((curve_shape(d1, d2, d3).2.y - free.y).abs() > 0.5, "a G3 blend follows the frame's change");
}
#[test]
fn four_sections_join_at_c3() {
let n = 9;
let sections: Vec<Vec<Frame>> = [(10.0, 0.0), (9.0, 10.0), (8.0, 20.0), (11.0, 30.0)]
.iter()
.enumerate()
.map(|(i, &(r, z))| {
let leave = if i == 3 { Vec3::new(0.0, 0.0, -1.0) } else { Vec3::new(0.0, 0.0, 1.0) };
circle_section(r, z, n, leave, |_| Vec3::ZERO, Vec3::ZERO)
})
.collect();
let body = loft_blend(&sections, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, n);
// Every joint now has a fourth station for the third difference, so the two sides agree in
// the tangent, the curvature and its change: the joints are C³.
let h = 1.0 / 3.0;
for (i, s) in [(1usize, 0usize), (1, 4), (1, 8), (2, 0), (2, 4), (2, 8)] {
let t = i as f64 / 3.0;
let v = s as f64 / (n - 1) as f64;
let (_, left1, left2, left3) = run_derivs(&body, t, v, true, 1e-4);
let (_, right1, right2, right3) = run_derivs(&body, t, v, false, 1e-4);
assert!((left1 - right1).len() < 1e-7 * (1.0 + left1.len()), "joint {i} station {s}: the tangent agrees");
assert!((left2 - right2).len() < 1e-4 * (1.0 + left2.len()), "joint {i} station {s}: the curvature agrees");
// The change of curvature agrees too, as far as finite differences can tell: its
// mismatch falls away with the step size, so the joint is C³, not just C².
assert!(
(left3 - right3).len() < 2e-2 * (1.0 + left3.len()),
"joint {i} station {s}: the change of curvature agrees: {left3:?} vs {right3:?}"
);
// What they hold are the sections' own differences along the run.
let p = |k: usize| sections[k][s].p;
let d1 = (p(i + 1) - p(i - 1)) * (1.0 / (2.0 * h));
let d2 = (p(i + 1) - p(i) * 2.0 + p(i - 1)) * (1.0 / (h * h));
let d3 = if i == 1 {
(p(0) * -1.0 + p(1) * 3.0 - p(2) * 3.0 + p(3)) * (1.0 / (h * h * h))
} else {
(p(0) - p(1) * 3.0 + p(2) * 3.0 - p(3)) * (1.0 / (h * h * h))
};
assert!((left1 - d1).len() < 1e-7 * (1.0 + d1.len()), "the tangent is the secant: {left1:?} vs {d1:?}");
assert!((left2 - d2).len() < 1e-4 * (1.0 + d2.len()), "the curvature is the second difference: {left2:?} vs {d2:?}");
assert!((left3 - d3).len() < 2e-2 * (1.0 + d3.len()), "the change of curvature is the third difference: {left3:?} vs {d3:?}");
}
}
#[test]
fn a_shape_that_is_not_a_scaled_copy_still_runs_through() {
let n = 17;
let sections = vec![
circle_section(10.0, 0.0, n, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO),
rounded_rect_section(9.0, 7.0, 2.5, 10.0, n),
circle_section(8.0, 20.0, n, Vec3::new(0.0, 0.0, -1.0), |_| Vec3::ZERO, Vec3::ZERO),
];
let body = loft_blend(&sections, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &sections, n);
// The interior joint is still smooth to C² and holds the neighbours' own estimates, whatever
// the sections' shapes are.
for s in [0usize, 4, 8, 12, 16] {
let v = s as f64 / (n - 1) as f64;
let (_, left1, left2, _) = run_derivs(&body, 0.5, v, false, 2e-4);
let (_, right1, right2, _) = run_derivs(&body, 0.5, v, true, 2e-4);
assert!((left1 - right1).len() < 1e-6 * (1.0 + left1.len()), "station {s}: the tangent is continuous");
assert!((left2 - right2).len() < 1e-6 * (1.0 + left2.len()), "station {s}: the curvature is continuous");
let p = |k: usize| sections[k][s].p;
let secant = p(2) - p(0);
let second = (p(2) - p(1) * 2.0 + p(0)) * 4.0;
assert!((left1 - secant).len() < 1e-6 * (1.0 + secant.len()), "station {s}: the tangent is the secant: {left1:?} vs {secant:?}");
assert!((left2 - second).len() < 1e-6 * (1.0 + second.len()), "station {s}: the curvature is the second difference: {left2:?} vs {second:?}");
}
}
#[test]
fn a_two_section_blend_is_the_bridge() {
let (a, b) = valley_between_cylinders(9, 10.0);
let made = loft_blend(&[a.clone(), b.clone()], [Continuity::G2, Continuity::G2], [0.0, 0.0], 9).unwrap();
let other = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
// The blend runs with the sections, the bridge crosses from one boundary to the other: the
// same face, its two parameters the other way round.
for (run, station) in [(0.0, 0.0), (0.25, 0.3), (0.5, 0.7), (0.75, 0.1), (1.0, 1.0)] {
let x = from_p3(surface_of(&made).subs(run, station));
let y = from_p3(surface_of(&other).subs(station, run));
assert!(x.dist(y) < 1e-9, "run {run} station {station}: {x:?} vs {y:?}");
}
let grid = sample_grid(&other, 9, 7).unwrap();
for (row, frame) in grid.iter().zip(&a) {
assert!(row[0].dist(frame.p) < 1e-9);
}
}
#[test]
fn crown_shapes_the_middle_without_moving_the_joints() {
let n = 9;
let sections = three_circles(n);
let mut heights = Vec::new();
let mut joints = Vec::new();
for crown in [-0.5, 0.0, 1.0, 2.0] {
let body = loft_blend(&sections, [Continuity::G2, Continuity::G2], [crown, crown], n).unwrap();
// The interior joint holds the sections' own estimates whatever the crown does: those do
// not depend on it.
let (p, d1, d2, _) = run_derivs(&body, 0.5, 0.5, false, 2e-4);
assert!(p.dist(sections[1][4].p) < 1e-9, "the crown left the joint where the sections put it");
joints.push((d1, d2));
// Halfway up the first span is where a fuller crown reaches further.
heights.push(from_p3(surface_of(&body).subs(0.25, 0.5)).z);
assert_runs_through(&body, &sections, n);
}
for (d1, d2) in &joints {
let (base1, base2) = joints[0];
assert!((*d1 - base1).len() < 1e-4 * (1.0 + base1.len()), "the crown left the joint's tangent alone: {d1:?}");
assert!((*d2 - base2).len() < 1e-4 * (1.0 + base2.len()), "the crown left the joint's curvature alone: {d2:?}");
}
let steps: Vec<f64> = heights.windows(2).map(|w| w[1] - w[0]).collect();
assert!(steps.iter().all(|d| d.abs() > 1e-6 && d.signum() == steps[0].signum()), "a fuller crown moves the middle one way: {heights:?}");
}
#[test]
fn the_blend_refuses_what_it_cannot_hold() {
let n = 5;
let orders = [Continuity::G1, Continuity::G1];
let good = three_circles(n);
assert!(loft_blend(&good, orders, [0.0, 0.0], n).is_ok());
// Fewer than two sections, and more than the cap.
assert!(loft_blend(&good[..1], orders, [0.0, 0.0], n).is_err(), "one section is no blend");
let many: Vec<Vec<Frame>> =
(0..=MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 3, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect();
assert!(loft_blend(&many, orders, [0.0, 0.0], 3).is_err(), "more than {MAX_BLEND_SECTIONS} sections");
// Station counts that do not match each other, or the count asked for.
let mut unequal = good.clone();
unequal[1] = unequal[1][..n - 1].to_vec();
assert!(loft_blend(&unequal, orders, [0.0, 0.0], n).is_err(), "unequal station counts");
assert!(loft_blend(&good, orders, [0.0, 0.0], n + 1).is_err(), "the samples must match the sections");
for samples in [0usize, 1, MAX_BLEND_STATIONS + 1] {
assert!(loft_blend(&good, orders, [0.0, 0.0], samples).is_err(), "samples {samples}");
}
// A crown outside its range, and one that is not a number.
for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] {
assert!(loft_blend(&good, orders, [crown, crown], n).is_err(), "crown {crown}");
}
assert!(loft_blend(&good, orders, [0.0, f64::NAN], n).is_err());
// A frame that is not a number, wherever it is not, and a leave direction with no direction.
for pick in 0..4 {
let mut bad = good.clone();
let frame = bad[1][2];
bad[1][2] = match pick {
0 => Frame { p: Vec3::new(f64::NAN, 0.0, 0.0), ..frame },
1 => Frame { leave: Vec3::new(f64::INFINITY, 0.0, 0.0), ..frame },
2 => Frame { bend: Vec3::new(0.0, f64::NAN, 0.0), ..frame },
_ => Frame { bend_change: Vec3::new(0.0, 0.0, f64::NAN), ..frame },
};
assert!(loft_blend(&bad, orders, [0.0, 0.0], n).is_err(), "a non-finite frame {pick}");
}
let mut no_leave = good.clone();
no_leave[1][2].leave = Vec3::ZERO;
assert!(loft_blend(&no_leave, orders, [0.0, 0.0], n).is_err(), "a leave direction with no direction");
// Two sections that meet at a station: no room for a blend.
let mut meeting = good.clone();
meeting[1] = meeting[0].clone();
assert!(loft_blend(&meeting, orders, [0.0, 0.0], n).is_err(), "sections that meet");
let mut touching = good.clone();
touching[1][2].p = touching[0][2].p;
assert!(loft_blend(&touching, orders, [0.0, 0.0], n).is_err(), "a station where the sections meet");
// Every order from G0 to G3 builds, alone and together, and so does the largest blend.
for (near, far) in [
(Continuity::G0, Continuity::G0),
(Continuity::G1, Continuity::G0),
(Continuity::G0, Continuity::G3),
(Continuity::G3, Continuity::G3),
(Continuity::G2, Continuity::G3),
] {
let body = loft_blend(&good, [near, far], [0.0, 0.0], n).unwrap();
assert_runs_through(&body, &good, n);
}
let full: Vec<Vec<Frame>> =
(0..MAX_BLEND_SECTIONS).map(|i| circle_section(10.0, i as f64, 5, Vec3::new(0.0, 0.0, 1.0), |_| Vec3::ZERO, Vec3::ZERO)).collect();
let body = loft_blend(&full, [Continuity::G3, Continuity::G3], [0.0, 0.0], 5).unwrap();
assert_runs_through(&body, &full, 5);
}
+5 -4
View File
@@ -32,7 +32,8 @@ fn segment(s: &Seg2) -> Result<w::Segment> {
Seg2::Conic { a, apex, b, w } => w::Segment::Conic { points: [a, apex, b].map(point2), middle_weight: w },
Seg2::Bezier { n, p } => {
let count = usize::from(n) + 1;
if !(5..=8).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO)) {
if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&count) || p.get(count..).is_none_or(|unused| unused.iter().any(|p| *p != Vec2::ZERO))
{
return Err(invalid("unsupported certificate Bezier layout"));
}
w::Segment::Bezier { points: p.get(..count).ok_or_else(|| invalid("Bezier count"))?.iter().copied().map(point2).collect() }
@@ -46,10 +47,10 @@ fn load_segment(s: w::Segment) -> Result<Seg2> {
w::Segment::Cubic { points: [a, b, c, d] } => Seg2::Cubic { p0: p2(a), p1: p2(b), p2: p2(c), p3: p2(d) },
w::Segment::Conic { points: [a, apex, b], middle_weight } => Seg2::Conic { a: p2(a), apex: p2(apex), b: p2(b), w: middle_weight },
w::Segment::Bezier { points } => {
if !(5..=8).contains(&points.len()) {
if !(5..=solidcraft_geom::MAX_BEZIER_CTRL).contains(&points.len()) {
return Err(invalid("certificate Bezier degree"));
}
let mut p = [Vec2::ZERO; 8];
let mut p = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL];
let n = (points.len() - 1) as u8;
for (slot, value) in p.iter_mut().zip(points) {
*slot = p2(value);
@@ -324,7 +325,7 @@ mod tests {
for value in sources {
assert_eq!(value, source(&load_source(value.clone(), 0).unwrap(), 0).unwrap());
}
assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; 9] }).is_err());
assert!(load_segment(w::Segment::Bezier { points: vec![[0., 0.]; solidcraft_geom::MAX_BEZIER_CTRL + 1] }).is_err());
}
#[test]