Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)
Thanks @midasdf.
This commit is contained in:
1 parent
3126962cd4
commit
bbd4f29ebb
43 files changed
+4753
-132
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, §ion, 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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms, &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;
|
||||
@@ -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(§ions, [Continuity::G2, Continuity::G2], [0.0, 0.0], n).unwrap();
|
||||
assert_runs_through(&body, §ions, 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(§ions, [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(§ions, [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(§ions, [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(§ions, [Continuity::G3, Continuity::G3], [0.0, 0.0], n).unwrap();
|
||||
assert_runs_through(&body, §ions, 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(§ions, [Continuity::G1, Continuity::G1], [0.0, 0.0], n).unwrap();
|
||||
assert_runs_through(&body, §ions, 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(§ions, [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, §ions, 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);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user