Class-A surfacing 1/3: the continuity core, a G3 free-form blend and patch crown (#1249)

The continuity core (kernel bridge: Hermite control rows reproducing a support surface's position, tangent, curvature and change of curvature, G0 to G3), surface.blend between two edge chains (#764), and a crown for fitted patches. Existing patches and fillets are unchanged by default (crown 0 is the plain patch bit for bit). SurfaceBlend and Patch.crown are noted under format 22.
This commit is contained in:
midasdf authored and GitHub committed 2026-10-15 13:32:16 -04:00
1 parent 4e10c31fdd
commit e8e2ccc8a9
17 files changed
+1687 -56

No files matched your search

+653
View File
@@ -0,0 +1,653 @@
//! Continuity-driven bridging surfaces: one B-spline face that leaves one surface and arrives at
//! another with a requested continuity (G0 up to G3). This is the core the Blend, Boundary and
//! Match surfaces are built on.
//!
//! A bridge is a tensor product. Across it (v) every station's cross-section is a Bézier whose
//! leading control points reproduce the support surface's derivatives — the Hermite rows — so the
//! finished face carries the requested continuity by construction: one control row per condition
//! (position, tangent, curvature, change of curvature). Along it (u) those cross-sections are
//! interpolated by a cubic, so the face passes through every station exactly. Every condition is
//! stated against the support surface's own derivatives measured along the direction the bridge
//! leaves in, which makes G2 the curvature vector and G3 the way that curvature vector changes
//! along that direction (its normal part: what a surface-to-surface match can hold).
//!
//! What the support surface does along the leave direction is read off from samples on its own
//! parameter line (the line through the station that leaves in the leave direction at unit
//! speed) by finite differences: the second derivative of a surface curve with a unit tangent is
//! its curvature vector, and the third is the arclength derivative of that curvature vector.
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::{CurveN, HPoint, NurbsSurface};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::surfaces::surface_body;
use crate::{KernelError, Result, guard};
/// How much of a support surface a bridge repeats along its boundary.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Continuity {
/// The boundary curve alone: the surfaces meet, nothing else.
G0,
/// Position and tangent plane.
#[default]
G1,
/// Position, tangent plane and curvature vector.
G2,
/// Position, tangent plane, curvature vector and its change along the leave direction.
G3,
}
impl Continuity {
/// Conditions a side carries: 1 (G0) up to 4 (G3).
pub fn conditions(self) -> usize {
match self {
Continuity::G0 => 1,
Continuity::G1 => 2,
Continuity::G2 => 3,
Continuity::G3 => 4,
}
}
/// The word a command parameter uses.
pub fn as_str(self) -> &'static str {
match self {
Continuity::G0 => "g0",
Continuity::G1 => "g1",
Continuity::G2 => "g2",
Continuity::G3 => "g3",
}
}
/// Parse `g0`…`g3` (upper case accepted), `None` for anything else.
pub fn parse(s: &str) -> Option<Continuity> {
match s.to_ascii_lowercase().as_str() {
"g0" => Some(Continuity::G0),
"g1" => Some(Continuity::G1),
"g2" => Some(Continuity::G2),
"g3" => Some(Continuity::G3),
_ => None,
}
}
}
/// One station of a boundary: where the bridge starts, the direction it leaves in, and what the
/// support surface does there along that direction.
#[derive(Clone, Copy, Debug)]
pub struct Frame {
/// The point on the boundary.
pub p: Vec3,
/// Unit direction the bridge leaves *towards the other boundary*, in the tangent plane of
/// the support surface at `p`.
pub leave: Vec3,
/// The support surface's curvature vector along `leave` (zero on a plane).
pub bend: Vec3,
/// The arclength derivative of `bend` along `leave` (zero on a plane).
pub bend_change: Vec3,
}
/// The edges of `body` picked by points on them, chained: each runs on from the last, and the
/// chain may close. Used with [`frames`], whose chain must be a chain of one face's own edges.
pub fn chain(body: &Body, at: &[Vec3]) -> Result<Vec<mt::Edge>> {
body.require_brep("blend")?;
if at.is_empty() || at.len() > 10_000 || at.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid("pick 1 to 10000 points on the edges of a chain".into()));
}
let mut pool: Vec<mt::Edge> = Vec::new();
for p in at {
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid("no edge near a picked point".to_string()))?;
if !pool.iter().any(|x| x.id() == e.id()) {
pool.push(e);
}
}
let first = pool.remove(0);
let mut chain = vec![first];
// The picks need not be given in order: run forward from the first edge, then backwards.
let mut end = match chain.first() {
Some(e) => e.back().clone(),
None => return Err(KernelError::Invalid("pick at least one edge".into())),
};
while let Some(k) = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()) {
let e = pool.remove(k);
let e = if e.front().id() == end.id() { e } else { e.inverse() };
end = e.back().clone();
chain.push(e);
}
let mut start = match chain.first() {
Some(e) => e.front().clone(),
None => return Err(KernelError::Invalid("pick at least one edge".into())),
};
while let Some(k) = pool.iter().position(|e| e.front().id() == start.id() || e.back().id() == start.id()) {
let e = pool.remove(k);
let e = if e.back().id() == start.id() { e } else { e.inverse() };
start = e.front().clone();
chain.insert(0, e);
}
if !pool.is_empty() {
return Err(KernelError::Invalid("the picked edges do not run on from one another".into()));
}
Ok(chain)
}
/// Whether a chain of edges closes back on itself.
pub fn chain_closes(chain: &[mt::Edge]) -> bool {
let (Some(first), Some(last)) = (chain.first(), chain.last()) else { return false };
first.front().id() == last.back().id()
}
/// The stations of a chain of edges, `count` of them evenly along its length, and the length
/// itself. A chain that closes is sampled with its last station back on its first.
fn chain_samples(edges: &[mt::Edge], count: usize) -> Result<(Vec<Vec3>, f64)> {
/// Samples per edge used for the length table.
const PER_EDGE: usize = 64;
let mut poly: Vec<Vec3> = Vec::with_capacity(edges.len() * (PER_EDGE + 1));
for e in edges {
let c = e.curve();
let (t0, t1) = c.range_tuple();
if !(t1 > t0) || ![t0, t1].into_iter().all(f64::is_finite) {
return Err(KernelError::Invalid("an edge has no parameter range".into()));
}
for k in 0..=PER_EDGE {
poly.push(from_p3(c.subs(t0 + (t1 - t0) * k as f64 / PER_EDGE as f64)));
}
}
if chain_closes(edges)
&& let Some(first) = poly.first().copied()
{
poly.push(first);
}
let mut cum = Vec::with_capacity(poly.len());
cum.push(0.0);
for w in poly.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let Some(last) = cum.last().copied() else { break };
cum.push(last + a.dist(*b));
}
let total = cum.last().copied().unwrap_or(0.0);
if !(total > 1e-9) || !total.is_finite() {
return Err(KernelError::Invalid("the edge chain has no length".into()));
}
let mut out = Vec::with_capacity(count);
for i in 0..count {
let s = total * i as f64 / (count.max(2) - 1) as f64;
let hi = cum.partition_point(|x| *x < s).clamp(1, poly.len().saturating_sub(1));
let (Some(a), Some(b)) = (poly.get(hi - 1), poly.get(hi)) else { return Err(KernelError::Failed("bridge: sample".into())) };
let (Some(&lo), Some(&hi_s)) = (cum.get(hi - 1), cum.get(hi)) else { return Err(KernelError::Failed("bridge: sample".into())) };
let span = hi_s - lo;
let t = if span > 1e-12 { (s - lo) / span } else { 0.0 };
out.push(*a + (*b - *a) * t);
}
Ok((out, total))
}
/// The support surface's frames along `edges` (a chain of its own edges), for a bridge that
/// leaves in direction `leave` (projected into the tangent plane at each station).
pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) -> Result<Vec<Frame>> {
if !(2..=4097).contains(&count) {
return Err(KernelError::Invalid("a bridge needs 2 to 4097 stations".into()));
}
let leave =
leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?;
let (pts, length) = chain_samples(edges, count)?;
let surf = face.surface();
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
};
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
// next to its exact ring), so the projection is only asked to be close, not exact.
let tol = (length * 1e-4).max(1e-6);
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
// locally but round-off stays far below the geometry.
let mut step = (length * 1e-3).clamp(1e-6, 0.5);
let mut out = Vec::with_capacity(pts.len());
// The station before this one, as the hint for the projection: it keeps the search on the
// same side of a periodic or split surface.
let mut hint: Option<(f64, f64)> = None;
for p in &pts {
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(*p), hint, 100);
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(*p) <= tol;
// The station before this one is the hint where it lands close; a chain that wraps the
// surface (or a periodic one) falls back to a free search.
let (u, v) = match project(hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
Some(uv) => uv,
None => return Err(KernelError::Invalid("a picked edge is not on the support surface".into())),
};
hint = Some((u, v));
let at = from_p3(surf.subs(u, v));
if at.dist(*p) > tol {
return Err(KernelError::Invalid("a picked edge is not on the support surface".into()));
}
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z)
.normalized()
.ok_or_else(|| KernelError::Failed("bridge: the support surface has no normal".into()))?;
let w = (leave - n * leave.dot(n))
.normalized()
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))?;
// Parameter-space direction that leaves in `w` at unit 3D speed: S_u·du + S_v·dv = w.
let su = tangent(&surf, u, v, range_u, true);
let sv = tangent(&surf, u, v, range_v, false);
let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = a * c - b * b;
let scale = a.max(c).max(1e-30);
if !(det.abs() > 1e-12 * scale * scale) {
return Err(KernelError::Failed("bridge: the support surface is singular at the boundary".into()));
}
let du = (c * w.dot(su) - b * w.dot(sv)) / det;
let dv = (a * w.dot(sv) - b * w.dot(su)) / det;
// The samples of the parameter line. A station sits on the support surface's own
// boundary, so the line usually leaves the surface: the shape is then read from the
// samples of the one side that is still on it, which is the surface's own extension past
// that boundary (exact for a plane and a cylinder, what a blend continues from).
let slack = 1e-9;
let on = |t: f64, h: f64| {
let (x, y) = (u + t * h * du, v + t * h * dv);
x >= range_u.0 - slack && x <= range_u.1 + slack && y >= range_v.0 - slack && y <= range_v.1 + slack
};
let central = [-2.0, -1.0, 0.0, 1.0, 2.0];
let forward = [0.0, 1.0, 2.0, 3.0, 4.0];
let backward = [0.0, -1.0, -2.0, -3.0, -4.0];
let pick = |h: f64| -> Option<[f64; 5]> {
if central.iter().all(|t| on(*t, h)) {
Some(central)
} else if forward.iter().all(|t| on(*t, h)) {
Some(forward)
} else if backward.iter().all(|t| on(*t, h)) {
Some(backward)
} else {
None
}
};
let mut h = step;
let mut chosen = pick(h);
for _ in 0..8 {
if chosen.is_some() {
break;
}
h *= 0.25;
chosen = pick(h);
}
let Some(offsets) = chosen else {
return Err(KernelError::Invalid("the support surface is too small around a picked edge to read its shape".into()));
};
let q: Vec<Vec3> = offsets.iter().map(|t| from_p3(surf.subs(u + t * h * du, v + t * h * dv))).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(KernelError::Failed("bridge: samples".into()));
};
// Central samples read the line both ways; one-sided samples read it one way only from the
// station, backwards down it in the second case (the odd derivatives then change sign).
let (d1, d2, d3) = if offsets[0] == 0.0 && offsets[1] < 0.0 {
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (-1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (-1.0 / (2.0 * h * h * h));
(d1, d2, d3)
} else if offsets[0] == 0.0 {
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
(d1, d2, d3)
} else {
let d1 = (f0 * -1.0 + f1 * 8.0 + f3 * -8.0 + f4) * (1.0 / (12.0 * h));
let d2 = (f0 + f1 * -16.0 + f2 * 30.0 + f3 * -16.0 + f4) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -1.0 + f1 * 2.0 + f3 * -2.0 + f4) * (1.0 / (2.0 * h * h * h));
(d1, d2, d3)
};
let (t, bend, bend_change) = shape(d1, d2, d3)?;
// Read the same curve the other way when the surface's own parameter direction runs
// against the leave direction: the curvature vector is the same either way, the change of
// curvature turns round with the direction of travel.
let bend_change = if t.dot(w) < 0.0 { -bend_change } else { bend_change };
out.push(Frame { p: *p, leave: w, bend, bend_change });
step = h;
}
Ok(out)
}
/// A first derivative of the surface in one parameter, by central differences (one-sided where
/// the parameter is at the edge of the domain).
fn tangent(surf: &mt::Surface, u: f64, v: f64, range: (f64, f64), along_u: bool) -> Vec3 {
let span = (range.1 - range.0).max(1e-12);
let h = (span * 1e-5).max(1e-12);
let at = |x: f64| if along_u { from_p3(surf.subs(x, v)) } else { from_p3(surf.subs(u, x)) };
let x = if along_u { u } else { v };
if x - h >= range.0 && x + h <= range.1 {
(at(x + h) - at(x - h)) * (0.5 / h)
} else if x + h <= range.1 {
(at(x + h) - at(x)) * (1.0 / h)
} else {
(at(x.min(range.1)) - at((x - h).max(range.0))) * (1.0 / h)
}
}
/// The geometric shape of a surface curve at a point from its first three derivatives along the
/// curve: the unit tangent, the curvature vector and the arclength derivative of that curvature
/// vector. All three are what a surface-to-surface continuity condition is stated with.
fn shape(d1: Vec3, d2: Vec3, d3: Vec3) -> Result<(Vec3, Vec3, Vec3)> {
let sigma = d1.len();
if !(sigma > 1e-12) || !sigma.is_finite() {
return Err(KernelError::Failed("bridge: the support surface's parameter line does not move".into()));
}
let t = d1 * (1.0 / sigma);
let tp = d2 * (1.0 / sigma) - d1 * (d1.dot(d2) / (sigma * sigma * sigma));
let nn = d2 - t * d2.dot(t);
let np = d3 - t * d3.dot(t) - tp * d2.dot(t) - t * d2.dot(tp);
let kappa = np * (sigma * sigma) - nn * (2.0 * d1.dot(d2));
let bend_change = kappa * (1.0 / (sigma * sigma * sigma * sigma * sigma));
if ![t, nn, bend_change].into_iter().all(Vec3::is_finite) {
return Err(KernelError::Failed("bridge: the support surface's shape is not finite at the boundary".into()));
}
Ok((t, nn * (1.0 / (sigma * sigma)), bend_change))
}
/// 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 and is left at zero.
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("bridge: 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("bridge: the curvature condition is not finite".into()));
}
Ok(x - t * x.dot(t))
}
/// One station's cross-section: a Bézier whose leading control points hold the conditions of
/// each side. `speed` is how fast the cross-section leaves each boundary (any positive value; it
/// shapes the middle without touching the conditions).
fn section(a: &Frame, b: &Frame, orders: [Continuity; 2], n: usize, speed: [f64; 2]) -> Result<Vec<Vec3>> {
let (Some(speed_a), Some(speed_b)) = (speed.first().copied(), speed.get(1).copied()) else {
return Err(KernelError::Failed("bridge: speed".into()));
};
let (ka, kb) = (orders[0].conditions(), orders[1].conditions());
if ka + kb - 1 != n {
return Err(KernelError::Failed("bridge: degree".into()));
}
let mut c = vec![Vec3::ZERO; n + 1];
if let Some(slot) = c.get_mut(0) {
*slot = a.p;
}
if let Some(slot) = c.get_mut(n) {
*slot = b.p;
}
let k = n as f64;
// The first end: one control point per condition.
if ka >= 2 {
let p1 = a.p + a.leave * (speed_a / k);
if let Some(slot) = c.get_mut(1) {
*slot = p1;
}
if ka >= 3 {
if n < 2 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let p2 = a.bend * (speed_a * speed_a / (k * (k - 1.0))) + p1 * 2.0 - a.p;
if let Some(slot) = c.get_mut(2) {
*slot = p2;
}
if ka >= 4 {
if n < 3 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let d1 = (p1 - a.p) * k;
let d2 = (a.p - p1 * 2.0 + p2) * (k * (k - 1.0));
let a3 = third_derivative(d1, d2, a.bend_change)?;
if let Some(slot) = c.get_mut(3) {
*slot = a3 * (1.0 / (k * (k - 1.0) * (k - 2.0))) + p2 * 3.0 - p1 * 3.0 + a.p;
}
}
}
}
// The second end, from its own conditions read backwards.
if kb >= 2 {
let q1 = b.p + b.leave * (speed_b / k);
if let Some(slot) = c.get_mut(n - 1) {
*slot = q1;
}
if kb >= 3 {
if n < 2 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let q2 = b.bend * (speed_b * speed_b / (k * (k - 1.0))) + q1 * 2.0 - b.p;
if let Some(slot) = c.get_mut(n - 2) {
*slot = q2;
}
if kb >= 4 {
if n < 3 {
return Err(KernelError::Failed("bridge: degree".into()));
}
let d1 = (q1 - b.p) * k;
let d2 = (b.p - q1 * 2.0 + q2) * (k * (k - 1.0));
let q3 = third_derivative(d1, d2, b.bend_change)?;
if let Some(slot) = c.get_mut(n - 3) {
*slot = q3 * (1.0 / (k * (k - 1.0) * (k - 2.0))) + q2 * 3.0 - q1 * 3.0 + b.p;
}
}
}
}
if c.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Failed("bridge: the cross-section is not finite".into()));
}
Ok(c)
}
/// The bridge surface between two boundaries of `stations` frames each: one B-spline face
/// leaving `a` and arriving at `b` with the requested continuity. `crown` scales how fast the
/// cross-sections leave each boundary (0: the plain bridge, up to 3: fuller, down to -0.9:
/// flatter), which shapes the middle without touching the conditions at either end.
pub fn bridge(a: &[Frame], b: &[Frame], orders: [Continuity; 2], crown: [f64; 2]) -> Result<Body> {
let (Some(&crown_a), Some(&crown_b)) = (crown.first(), crown.get(1)) else { return Err(KernelError::Failed("bridge: crown".into())) };
if a.len() != b.len() || a.len() < 2 || a.len() > 4097 {
return Err(KernelError::Invalid("a bridge needs 2 to 4097 stations, the same on both sides".into()));
}
for c in [crown_a, crown_b] {
if !c.is_finite() || !(-0.9..=3.0).contains(&c) {
return Err(KernelError::Invalid("a bridge's crown must be between -0.9 and 3".into()));
}
}
let stations = a.len();
let n = orders[0].conditions() + orders[1].conditions() - 1;
if n == 0 || n > solidcraft_geom::bspline::MAX_SPLINE_DEGREE {
return Err(KernelError::Failed("bridge: degree".into()));
}
let mut layers: Vec<Vec<Vec3>> = vec![Vec::with_capacity(stations); n + 1];
for (fa, fb) in a.iter().zip(b) {
if !fa.p.is_finite() || !fb.p.is_finite() || !fa.leave.is_finite() || !fb.leave.is_finite() {
return Err(KernelError::Invalid("a bridge station must be finite".into()));
}
let chord = fa.p.dist(fb.p);
if !(chord > 1e-9) {
return Err(KernelError::Invalid("the two boundaries meet: there is no room for a bridge".into()));
}
let speed = [chord / 3.0 * (1.0 + crown_a), chord / 3.0 * (1.0 + crown_b)];
let controls = section(fa, fb, orders, n, speed)?;
for (layer, c) in layers.iter_mut().zip(controls) {
layer.push(c);
}
}
// Along the bridge: a cubic through the stations' cross-sections, so the face passes through
// each of them exactly.
let params: Vec<f64> = (0..stations).map(|i| i as f64 / (stations - 1) as f64).collect();
let degree = 3.min(stations - 1);
let mut knots_u: Option<Vec<f64>> = None;
let mut ctrl = vec![Vec3::ZERO; (n + 1) * stations];
for (j, layer) in layers.iter().enumerate() {
let points: Vec<[f64; 3]> = layer.iter().map(|p| [p.x, p.y, p.z]).collect();
let curve = CurveN::<3>::interpolate(&params, &points, degree).map_err(|e| KernelError::Failed(format!("bridge: {e}")))?;
if knots_u.is_none() {
knots_u = Some(curve.knots.clone());
}
for (i, c) in curve.ctrl.iter().enumerate() {
if let Some(slot) = ctrl.get_mut(j * stations + i) {
*slot = Vec3::new(c[0], c[1], c[2]);
}
}
}
let knots_u = knots_u.ok_or_else(|| KernelError::Failed("bridge: knots".into()))?;
let mut knots_v = vec![0.0; n + 1];
knots_v.extend(std::iter::repeat_n(1.0, n + 1));
let points: Vec<HPoint> = ctrl.iter().map(|p| HPoint::weighted(*p, 1.0)).collect();
let nurbs = NurbsSurface::from_homogeneous([degree, n], [knots_u, knots_v], points).map_err(|e| KernelError::Failed(format!("bridge: {e}")))?;
guard("bridge surface", || crate::spline_conv::tensor_face(&nurbs).map(|f| surface_body(vec![f])))
}
/// The surface of a bridge face as a grid of points, for callers that measure the result.
pub fn sample_grid(body: &Body, along: usize, across: usize) -> Result<Vec<Vec<Vec3>>> {
if !(2..=1025).contains(&along) || !(2..=1025).contains(&across) {
return Err(KernelError::Invalid("a grid needs 2 to 1025 samples each way".into()));
}
let face = body.solid.face_iter().next().ok_or_else(|| KernelError::Failed("bridge: no face".into()))?;
let surf = face.surface();
let (Some((u0, u1)), Some((v0, v1))) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the surface is unbounded".into()));
};
let mut out = Vec::with_capacity(along);
for i in 0..along {
let u = u0 + (u1 - u0) * i as f64 / (along - 1) as f64;
let mut row = Vec::with_capacity(across);
for j in 0..across {
let v = v0 + (v1 - v0) * j as f64 / (across - 1) as f64;
row.push(from_p3(surf.subs(u, v)));
}
out.push(row);
}
Ok(out)
}
/// The most any point of a bridge's boundary row is from the chain it was built on — the row at
/// `v0` for the first boundary, at `v1` for the second: what a stitch would have to close up.
fn boundary_gap(made: &Body, chain: &[mt::Edge], at_end: bool) -> Result<f64> {
let face = made.solid.face_iter().next().ok_or_else(|| KernelError::Failed("bridge: no face".into()))?;
let surf = face.surface();
let (Some((u0, u1)), Some((v0, v1))) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the surface is unbounded".into()));
};
let v = if at_end { v1 } else { v0 };
let (poly, _) = chain_samples(chain, 257)?;
let mut worst: f64 = 0.0;
for i in 0..=256 {
let u = u0 + (u1 - u0) * i as f64 / 256.0;
let p = from_p3(surf.subs(u, v));
let mut best = f64::INFINITY;
for w in poly.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
let t = if b.dist(*a) > 1e-12 { ((p - *a).dot(*b - *a) / b.dist(*a).powi(2)).clamp(0.0, 1.0) } else { 0.0 };
best = best.min(p.dist(*a + (*b - *a) * t));
}
worst = worst.max(best);
}
Ok(worst)
}
/// The midpoint of a chain of edges.
fn chain_mid(edges: &[mt::Edge]) -> Result<Vec3> {
let (pts, _) = chain_samples(edges, 3)?;
pts.get(1).copied().ok_or_else(|| KernelError::Failed("bridge: no chain".into()))
}
/// Which face of `body` the blend should continue from a chain, and the direction it leaves in:
/// the face whose tangent plane holds as much of `leave` as possible.
fn support_face(body: &Body, chain: &[mt::Edge], leave: Vec3) -> Result<(mt::Face, Vec3)> {
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 mid = chain_mid(chain)?;
let surf = face.surface();
let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) else { continue };
if from_p3(surf.subs(u, v)).dist(mid) > body.size() * 1e-6 + 1e-9 {
continue;
}
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z).normalized().unwrap_or(Vec3::ZERO);
let in_plane = leave - n * leave.dot(n);
let score = in_plane.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 other boundary lies in no face at the picked edges: give `align` points to leave by".into(),
));
}
Ok((face, leave))
}
/// A blend surface between two picked edge chains of two bodies: the surfaces the blend leaves
/// and arrives at are the faces the chains bound (the best-facing one at each side), and each
/// side repeats that surface up to its requested continuity. `align` overrides the direction
/// each side leaves in (points in world space); `crown` how full each side is; `stitch` sews the
/// blend to its two bodies where their boundaries meet. The reply's `gap`, if any, is how far the
/// blend's own boundary lay from the chains it was built on.
pub fn blend_between(
a: &Body,
picks_a: &[Vec3],
b: &Body,
picks_b: &[Vec3],
orders: [Continuity; 2],
crown: [f64; 2],
align: Option<[Vec3; 2]>,
samples: usize,
stitch: bool,
) -> Result<(Body, f64)> {
a.require_brep("blend")?;
b.require_brep("blend")?;
if !(5..=401).contains(&samples) {
return Err(KernelError::Invalid("a blend needs 5 to 401 stations".into()));
}
let chain_a = chain(a, picks_a)?;
let chain_b = chain(b, picks_b)?;
let (mid_a, mid_b) = (chain_mid(&chain_a)?, chain_mid(&chain_b)?);
let (leave_a, leave_b) = match align {
Some([p, q]) => (
(p - mid_a).normalized().ok_or_else(|| KernelError::Invalid("`align` must not sit on the boundary".into()))?,
(q - mid_b).normalized().ok_or_else(|| KernelError::Invalid("`align` must not sit on the boundary".into()))?,
),
None => (
(mid_b - mid_a).normalized().ok_or_else(|| KernelError::Invalid("the two boundaries coincide: there is nothing to blend".into()))?,
(mid_a - mid_b).normalized().ok_or_else(|| KernelError::Invalid("the two boundaries coincide: there is nothing to blend".into()))?,
),
};
let (face_a, leave_a) = support_face(a, &chain_a, leave_a)?;
let (face_b, leave_b) = support_face(b, &chain_b, leave_b)?;
let frames_a = frames(&face_a, &chain_a, leave_a, samples)?;
let mut frames_b = frames(&face_b, &chain_b, leave_b, samples)?;
// The two chains are sampled from their own starts: turn one round when that pairs the
// stations better (the blend is built station to station).
let direct: f64 = frames_a.iter().zip(&frames_b).map(|(x, y)| x.p.dist(y.p)).sum();
let reversed: f64 = frames_a.iter().zip(frames_b.iter().rev()).map(|(x, y)| x.p.dist(y.p)).sum();
if reversed < direct {
frames_b.reverse();
}
let made = bridge(&frames_a, &frames_b, orders, crown)?;
let gap = boundary_gap(&made, &chain_a, false)?.max(boundary_gap(&made, &chain_b, true)?);
let faces: Vec<mt::Face> = made.solid.face_iter().cloned().collect();
let mut rec = crate::record::Rebuild::default();
for f in &faces {
rec.made(f, Vec::new());
}
let blend = crate::record::rebuilt_body(surface_body(faces), &[&a.solid, &b.solid], &rec);
if !stitch {
return Ok((blend, gap));
}
// Sewing up a boundary the blend only approximates: the stitch is given what the boundary
// actually strays, so that a real gap between the bodies is still refused.
let out = crate::surfaces::stitch(&[a, b, &blend], gap * 1.05 + 1e-9)?;
Ok((out, gap))
}
#[cfg(test)]
#[path = "bridge_tests.rs"]
mod tests;
+244
View File
@@ -0,0 +1,244 @@
//! Tests of the bridge core: the support surface's shape is read off a real body, and a bridge's
//! continuity is measured back off the surface it built.
use super::*;
use crate::{box_solid, cylinder};
/// The shape of a bridge's cross-section at the parameter `u`, measured off the finished face (not
/// from the construction): position, unit tangent, curvature vector and the arclength derivative
/// of that curvature vector at the start of the cross-section. The samples run inwards from the
/// boundary, so nothing is asked of the surface outside its own domain.
fn section_at(body: &Body, u: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let (Some(_), Some((v0, _))) = surf.try_range_tuple() else { panic!("unbounded") };
let h = 1e-3;
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(u, v0 + k as f64 * h))).collect();
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (1.0 / (2.0 * h * h * h));
let (t, bend, bend_change) = shape(d1, d2, d3).unwrap();
(f0, t, bend, bend_change)
}
/// The same at the end of the cross-section (`v = v1`), with the samples running back inwards and
/// the odd derivatives turned round.
fn section_end_at(body: &Body, u: f64) -> (Vec3, Vec3, Vec3, Vec3) {
let face = body.solid.face_iter().next().unwrap();
let surf = face.surface();
let (Some(_), Some((_, v1))) = surf.try_range_tuple() else { panic!("unbounded") };
let h = 1e-3;
let q: Vec<Vec3> = (0..5).map(|k| from_p3(surf.subs(u, v1 - k as f64 * h))).collect();
let (f0, f1, f2, f3, f4) = (q[0], q[1], q[2], q[3], q[4]);
let d1 = (f0 * -25.0 + f1 * 48.0 + f2 * -36.0 + f3 * 16.0 + f4 * -3.0) * (-1.0 / (12.0 * h));
let d2 = (f0 * 35.0 + f1 * -104.0 + f2 * 114.0 + f3 * -56.0 + f4 * 11.0) * (1.0 / (12.0 * h * h));
let d3 = (f0 * -5.0 + f1 * 18.0 + f2 * -24.0 + f3 * 14.0 + f4 * -3.0) * (-1.0 / (2.0 * h * h * h));
let (t, bend, bend_change) = shape(d1, d2, d3).unwrap();
(f0, t, bend, bend_change)
}
/// 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()
}
/// Two planes 10 mm apart: the lower one leaves along +y, the upper one has no curvature, and the
/// direction between them is +z — an S-shaped bridge.
fn two_planes(count: usize) -> (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::ZERO, bend_change: Vec3::ZERO });
b.push(Frame { p: Vec3::new(0.0, y, 10.0), leave: Vec3::new(0.0, -1.0, 0.0), bend: Vec3::ZERO, bend_change: Vec3::ZERO });
}
(a, b)
}
/// Two parallel circular cylinders of radius `r`, axes along x at z = r, 20 mm apart in y: their
/// lowest lines are the two boundaries. Each leaves towards the other along ∓y, bends up towards
/// its own axis (curvature 1/r), and its curvature does not change as it goes round — what does
/// change is purely tangential.
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)
}
/// The edges of `face` chained into one loop.
fn loop_of(face: &mt::Face) -> Vec<mt::Edge> {
let mut pool: Vec<mt::Edge> = face.edge_iter().collect();
let mut out = vec![pool.remove(0)];
let mut end = out[0].back().clone();
while !pool.is_empty() {
let k = pool.iter().position(|e| e.front().id() == end.id() || e.back().id() == end.id()).unwrap();
let e = pool.remove(k);
let e = if e.front().id() == end.id() { e } else { e.inverse() };
end = e.back().clone();
out.push(e);
}
out
}
/// The face of `body` that contains `at`.
fn face_at(body: &Body, at: Vec3) -> mt::Face {
body.solid
.face_iter()
.find(|f| f.surface().search_nearest_parameter(p3(at), None, 100).is_some_and(|(u, v)| from_p3(f.surface().subs(u, v)).dist(at) < 1e-6))
.unwrap()
.clone()
}
#[test]
fn frames_read_a_plane_and_a_cylinder_wall() {
// A 40 x 30 x 20 box: the top face's rim leaves along +x, a plane with no curvature.
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let top = face_at(&b, Vec3::new(20.0, 15.0, 20.0));
let rim = loop_of(&top);
assert!(chain_closes(&rim));
let f = frames(&top, &rim, Vec3::new(1.0, 0.0, 0.0), 9).unwrap();
assert_eq!(f.len(), 9);
for s in &f {
assert!((s.p.z - 20.0).abs() < 1e-9, "{s:?}");
assert!(angle_deg(s.leave, Vec3::new(1.0, 0.0, 0.0)) < 1e-6, "{s:?}");
assert!(s.bend.len() < 1e-6 && s.bend_change.len() < 1e-6, "{s:?}");
}
// The chain's picks work the same way: the rim's own points pick the rim's own edges.
let picked = chain(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0)]).unwrap();
assert_eq!(picked.len(), 2);
// A cylinder's wall: along the axis it is straight, and a leave direction that is normal to it
// has no tangent plane to work in.
let c = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), 10.0, 30.0).unwrap();
let wall = face_at(&c, Vec3::new(10.0, 0.0, 15.0));
let ring = loop_of(&wall);
let f = frames(&wall, &ring, Vec3::new(0.0, 0.0, 1.0), 5).unwrap();
for s in &f {
assert!(s.bend.len() < 1e-6, "the axis direction is straight: {s:?}");
}
// A leave direction that is normal to the support surface has no tangent plane to work in.
assert!(frames(&top, &rim, Vec3::new(0.0, 0.0, 1.0), 9).is_err(), "a face's own normal is no direction to leave in");
// On the cylinder's wall only a direction with a tangential part is usable.
assert!(frames(&wall, &ring, Vec3::new(1.0, 0.0, 0.0), 5).is_ok());
}
#[test]
fn a_g1_bridge_meets_both_boundaries_tangentially() {
let (a, b) = two_planes(9);
let body = bridge(&a, &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).unwrap();
for u in [0.0, 0.5, 1.0] {
let (_, t, _, _) = section_at(&body, u);
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "the bridge leaves along the tangent: {u}");
}
// The boundary itself is on each support: the first and last rows of the grid.
let grid = sample_grid(&body, 9, 7).unwrap();
for (row, frame) in grid.iter().zip(&a) {
assert!(row[0].dist(frame.p) < 1e-9, "{:?} vs {:?}", row[0], frame.p);
}
for (row, frame) in grid.iter().zip(&b) {
assert!(row[6].dist(frame.p) < 1e-9, "{:?} vs {:?}", row[6], frame.p);
}
}
#[test]
fn g2_and_g3_bridges_repeat_the_support_shape() {
let (a, b) = valley_between_cylinders(9, 10.0);
// A G2 bridge to two cylinders: each end holds the cylinder's own curvature (1/10 towards its
// axis) and leaves along its tangent.
let g2 = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
for (i, u) in [(0usize, 0.0), (4, 0.5), (8, 1.0)] {
let (p, t, bend, _) = section_at(&g2, u);
assert!(p.dist(a[i].p) < 1e-9, "the boundary is on the cylinder: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "leaves along the tangent: {u}");
assert!((bend.dist(a[i].bend) / a[i].bend.len()) < 1e-3, "curvature {bend:?} vs {:?}", a[i].bend);
}
// The far end, read back to front.
let (p, t, bend, _) = section_end_at(&g2, 1.0);
assert!(p.dist(b[8].p) < 1e-9, "the far boundary is on its cylinder: {p:?}");
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "arrives along the far tangent");
assert!((bend.dist(b[8].bend) / b[8].bend.len()) < 1e-3, "curvature {bend:?} vs {:?}", b[8].bend);
// A G2 bridge leaves the change of curvature free; asking for G3 pins its normal part to the
// cylinder's own (zero: a cylinder's curvature does not change as you go round it).
let (_, _, _, free) = section_at(&g2, 0.0);
let g3 = bridge(&a, &b, [Continuity::G3, Continuity::G3], [0.0, 0.0]).unwrap();
let (_, _, bend3, held) = section_at(&g3, 0.0);
assert!((bend3.dist(a[0].bend) / a[0].bend.len()) < 1e-3, "G3 still holds the curvature: {bend3:?}");
let along_normal = |v: Vec3| v.dot(Vec3::new(0.0, 0.0, 1.0));
assert!(along_normal(held).abs() < 1e-3, "a G3 bridge holds the change of curvature: {held:?}");
assert!(along_normal(free).abs() > 1e-3, "a G2 bridge is free to change it: {free:?}");
}
#[test]
fn crown_shapes_the_middle_without_touching_the_ends() {
let (a, b) = valley_between_cylinders(11, 10.0);
let base = bridge(&a, &b, [Continuity::G2, Continuity::G2], [0.0, 0.0]).unwrap();
let (_, t0, bend0, _) = section_at(&base, 0.0);
let mut heights = Vec::new();
for crown in [-0.5, 0.0, 1.0, 2.0] {
let body = bridge(&a, &b, [Continuity::G2, Continuity::G2], [crown, crown]).unwrap();
let grid = sample_grid(&body, 11, 9).unwrap();
heights.push(grid[5][4].z);
// The ends hold in every case: the same boundary points, the same tangent, and the same
// seam curvature as the uncrowned bridge.
let (_, t, bend, _) = section_at(&body, 0.0);
assert!(angle_deg(t, t0) < 0.01, "crown {crown} left the tangent alone");
assert!(bend.dist(bend0) < 1e-9, "crown {crown} left the curvature alone: {bend:?} vs {bend0:?}");
assert!(grid[5][0].dist(a[5].p) < 1e-9 && grid[5][8].dist(b[5].p) < 1e-9, "crown {crown} left the boundary alone");
}
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_bridge_refuses_what_it_cannot_hold() {
let (a, b) = two_planes(9);
// Different station counts.
assert!(bridge(&a[..6], &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// A crown outside its range, and a crown that is not a number.
for crown in [f64::NAN, 5.0, -1.0, f64::INFINITY] {
assert!(bridge(&a, &b, [Continuity::G1, Continuity::G1], [crown, crown]).is_err(), "{crown}");
}
// Boundaries that meet: no room for a bridge.
let same: Vec<Frame> = a.iter().map(|f| Frame { p: f.p, ..*f }).collect();
assert!(bridge(&a, &same, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// A station that is not a number.
let mut bad = a.clone();
bad[3].leave = Vec3::new(f64::NAN, 0.0, 0.0);
assert!(bridge(&bad, &b, [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// Fewer than two stations.
assert!(bridge(&a[..1], &b[..1], [Continuity::G1, Continuity::G1], [0.0, 0.0]).is_err());
// Every order from G0 to G3 builds.
for (x, y) in [(Continuity::G0, Continuity::G0), (Continuity::G1, Continuity::G0), (Continuity::G3, Continuity::G1)] {
let body = bridge(&a, &b, [x, y], [0.0, 0.0]).unwrap();
let (_, t, _, _) = section_at(&body, 0.0);
if x != Continuity::G0 {
assert!(angle_deg(t, Vec3::new(0.0, 1.0, 0.0)) < 0.01, "{x:?}");
}
}
}
#[test]
fn continuity_parses_its_words() {
assert_eq!(Continuity::parse("g0"), Some(Continuity::G0));
assert_eq!(Continuity::parse("G3"), Some(Continuity::G3));
assert_eq!(Continuity::parse("g9"), None);
assert_eq!(Continuity::parse(""), None);
assert_eq!([Continuity::G0, Continuity::G1, Continuity::G2, Continuity::G3].map(Continuity::conditions), [1, 2, 3, 4]);
}
+88 -2
View File
@@ -16,6 +16,13 @@ use crate::{KernelError, Result};
/// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it
/// under [`crate::guard`].
pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result<Body> {
fill_loop_crowned(body, chain, 0.0)
}
/// [`fill_loop`] with a crown: positive makes the fitted patch's middle fuller, negative flatter,
/// by `crown` times the loop's size (see `solidcraft_geom::fill::fill_loop`). The bump vanishes on
/// the loop, so the face still meets the body's own edges exactly as the plain fill does.
pub(crate) fn fill_loop_crowned(body: &Body, chain: &[mt::Edge], crown: f64) -> Result<Body> {
let tol = face_tolerance(body);
let mut sides: Vec<(EdgeSampler, Vec3)> = Vec::with_capacity(chain.len());
for e in chain {
@@ -27,7 +34,7 @@ pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result<Body> {
// The loop's corners are its vertices (an imported edge's curve may end a little off).
if t <= 0.0 { Some(*start) } else { sampler.at(t) }
};
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol).map_err(|e| match e {
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown).map_err(|e| match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
})?;
@@ -40,6 +47,37 @@ pub(crate) fn fill_loop(body: &Body, chain: &[mt::Edge]) -> Result<Body> {
Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec))
}
/// [`crate::patch_edges`] with a crown on the fitted face: positive makes the patch's middle
/// fuller, negative flatter, by `crown` times the loop's size (its projected box's diagonal), the
/// bump vanishing along the loop, so the face still meets the body's own edges. `crown` 0 is
/// exactly [`crate::patch_edges`], and a loop that is certainly planar ([`crate::plane_fit`])
/// makes the same exact planar face whatever the crown. Its history is over `[body]`: the face is
/// new, its edges the body's own.
pub fn patch_edges_crowned(body: &Body, at: &[Vec3], crown: f64) -> Result<Body> {
if crown == 0.0 {
return crate::surfaces::patch_edges(body, at);
}
body.require_brep("patch")?;
if at.is_empty() || at.len() > 10_000 {
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
}
// A range test also throws out NaN.
if !(solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(&crown) {
return Err(KernelError::Invalid("a patch's crown must be between -0.9 and 3".into()));
}
crate::guard("patch", || {
let chain = crate::surfaces::picked_loop(body, at)?;
let Some(face) = crate::plane_fit::planar_face(&chain, planarity_tolerance(body))? else {
// Not certainly planar: a fitted surface, crowned.
return fill_loop_crowned(body, &chain, crown);
};
// A new face on the body's own edges (a plane has no middle to fill).
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
Ok(rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec))
})
}
/// How far a loop's edges may be from a plane for its patch to be that plane: `10⁻⁷` of the
/// body's size, at least the linear tolerance (`10⁻⁶` mm) — the body's own "same point"
/// tolerance, since a planar face is exact and other operations treat it as containing its
@@ -71,7 +109,7 @@ mod tests {
use crate::body::{Body, from_p3, p3};
use crate::boolean_record::tests::{Reach, checked};
use crate::history::{Change, EdgeOrigin};
use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, stitch};
use crate::{BoolOp, boolean, copy_faces, cylinder, extrude, measure, patch_edges, patch_edges_crowned, stitch};
/// The cutting cylinder: axis along y at x = 0, z = 50, radius 40, so the top of a prism
/// within 20 of the axis and 20 high is cut away everywhere (to between 10 and 15.4 mm).
@@ -524,4 +562,52 @@ mod tests {
assert!(patch_edges(&open, &[Vec3::new(1e300, 0.0, 0.0)]).is_err());
assert!(patch_edges(&open, &vec![rim[0]; 20_000]).is_err());
}
/// A crowned patch: the middle is fuller by `crown` times the loop's size, the loop's own
/// edges stay exactly where they were (the face still meets them within [`face_tolerance`]),
/// and a planar loop is the same exact plane whatever the crown.
#[test]
fn a_crowned_patch_fills_its_middle_and_keeps_its_edges() {
let (_, open, rim) = cup(&regular(5, 0.3), Vec2::ZERO);
let plain = patch_edges(&open, &rim).unwrap();
let same = patch_edges_crowned(&open, &rim, 0.0).unwrap();
// Crown 0 is the plain patch: the same surface, to the last bit.
assert_eq!(measure(&plain).unwrap().area, measure(&same).unwrap().area, "crown 0 is patch_edges");
for crown in [0.25, -0.25] {
let full = patch_edges_crowned(&open, &rim, crown).unwrap();
assert_eq!(full.face_count(), 1, "one face");
let face = full.solid.face_iter().next().unwrap().clone();
assert!(!matches!(face.surface(), mt::Surface::Plane(_)));
let own: Vec<mt::EdgeID> = open.solid.edge_iter().map(|e| e.id()).collect();
assert!(full.solid.edge_iter().all(|e| own.contains(&e.id())), "an edge that is not the body's");
// The loop's edges are within the fill's tolerance of the face, as for the plain fill.
let off = edges_off_surface(&face);
assert!(off <= super::face_tolerance(&open), "an edge is {off} off the crowned face");
mesh_is_clean(&full);
// The middle of the loop's box moved by crown × its diagonal (the bump is 1 there).
let a = plain.solid.face_iter().next().unwrap().surface();
let b = face.surface();
let (Some((bu0, bu1)), Some((bv0, bv1))) = a.try_range_tuple() else { panic!("a bounded surface") };
let (uc, vc) = ((bu0 + bu1) / 2.0, (bv0 + bv1) / 2.0);
let size = (bu1 - bu0).hypot(bv1 - bv0);
let bump = a.subs(uc, vc).distance(b.subs(uc, vc));
let want = (crown * size).abs();
eprintln!("crown {crown}: the middle moved {bump:.6}, wanted {want:.6}");
assert!((bump - want).abs() < 1e-6 * size, "middle {bump} against {want}");
}
// A planar loop: the exact plane, crowned or not (the crown has no middle to fill).
let boxed = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let open = copy_faces(
&boxed,
&[Vec3::new(5.0, 5.0, 0.0), Vec3::new(5.0, 0.0, 5.0), Vec3::new(10.0, 5.0, 5.0), Vec3::new(5.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)],
)
.unwrap();
let rim = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)];
let lid = patch_edges_crowned(&open, &rim, 1.0).unwrap();
assert!(matches!(lid.solid.face_iter().next().unwrap().surface(), mt::Surface::Plane(_)), "a planar loop is a plane");
// Hostile crowns are refused before anything is built.
for crown in [f64::NAN, f64::INFINITY, 3.01, -0.91, 1e300] {
assert!(patch_edges_crowned(&open, &rim, crown).is_err(), "crown {crown}");
}
}
}
+3
View File
@@ -22,6 +22,7 @@ mod boolean_diagnostics;
mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod bridge;
mod build;
mod clearance;
mod coplanar;
@@ -117,12 +118,14 @@ pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, sample_grid};
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 delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use fill::patch_edges_crowned;
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;
+28
View File
@@ -4,6 +4,7 @@
use solidcraft_geom::bspline::{HPoint, NurbsSurface};
use truck_modeling as mt;
use crate::body::p3;
use crate::{KernelError, Result};
fn fail(m: impl std::fmt::Display) -> KernelError {
@@ -34,3 +35,30 @@ pub(crate) fn surface(s: &NurbsSurface) -> Result<mt::Surface> {
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(s.point(i, j))).collect()).collect();
nurbs_surface((mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone())), rows)
}
/// One face of a whole tensor surface: its four boundary curves as edges, sharing the corners.
/// This is what a patch built as a single control net (`bridge`, `boundary`, the T-spline sheet)
/// hands to the B-rep.
pub(crate) fn tensor_face(s: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = s.count();
if nu < 2 || nv < 2 {
return Err(fail("a face needs at least 2 x 2 control points"));
}
let [ku, kv] = s.knots();
let corners = [s.point(0, 0), s.point(nu - 1, 0), s.point(nu - 1, nv - 1), s.point(0, nv - 1)];
let mut v: Vec<mt::Vertex> = Vec::with_capacity(4);
for c in corners {
let p = c.project().ok_or_else(|| fail("a corner has no position"))?;
v.push(mt::builder::vertex(p3(p)));
}
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| s.point(i, j)).collect() };
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| s.point(i, j)).collect() };
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
let bottom = edge(v0, v1, nurbs_curve(mt::KnotVec::from(ku.clone()), &row(0))?)?;
let right = edge(v1, v2, nurbs_curve(mt::KnotVec::from(kv.clone()), &column(nu - 1))?)?;
let top = edge(v3, v2, nurbs_curve(mt::KnotVec::from(ku.clone()), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(mt::KnotVec::from(kv.clone()), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
mt::Face::try_new(vec![wire], surface(s)?).map_err(fail)
}
+1 -15
View File
@@ -42,21 +42,7 @@ fn vertex(h: HPoint) -> Result<mt::Vertex> {
/// One face from a clamped tensor surface, bounded by its four boundary curves.
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
let [nu, nv] = n.count();
let [ku, kv] = n.knots();
let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)];
let v: Vec<mt::Vertex> = corners.into_iter().map(vertex).collect::<Result<_>>()?;
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| n.point(i, j)).collect() };
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| n.point(i, j)).collect() };
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?;
let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?;
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
mt::Face::try_new(vec![wire], crate::spline_conv::surface(n)?).map_err(fail)
crate::spline_conv::tensor_face(n)
}
/// A parameter point, exact (knot values are shared, so equal points have equal bits).