diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs
index 5877fb3d86..f63cc99b58 100644
--- a/crates/kernel/src/body.rs
+++ b/crates/kernel/src/body.rs
@@ -440,6 +440,13 @@ fn rephase_revolved(solid: Solid) -> Solid {
faces.push(f.clone());
continue;
};
+ // A face bounded by a seam it uses both ways goes all the way round (SMLib's
+ // sphere, torus or cylinder side as one face): its wrap belongs on that seam,
+ // and turning the surface would run it through the face or one of its holes.
+ if uses_an_edge_twice(f) {
+ faces.push(f.clone());
+ continue;
+ }
let surf = f.surface();
// The face's middle angle.
let mut acc = Vec3::ZERO;
@@ -494,6 +501,12 @@ fn rephase_revolved(solid: Solid) -> Solid {
}
}
+/// Whether a face's boundary uses one edge twice (a seam).
+fn uses_an_edge_twice(f: &mt::Face) -> bool {
+ let mut seen = std::collections::HashSet::new();
+ f.boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| !seen.insert(e.id()))
+}
+
pub(crate) fn p3(v: Vec3) -> mt::Point3 {
mt::Point3::new(v.x, v.y, v.z)
}
@@ -674,6 +687,16 @@ impl Body {
})
}
+ /// Like [`Body::new`], for a solid whose faces already point out of its material as
+ /// recorded (another kernel's B-rep, through its schema 4 record): revolved faces turned as
+ /// `new` turns them, the orientation kept. `new` orients by the volume of truck's coarse
+ /// mesh, which leaves out a face it cannot mesh (SMLib's sphere bounded by its seam alone)
+ /// and then turns a correct body inside out.
+ #[cfg_attr(not(feature = "smlib"), allow(dead_code))]
+ pub(crate) fn oriented(solid: Solid) -> Result
{
+ Body::new_placed(solid, false, None, false)
+ }
+
/// Like [`Body::new`], for a solid made by placing an already-oriented body (see
/// [`crate::ops::transform_matrix`]): a placement keeps the faces pointing the way they did
/// unless it mirrors them, so its orientation is read from the matrix rather than meshed
diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs
index 0baffb2c91..ac6da1eccb 100644
--- a/crates/kernel/src/native.rs
+++ b/crates/kernel/src/native.rs
@@ -154,11 +154,11 @@ pub fn decode_native_body(bytes: &[u8]) -> Result {
/// An SMLib body as a kernel body, through its schema 4 record: SMLib's export mapped by
/// `solidcraft_smlib` (canonical order, radians, pcurves), then this adapter's decode of a
/// foreign record (analytic surfaces as truck's revolutions). The body is oriented as the record
-/// says; `Body::new` only measures it.
+/// says (`Body::oriented`), never by meshing it.
#[cfg(feature = "smlib")]
pub fn body_from_smlib(brep: &solidcraft_smlib::Brep) -> Result {
let record = brep.to_native().map_err(|e| KernelError::Failed(e.to_string()))?;
- guard("decode SMLib B-rep", || Body::new(adapter::decode_from(record, adapter::Producer::Foreign)?))
+ guard("decode SMLib B-rep", || Body::oriented(adapter::decode_from(record, adapter::Producer::Foreign)?))
}
fn validate_topology(saved: &SavedBody) -> Result<()> {
diff --git a/crates/kernel/src/native/adapter_tests.rs b/crates/kernel/src/native/adapter_tests.rs
index d0ca1a8e2e..64152f5116 100644
--- a/crates/kernel/src/native/adapter_tests.rs
+++ b/crates/kernel/src/native/adapter_tests.rs
@@ -281,3 +281,69 @@ fn smlib_hole_rim_fillet_decodes_with_its_volume() {
let ours = crate::measure(&body).unwrap().volume;
assert!((ours - smlib).abs() < 1e-3 * smlib, "decoded volume {ours}, SMLib's {smlib}");
}
+
+/// SMLib's sphere cut by a box keeps its meridian seam from pole to pole: whole beside a cap
+/// cut away from it, split where the cap crosses it. Decoded, each body is oriented as its
+/// record says (never by truck's coarse mesh, which leaves the seam-bounded sphere out and
+/// turned one inside out), the sphere keeps its wrap on its seam and every face meshes: the
+/// volume is the sphere's less the cap, within 1e-3 (it came out −V, or short by the sphere).
+#[cfg(feature = "smlib")]
+#[test]
+fn smlib_spheres_cut_beside_or_across_their_seam_decode_with_their_volume() {
+ use solidcraft_smlib::{BooleanOp, Brep as S};
+ use std::f64::consts::PI;
+ let (r, h) = (5., 2.);
+ let exact = 4. / 3. * PI * r * r * r - PI * h * h * (3. * r - h) / 3.;
+ // The seam is the meridian on +x: a cap on −x or +y leaves it whole, one on +x splits it.
+ let caps =
+ [("−x", [-10., -10., -10.], [h - r, 10., 10.]), ("+x", [r - h, -10., -10.], [10., 10., 10.]), ("+y", [-10., r - h, -10.], [10., 10., 10.])];
+ for (name, lo, hi) in caps {
+ let ball = S::make_sphere([0.; 3], r).unwrap();
+ let tool = S::make_box(lo, [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]).unwrap();
+ let cut = ball.boolean(&tool, BooleanOp::Subtract).unwrap().unwrap();
+ let record = cut.to_native().unwrap();
+ record.check_geometry().unwrap();
+ crate::body::ORIENTED.with(|n| n.set(0));
+ let body = crate::native::body_from_smlib(&cut).unwrap();
+ assert_eq!(crate::body::ORIENTED.with(|n| n.get()), 0, "{name}: the decode meshed the body to orient it");
+ let forward: Vec = record
+ .shells
+ .iter()
+ .flat_map(|sh| match sh {
+ b::Shell::Faces { faces, .. } => faces.iter().map(|f| f.forward).collect(),
+ _ => Vec::new(),
+ })
+ .collect();
+ let loaded: Vec = body.solid.boundaries().iter().flat_map(|sh| sh.face_iter().map(|f| f.orientation()).collect::>()).collect();
+ assert_eq!(loaded, forward, "{name}: the body was turned inside out");
+ let mesh = body.tessellate(0.02).unwrap();
+ for f in 0..body.face_count() {
+ assert!(mesh.tri_face.iter().any(|&t| t as usize == f), "{name}: face {f} meshes");
+ }
+ let ours = crate::measure(&body).unwrap().volume;
+ assert!((ours - exact).abs() < 1e-3 * exact, "{name}: decoded volume {ours}, exact {exact}");
+ }
+ // A bar bored into the sphere's side away from its seam: truck's turning of revolved faces
+ // away from their wrap ran the wrap through the hole, and the face meshed as nothing. The
+ // volume the bar takes, by midpoint rule over its disc of the chord inside the ball.
+ let (c, big, y0, z0, x0, x1, rb) = ([25., 17.5, 2.5], 10., 22.5, 10., 12.5, 30., 2.5);
+ let n = 400;
+ let mut taken = 0.;
+ for i in 0..n {
+ let rho = rb * (f64::from(i) + 0.5) / f64::from(n);
+ for j in 0..n {
+ let t = 2. * PI * (f64::from(j) + 0.5) / f64::from(n);
+ let (y, z) = (y0 + rho * t.cos(), z0 + rho * t.sin());
+ let d = big * big - (y - c[1]).powi(2) - (z - c[2]).powi(2);
+ if d > 0. {
+ taken += ((c[0] + d.sqrt()).min(x1) - (c[0] - d.sqrt()).max(x0)).max(0.) * rho;
+ }
+ }
+ }
+ let exact = 4. / 3. * PI * big.powi(3) - taken * rb / f64::from(n) * 2. * PI / f64::from(n);
+ let ball = S::make_sphere(c, big).unwrap();
+ let bar = crate::cylinder(Vec3::new(x0, y0, z0), Vec3::X, rb, x1 - x0).unwrap();
+ let cut = ball.boolean(&crate::native::smlib_body(&bar).unwrap(), BooleanOp::Subtract).unwrap().unwrap();
+ 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}");
+}
diff --git a/crates/kernel/src/tess/mod.rs b/crates/kernel/src/tess/mod.rs
index a787bfea56..f22c9f3d61 100644
--- a/crates/kernel/src/tess/mod.rs
+++ b/crates/kernel/src/tess/mod.rs
@@ -466,17 +466,22 @@ fn off_surface(rules: &size::Rules) -> f64 {
(rules.extent * ON_SURFACE).max(rules.boundary_tol.unwrap_or(0.0)).max(1e-6)
}
-/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
-/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
-/// Whether a face of `solid` is bounded by one seam edge alone, used both ways between two
-/// distinct vertices (poles): a whole sphere as one face, as SMLib writes it. Truck's mesher
-/// projects both uses onto one parameter line and meshes nothing; the display plan opens the
-/// loop across the period ([`open_pole_to_pole`]).
+/// Whether a face of `solid` has a loop that runs along a seam edge between two distinct
+/// vertices and straight back along it, the two uses one after the other (they meet at a pole):
+/// a whole sphere as one face bounded by its meridian alone, as SMLib writes it, or a sphere
+/// face whose meridian is split where a trim crosses it. Truck's mesher projects both uses onto
+/// one parameter line and meshes nothing; the display plan opens the loop across the period
+/// ([`open_pole_to_pole`]).
pub(crate) fn has_pole_to_pole_seam(solid: &Solid) -> bool {
solid.face_iter().any(|f| {
f.boundaries().iter().any(|w| {
let edges: Vec<&mt::Edge> = w.edge_iter().collect();
- matches!(edges.as_slice(), [a, b] if a.id() == b.id() && a.front() != a.back())
+ let n = edges.len();
+ n >= 2
+ && (0..n).any(|k| match (edges.get(k), edges.get((k + 1) % n)) {
+ (Some(a), Some(b)) => a.id() == b.id() && a.orientation() != b.orientation() && a.front() != a.back(),
+ _ => false,
+ })
})
})
}
@@ -487,21 +492,24 @@ fn area2(uv: &[[f64; 2]]) -> f64 {
(0..n).map(|k| uv.get(k).zip(uv.get((k + 1) % n)).map_or(0.0, |(a, b)| a[0] * b[1] - b[0] * a[1])).sum()
}
-/// A loop that runs from one pole to the other and back along the same parameter line (both
-/// uses of a seam joined only at the poles) encloses nothing once projected: the run after the
-/// second pole is moved one period along the periodic direction, which way gives the loop a
-/// positive area, so the face spans the period between its two seam copies.
+/// A loop through both poles that turns straight back at one of them (it runs along a seam
+/// and back: a whole sphere bounded by its meridian, or a sphere face whose split meridian it
+/// uses both ways) retraces that seam once projected, since the periodic parameter carries on
+/// unchanged across the pole. Across a pole it may jump by any amount, so the run from the
+/// second pole back to the first is moved one period along, whichever way keeps the loop
+/// within one period with a positive area: the seam's two uses then lie a period apart.
fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
let n = l.uv.len();
let poles: Vec = (0..n).filter(|&k| l.uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate(s, *q, d)))).collect();
let [p0, p1] = poles.as_slice() else { return };
- let (lo, hi) = l.uv.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q[0].min(q[1])), b.max(q[0].max(q[1]))));
- let extent = (hi - lo).abs().max(1e-12);
- if area2(&l.uv).abs() > 1e-9 * extent * extent {
- return;
- }
for d in 0..2 {
let Some(per) = dom.period[d] else { continue };
+ let near =
+ |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>| a.zip(b).is_some_and(|(a, b)| (a[0] - b[0]).abs().max((a[1] - b[1]).abs()) <= 1e-9 * per);
+ let turns_back = [*p0, *p1].iter().any(|&p| near(l.uv.get((p + n - 1) % n), l.uv.get((p + 1) % n)));
+ if !turns_back {
+ continue;
+ }
// Along the seam the periodic parameter is constant between the poles.
let second: Vec = (0..n).filter(|k| (k > p1 || k < p0) && *k != *p0 && *k != *p1).collect();
if second.is_empty() {
@@ -514,7 +522,21 @@ fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
q[d] += sign * per;
}
}
- if area2(&moved) > 1e-9 * extent * extent {
+ // The poles as the planner splits them: one copy at each neighbour's parameter.
+ let mut ring = Vec::with_capacity(n + 2);
+ for k in 0..n {
+ let (Some(q), Some(a), Some(b)) = (moved.get(k), moved.get((k + n - 1) % n), moved.get((k + 1) % n)) else { continue };
+ if k == *p0 || k == *p1 {
+ let (mut qa, mut qb) = (*q, *q);
+ qa[d] = a[d];
+ qb[d] = b[d];
+ ring.extend([qa, qb]);
+ } else {
+ ring.push(*q);
+ }
+ }
+ let (lo, hi) = ring.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q[d]), b.max(q[d])));
+ if hi - lo <= per * (1.0 + 1e-9) && area2(&ring) > 0.0 {
l.uv = moved;
return;
}
@@ -522,6 +544,8 @@ fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
}
}
+/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
+/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
fn plan_face(
fi: usize,
s: &EvalSurface,