feat: strengthen modeling, configuration, and assembly workflows

Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy.

Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
bherbruck committed 2026-10-11 00:37:26 -04:00
1 parent 22672585c9
commit d71bdb78ec
54 files changed
+4179 -316

No files matched your search

+236 -44
View File
@@ -19,6 +19,144 @@ fn unsupported(msg: &str) -> KernelError {
/// Samples per edge for the contact curves and the blend surface.
const SAMPLES: usize = 24;
const MAX_SAMPLES: usize = 369;
const MAX_CHAIN_SAMPLES: usize = 100_000;
struct Fit {
surface: mt::Surface,
contacts: [mt::Curve; 2],
/// Parameter range belonging to this edge (the fit includes neighbouring sections).
range: (f64, f64),
}
struct Guide {
sections: Vec<Section>,
lengths: Vec<f64>,
}
impl Guide {
fn new(sections: Vec<Section>) -> Result<Self> {
let mut lengths = vec![0.0];
let mut total = 0.0;
for pair in sections.windows(2) {
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
total += a.c.dist(b.c);
lengths.push(total);
}
if !total.is_finite() || total <= 1e-12 {
return Err(unsupported("degenerate rolling-ball spine"));
}
Ok(Self { sections, lengths })
}
fn anchor(&self, along: f64) -> Option<(Vec3, Vec3)> {
if self.lengths.len() < 2 || !along.is_finite() || !(0.0..=1.0).contains(&along) {
return None;
}
let distance = self.lengths.last()? * along;
let hi = self.lengths.partition_point(|length| *length < distance).clamp(1, self.lengths.len().checked_sub(1)?);
let (a, b) = (self.sections.get(hi - 1)?, self.sections.get(hi)?);
let (lo, hi_length) = (self.lengths.get(hi - 1)?, self.lengths.get(hi)?);
let span = hi_length - lo;
if span <= 1e-12 {
return None;
}
let fraction = (distance - lo) / span;
Some((a.c + (b.c - a.c) * fraction, (b.c - a.c).normalized()?))
}
}
/// One interpolation for both the face and its boundary curves. Fitting the contacts
/// independently gives different end conditions and leaves gaps between the same faces.
fn fit_sections(sections: &[Vec<Section>], k: usize, closed: bool, round: bool) -> Result<Fit> {
use mt::Cut;
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
let n = sections.len();
let list = sections.get(k).ok_or_else(|| fail("section"))?;
let before = usize::from(closed || k > 0) * 2;
let mut ext = Vec::with_capacity(list.len() + 4);
if before > 0 {
let prev = sections.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = sections.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.x, c.y, c.z)).collect();
let ww: Vec<Vec3> = ctrl.iter().filter_map(|c| c.get(j)).map(|c| Vec3::new(c.w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect::<Vec<_>>());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let den = ext.len().saturating_sub(1) as f64;
if den <= 0.0 {
return Err(fail("empty sections"));
}
let range = (before as f64 / den, (before + list.len().saturating_sub(1)) as f64 / den);
let contact = |row: usize| -> Result<mt::Curve> {
let points = rows.get(row).cloned().ok_or_else(|| fail("contact row"))?;
let bs = mt::BSplineCurve::try_new(vk.clone(), points).map_err(|e| fail(&e.to_string()))?;
let mut curve = mt::NurbsCurve::new(bs);
if range.0 > 0.0 {
curve = curve.cut(range.0);
}
if range.1 < 1.0 {
let _ = curve.cut(range.1);
}
Ok(mt::Curve::NurbsCurve(curve))
};
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 })
}
/// Compare the fitted strip with a solved circular section between interpolation nodes.
/// This checks the supporting contacts as well as the middle of the fillet band.
fn section_fits(fit: &Fit, section: &Section, along: f64, round: bool, sigma: f64, tolerance: f64) -> bool {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let Some(ctrl) = section_controls(section, round) else { return false };
let v = fit.range.0 + (fit.range.1 - fit.range.0) * along;
for u in [0.0, 0.25, 0.5, 0.75, 1.0] {
let [a, b, c] = ctrl;
let h = a * (1.0 - u) * (1.0 - u) + b * (2.0 * u * (1.0 - u)) + c * u * u;
if !h.w.is_finite() || h.w <= 1e-12 {
return false;
}
let exact = Vec3::new(h.x / h.w, h.y / h.w, h.z / h.w);
// The input intersection edge may use piecewise leader-curve parameters. A
// parameter-speed change is not a geometric error: compare nearest points, with
// contacts constrained to the corresponding boundary and all probes to the strip.
let (probe_u, probe_v) = if u == 0.0 || u == 1.0 {
let Some(contact) = fit.contacts.get(usize::from(u == 1.0)) else { return false };
let Some(parameter) = contact.search_nearest_parameter(p3(exact), v, 32) else { return false };
(u, parameter.clamp(fit.range.0, fit.range.1))
} else {
let Some((pu, pv)) = fit.surface.search_nearest_parameter(p3(exact), Some((u, v)), 32) else { return false };
(pu.clamp(0.0, 1.0), pv.clamp(fit.range.0, fit.range.1))
};
let actual = from_p3(fit.surface.subs(probe_u, probe_v));
if !actual.is_finite() || actual.dist(exact) > tolerance {
return false;
}
if round && (u == 0.0 || u == 1.0) {
let Some(want) = ((section.c - exact) * sigma).normalized() else { return false };
let normal = fit.surface.normal(probe_u, probe_v);
let Some(normal) = Vec3::new(normal.x, normal.y, normal.z).normalized() else { return false };
if normal.dot(want).abs() < 0.5f64.to_radians().cos() {
return false;
}
}
}
true
}
/// One cross-section of the blend: ball centre and its contacts with side 1 and side 2.
#[derive(Clone, Copy, Debug)]
@@ -102,6 +240,16 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
/// Blend the closed chain of edges (ids in any order) with radius `r`.
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
}
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<Solid> {
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
}
if !(4..=MAX_SAMPLES).contains(&max_samples) {
return Err(KernelError::Invalid("curve blend section limit is outside the supported range".into()));
}
let tol = (size * 1e-7).max(1e-9);
let shells = solid.boundaries();
let (si, shell) = shells
@@ -303,16 +451,85 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
vsec.push(s);
}
let mut esec: Vec<Vec<Section>> = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..SAMPLES - 1 {
let (p, t) = tangent_at(e, j as f64 / (SAMPLES - 1) as f64).ok_or_else(|| KernelError::Failed("tangent".into()))?;
list.push(solve(&s1, &s2, p, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?);
// Uniform refinement preserves a common spacing across neighbouring patches. Work is
// capped: a difficult strip returns an error instead of accepting an inaccurate fit.
let fit_tolerance = (size * 1e-5).clamp(1e-5, 0.001);
// At a seam the contact lies on the seam plane, which need not be square to the
// original edge. Its ball centre therefore need not lie in the edge's normal plane.
// Interpolate that plane's displacement so the interior sections tend to the exact
// seam sections, rather than leaving a finite jump as sampling gets finer.
let offsets: Vec<(f64, f64)> = chain
.iter()
.enumerate()
.map(|(k, edge)| {
let (p0, t0) = tangent_at(edge, 0.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let (p1, t1) = tangent_at(edge, 1.0).ok_or_else(|| KernelError::Failed("tangent".into()))?;
let start = vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
let end = vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?;
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 (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);
solve(&s1, &s2, anchor, t, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))
};
let mut samples = SAMPLES.min(max_samples);
let (esec, fits) = loop {
if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) {
return Err(unsupported("curve blend exceeds the section fitting limit"));
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
esec.push(list);
}
let mut guides = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..samples - 1 {
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
}
list.push(*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?);
guides.push(Guide::new(list)?);
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine.
// Resample by distance along the rolling centres, projecting each interpolated
// centre back onto the exact two offset surfaces before it becomes a section.
let uniform_section = |k: usize, along: f64| -> Result<Section> {
let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
if along == 0.0 {
return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
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"))?;
solve(&s1, &s2, anchor, tangent, r, sigma, None, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
.collect::<Result<_>>()?;
let fits = crate::guard("fit curve blend", || (0..n).map(|k| fit_sections(&esec, k, closed, round)).collect::<Result<Vec<_>>>())?;
let mut accurate = true;
'edges: for k in 0..n {
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
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;
let section = uniform_section(k, along)?;
if !crate::guard("check curve blend fit", || Ok(section_fits(fit, &section, along, round, sigma, fit_tolerance)))? {
accurate = false;
break 'edges;
}
}
}
}
if accurate {
break (esec, fits);
}
if samples >= max_samples {
return Err(KernelError::Failed("curve blend: could not fit the rolling-ball surface within the accuracy and section limits".into()));
}
samples = (2 * samples - 1).min(max_samples);
};
crate::guard("curve blend", || {
let fail = |m: &str| KernelError::Failed(format!("curve blend: {m}"));
// Vertices and arcs at the chain's vertices.
@@ -334,47 +551,18 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut c2: Vec<mt::Edge> = Vec::with_capacity(n);
let mut blends: Vec<mt::Surface> = Vec::with_capacity(n);
for (k, list) in esec.iter().enumerate() {
let q1: Vec<Vec3> = list.iter().map(|s| s.q1).collect();
let q2: Vec<Vec3> = list.iter().map(|s| s.q2).collect();
let k1 = (k + 1) % nv;
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
let cv1 = crate::build::interpolate_cubic(&q1).ok_or_else(|| fail("contact curve"))?;
let cv2 = crate::build::interpolate_cubic(&q2).ok_or_else(|| fail("contact curve"))?;
let e1 = mt::Edge::new(a1, b1, mt::Curve::BSplineCurve(cv1));
let e2 = mt::Edge::new(a2, b2, mt::Curve::BSplineCurve(cv2));
// The blend surface: each control row interpolated along the edge (homogeneous),
// through two sections of each neighbouring edge too, so that neighbouring blend
// faces meet smoothly.
let mut ext: Vec<Section> = Vec::with_capacity(list.len() + 4);
if closed || k > 0 {
let prev = esec.get((k + n - 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(prev.iter().rev().skip(1).take(2).rev().copied());
}
ext.extend(list.iter().copied());
if closed || k + 1 < n {
let next = esec.get((k + 1) % n).ok_or_else(|| fail("section"))?;
ext.extend(next.iter().skip(1).take(2).copied());
}
let ctrl: Vec<[mt::Vector4; 3]> = ext.iter().map(|s| section_controls(s, round)).collect::<Option<_>>().ok_or_else(|| fail("section"))?;
let mut rows: Vec<Vec<mt::Vector4>> = Vec::with_capacity(3);
let mut vknots = None;
for j in 0..3 {
let xyz: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].x, c[j].y, c[j].z)).collect();
let ww: Vec<Vec3> = ctrl.iter().map(|c| Vec3::new(c[j].w, 0.0, 0.0)).collect();
let bx = crate::build::interpolate_cubic(&xyz).ok_or_else(|| fail("blend surface"))?;
let bw = crate::build::interpolate_cubic(&ww).ok_or_else(|| fail("blend surface"))?;
vknots = Some(bx.knot_vec().clone());
rows.push(bx.control_points().iter().zip(bw.control_points()).map(|(p, w)| mt::Vector4::new(p.x, p.y, p.z, w.x)).collect());
}
let vk = vknots.ok_or_else(|| fail("knots"))?;
let uk = mt::KnotVec::bezier_knot(2);
let bs = mt::BSplineSurface::try_new((uk, vk), rows).map_err(|e| fail(&e.to_string()))?;
let mut surface = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs));
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
let [cv1, cv2] = &fit.contacts;
let e1 = mt::Edge::new(a1, b1, cv1.clone());
let e2 = mt::Edge::new(a2, b2, cv2.clone());
let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(SAMPLES / 2).ok_or_else(|| fail("section"))?;
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;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
@@ -527,3 +715,7 @@ pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
})
}
#[cfg(test)]
#[path = "curveblend_tests.rs"]
mod tests;
+194
View File
@@ -0,0 +1,194 @@
//! Independent analytic reference: the centre of a ball tangent to two perpendicular
//! cylinders lies on the intersection of their radius-offset cylinders.
use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
use super::*;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
fn tee(scale: f64, origin: Vec3) -> Body {
let main = cylinder(origin + Vec3::new(-40.0, 0.0, 0.0) * scale, Vec3::X, 15.0 * scale, 80.0 * scale).unwrap();
let branch = cylinder(origin, Vec3::Z, 10.0 * scale, 35.0 * scale).unwrap();
boolean(&main, &branch, BoolOp::Union).unwrap().unwrap()
}
fn analytic_section(theta: f64, scale: f64, origin: Vec3) -> (Vec3, Vec3, Vec3) {
let centre = Vec3::new(13.0 * theta.cos(), 13.0 * theta.sin(), (324.0 - 169.0 * theta.sin().powi(2)).sqrt());
let main = Vec3::new(centre.x, centre.y * 15.0 / 18.0, centre.z * 15.0 / 18.0);
let branch = Vec3::new(centre.x * 10.0 / 13.0, centre.y * 10.0 / 13.0, centre.z);
(origin + centre * scale, origin + main * scale, origin + branch * scale)
}
fn nearest_blend(surfaces: &[mt::Surface], point: Vec3) -> (f64, Vec3) {
surfaces
.iter()
.filter_map(|s| {
let (u, v) = s.search_nearest_parameter(p3(point), None, 100)?;
let actual = from_p3(s.subs(u, v));
let n = s.normal(u, v);
Some((actual.dist(point), Vec3::new(n.x, n.y, n.z).normalized()?))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.unwrap()
}
fn distance_to_analytic_arc(theta: f64, point: Vec3) -> f64 {
let (centre, main, branch) = analytic_section(theta, 1.0, Vec3::ZERO);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let plane = a.cross(b).normalized().unwrap();
let radial = point - centre;
let projected = radial - plane * radial.dot(plane);
let direction = projected.normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
let from_main = a.dot(direction).clamp(-1.0, 1.0).acos();
let from_branch = b.dot(direction).clamp(-1.0, 1.0).acos();
if (from_main + from_branch - angle).abs() < 1e-7 { point.dist(centre + direction * 3.0) } else { point.dist(main).min(point.dist(branch)) }
}
fn distance_to_analytic_band(point: Vec3) -> f64 {
// The arc's XY azimuth stays between its two contacts' azimuths. Their difference
// for this tee is less than 0.1 rad; bracket generously around the query azimuth.
let azimuth = point.y.atan2(point.x);
let (mut lo, mut hi) = (azimuth - 0.3, azimuth + 0.3);
for _ in 0..55 {
let a = lo + (hi - lo) * 0.38196601125;
let b = lo + (hi - lo) * 0.61803398875;
if distance_to_analytic_arc(a, point) < distance_to_analytic_arc(b, point) {
hi = b;
} else {
lo = a;
}
}
distance_to_analytic_arc((lo + hi) * 0.5, point)
}
#[test]
fn fitted_contact_edges_coincide_with_their_surface_boundaries() {
let sections: Vec<Vec<Section>> = (0..4)
.map(|k| {
(0..SAMPLES)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_2 * (k as f64 + i as f64 / (SAMPLES - 1) as f64);
let (c, q1, q2) = analytic_section(theta, 1.0, Vec3::ZERO);
Section { c, q1, q2 }
})
.collect()
})
.collect();
for k in 0..sections.len() {
let fit = crate::guard("contact consistency", || fit_sections(&sections, k, true, true)).unwrap();
for (side, curve) in fit.contacts.iter().enumerate() {
let (lo, hi) = curve.range_tuple();
for i in 0..=200 {
let parameter = lo + (hi - lo) * i as f64 / 200.0;
let edge = from_p3(curve.subs(parameter));
let surface = from_p3(fit.surface.subs(side as f64, parameter));
assert!(edge.dist(surface) < 1e-9, "contact-to-surface gap {}", edge.dist(surface));
}
}
}
}
#[test]
fn rolling_ball_matches_analytic_tee_and_contacts() {
for (scale, origin) in [(1.0, Vec3::ZERO), (0.25, Vec3::new(23.0, -17.0, 9.0))] {
let body = tee(scale, origin);
let point = origin + Vec3::new(0.0, 10.0, 125f64.sqrt()) * scale;
let blend = fillet(&body, &[point], 3.0 * scale).unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
assert!(!surfaces.is_empty());
let mut distance: f64 = 0.0;
let mut tangent_angle: f64 = 0.0;
for i in 0..96 {
// Offset the angular samples from patch seams; contact endpoints are tested below.
let theta = std::f64::consts::TAU * (i as f64 + 0.37) / 96.0;
let (centre, main, branch) = analytic_section(theta, scale, origin);
let a = (main - centre).normalized().unwrap();
let b = (branch - centre).normalized().unwrap();
let angle = a.dot(b).clamp(-1.0, 1.0).acos();
for fraction in [0.0, 0.25, 0.5, 0.75, 1.0] {
// Spherical linear interpolation of the radial unit vectors, independent of
// the production rational control-point construction and fitting parameters.
let radial = (a * ((1.0 - fraction) * angle).sin() + b * (fraction * angle).sin()) / angle.sin();
let exact = centre + radial * (3.0 * scale);
let (error, normal) = nearest_blend(&surfaces, exact);
distance = distance.max(error);
if fraction == 0.0 || fraction == 1.0 {
let want = (centre - exact).normalized().unwrap();
tangent_angle = tangent_angle.max(normal.dot(want).abs().min(1.0).acos().to_degrees());
}
}
}
assert!(distance <= 0.005, "scale {scale}: analytic surface deviation {distance} mm");
assert!(tangent_angle <= 0.5, "scale {scale}: contact normals differ by {tangent_angle} degrees");
// The reverse direction uses points on the actual trimmed face mesh, so an
// untrimmed neighbouring surface cannot conceal an overshooting blend patch.
let mesh = blend.tessellate_with(0.002, true).unwrap();
let mut reverse_distance: f64 = 0.0;
let mut probes = 0;
for point in mesh.positions.iter().step_by(19) {
let p = (*point - origin) / scale;
if (p.x * p.x + p.y * p.y).sqrt() <= 10.000001
|| (p.y * p.y + p.z * p.z).sqrt() <= 15.000001
|| !(10.0..20.0).contains(&p.z)
|| p.x.abs() > 16.0
{
continue;
}
probes += 1;
reverse_distance = reverse_distance.max(distance_to_analytic_band(p) * scale);
}
assert!(probes > 300, "only {probes} trimmed surface points checked");
assert!(reverse_distance <= 0.005, "scale {scale}: reverse analytic deviation {reverse_distance} mm");
eprintln!("scale {scale}: surface {distance:.9} mm, reverse {reverse_distance:.9} mm, contact normals {tangent_angle:.6} degrees");
let mut contact_count = 0;
for edge in Body::unique_edges(&blend.solid) {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let endpoints = [from_p3(curve.subs(lo)), from_p3(curve.subs(hi))].map(|p| (p - origin) / scale);
if endpoints.iter().any(|p| !(9.0..20.0).contains(&p.z) || p.x.abs() > 16.0) {
continue;
}
let branch = endpoints.iter().all(|p| ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 1e-5);
let main = endpoints.iter().all(|p| ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 1e-5);
if !branch && !main {
continue;
}
contact_count += 1;
for i in 0..=200 {
let p = (from_p3(curve.subs(lo + (hi - lo) * i as f64 / 200.0)) - origin) / scale;
let radius_error = if branch { ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() } else { ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() };
assert!(radius_error * scale <= 0.002, "supporting cylinder deviation {} mm", radius_error * scale);
}
}
assert!(contact_count >= 4, "only {contact_count} contacts checked");
}
}
#[test]
fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
let body = tee(1.0, Vec3::ZERO);
let solid = body.deep_copy();
let ids: Vec<_> = Body::unique_edges(&solid)
.iter()
.filter(|edge| {
let curve = edge.oriented_curve();
let (lo, hi) = curve.range_tuple();
let p = from_p3(curve.subs((lo + hi) * 0.5));
p.z > 10.0 && ((p.x * p.x + p.y * p.y).sqrt() - 10.0).abs() < 0.01 && ((p.y * p.y + p.z * p.z).sqrt() - 15.0).abs() < 0.01
})
.map(mt::Edge::id)
.collect();
assert!(!ids.is_empty());
let before = crate::step_export(&[&body], "unchanged").unwrap();
let input_before = crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap();
// Four sections deliberately undersample the saddle; production starts at 24.
let error = crate::guard("limited rolling ball", || curve_blend_with_limit(&solid, &ids, body.size(), 3.0, true, 4)).unwrap_err();
assert!(error.to_string().contains("accuracy and section limits"), "{error}");
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
}
+64 -6
View File
@@ -1,7 +1,9 @@
//! STEP geometry (ISO 10303-42 entities) to kernel curves and surfaces.
//!
//! Points, placements, lines, conics, B-splines (plain and rational), elementary surfaces and
//! swept surfaces. Edge curves are trimmed to their vertices here, so every kernel edge runs
//! swept surfaces, with exact offsets of planes, cylinders, cones, spheres and regular tori.
//! Free-form offsets and offsets through collapsed radii remain explicit import warnings.
//! Edge curves are trimmed to their vertices here, so every kernel edge runs
//! exactly from its start vertex to its end vertex. Lengths are scaled to millimetres while
//! reading.
@@ -761,6 +763,59 @@ pub(crate) fn bspline_surface(cx: &Ctx, e: &Entity) -> R<mt::Surface> {
}
}
/// Resolve exact analytic offsets. Each intermediate offset is checked: an invalid inner
/// offset must not become valid merely because an outer offset cancels its distance.
/// Entity attributes follow ISO 10303-42 (`basis_surface`, `distance`, `self_intersect`).
fn offset_basis(cx: &Ctx, id: u64, depth: usize) -> R<(u64, f64)> {
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let e = entity(cx, id)?;
if e.name() != "OFFSET_SURFACE" {
return Ok((id, 0.0));
}
let p = e.params();
let basis = p.get(1).and_then(Param::as_ref_id).ok_or("offset surface without basis")?;
let distance = length(cx, p.get(2), "offset distance")?;
if !matches!(p.get(3).and_then(Param::as_enum), Some("T" | "F" | "U")) {
return Err("offset surface without a logical self_intersect flag".into());
}
let (basis, inner) = offset_basis(cx, basis, depth + 1)?;
let offset = inner + distance;
if !offset.is_finite() || offset.abs() >= MAX_COORD {
return Err("offset distance out of range after unit conversion".into());
}
let e = entity(cx, basis)?;
let p = e.params();
match e.name() {
"PLANE" => {}
"CYLINDRICAL_SURFACE" | "SPHERICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
if radius <= 0.0 || !(1e-9..MAX_COORD).contains(&(radius + offset)) {
return Err("offset surface collapses or reverses its radius".into());
}
}
"CONICAL_SURFACE" => {
let radius = length(cx, p.get(2), "radius")?;
let angle = num(p.get(3), "semi angle")? * cx.ang;
if radius < 0.0 || !(angle.abs() > 1e-9 && angle.abs() < FRAC_PI_2 - 1e-9) || !(0.0..MAX_COORD).contains(&(radius + offset / angle.cos()))
{
return Err("offset cone has an invalid reference radius or angle".into());
}
}
"TOROIDAL_SURFACE" => {
let major = length(cx, p.get(2), "major radius")?;
let minor = length(cx, p.get(3), "minor radius")?;
if minor <= 0.0 || major <= minor || major >= MAX_COORD || !(1e-9..major).contains(&(minor + offset)) {
return Err("offset torus collapses or becomes self-intersecting".into());
}
}
_ if offset == 0.0 => {}
n => return Err(format!("unsupported OFFSET_SURFACE basis surface {n}")),
}
Ok((basis, offset))
}
/// A STEP surface entity as a kernel surface whose normal is the STEP surface normal.
/// `extent` holds points of the face boundary (bounds the analytic surfaces' parameter ranges).
/// `sense` false gives the opposite normal (a face with `same_sense = .F.`).
@@ -776,6 +831,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
if depth > MAX_GEOM_DEPTH {
return Err("surface definitions nested too deeply".into());
}
let (id, offset) = offset_basis(cx, id, depth)?;
let e = entity(cx, id)?;
if e.is_complex() {
return bspline_surface(cx, e).map(flip);
@@ -785,11 +841,12 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
match e.name() {
"PLANE" => {
let f = placement()?;
Ok(flip(mt::Surface::Plane(mt::Plane::new(f.o, f.o + f.x, f.o + f.y))))
let o = f.o + f.z * offset;
Ok(flip(mt::Surface::Plane(mt::Plane::new(o, o + f.x, o + f.y))))
}
"CYLINDRICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("cylinder radius must be positive".into());
}
@@ -799,8 +856,9 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"CONICAL_SURFACE" => {
let f = seam_frame(&placement()?, extent, seam);
let r = length(cx, p.get(2), "radius")?;
let basis_r = length(cx, p.get(2), "radius")?;
let a = num(p.get(3), "semi angle")? * cx.ang;
let r = basis_r + offset / a.cos();
if r < 0.0 || !(a.abs() > 1e-9 && a.abs() < FRAC_PI_2 - 1e-9) {
return Err("bad cone".into());
}
@@ -821,7 +879,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"SPHERICAL_SURFACE" => {
let f = placement()?;
let r = length(cx, p.get(2), "radius")?;
let r = length(cx, p.get(2), "radius")? + offset;
if r <= 0.0 {
return Err("sphere radius must be positive".into());
}
@@ -844,7 +902,7 @@ pub(crate) fn surface(cx: &Ctx, id: u64, extent: &[mt::Point3], seam: &[mt::Poin
}
"TOROIDAL_SURFACE" => {
let f = placement()?;
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")?);
let (big, small) = (length(cx, p.get(2), "major radius")?, length(cx, p.get(3), "minor radius")? + offset);
if small <= 0.0 || big < 0.0 {
return Err("bad torus radii".into());
}
+100
View File
@@ -76,6 +76,106 @@ fn imported_bodies_take_features() {
const HEAD: &str = "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nFILE_NAME('t','',(''),(''),'','hand','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n";
const TAIL: &str = "ENDSEC;\nEND-ISO-10303-21;\n";
/// Original fixtures: elementary basis surfaces plus offsets, with no external CAD files.
fn offset_geometry(definition: &str, offset: &str, len: f64, sense: bool, points: &[mt::Point3]) -> crate::Result<mt::Surface> {
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=AXIS2_PLACEMENT_3D('',#1,$,$);\n#3={definition};\n#4=OFFSET_SURFACE('',#3,{offset},.U.);\n{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len, ang: 1.0, tol: 1e-6 };
crate::guard("test offset surface", || geom::surface(&cx, 4, points, &[], sense, 0).map_err(KernelError::Failed))
}
#[test]
fn offset_surface_analytic_geometry_and_orientation() {
use mt::{InnerSpace, MetricSpace, ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let angle: f64 = 0.4;
let cases = [
("PLANE('',#2)".to_string(), mt::Point3::new(2.0, 3.0, 1.0), mt::Vector3::unit_z()),
("CYLINDRICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 2.0), mt::Vector3::unit_x()),
("SPHERICAL_SURFACE('',#2,5.)".to_string(), mt::Point3::new(6.0, 0.0, 0.0), mt::Vector3::unit_x()),
("TOROIDAL_SURFACE('',#2,10.,2.)".to_string(), mt::Point3::new(13.0, 0.0, 0.0), mt::Vector3::unit_x()),
(
format!("CONICAL_SURFACE('',#2,5.,{angle})"),
mt::Point3::new(5.0 + 1.0 / angle.cos() + 2.0 * angle.tan(), 0.0, 2.0),
mt::Vector3::new(angle.cos(), 0.0, -angle.sin()),
),
];
for (definition, point, normal) in cases {
for sense in [true, false] {
let extent = [point, point + mt::Vector3::unit_z()];
let surface = offset_geometry(&definition, "1.", 1.0, sense, &extent).unwrap();
let uv = surface.search_nearest_parameter(point, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(point) < 1e-6, "{definition}");
assert!(surface.normal(uv.0, uv.1).dot(normal) * if sense { 1.0 } else { -1.0 } > 0.99, "{definition}, sense {sense}");
}
}
let p = mt::Point3::new(40.0, 0.0, 2.0);
let surface = offset_geometry("CYLINDRICAL_SURFACE('',#2,5.)", "-1.", 10.0, true, &[p, p + mt::Vector3::unit_z()]).unwrap();
let uv = surface.search_nearest_parameter(p, None, 100).unwrap();
assert!(surface.subs(uv.0, uv.1).distance(p) < 1e-6);
}
#[test]
fn offset_surfaces_nested_and_hostile() {
let points = [mt::Point3::new(0.0, 0.0, 0.0), mt::Point3::new(0.0, 0.0, 1.0)];
for (basis, offset) in [
("CYLINDRICAL_SURFACE('',#2,5.)", "-5."),
("SPHERICAL_SURFACE('',#2,5.)", "-6."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "8."),
("TOROIDAL_SURFACE('',#2,10.,2.)", "-2."),
("CONICAL_SURFACE('',#2,5.,0.4)", "-10."),
("PLANE('',#2)", "1.E100"),
("PLANE('',#2)", "$"),
] {
assert!(offset_geometry(basis, offset, 1.0, true, &points).is_err(), "{basis}, {offset}");
}
let text = format!("{HEAD}#1=OFFSET_SURFACE('',#1,1.,.F.);{TAIL}");
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 1, &points, &[], true, 0).unwrap_err().contains("nested too deeply"));
let text = format!(
"{HEAD}#1=CARTESIAN_POINT('',(0.,0.,0.));#2=AXIS2_PLACEMENT_3D('',#1,$,$);#3=CYLINDRICAL_SURFACE('',#2,5.);#4=OFFSET_SURFACE('',#3,-6.,.F.);#5=OFFSET_SURFACE('',#4,6.,.F.);{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
assert!(geom::surface(&cx, 5, &points, &[], true, 0).is_err());
}
#[test]
fn offset_surface_cube_import_is_closed_and_editable() {
// Move each basis plane half a file unit inward; nested quarter-unit offsets
// bring its supporting surface back to the original unit cube boundary.
let mut entities = cube_entities();
let origins = [(0.0, 0.0, 0.5), (0.0, 0.0, 0.5), (0.0, 0.5, 0.0), (0.5, 0.0, 0.0), (1.0, 0.5, 0.0), (0.5, 1.0, 0.0)];
for (f, (x, y, z)) in origins.into_iter().enumerate() {
let base = 400 + f * 20;
let extra = 1000 + f * 10;
entities = entities
.replace(&format!("#{}=PLANE('',#{});", base + 10, base + 9), &format!("#{}=OFFSET_SURFACE('',#{},0.25,.F.);", base + 10, extra + 3));
entities += &format!(
"#{extra}=CARTESIAN_POINT('',({x},{y},{z}));\n#{}=AXIS2_PLACEMENT_3D('',#{extra},#{},$);\n#{}=PLANE('',#{});\n#{}=OFFSET_SURFACE('',#{},0.25,.F.);\n",
extra + 1,
base + 8,
extra + 2,
extra + 1,
extra + 3,
extra + 2
);
}
entities += &context(10, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.CENTI.,.METRE.))");
entities += "#9=ADVANCED_BREP_SHAPE_REPRESENTATION('Offsets',(#101),#10);\n";
let import = step_import(&format!("{HEAD}{entities}{TAIL}")).unwrap();
assert!(import.warnings.is_empty(), "{:?}", import.warnings);
assert!(import.bodies[0].closed);
assert_eq!(import.bodies[0].file_faces, 6);
let body = &import.bodies[0].body;
assert!(rel(measure(body).unwrap().volume, 1000.0) < 1e-6);
let cut = cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
let result = boolean(body, &cut, BoolOp::Cut).unwrap().unwrap();
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();