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,