mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 19:45:54 +00:00
brep: a revolution of a closed profile is periodic along it
The adapter loads an analytic torus as truck's revolution of the whole profile
circle, a rational quadratic closed on itself, so an SMLib torus is one face
bounded by its two seam circles, each used both ways. The display evaluator's
domain gave a revolution a period around its axis only: projected along the
profile the loop jumped from one end of the knot interval to the other at its
start vertex, the plan failed ("boundary crosses itself") and the body fell
back to truck's mesher, which meshes a sphere bounded by its seam alone as
nothing (seed 60 union, a torus beside a sphere: 925.0 of 990.7 mm³).
tess::uv::closed_curve detects a closed profile the way closed does a closed
spline surface (clamped knots, first and last control point and weight the
same within 1e-9 of the polygon's extent; the shared checks are factored out),
the domain then has period = the profile's knot interval along it, and
EvalSurface::wrap/derivs wrap that parameter back into the interval, so the
plan's projection, unwrapping and both evaluators treat it as periodic. A
cylinder's or cone's line profile is unchanged.
Tests: a revolved whole circle, either parameter order, has both periods and
evaluates a period away as inside; SMLib's torus, alone and beside a sphere,
decodes without meshing to orient it, plans every face and measures within
1e-3 of 2π²Rr² (+ 4/3 πr³). With the closed profile undone the torus plan
fails as above.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
+17
-3
@@ -237,10 +237,23 @@ impl EvalSurface {
|
||||
}
|
||||
}
|
||||
|
||||
/// `uv` with each parameter along which a spline closes on itself moved by whole periods
|
||||
/// into the spline's active knot interval (unchanged inside it, and for any other surface):
|
||||
/// the parameters both evaluators take for a point of a closed spline found a period away.
|
||||
/// `uv` with each parameter along which a spline closes on itself (or a revolution's
|
||||
/// profile does) moved by whole periods into the active knot interval (unchanged inside it,
|
||||
/// and for any other surface): the parameters both evaluators take for a point of a closed
|
||||
/// spline found a period away.
|
||||
pub fn wrap(&self, uv: [f64; 2]) -> [f64; 2] {
|
||||
if let EvalSurface::Revolved { profile, swap, .. } = self {
|
||||
let d = usize::from(*swap);
|
||||
let range = (|| Some((*profile.knots.get(profile.degree)?, *profile.knots.get(profile.ctrl.len())?)))();
|
||||
let mut out = uv;
|
||||
if let (Some(r), Some(x)) = (range, out.get_mut(d))
|
||||
&& !(r.0..=r.1).contains(x)
|
||||
&& uv::closed_curve(profile)
|
||||
{
|
||||
*x = uv::wrap_into(*x, r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let EvalSurface::Nurbs { degree, knots, count, ctrl, .. } = self else { return uv };
|
||||
let range = |d: usize| Some((*knots.get(d)?.get(*degree.get(d)?)?, *knots.get(d)?.get(*count.get(d)?)?));
|
||||
let outside: Vec<usize> = (0..2).filter(|&d| range(d).is_some_and(|(a, b)| !(a..=b).contains(&uv[d]))).collect();
|
||||
@@ -270,6 +283,7 @@ impl EvalSurface {
|
||||
nurbs_derivs(*degree, knots, *count, ctrl, u, v)
|
||||
}
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
let [u, v] = self.wrap([u, v]);
|
||||
let (s, a) = if *swap { (v, u) } else { (u, v) };
|
||||
let (c, dc) = profile.eval(s)?;
|
||||
let (sin, cos) = a.sin_cos();
|
||||
|
||||
+81
-18
@@ -6,7 +6,8 @@
|
||||
//! weights included, and its knots are clamped there: a NURBS cylinder, a fillet ring round a
|
||||
//! hole's rim) is periodic along it, one active knot interval per period: the record stays as it
|
||||
//! is, and parameters outside the interval are evaluated a whole number of periods back inside
|
||||
//! it ([`EvalSurface::derivs`]).
|
||||
//! it ([`EvalSurface::derivs`]). So is a revolution along a profile closed on itself (a torus's
|
||||
//! whole circle).
|
||||
|
||||
use super::EvalSurface;
|
||||
use crate::Vec3;
|
||||
@@ -33,7 +34,10 @@ pub fn domain(s: &EvalSurface) -> Domain {
|
||||
}
|
||||
EvalSurface::Revolved { profile, swap, .. } => {
|
||||
let r = (|| Some((*profile.knots.get(profile.degree)?, *profile.knots.get(profile.ctrl.len())?)))();
|
||||
let (a, b) = ((r, None), (Some((0.0, tau)), Some(tau)));
|
||||
// A profile closed on itself (a whole circle: a torus as another kernel writes it)
|
||||
// is periodic along the profile too.
|
||||
let along = r.filter(|_| closed_curve(profile)).map(|(a, b)| b - a).filter(|p| *p > 0.0);
|
||||
let (a, b) = ((r, along), (Some((0.0, tau)), Some(tau)));
|
||||
let (u, v) = if *swap { (b, a) } else { (a, b) };
|
||||
Domain { range: [u.0, v.0], period: [u.1, v.1] }
|
||||
}
|
||||
@@ -46,6 +50,13 @@ pub fn domain(s: &EvalSurface) -> Domain {
|
||||
/// come near each other does not.
|
||||
const CLOSED: f64 = 1e-9;
|
||||
|
||||
/// Whether a spline curve closes on itself: its knots clamped at both ends and its first and
|
||||
/// last control points the same, weights included ([`closed`] for a curve).
|
||||
pub fn closed_curve(c: &super::EvalCurve) -> bool {
|
||||
let Some(extent) = net_extent(&c.ctrl) else { return false };
|
||||
c.ctrl.len() >= 2 && clamped(c.degree, c.ctrl.len(), &c.knots) && same_point(c.ctrl.first(), c.ctrl.last(), extent)
|
||||
}
|
||||
|
||||
/// Whether the tensor spline closes on itself along each direction: its knots clamped at both
|
||||
/// ends of that direction (so its boundary rows are its edges) and its first and last control
|
||||
/// rows the same points with the same weights. Control point `(i, j)` is `ctrl[j·count[0] + i]`.
|
||||
@@ -54,33 +65,45 @@ pub fn closed(degree: [usize; 2], knots: &[Vec<f64>; 2], count: [usize; 2], ctrl
|
||||
if nu.checked_mul(nv) != Some(ctrl.len()) || nu < 2 || nv < 2 {
|
||||
return [false; 2];
|
||||
}
|
||||
let cart = |c: &[f64; 4]| (c[3] != 0.0).then(|| Vec3::new(c[0] / c[3], c[1] / c[3], c[2] / c[3]));
|
||||
let Some(extent) = net_extent(ctrl) else { return [false; 2] };
|
||||
let same = |a, b| same_point(ctrl.get(a), ctrl.get(b), extent);
|
||||
let u = clamped(degree[0], nu, &knots[0]) && (0..nv).all(|j| same(j * nu, j * nu + nu - 1));
|
||||
let v = clamped(degree[1], nv, &knots[1]) && (0..nu).all(|i| same(i, (nv - 1) * nu + i));
|
||||
[u, v]
|
||||
}
|
||||
|
||||
fn cartesian(c: &[f64; 4]) -> Option<Vec3> {
|
||||
(c[3] != 0.0).then(|| Vec3::new(c[0] / c[3], c[1] / c[3], c[2] / c[3]))
|
||||
}
|
||||
|
||||
/// The diagonal of a control net's box; `None` when a weight vanishes or the net is a point.
|
||||
fn net_extent(ctrl: &[[f64; 4]]) -> Option<f64> {
|
||||
let (mut lo, mut hi) =
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
for c in ctrl {
|
||||
let Some(p) = cart(c) else { return [false; 2] };
|
||||
let p = cartesian(c)?;
|
||||
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
|
||||
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
|
||||
}
|
||||
let extent = hi.dist(lo);
|
||||
if !(extent.is_finite() && extent > 0.0) {
|
||||
return [false; 2];
|
||||
}
|
||||
let same = |a: Option<&[f64; 4]>, b: Option<&[f64; 4]>| match (a, b) {
|
||||
(extent.is_finite() && extent > 0.0).then_some(extent)
|
||||
}
|
||||
|
||||
/// Two control points the same within [`CLOSED`] of the net's extent, weights included.
|
||||
fn same_point(a: Option<&[f64; 4]>, b: Option<&[f64; 4]>, extent: f64) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => {
|
||||
matches!((cart(a), cart(b)), (Some(p), Some(q)) if p.dist(q) <= CLOSED * extent)
|
||||
matches!((cartesian(a), cartesian(b)), (Some(p), Some(q)) if p.dist(q) <= CLOSED * extent)
|
||||
&& (a[3] - b[3]).abs() <= CLOSED * a[3].abs().max(b[3].abs())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let clamped = |d: usize| {
|
||||
let (p, n, k) = (degree[d], count[d], &knots[d]);
|
||||
let ends = |r: std::ops::RangeInclusive<usize>| k.get(r).is_some_and(|s| s.windows(2).all(|w| w.first() == w.get(1)));
|
||||
k.len() == n + p + 1 && ends(0..=p) && ends(n..=n + p)
|
||||
};
|
||||
let u = clamped(0) && (0..nv).all(|j| same(ctrl.get(j * nu), ctrl.get(j * nu + nu - 1)));
|
||||
let v = clamped(1) && (0..nu).all(|i| same(ctrl.get(i), ctrl.get((nv - 1) * nu + i)));
|
||||
[u, v]
|
||||
}
|
||||
}
|
||||
|
||||
/// Knots of degree `p` over `n` control points, clamped at both ends.
|
||||
fn clamped(p: usize, n: usize, k: &[f64]) -> bool {
|
||||
let ends = |r: std::ops::RangeInclusive<usize>| k.get(r).is_some_and(|s| s.windows(2).all(|w| w.first() == w.get(1)));
|
||||
k.len() == n + p + 1 && ends(0..=p) && ends(n..=n + p)
|
||||
}
|
||||
|
||||
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside.
|
||||
@@ -283,6 +306,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A revolution of a whole circle (a torus, the profile a rational quadratic closed on
|
||||
/// itself) is periodic along the profile too, over its knot interval; a cylinder's line
|
||||
/// profile is not.
|
||||
#[test]
|
||||
fn a_revolution_of_a_closed_profile_is_periodic_along_it() {
|
||||
let w = std::f64::consts::FRAC_1_SQRT_2;
|
||||
// The circle of radius 1 about (3, 0, 0) in the xz plane, four quarter arcs.
|
||||
let pts = [[4.0, 0.0], [4.0, 1.0], [3.0, 1.0], [2.0, 1.0], [2.0, 0.0], [2.0, -1.0], [3.0, -1.0], [4.0, -1.0], [4.0, 0.0]];
|
||||
let ctrl: Vec<[f64; 4]> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, [x, z])| {
|
||||
let wt = if i % 2 == 1 { w } else { 1.0 };
|
||||
[x * wt, 0.0, z * wt, wt]
|
||||
})
|
||||
.collect();
|
||||
let knots = vec![0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0, 1.0, 1.0];
|
||||
for swap in [false, true] {
|
||||
let s = EvalSurface::Revolved {
|
||||
profile: EvalCurve { degree: 2, knots: knots.clone(), ctrl: ctrl.clone() },
|
||||
origin: Vec3::ZERO,
|
||||
axis: Vec3::Z,
|
||||
transform: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]],
|
||||
swap,
|
||||
};
|
||||
let (along, around) = if swap { (1, 0) } else { (0, 1) };
|
||||
let dom = domain(&s);
|
||||
assert_eq!((dom.period[along], dom.period[around]), (Some(1.0), Some(std::f64::consts::TAU)), "swap {swap}");
|
||||
let at = |t: f64, a: f64| if swap { [a, t] } else { [t, a] };
|
||||
let wrapped = s.wrap(at(1.3, 0.4));
|
||||
assert!((wrapped[along] - 0.3).abs() < 1e-12 && wrapped[around] == 0.4, "swap {swap}: {wrapped:?}");
|
||||
assert_eq!(s.wrap(at(0.3, 7.0)), at(0.3, 7.0), "the angle is left alone");
|
||||
let (Some(p), Some(q)) = (s.derivs(at(1.3, 0.4)[0], at(1.3, 0.4)[1]), s.derivs(at(0.3, 0.4)[0], at(0.3, 0.4)[1])) else {
|
||||
panic!("evaluates")
|
||||
};
|
||||
assert!(p.0.dist(q.0) < 1e-12, "swap {swap}: {p:?} {q:?}");
|
||||
}
|
||||
assert_eq!(domain(&cylinder()).period, [None, Some(std::f64::consts::TAU)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_across_the_period() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
|
||||
@@ -347,3 +347,27 @@ fn smlib_spheres_cut_beside_or_across_their_seam_decode_with_their_volume() {
|
||||
let ours = crate::measure(&crate::native::body_from_smlib(&cut).unwrap()).unwrap().volume;
|
||||
assert!((ours - exact).abs() < 1e-3 * exact, "bored: decoded volume {ours}, exact {exact}");
|
||||
}
|
||||
|
||||
/// SMLib's torus is one face bounded by its two seam circles, each used both ways: the display
|
||||
/// plan treats the closed profile as periodic, so the torus meshes and, beside a sphere bounded
|
||||
/// by its seam alone (which only the plan meshes), the body keeps both (the sphere was lost).
|
||||
/// The decode never meshes the body (it took half a second, meshing a torus to orient it).
|
||||
#[cfg(feature = "smlib")]
|
||||
#[test]
|
||||
fn smlib_torus_decodes_without_meshing_and_plans_with_a_sphere_beside_it() {
|
||||
use solidcraft_smlib::{BooleanOp, Brep as S};
|
||||
use std::f64::consts::PI;
|
||||
let ring = S::make_torus([0., 30., 15.], 7.5, 2.5).unwrap();
|
||||
let ball = S::make_sphere([2.5, 15., 7.5], 2.5).unwrap();
|
||||
let (torus, sphere) = (2. * PI * PI * 7.5 * 2.5 * 2.5, 4. / 3. * PI * 2.5 * 2.5 * 2.5);
|
||||
let both = ring.boolean(&ball, BooleanOp::Union).unwrap().unwrap();
|
||||
for (name, s, exact) in [("torus", ring, torus), ("torus and sphere", both, torus + sphere)] {
|
||||
crate::body::ORIENTED.with(|n| n.set(0));
|
||||
let body = crate::native::body_from_smlib(&s).unwrap();
|
||||
assert_eq!(crate::body::ORIENTED.with(|n| n.get()), 0, "{name}: the decode meshed the body to orient it");
|
||||
let plan = body.display_plan(0.05).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(plan.faces.len(), body.face_count(), "{name}");
|
||||
let ours = crate::measure(&body).unwrap().volume;
|
||||
assert!((ours - exact).abs() < 1e-3 * exact, "{name}: decoded volume {ours}, exact {exact}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user