kernel: planes go into SMLib as its planes; analytic angles start at zero

Truck's planes run through three points, their axes the face's sides, so the SMLib import sent
every truck planar face as a bilinear NURBS patch: SMLib then intersected splines where it has
planes. A plane on any axes now goes as SMLib's plane on unit axes along U and at right angles
in the plane, its range the record's domain on those axes (a face's range need not be tight;
SMLib rebuilds an analytic face's trims from its edges).

A revolution's analytic frame turns to the face's first angle so u starts at 0: SMLib's check
refuses a cylinder whose range ends past 360°. A failed SMLib check of a boolean's result now
says whether an operand already fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 05:32:54 -04:00
parent d271add1ad
commit b27751d43f
4 changed files with 71 additions and 26 deletions
+12 -1
View File
@@ -76,7 +76,18 @@ mod smlib {
let Some(r) = ta.boolean(&tb, kind).map_err(|e| failed("operation", e))? else { return Ok(None) };
let check = r.check(CheckLevel::All).map_err(|e| failed("check", e))?;
if !check.is_valid() {
return Err(failed("SMLib's check refuses the result", check));
// Whether an operand failed it already says whose the fault is.
let operands: Vec<&str> = [("the first operand", &sa), ("the second operand", &sb)]
.into_iter()
.filter(|(_, s)| s.check(CheckLevel::All).is_ok_and(|c| !c.is_valid()))
.map(|(name, _)| name)
.collect();
let what = if operands.is_empty() {
"SMLib's check refuses the result".to_string()
} else {
format!("SMLib's check refuses the result ({} already fails it)", operands.join(" and "))
};
return Err(failed(&what, check));
}
let (body, or) = body_from_smlib_ordered(&r)?;
let history = match (oa, ob, or) {
+22 -14
View File
@@ -388,23 +388,31 @@ fn net_rows(a: &mut Arrays, cps: impl Iterator<Item = ([f64; 3], f64)>) {
net(a, cps);
}
/// A plane `origin + u·U + v·V`. With unit, orthogonal axes it is SMLib's plane, whose (u, v) are
/// distances along them; otherwise it is the exact bilinear patch over the domain.
/// A plane `origin + u·U + v·V` as SMLib's plane, whose (u, v) are distances along unit,
/// orthogonal axes: U's direction, and the one at a right angle to it in the plane, the normal
/// U × V as the record's. The range is the record's domain mapped onto those axes (its bounding
/// box: a face's range need not be tight, and SMLib rebuilds an analytic face's trims from its
/// edges). Truck's planes run through three points, their axes the face's own sides.
fn plane(a: &mut Arrays, origin: [f64; 3], u: [f64; 3], v: [f64; 3], domain: [[f64; 2]; 2]) -> R<()> {
let [[u0, u1], [v0, v1]] = domain;
let unit = |x: [f64; 3]| (dot(x, x).sqrt() - 1.0).abs() <= 1e-12;
if unit(u) && unit(v) && dot(u, v).abs() <= 1e-12 {
a.i(flat::FACE_SURFACE_TYPE).push(i64::from(flat::SURFACE_PLANE));
a.r3(flat::FACE_PLANE_ORIGIN, origin);
a.r3(flat::FACE_PLANE_AXIS, cross(u, v));
a.r3(flat::FACE_PLANE_REF_DIRECTION, u);
a.r(flat::FACE_RANGE).extend([u0, v0, u1, v1]);
return Ok(());
let n = cross(u, v);
let (lu, ln) = (dot(u, u).sqrt(), dot(n, n).sqrt());
if !(lu > 0.0 && ln > 0.0 && lu.is_finite() && ln.is_finite()) {
return Err(Unsupported::Invalid("plane axes"));
}
nurbs_face(a, &[u0, u0, u1, u1], &[v0, v0, v1, v1], 2, 2)?;
let at = |s: f64, t: f64| add(origin, add(scale(u, s), scale(v, t)));
net(a, [at(u0, v0), at(u0, v1), at(u1, v0), at(u1, v1)].into_iter().map(|p| (p, 1.0)));
a.r(flat::FACE_RANGE).extend([u0, v0, u1, v1]);
let (x, z) = (scale(u, 1.0 / lu), scale(n, 1.0 / ln));
let y = cross(z, x);
let corners = [[u0, v0], [u0, v1], [u1, v0], [u1, v1]].map(|[s, t]| {
let d = add(scale(u, s), scale(v, t));
[dot(d, x), dot(d, y)]
});
let lo = |k: usize| corners.iter().map(|c| c[k]).fold(f64::INFINITY, f64::min);
let hi = |k: usize| corners.iter().map(|c| c[k]).fold(f64::NEG_INFINITY, f64::max);
a.i(flat::FACE_SURFACE_TYPE).push(i64::from(flat::SURFACE_PLANE));
a.r3(flat::FACE_PLANE_ORIGIN, origin);
a.r3(flat::FACE_PLANE_AXIS, z);
a.r3(flat::FACE_PLANE_REF_DIRECTION, x);
a.r(flat::FACE_RANGE).extend([lo(0), lo(1), hi(0), hi(1)]);
Ok(())
}
+34 -8
View File
@@ -32,13 +32,18 @@ pub(super) fn analytic(brep: &b::Brep) -> R<Cow<'_, b::Brep>> {
for (i, f) in out.faces.iter_mut().enumerate() {
let b::Surface::PlacedRevolution { profile, origin, axis, matrix, forward } = &f.surface else { continue };
let rev = Revolution::new(profile, *origin, *axis, matrix, *forward)?;
let (surface, mut domain, v) = rev.analytic(f.domain)?;
// Angles start within the first turn, where SMLib's degree domains are at home.
domain[0] = first_turn(domain[0]);
let (mut surface, mut domain, v) = rev.analytic(f.domain)?;
// The angle about the axis starts at 0: the frame turns to the face's first angle (SMLib
// refuses an analytic range past 360°). A torus's tube angle can't turn so: it starts
// within the first turn, where SMLib's degree domains are at home.
let start = domain[0][0];
turn_frame(&mut surface, start);
domain[0] = [0.0, domain[0][1] - start];
if matches!(surface, b::Surface::Torus { .. }) {
domain[1] = first_turn(domain[1]);
}
let flip = agree(&f.surface, f.domain, &surface, domain, &rev, &v, brep.tolerance.max(1e-9 * size))?;
let map = Map { rev: &rev, start, v: &v };
let flip = agree((&f.surface, f.domain), (&surface, domain), map, brep.tolerance.max(1e-9 * size))?;
f.surface = surface;
f.domain = domain;
if flip {
@@ -51,6 +56,18 @@ pub(super) fn analytic(brep: &b::Brep) -> R<Cow<'_, b::Brep>> {
Ok(Cow::Owned(out))
}
/// An analytic surface's frame turned by `angle` about its axis: u then counts from there.
fn turn_frame(s: &mut b::Surface, angle: f64) {
if let b::Surface::Cylinder { frame, .. } | b::Surface::Cone { frame, .. } | b::Surface::Sphere { frame, .. } | b::Surface::Torus { frame, .. } =
s
{
let (x, y) = (v3(frame.x_axis), v3(frame.y_axis));
let (sin, cos) = angle.sin_cos();
frame.x_axis = arr(x * cos + y * sin);
frame.y_axis = arr(y * cos - x * sin);
}
}
/// An angle range moved by whole turns to start in [0, 2π).
fn first_turn([a, z]: [f64; 2]) -> [f64; 2] {
let shift = (a / TAU).floor() * TAU;
@@ -243,13 +260,21 @@ impl Revolution {
/// The analytic (u, v) of the record's (u, v): the angle is the revolution's own, v that of
/// the profile's point (at angle 0), unwrapped to within half a turn of `near`.
fn map(&self, v: &V, uv: [f64; 2], near: f64) -> Option<[f64; 2]> {
fn map(&self, v: &V, uv: [f64; 2], start: f64, near: f64) -> Option<[f64; 2]> {
let (t, angle) = if self.forward { (uv[0], uv[1]) } else { (uv[1], uv[0]) };
let p = self.profile.point(t)?;
Some([angle, v.of(p, near)])
Some([angle - start, v.of(p, near)])
}
}
/// From a revolution's (u, v) to its analytic surface's: the revolution, the angle the
/// surface's u starts at, and how its v is found.
struct Map<'a> {
rev: &'a Revolution,
start: f64,
v: &'a V,
}
/// How the analytic v of a profile point is found.
enum V {
/// Height along `z` above `foot` (cylinder, cone).
@@ -280,7 +305,8 @@ impl V {
/// Checks the analytic surface against the revolution over the face's range, 5 × 5 points
/// within `tol`, and says whether their natural normals are opposite (the face must be turned
/// over), read at the range's middle.
fn agree(record: &b::Surface, domain: [[f64; 2]; 2], analytic: &b::Surface, range: [[f64; 2]; 2], rev: &Revolution, v: &V, tol: f64) -> R<bool> {
fn agree(record: (&b::Surface, [[f64; 2]; 2]), analytic: (&b::Surface, [[f64; 2]; 2]), map: Map, tol: f64) -> R<bool> {
let ((record, domain), (analytic, range), Map { rev, start, v }) = (record, analytic, map);
let bad = Unsupported::Invalid("a revolution its analytic surface does not match");
let s = SurfaceEval::new(record).map_err(|_| bad.clone())?;
let a = SurfaceEval::new(analytic).map_err(|_| bad.clone())?;
@@ -288,7 +314,7 @@ fn agree(record: &b::Surface, domain: [[f64; 2]; 2], analytic: &b::Surface, rang
let near = 0.5 * (range[1][0] + range[1][1]);
let normals = |u: f64, w: f64| -> R<f64> {
let (p, du, dv) = s.derivs(u, w).ok_or(bad.clone())?;
let uv = rev.map(v, [u, w], near).ok_or(bad.clone())?;
let uv = rev.map(v, [u, w], start, near).ok_or(bad.clone())?;
let (q, au, av) = a.derivs(uv[0], uv[1]).ok_or(bad.clone())?;
if !((p - q).len() <= tol) {
return Err(bad.clone());
+3 -3
View File
@@ -127,9 +127,9 @@ fn a_cube_record_imports_into_smlib() {
#[cfg(feature = "native")]
#[test]
fn a_skewed_plane_goes_as_an_exact_bilinear_patch() {
fn a_plane_on_other_axes_goes_as_smlibs_plane() {
// A tetrahedron: its faces' natural frames are unit and orthogonal, so make one plane's axes
// non-unit to force the NURBS form and check the volume is unchanged.
// non-unit and check it still goes as SMLib's plane, the volume unchanged.
let c = [[0., 0., 0.], [6., 0., 0.], [0., 6., 0.], [0., 0., 6.]];
let mut r = polyhedron(&c, &[&[0, 2, 1], &[0, 1, 3], &[1, 2, 3], &[2, 0, 3]]);
if let Some(Face { surface: Surface::Plane { u_axis, v_axis, .. }, domain, loops }) = r.faces.get_mut(0) {
@@ -146,7 +146,7 @@ fn a_skewed_plane_goes_as_an_exact_bilinear_patch() {
}
r.validate().unwrap();
let a = arrays(&r, true).unwrap();
assert_eq!(a.ints[&flat::FACE_SURFACE_TYPE][0], i64::from(flat::SURFACE_NURBS));
assert_eq!(a.ints[&flat::FACE_SURFACE_TYPE][0], i64::from(flat::SURFACE_PLANE));
let b = crate::Brep::from_record(&r, true).unwrap();
assert!((b.volume(1e-7).unwrap() - 36.0).abs() < 1e-6, "{:?}", b.volume(1e-7));
}