diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index 239b58be9f..aba5ccab9f 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -98,10 +98,33 @@ pub fn brep_record(body: &Body) -> Result { /// 9e-7 where the record as given is exact to 1e-7. #[cfg(feature = "smlib")] pub fn smlib_body(body: &Body) -> Result { - let record = brep_record(body)?; + let record = tolerant_record(body)?; solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}"))) } +/// [`brep_record`] for another kernel: where an edge lies a little farther from a face it bounds +/// than the body's working tolerance (truck's offsets leave edges some 3e-5 mm off their faces), +/// the record takes a tolerance ten, then a hundred times as large for the body, its edges and +/// vertices, as the kernel it goes to works to tolerances (SMLib's own are far larger). Past +/// that it is refused. +#[cfg(feature = "smlib")] +fn tolerant_record(body: &Body) -> Result { + guard("B-rep record", || { + if body.mesh.is_some() { + return Err(invalid("mesh bodies are source blobs, not B-rep")); + } + let working = crate::fill::face_tolerance(body); + let mut last = None; + for factor in [1.0, 10.0, 100.0] { + match adapter::encode_within(body, working * factor)? { + Ok(record) => return Ok(record), + Err(e) => last = Some(e), + } + } + Err(last.unwrap_or_else(|| invalid("no record"))) + }) +} + /// The truck solid of a schema 4 record, as a saved body's is built: tests compare records /// made elsewhere (the STEP reader's) with truck's own import. #[cfg(test)] @@ -174,13 +197,28 @@ pub(crate) fn body_from_smlib_ordered(brep: &solidcraft_smlib::Brep) -> Result<( }) } -/// `body` in SMLib ([`smlib_body`]), with where each of SMLib's faces and edges is in the body's -/// face and edge order: SMLib's export of the imported body, checked to number every face, -/// edge and vertex as the body's record does. `None` where it does not. +/// `body` in SMLib ([`smlib_body`], healed where SMLib's check refuses it as given), with where +/// each of SMLib's faces and edges is in the body's face and edge order: SMLib's export of the +/// imported body, checked to number every face, edge and vertex as the body's record does. +/// `None` where it does not. #[cfg(feature = "smlib")] pub(crate) fn smlib_body_ordered(body: &Body) -> Result<(solidcraft_smlib::Brep, Option)> { - let record = brep_record(body)?; - let brep = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?; + use solidcraft_smlib::{Brep, CheckLevel}; + let record = tolerant_record(body)?; + let import = |trusted: bool| Brep::from_record(&record, trusted).map_err(|e| KernelError::Failed(format!("SMLib: {e}"))); + let valid = |b: &Brep| b.check(CheckLevel::All).is_ok_and(|c| c.is_valid()); + // A record SMLib's check refuses as given (a gap a little over the record's tolerance, a + // hole listed before its face's outer loop) goes through SMLib's healer; healed, it is kept + // only where SMLib then finds it valid. + let trusted = import(true)?; + let brep = if valid(&trusted) { + trusted + } else { + match import(false) { + Ok(healed) if valid(&healed) => healed, + _ => trusted, + } + }; let order = match brep.to_native_ordered() { Ok((back, order)) => same_numbering(&record, &back).then_some(order), Err(e) => { diff --git a/crates/kernel/src/native/adapter.rs b/crates/kernel/src/native/adapter.rs index 55971cd1ef..f0a8a7cad1 100644 --- a/crates/kernel/src/native/adapter.rs +++ b/crates/kernel/src/native/adapter.rs @@ -97,8 +97,14 @@ fn working_tolerance(body: &Body) -> f64 { /// The record of `body`'s solid. Each face's pcurves come from the records written for it /// (`brep::pcurve`, the routine every backend shares), within the body's working tolerance. pub(super) fn encode(body: &Body) -> Result { + encode_within(body, working_tolerance(body))? +} + +/// The record of `body`'s solid with `tolerance` for the body, its edges and vertices. +/// `Ok(Err(_))`: some edge lies farther than that from a face it bounds, so no pcurve fits it +/// within the tolerance. +pub(super) fn encode_within(body: &Body, tolerance: f64) -> Result> { let solid = &body.solid; - let tolerance = working_tolerance(body); let shells = solid.boundaries(); let regions = regions(body)?; @@ -140,7 +146,10 @@ pub(super) fn encode(body: &Body) -> Result { uses.get_mut(i).ok_or_else(|| invalid("edge use"))?.push(at); } } - faces.push(face_record(face, wires, &edge_index, &edges, tolerance)?); + match face_record(face, wires, &edge_index, &edges, tolerance)? { + Ok(f) => faces.push(f), + Err(e) => return Ok(Err(e)), + } } shell_records.push(b::Shell::Faces { faces: oriented, wires: Vec::new() }); } @@ -163,7 +172,7 @@ pub(super) fn encode(body: &Body) -> Result { let result = b::Brep { version: b::VERSION, tolerance, regions, shells: shell_records, faces, edges: edge_records, vertices }; result.validate().map_err(invalid)?; compatible(&result, Producer::Own)?; - Ok(result) + Ok(Ok(result)) } /// A face's record: its surface, its loops as truck holds them (relative to the surface) and @@ -174,7 +183,7 @@ fn face_record( edge_index: &HashMap, edges: &[(mt::Edge, b::Curve, [f64; 2])], tolerance: f64, -) -> Result { +) -> Result> { let surface = save_surface(&face.surface(), 0)?; let mut uses = Vec::with_capacity(wires.len()); for wire in wires { @@ -188,7 +197,10 @@ fn face_record( .collect::>>()?; uses.push(l); } - let curves = b::pcurve::pcurves(&surface, &uses, tolerance).map_err(pcurve_error)?; + let curves = match b::pcurve::pcurves(&surface, &uses, tolerance) { + Ok(c) => c, + Err(e) => return Ok(Err(pcurve_error(e))), + }; let loops = uses .iter() .zip(curves) @@ -197,7 +209,7 @@ fn face_record( }) .collect::>(); let domain = domain(&surface, &loops)?; - Ok(b::Face { surface, domain, loops }) + Ok(Ok(b::Face { surface, domain, loops })) } /// `face`'s record on its own, its edges numbered in first appearance: what [`encode`] writes @@ -214,7 +226,7 @@ pub(super) fn face_alone(face: &mt::Face, tolerance: f64) -> Result { slot.insert(edges.len() - 1); } } - face_record(face, wires, &edge_index, &edges, tolerance) + face_record(face, wires, &edge_index, &edges, tolerance)? } /// The kernel's error for the pcurve routine's. diff --git a/crates/kernel/src/native/adapter_tests.rs b/crates/kernel/src/native/adapter_tests.rs index a386276c01..9a5ec0a350 100644 --- a/crates/kernel/src/native/adapter_tests.rs +++ b/crates/kernel/src/native/adapter_tests.rs @@ -70,9 +70,10 @@ fn analytic_records_decode_as_their_mathematical_loci_and_normals() { (b::Surface::Torus { frame, major_radius: 4., minor_radius: 1. }, [0., TAU], 0., [6., 2., 3.], [1., 0., 0.]), ]; for (record, height, parameter, expected, normal) in cases { + // Truck's revolution as truck makes its own: (u, v) = (the profile's parameter, the angle). let surface = load_surface(record, [[0., TAU], height]).unwrap(); - assert!(surface.subs(0., parameter).distance(p(expected)) < 1e-10); - assert!(surface.normal(0., parameter).normalize().dot(v(normal).normalize()) > 1. - 1e-10); + assert!(surface.subs(parameter, 0.).distance(p(expected)) < 1e-10); + assert!(surface.normal(parameter, 0.).normalize().dot(v(normal).normalize()) > 1. - 1e-10); } } diff --git a/crates/kernel/src/native/analytic.rs b/crates/kernel/src/native/analytic.rs index 934f4b92ef..49103872fd 100644 --- a/crates/kernel/src/native/analytic.rs +++ b/crates/kernel/src/native/analytic.rs @@ -30,7 +30,11 @@ pub(super) fn revolved(profile: mt::Curve, origin: mt::Point3, axis: mt::Vector3 /// An unbounded analytic surface as a truck revolution, its profile over `height` (the face's /// v range) for a cylinder or cone, and the whole meridian for a sphere or torus. Truck's -/// revolution has its own (u, v); the record's loops, not its parameters, bound the face. +/// revolution has its own (u, v); the record's loops, not its parameters, bound the face. The +/// profile runs against the record's v so the revolution's (profile, angle) has the record's +/// natural normal unswapped, as truck's own revolutions are: the tessellator meets a cone's +/// apex or a sphere's pole at the profile's end, where it places it right (swapped, a drilled +/// hole's tip came out 41 % too large). pub(super) fn load_analytic(surface: b::Surface, height: [f64; 2]) -> Result { use std::f64::consts::{FRAC_PI_2, TAU}; let axis = |f: &b::Frame| v(f.x_axis).cross(v(f.y_axis)); @@ -38,22 +42,22 @@ pub(super) fn load_analytic(surface: b::Surface, height: [f64; 2]) -> Result { let z = axis(&frame); let at = |h| p(frame.origin) + v(frame.x_axis) * radius + z * h; - revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true) + revolved(mt::Curve::Line(mt::Line(at(height[1]), at(height[0]))), p(frame.origin), z, false) } b::Surface::Cone { frame, radius_at_origin, half_angle } => { let z = axis(&frame); let at = |h| p(frame.origin) + v(frame.x_axis) * (radius_at_origin + h * half_angle.tan()) + z * h; - revolved(mt::Curve::Line(mt::Line(at(height[0]), at(height[1]))), p(frame.origin), z, true) + revolved(mt::Curve::Line(mt::Line(at(height[1]), at(height[0]))), p(frame.origin), z, false) } b::Surface::Sphere { frame, radius } => { let z = axis(&frame); let meridian = b::Frame { origin: frame.origin, x_axis: frame.x_axis, y_axis: vector(z) }; - revolved(arc(&meridian, [radius; 2], [-FRAC_PI_2, FRAC_PI_2])?, p(frame.origin), z, true) + revolved(arc(&meridian, [radius; 2], [-FRAC_PI_2, FRAC_PI_2])?.inverse(), p(frame.origin), z, false) } b::Surface::Torus { frame, major_radius, minor_radius } => { let z = axis(&frame); let profile = b::Frame { origin: point(p(frame.origin) + v(frame.x_axis) * major_radius), x_axis: frame.x_axis, y_axis: vector(z) }; - revolved(arc(&profile, [minor_radius; 2], [0., TAU])?, p(frame.origin), z, true) + revolved(arc(&profile, [minor_radius; 2], [0., TAU])?.inverse(), p(frame.origin), z, false) } _ => return Err(invalid("expected analytic surface")), }) diff --git a/crates/kernel/src/native/smlib_import_tests.rs b/crates/kernel/src/native/smlib_import_tests.rs index 106c1cf4a7..28dc86de2b 100644 --- a/crates/kernel/src/native/smlib_import_tests.rs +++ b/crates/kernel/src/native/smlib_import_tests.rs @@ -93,3 +93,35 @@ fn a_cavity_comes_back_from_smlib() { let got = super::smlib_body(&back).unwrap().volume(1e-7).unwrap(); assert!((got - (64_000.0 - 125.0)).abs() < 1e-6, "{got}"); } + +/// A square revolved off the axis through three quarters of a turn: its flat ends are planes +/// square to the axis (annular sectors), its walls cylinders. +#[test] +fn a_revolved_annulus_goes_with_its_flat_faces() { + use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + let square = + Region2 { outer: Loop2::polygon(&[Vec2::new(4., 12.), Vec2::new(12., 12.), Vec2::new(12., 20.), Vec2::new(4., 20.)]), holes: vec![] }; + let plane = Plane { y: v(0., 0., 1.), ..Plane::XZ }; + let body = crate::revolve(&plane, &[square], Vec2::ZERO, Vec2::Y, 1.5 * PI).unwrap().pop().unwrap(); + let s = super::smlib_body(&body).unwrap(); + let check = s.check(solidcraft_smlib::CheckLevel::All).unwrap(); + assert!(check.is_valid(), "{check}"); + let exact = 0.75 * PI * (144.0 - 16.0) * 8.0; + let got = s.volume(1e-7).unwrap(); + assert!((got - exact).abs() <= 1e-6 * exact, "SMLib volume {got}, exact {exact}"); +} +/// A drill point (a cone ending in its apex) through SMLib and back measures as it went in. +#[test] +fn a_cone_apex_comes_back_measuring_as_it_went() { + use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + let r = 3.4; + let h = r / (59f64.to_radians()).tan(); + let profile = + Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 3.0 - h), Vec2::new(r, 3.0), Vec2::new(r, 16.0), Vec2::new(0.0, 16.0)]), holes: vec![] }; + let plane = Plane { origin: v(20.0, 15.0, 0.0), x: v(1., 0., 0.), y: v(0., 0., 1.) }; + let tool = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap(); + let back = super::body_from_smlib(&super::smlib_body(&tool).unwrap()).unwrap(); + let (m0, m1) = (crate::measure(&tool).unwrap(), crate::measure(&back).unwrap()); + assert!((m1.area - m0.area).abs() < 1e-5 * m0.area, "area {} -> {}", m0.area, m1.area); + assert!((m1.volume - m0.volume).abs() < 1e-5 * m0.volume, "volume {} -> {}", m0.volume, m1.volume); +} diff --git a/crates/smlib/src/import/analytic.rs b/crates/smlib/src/import/analytic.rs index e40981f6e5..8eaeed53ff 100644 --- a/crates/smlib/src/import/analytic.rs +++ b/crates/smlib/src/import/analytic.rs @@ -1,8 +1,8 @@ //! Revolutions and extruded circles as SMLib's analytic surfaces. //! //! A `placed_revolution` record holds a surface the way truck does (a profile turned about an -//! axis, then placed); SMLib has no such surface, but every one truck makes for a cylinder, cone, -//! sphere or torus is one of those exactly: a line along the axis, a line in a plane through it, a +//! axis, then placed); SMLib has no such surface, but every one truck makes for a plane, cylinder, cone, +//! sphere or torus is one of those exactly: a line square to the axis, a line along it, a line in a plane through it, a //! circle centred on it, a circle in a plane through it. A rational spline that is a circular arc //! swept straight along its axis (truck's extruded circles) is a cylinder. Each such face becomes //! that analytic surface with the face's range on it, checked point by point over the face's @@ -21,7 +21,7 @@ use solidcraft_geom::brep::eval::{CurveEval, SurfaceEval}; use super::{R, Unsupported}; -const NOT_ANALYTIC: Unsupported = Unsupported::NotYet("revolved surfaces other than cylinders, cones, spheres and tori"); +const NOT_ANALYTIC: Unsupported = Unsupported::NotYet("revolved surfaces other than planes, cylinders, cones, spheres and tori"); /// `brep` with every revolution and extruded circle replaced by its analytic surface (borrowed /// when it has none). @@ -44,8 +44,10 @@ pub(super) fn analytic(brep: &b::Brep) -> R> { // 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. - turn_frame(&mut surface, range[0][0]); - range[0] = [0.0, range[0][1] - range[0][0]]; + if !matches!(surface, b::Surface::Plane { .. }) { + turn_frame(&mut surface, range[0][0]); + range[0] = [0.0, range[0][1] - range[0][0]]; + } if matches!(surface, b::Surface::Torus { .. }) { range[1] = first_turn(range[1]); } @@ -255,7 +257,21 @@ impl Revolution { let (h0, h1) = (height(at(t[0])?), height(at(t[1])?)); let (dh, dr) = (d.dot(self.z), d.dot(x)); if dh.abs() <= 1e-12 * len { - return Err(NOT_ANALYTIC); // a disc: a plane, which a revolution face never is in the kernel + // A disc or an annulus: the plane square to the axis, its range the box round the + // sector the line sweeps (each end's radius at both end angles and every quarter + // turn between: in the plane a point is linear in the radius). + let (ra, rb) = (radial(at(t[0])?), radial(at(t[1])?)); + let mut angles = vec![angle[0], angle[1]]; + let mut q = (angle[0] / FRAC_PI_2).ceil() * FRAC_PI_2; + while q < angle[1] && angles.len() < 16 { + angles.push(q); + q += FRAC_PI_2; + } + let corners: Vec<[f64; 2]> = angles.iter().flat_map(|a| [ra, rb].map(|r| [r * a.cos(), r * a.sin()])).collect(); + 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); + let plane = b::Surface::Plane { origin: arr(foot), u_axis: arr(x), v_axis: arr(y) }; + return Ok((plane, [[lo(0), hi(0)], [lo(1), hi(1)]])); } let r0 = radial(s) - dr / dh * height(s); let v = sorted(h0, h1); @@ -329,6 +345,10 @@ fn analytic_uv(s: &b::Surface, p: Vec3, near: [f64; 2]) -> Option<[f64; 2]> { } a }; + if let b::Surface::Plane { origin, u_axis, v_axis } = s { + let q = p - v3(*origin); + return Some([q.dot(v3(*u_axis)), q.dot(v3(*v_axis))]); + } let (frame, round) = match s { b::Surface::Cylinder { frame, .. } | b::Surface::Cone { frame, .. } | b::Surface::Sphere { frame, .. } => (frame, 0.0), b::Surface::Torus { frame, major_radius, .. } => (frame, *major_radius),