mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
kernel: SMLib spheres cut by a boolean decode oriented and meshed
Results of SMLib booleans on its own spheres came back wrong through
body_from_smlib (side-by-side runner: seed 13 cut −1611.9, seed 61 cut −252.4
against SMLib's 4056.9, seeds 46/61 union 80 % short). The schema 4 records were
right (check_geometry passes, outer loops counter-clockwise, shells out of the
material); three things on the truck side were not:
- Body::new orients a solid by the volume of truck's coarse mesh, and truck's
mesher meshes a sphere bounded by its seam alone as nothing: the rest of the
body set the sign and turned it inside out. A foreign record is oriented by
its shells, so body_from_smlib now builds the body with Body::oriented
(revolved faces turned as before, orientation kept, nothing meshed).
- rephase_revolved turns each revolved face so it sits around angle π. A face
that uses its seam both ways goes all the way round; turning its surface ran
the wrap through the face's hole, the display plan failed and the sphere
meshed as nothing. Such faces now keep their wrap on their seam (truck's own
primitives have none: their round faces are split).
- The display plan opened only a loop of one seam used both ways. A sphere face
whose meridian a trim splits uses each piece both ways, turning back at each
pole, and projected onto one parameter line ("boundary crosses itself"), so
the whole body fell back to truck's mesher and lost its sphere faces. A loop
through both poles that turns straight back at one now has its run between
the poles moved one period along, whichever way keeps it within one period
with a positive area (poles split as the planner splits them); the routing
check finds such loops too.
Test: SMLib's sphere less a box cap beside its seam, across it (seam split) and
a bar bored into its side away from the seam decode without meshing to orient,
oriented as recorded, every face meshed, within 1e-3 of the exact volume.
Before, the decode meshed the body to orient it; with only the plan's pole
handling undone the −x cap measures 50.3 of 469.1 mm³, with only the
rephase undone the bored ball 252.4 of 4059.5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
@@ -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> {
|
||||
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
|
||||
|
||||
@@ -154,11 +154,11 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
/// 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<Body> {
|
||||
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<()> {
|
||||
|
||||
@@ -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<bool> = 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<bool> = body.solid.boundaries().iter().flat_map(|sh| sh.face_iter().map(|f| f.orientation()).collect::<Vec<_>>()).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}");
|
||||
}
|
||||
|
||||
@@ -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<usize> = (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<usize> = (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,
|
||||
|
||||
Reference in New Issue
Block a user