diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index cb414789f7..a56a7a89a6 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -283,6 +283,7 @@ mod smlib_import_tests; mod tests { use super::*; use solidcraft_geom::Vec3; + use std::sync::Arc; fn equal(a: &Body, b: &Body) { assert_eq!(a.face_count(), b.face_count()); assert_eq!(a.face_sources, b.face_sources); @@ -419,390 +420,4 @@ mod tests { let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap(); equal(&body, &restored); } - - /// A face's pcurves as the adapter writes them: its loops relative to its surface. - fn pcurves_of(face: &truck_modeling::Face, tol: f64) -> Result>> { - let record = adapter::face_alone(face, tol)?; - Ok(record - .loops - .into_iter() - .map(|l| match l { - solidcraft_geom::brep::Loop::Edges { edges } => edges.into_iter().map(|e| e.pcurve).collect(), - solidcraft_geom::brep::Loop::Vertex { .. } => Vec::new(), - }) - .collect()) - } - /// Every pcurve of every face of `body` validates as the schema validator does, spans its - /// curve's range, lifts onto the surface within `tol` (64 samples and every span midpoint), - /// and each wire closes in (u, v): consecutive uses meet, or meet across a pole (the gap - /// lifts to one point). Returns the wires whose closure is a whole number of periods (a loop - /// round a pole or the surface), as (face, wire). - fn check_pcurves(body: &Body, tol: f64) -> Vec<(usize, usize)> { - check_faces(&body.solid.face_iter().cloned().collect::>(), tol) - } - fn check_faces(faces: &[truck_modeling::Face], tol: f64) -> Vec<(usize, usize)> { - use truck_modeling::{BoundedCurve, MetricSpace, ParametricCurve, ParametricSurface}; - let mut winding = Vec::new(); - for (f, face) in faces.iter().enumerate() { - let surface = face.surface(); - let all = pcurves_of(face, tol).unwrap_or_else(|e| panic!("face {f}: {e}")); - let wires = face.absolute_boundaries().clone(); - assert_eq!(all.len(), wires.len()); - let lift = |uv: [f64; 2]| surface.subs(uv[0], uv[1]); - let mut areas = Vec::new(); - for (w, (pcs, wire)) in all.iter().zip(&wires).enumerate() { - let ring: Vec<[f64; 2]> = pcs - .iter() - .zip(wire.edge_iter()) - .flat_map(|(pc, e)| { - let [a, b] = pc.interval().unwrap(); - (0..16) - .map(move |k| if e.orientation() { a + (b - a) * k as f64 / 16. } else { b - (b - a) * k as f64 / 16. }) - .map(|t| pc.eval(t).unwrap()) - }) - .collect(); - areas.push( - (0..ring.len()) - .map(|i| { - let (p, q) = (ring[i], ring[(i + 1) % ring.len()]); - p[0] * q[1] - q[0] * p[1] - }) - .sum::() - * 0.5, - ); - assert_eq!(pcs.len(), wire.len()); - let mut ends = Vec::new(); - for (i, (pc, edge)) in pcs.iter().zip(wire.edge_iter()).enumerate() { - let at = format!("face {f} wire {w} edge {i}"); - let (knots, count, values): (&Vec, usize, Vec) = match pc { - solidcraft_geom::brep::Curve2::Spline { knots, points } => (knots, points.len(), points.iter().flatten().copied().collect()), - solidcraft_geom::brep::Curve2::RationalSpline { knots, homogeneous } => { - assert!(homogeneous.iter().all(|h| h[2] >= 1e-12), "{at}: weight"); - (knots, homogeneous.len(), homogeneous.iter().flatten().copied().collect()) - } - }; - let degree = knots.len() - count - 1; - assert!((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "{at}: size"); - assert!(knots.windows(2).all(|k| k[0] <= k[1]) && knots[degree] < knots[count], "{at}: knots"); - assert!(values.iter().chain(knots).all(|x| x.is_finite() && x.abs() <= 1e12), "{at}: numbers"); - let curve = edge.curve(); - let (t0, t1) = curve.range_tuple(); - assert_eq!(pc.interval(), Some([t0, t1]), "{at}: interval"); - let mut ts: Vec = (0..=64).map(|k| t0 + (t1 - t0) * k as f64 / 64.).collect(); - ts.extend(knots.windows(2).filter(|k| k[0] < k[1]).map(|k| 0.5 * (k[0] + k[1]))); - for t in ts { - let uv = pc.eval(t).unwrap_or_else(|| panic!("{at}: eval at {t}")); - // Truck's intersection points are its Newton iterate, within 1e-6 mm of - // the intersection the pcurve follows. - let slack = if matches!(curve, truck_modeling::Curve::IntersectionCurve(_)) { 1e-6 } else { 0. }; - let d = lift(uv).distance(curve.subs(t)); - assert!(d <= tol + slack, "{at}: lift error {d} at {t}"); - } - let (a, b) = (pc.eval(t0).unwrap(), pc.eval(t1).unwrap()); - ends.push(if edge.orientation() { (a, b) } else { (b, a) }); - } - let mut wraps = false; - for k in 0..ends.len() { - let (end, start) = (ends[k].1, ends[(k + 1) % ends.len()].0); - let gap = (end[0] - start[0]).abs().max((end[1] - start[1]).abs()); - if gap <= 1e-9 { - continue; - } - // A pole: the gap's ends and middle are one point in space. - let mid = [0.5 * (end[0] + start[0]), 0.5 * (end[1] + start[1])]; - let one = lift(end).distance(lift(start)).max(lift(end).distance(lift(mid))); - if one <= tol { - continue; - } - let periods = [surface.u_period(), surface.v_period()]; - let whole = (0..2).all(|d| { - let g = start[d] - end[d]; - g.abs() <= 1e-9 || periods[d].is_some_and(|p| (g / p - (g / p).round()).abs() <= 1e-9) - }); - assert!( - whole && lift(end).distance(lift(start)) <= tol, - "face {f} wire {w}: open between uses {k} and {}: {end:?} {start:?}", - (k + 1) % ends.len() - ); - wraps = true; - } - if wraps { - winding.push((f, w)); - } - } - // Loops are relative to the surface: the outer loop (the most area) turns - // counter-clockwise in (u, v), holes clockwise, whichever side the face uses. - if !winding.iter().any(|(wf, _)| *wf == f) { - let outer = (0..areas.len()).max_by(|a, b| areas[*a].abs().total_cmp(&areas[*b].abs())).unwrap(); - for (w, area) in areas.iter().enumerate() { - assert_eq!(*area > 0., w == outer, "face {f} wire {w}: turns the wrong way ({area})"); - } - } - } - winding - } - - #[test] - fn pcurves_on_planes_are_the_curves_mapped_exactly() { - use truck_modeling::{BoundedCurve, ParametricCurve, ParametricSurface}; - let cube = crate::box_solid(Vec3::new(-3., 1., 2.), Vec3::new(40., 30., 10.)).unwrap(); - assert!(check_pcurves(&cube, 1e-9).is_empty()); - // Bit for bit: each line's pcurve ends lift to the line's own ends. - for face in cube.solid.face_iter() { - let surface = face.surface(); - for (pcs, wire) in pcurves_of(face, 1e-9).unwrap().iter().zip(face.absolute_boundaries().clone()) { - for (pc, edge) in pcs.iter().zip(wire.edge_iter()) { - let solidcraft_geom::brep::Curve2::Spline { points, .. } = pc else { panic!("a line is a degree-1 spline") }; - let curve = edge.curve(); - let (t0, t1) = curve.range_tuple(); - assert_eq!(surface.subs(points[0][0], points[0][1]), curve.subs(t0)); - assert_eq!(surface.subs(points[1][0], points[1][1]), curve.subs(t1)); - } - } - } - // Arcs (rational) on the cylinder's caps. - let cyl = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 40.).unwrap(); - for face in cyl.solid.face_iter().filter(|f| matches!(f.surface(), truck_modeling::Surface::Plane(_))) { - assert!(pcurves_of(face, 1e-9).unwrap().iter().flatten().all(|c| matches!(c, solidcraft_geom::brep::Curve2::RationalSpline { .. }))); - } - } - - /// The kernel's own curved bodies: the cylinder (in halves), patch sphere, torus, a cone - /// (apex), a revolved ball, a tube, the cylinder cut by a tilted box (intersection curves), - /// a filleted box. - fn curved_bodies() -> Vec<(&'static str, Body)> { - use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; - use std::f64::consts::{FRAC_PI_2, PI, TAU}; - let turn = |outer: Loop2| crate::revolve(&Plane::XZ, &[Region2 { outer, holes: vec![] }], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap(); - let cyl = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 40.).unwrap(); - let block = crate::box_solid(Vec3::new(-20., -20., 20.), Vec3::new(20., 20., 60.)).unwrap(); - let block = crate::transform(&block, Vec3::ZERO, Vec3::new(0., 0., 20.), Vec3::X, 0.4).unwrap(); - let cut = crate::boolean(&cyl, &block, crate::BoolOp::Cut).unwrap().unwrap(); - let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap(); - let rounded = crate::fillet(&cube, &[Vec3::new(20., 0., 10.)], 2.).unwrap(); - let half = Loop2 { - segs: vec![ - Seg2::Arc { center: Vec2::ZERO, radius: 6., start: -FRAC_PI_2, sweep: PI }, - Seg2::Line { a: Vec2::new(0., 6.), b: Vec2::new(0., -6.) }, - ], - }; - vec![ - ("cylinder", cyl), - ("sphere", crate::sphere(Vec3::ZERO, 12.).unwrap()), - ("torus", crate::torus(Vec3::ZERO, 20., 4.).unwrap()), - ("cone", turn(Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(5., 0.), Vec2::new(0., 10.)]))), - ("ball", turn(half)), - ("tube", turn(Loop2::polygon(&[Vec2::new(3., 0.), Vec2::new(5., 0.), Vec2::new(5., 4.), Vec2::new(3., 4.)]))), - ("cut", cut), - ("fillet", rounded), - ] - } - - #[test] - fn pcurves_fit_curved_faces_within_tolerance() { - for (name, body) in curved_bodies() { - for tol in [1e-3, 1e-5, 1e-6] { - let start = std::time::Instant::now(); - for face in body.solid.face_iter() { - pcurves_of(face, tol).unwrap(); - } - let took = start.elapsed(); - let winding = check_pcurves(&body, tol); - eprintln!("{name} at {tol}: {took:?}; loops round the surface: {winding:?}"); - assert!(winding.is_empty(), "{name}"); - } - } - } - - /// Faces with seams, as imported faces have them (the kernel's own revolutions come in - /// pieces): a cylinder whose seam line is used twice between two full circles, each a closed - /// edge on one vertex, and a sphere bounded by one meridian used twice from pole to pole. - fn seam_faces() -> Vec<(&'static str, truck_modeling::Face)> { - use crate::face_params::iso_curve; - use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; - use std::f64::consts::{FRAC_PI_2, PI, TAU}; - use truck_modeling::{self as mt, ParametricSurface}; - let turn = |outer: Loop2| crate::revolve(&Plane::XZ, &[Region2 { outer, holes: vec![] }], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap(); - let tube = turn(Loop2::polygon(&[Vec2::new(3., 0.), Vec2::new(5., 0.), Vec2::new(5., 4.), Vec2::new(3., 4.)])); - let wall = tube - .solid - .face_iter() - .map(|f| f.surface()) - .find(|s| matches!(s, mt::Surface::RevolutedCurve(_)) && (s.subs(0., 0.).x.hypot(s.subs(0., 0.).y) - 5.).abs() < 1e-9) - .unwrap(); - let (p0, p1) = (mt::Vertex::new(wall.subs(0., 0.)), mt::Vertex::new(wall.subs(1., 0.))); - let seam = mt::Edge::new_unchecked(&p0, &p1, iso_curve(&wall, true, 0., 0., 1.).unwrap()); - let low = mt::Edge::new_unchecked(&p0, &p0, iso_curve(&wall, false, 0., 0., TAU).unwrap()); - let high = mt::Edge::new_unchecked(&p1, &p1, iso_curve(&wall, false, 1., 0., TAU).unwrap()); - let cylinder = mt::Face::new_unchecked(vec![mt::Wire::from(vec![seam.clone(), high.clone(), seam.inverse(), low.inverse()])], wall.clone()); - let band = mt::Face::new_unchecked(vec![mt::Wire::from(vec![low]), mt::Wire::from(vec![high.inverse()])], wall); - let half = Loop2 { - segs: vec![ - Seg2::Arc { center: Vec2::ZERO, radius: 6., start: -FRAC_PI_2, sweep: PI }, - Seg2::Line { a: Vec2::new(0., 6.), b: Vec2::new(0., -6.) }, - ], - }; - let ball = turn(half); - let round = ball.solid.face_iter().map(|f| f.surface()).find(|s| matches!(s, mt::Surface::RevolutedCurve(_))).unwrap(); - let (s0, s1) = (mt::Vertex::new(round.subs(0., 0.)), mt::Vertex::new(round.subs(1., 0.))); - let meridian = mt::Edge::new_unchecked(&s0, &s1, iso_curve(&round, true, 0., 0., 1.).unwrap()); - let sphere = mt::Face::new_unchecked(vec![mt::Wire::from(vec![meridian.clone(), meridian.inverse()])], round); - vec![("seam cylinder", cylinder), ("seam sphere", sphere), ("band", band)] - } - - #[test] - fn pcurves_of_seams_lie_a_period_apart() { - use solidcraft_geom::brep::Curve2; - for (name, face) in seam_faces() { - for bad in [0., -1., f64::NAN, f64::INFINITY] { - assert!(pcurves_of(&face, bad).is_err()); - } - for tol in [1e-3, 1e-6] { - // A face round the surface without a seam: each loop closes a period apart. - if name == "band" { - assert_eq!(check_faces(std::slice::from_ref(&face), tol), [(0, 0), (0, 1)]); - continue; - } - assert!(check_faces(std::slice::from_ref(&face), tol).is_empty(), "{name}"); - let wire = &pcurves_of(&face, tol).unwrap()[0]; - let at = |c: &Curve2, f: f64| { - let [a, b] = c.interval().unwrap(); - c.eval(a + (b - a) * f).unwrap() - }; - // The seam's two uses: the same curve on either side of the period. - let (first, second) = if name == "seam cylinder" { (&wire[0], &wire[2]) } else { (&wire[0], &wire[1]) }; - for f in [0., 0.3, 1.] { - let (a, b) = (at(first, f), at(second, f)); - assert!((a[0] - b[0]).abs() < 1e-9 && ((b[1] - a[1]).abs() - std::f64::consts::TAU).abs() < 1e-9, "{name}: {a:?} {b:?}"); - } - for c in wire { - let (lo, hi) = (0..=16).map(|k| at(c, k as f64 / 16.)[1]).fold((f64::MAX, f64::MIN), |(l, h), v| (l.min(v), h.max(v))); - assert!(lo >= -1e-9 && hi <= std::f64::consts::TAU + 1e-9, "{name}: in the base period"); - } - } - } - } - - /// With `SOLIDCRAFT_PCURVE_SVG=`, each test body's face loops in (u, v), one panel per - /// face: the surface's base period framed, each edge use in its own colour with an arrow - /// along the wire. - #[test] - fn pcurve_pictures() { - let Ok(dir) = std::env::var("SOLIDCRAFT_PCURVE_SVG") else { return }; - let box_ = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap(); - let mut sets: Vec<(String, Vec)> = vec![("box".into(), box_.solid.face_iter().cloned().collect())]; - sets.extend(curved_bodies().into_iter().map(|(n, b)| (n.to_string(), b.solid.face_iter().cloned().collect()))); - sets.extend(seam_faces().into_iter().map(|(n, f)| (n.to_string(), vec![f]))); - for (name, faces) in sets { - let svg = draw(&name, &faces, 1e-5); - std::fs::write(format!("{dir}/{}.svg", name.replace(' ', "-")), svg).unwrap(); - } - } - - fn draw(name: &str, faces: &[truck_modeling::Face], tol: f64) -> String { - use std::fmt::Write; - use std::ops::Bound::{Excluded, Included}; - use truck_modeling::ParametricSurface; - const W: f64 = 300.; - const COLOURS: [&str; 8] = ["#d62728", "#1f77b4", "#2ca02c", "#9467bd", "#ff7f0e", "#17becf", "#e377c2", "#8c564b"]; - let cols = faces.len().min(4); - let rows = faces.len().div_ceil(4); - let mut out = String::new(); - write!(out, r#"{name}: face loops in (u, v)"#, cols as f64 * W, rows as f64 * W + 30.).unwrap(); - for (f, face) in faces.iter().enumerate() { - let (ox, oy) = ((f % 4) as f64 * W, (f / 4) as f64 * W + 30.); - let surface = face.surface(); - let wires = pcurves_of(face, tol).unwrap(); - let lines: Vec)>> = wires - .iter() - .zip(face.absolute_boundaries().clone()) - .map(|(pcs, wire)| { - pcs.iter() - .zip(wire.edge_iter()) - .enumerate() - .map(|(i, (c, e))| { - let [a, b] = c.interval().unwrap(); - (i, e.orientation(), (0..=64).map(|k| c.eval(a + (b - a) * k as f64 / 64.).unwrap()).collect()) - }) - .collect() - }) - .collect(); - // The frame: the base period along periodic directions, the surface's range along - // bounded ones, else the loops' extent. - let all: Vec<[f64; 2]> = lines.iter().flatten().flat_map(|l| l.2.clone()).collect(); - let ext = |d: usize| all.iter().map(|p| p[d]).fold((f64::MAX, f64::MIN), |(l, h), v| (l.min(v), h.max(v))); - let (ru, rv) = surface.parameter_range(); - let range = |r: (std::ops::Bound, std::ops::Bound), d: usize| match r { - (Included(a) | Excluded(a), Included(b) | Excluded(b)) => (a, b), - _ => ext(d), - }; - let frame = if matches!(surface, truck_modeling::Surface::Plane(_)) { [ext(0), ext(1)] } else { [range(ru, 0), range(rv, 1)] }; - let view = |d: usize| { - let (e, r) = (ext(d), frame[d]); - let (lo, hi) = (e.0.min(r.0), e.1.max(r.1)); - let pad = (hi - lo).max(1e-9) * 0.08; - (lo - pad, hi + pad) - }; - let (vu, vv) = (view(0), view(1)); - let px = |p: [f64; 2]| (ox + 50. + (p[0] - vu.0) / (vu.1 - vu.0) * (W - 70.), oy + W - 30. - (p[1] - vv.0) / (vv.1 - vv.0) * (W - 50.)); - let kind = match surface { - truck_modeling::Surface::Plane(_) => "plane", - truck_modeling::Surface::BSplineSurface(_) => "B-spline", - truck_modeling::Surface::NurbsSurface(_) => "NURBS", - truck_modeling::Surface::RevolutedCurve(_) => "revolution", - }; - let periodic = match (surface.u_period(), surface.v_period()) { - (Some(_), _) => ", u periodic", - (_, Some(_)) => ", v periodic", - _ => "", - }; - let (a, b) = (px([frame[0].0, frame[1].1]), px([frame[0].1, frame[1].0])); - write!(out, r##"face {f}: {kind}{periodic}"##, a.0, a.1, b.0 - a.0, b.1 - a.1, ox + 10., oy + 14.).unwrap(); - for (w, wire) in lines.iter().enumerate() { - for (i, fwd, pts) in wire { - let colour = COLOURS[(i + 3 * w) % COLOURS.len()]; - let path: Vec = pts - .iter() - .map(|p| { - let q = px(*p); - format!("{:.2} {:.2}", q.0, q.1) - }) - .collect(); - write!(out, r#""#, path.join(" L ")).unwrap(); - // An arrow at the middle, along the wire. - let (m0, m1) = (px(pts[31]), px(pts[33])); - let (dx, dy) = if *fwd { (m1.0 - m0.0, m1.1 - m0.1) } else { (m0.0 - m1.0, m0.1 - m1.1) }; - let len = dx.hypot(dy).max(1e-9); - let (ux, uy) = (dx / len * 9., dy / len * 9.); - let c = px(pts[32]); - write!( - out, - r#""#, - c.0 + ux, - c.1 + uy, - c.0 - ux - uy * 0.6, - c.1 - uy + ux * 0.6, - c.0 - ux + uy * 0.6, - c.1 - uy - ux * 0.6 - ) - .unwrap(); - let s = px(if *fwd { pts[0] } else { pts[64] }); - write!( - out, - r#"{w}.{i}"#, - s.0, - s.1, - c.0 + 6., - c.1 - 6. - ) - .unwrap(); - } - } - let (l0, l1) = (px([frame[0].0, frame[1].0]), px([frame[0].1, frame[1].1])); - write!(out, r##"u {:.3}{:.3}v {:.3}{:.3}"##, - l0.0, oy + W - 12., frame[0].0, l1.0, oy + W - 12., frame[0].1, ox + 46., l0.1, frame[1].0, ox + 46., l1.1 + 10., frame[1].1).unwrap(); - } - out.push_str(""); - out - } } diff --git a/crates/kernel/src/native/smlib_import_tests.rs b/crates/kernel/src/native/smlib_import_tests.rs index c52ea5266a..d1f558543b 100644 --- a/crates/kernel/src/native/smlib_import_tests.rs +++ b/crates/kernel/src/native/smlib_import_tests.rs @@ -1,6 +1,5 @@ -//! Truck bodies into SMLib through their schema 4 records: the kernel's primitives keep their -//! analytic volume. Truck holds cylinders, spheres and tori as rational B-splines, which SMLib -//! integrates to a few 1e-7 (truck's own mesh measure is 1e-4 off). +//! Bodies into SMLib through their schema 4 records: the kernel's primitives keep their analytic +//! volume. use std::f64::consts::PI; use solidcraft_geom::Vec3; @@ -44,8 +43,8 @@ fn revolutions_keep_their_analytic_volume() { } } -/// A body SMLib made (analytic faces) comes back from truck as revolutions, and goes into SMLib -/// again as the same analytic faces: a second boolean on a first one's result. +/// A body SMLib made (analytic faces) comes back as a record and goes into SMLib again as the +/// same analytic faces: a second boolean on a first one's result. #[test] fn smlib_results_go_back_into_smlib() { for (name, body, exact) in [ @@ -62,8 +61,8 @@ fn smlib_results_go_back_into_smlib() { } } -/// Truck's cylinders are circles extruded as rational splines: they go into SMLib as its -/// cylinder, so a boolean with one intersects a plane with a cylinder, not with a spline. +/// Extruded circles go into SMLib as its cylinder, so a boolean with one intersects a plane with +/// a cylinder, not with a spline. #[test] fn extruded_circles_go_as_cylinders() { use solidcraft_smlib::flat; @@ -89,7 +88,7 @@ fn a_cavity_comes_back_from_smlib() { let cavity = box_solid(v(25., 25., 25.), v(30., 30., 30.)).unwrap(); let r = super::smlib_body(&block).unwrap().boolean(&super::smlib_body(&cavity).unwrap(), solidcraft_smlib::BooleanOp::Subtract).unwrap().unwrap(); let back = super::body_from_smlib(&r).unwrap(); - assert_eq!((back.solid.boundaries().len(), back.face_count()), (2, 12)); + assert_eq!((back.shell_count(), back.face_count()), (2, 12)); let got = super::smlib_body(&back).unwrap().volume(1e-7).unwrap(); assert!((got - (64_000.0 - 125.0)).abs() < 1e-6, "{got}"); }