diff --git a/crates/kernel/src/tess/mod.rs b/crates/kernel/src/tess/mod.rs index 28f7aff955..43a8119545 100644 --- a/crates/kernel/src/tess/mod.rs +++ b/crates/kernel/src/tess/mod.rs @@ -61,8 +61,10 @@ const PARALLEL_FACES: usize = 4; const PARALLEL_CURVES: usize = 2; const PARALLEL_EDGES: usize = 256; -/// Most lines a curved face's grid gets in one direction. -const MAX_LINES: usize = 1024; +/// Most lines a curved face's grid gets in one direction: a turn's worth for each of the many +/// turns a coil's or a thread's wall winds (the grid as a whole stays within +/// [`grid::MAX_FACE_GRID`]). +const MAX_LINES: usize = 8192; /// Most points Ruppert refinement may add to one face, beyond its seeds. const MAX_REFINE: usize = 20_000; /// Two boundary nodes this close together on the surface (mm) are one node. Far below any diff --git a/crates/kernel/src/tess/seam.rs b/crates/kernel/src/tess/seam.rs index 1982cb67c8..307fcfb02f 100644 --- a/crates/kernel/src/tess/seam.rs +++ b/crates/kernel/src/tess/seam.rs @@ -385,29 +385,18 @@ fn seam_nodes(s: &EvalSurface, swap: bool, x: f64, y0: f64, y1: f64, rules: &Rul Some(inner.iter().map(|&y| (y, s.eval(uv(y)[0], uv(y)[1]).0)).collect()) } -/// Whether a face of `solid` lies on a B-spline surface that joins itself (a lofted tube). -/// Truck meshes a face in its surface's parameters and treats only a revolved surface's -/// rotation as periodic, so a face that runs across such a join comes out folded over itself or -/// with a hole in it. The reference mesh of such a body is made from the display plan, -/// which knows the join is periodic. -pub(crate) fn closes_on_itself(solid: &crate::body::Solid) -> bool { - solid.face_iter().any(|f| matches!(super::surface::eval_surface(&f.surface()), Some(EvalSurface::Nurbs { closed, .. }) if closed[0] || closed[1])) -} - -/// Whether a face of `solid` lies on a surface of revolution and has a boundary loop turning -/// more than once around its axis (a helical trim). Truck's mesher cannot mesh such a face (its -/// triangles cut across the solid), so the reference mesh is made from the display plan instead. +/// Whether a face of `solid` has a boundary loop turning more than once around the axis its +/// surface turns about (a helical trim): a surface of revolution's own axis, or the axis a +/// spline's iso-line winds around (the flank of a screw sweep, as SMLib writes a modelled +/// thread's groove, whose bore or shaft it splits into one face per turn). Truck's mesher cannot +/// mesh such a face (its triangles cut across the solid, or its triangulation fails), so the +/// reference mesh is made from the display plan instead. pub(crate) fn turns_more_than_once(solid: &crate::body::Solid) -> bool { use truck_meshalgo::prelude::*; use truck_modeling as mt; let tau = std::f64::consts::TAU; for face in solid.face_iter() { - let mt::Surface::RevolutedCurve(proc) = face.surface() else { continue }; - let rev = proc.entity(); - let m = *proc.transform(); - let o = crate::body::from_p3(m.transform_point(rev.origin())); - let a = m.transform_vector(rev.axis()); - let Some(k) = Vec3::new(a.x, a.y, a.z).normalized() else { continue }; + let Some((o, k)) = winding_axis(&face.surface()) else { continue }; let e1 = k.cross(if k.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized().unwrap_or(Vec3::X); let e2 = k.cross(e1); let angle = |p: mt::Point3| { @@ -424,7 +413,7 @@ pub(crate) fn turns_more_than_once(solid: &crate::body::Solid) -> bool { let mut n = 16usize; let steps = loop { let xs: Vec = (0..=n).map(|i| angle(c.subs(t0 + (t1 - t0) * i as f64 / n as f64))).collect(); - let ok = xs.windows(2).all(|w| ((w[1] - w[0] + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI).abs() < 1.0); + let ok = xs.windows(2).all(|w| half_turn(w[1] - w[0]).abs() < 1.0); if ok || n >= 4096 { break xs; } @@ -432,7 +421,7 @@ pub(crate) fn turns_more_than_once(solid: &crate::body::Solid) -> bool { }; for x in steps { if let Some(p) = prev { - total += (x - p + std::f64::consts::PI).rem_euclid(tau) - std::f64::consts::PI; + total += half_turn(x - p); lo = lo.min(total); hi = hi.max(total); } @@ -446,3 +435,67 @@ pub(crate) fn turns_more_than_once(solid: &crate::body::Solid) -> bool { } false } + +/// An angle step taken the short way round, in (−π, π]. +fn half_turn(x: f64) -> f64 { + let pi = std::f64::consts::PI; + (x + pi).rem_euclid(std::f64::consts::TAU) - pi +} + +/// Samples per knot span of a spline's iso-line in [`winding_axis`], and their bounds. +const ISO_SPAN_SAMPLES: usize = 4; +const ISO_MIN_SAMPLES: usize = 64; +const ISO_MAX_SAMPLES: usize = 8192; + +/// The axis (a point on it, its unit direction) a face's surface turns about, if it turns at +/// all: a surface of revolution's own axis; for a spline, the axis an iso-line through the +/// middle of the other parameter winds more than once around (a screw sweep's flank, a coil's +/// wall). That axis is the mean binormal of the iso-line (each pair of successive tangents +/// turns about it), through the mean of its samples; `None` for a spline neither iso-line of +/// which turns more than once about it. +fn winding_axis(surface: &truck_modeling::Surface) -> Option<(Vec3, Vec3)> { + use truck_meshalgo::prelude::*; + use truck_modeling as mt; + if let mt::Surface::RevolutedCurve(proc) = surface { + let rev = proc.entity(); + let m = *proc.transform(); + let o = crate::body::from_p3(m.transform_point(rev.origin())); + let a = m.transform_vector(rev.axis()); + return Vec3::new(a.x, a.y, a.z).normalized().map(|k| (o, k)); + } + if matches!(surface, mt::Surface::Plane(_)) { + return None; + } + let s = super::surface::eval_surface(surface)?; + let EvalSurface::Nurbs { knots, .. } = &s else { return None }; + let dom = super::uv::domain(&s); + let tau = std::f64::consts::TAU; + (0..2).find_map(|d| { + let (a, b) = dom.range[d]?; + let (c0, c1) = dom.range[1 - d]?; + let across = 0.5 * (c0 + c1); + let n = knots.get(d).map_or(0, |k| k.len()).saturating_mul(ISO_SPAN_SAMPLES).clamp(ISO_MIN_SAMPLES, ISO_MAX_SAMPLES); + let pts: Vec = (0..=n) + .filter_map(|i| { + let t = a + (b - a) * i as f64 / n as f64; + s.derivs(if d == 0 { t } else { across }, if d == 0 { across } else { t }).map(|(p, _, _)| p) + }) + .collect(); + if pts.len() != n + 1 { + return None; + } + let tangents: Vec = pts.windows(2).filter_map(|w| (w[1] - w[0]).normalized()).collect(); + let binormal = tangents.windows(2).fold(Vec3::ZERO, |acc, w| acc + w[0].cross(w[1])); + let k = binormal.normalized()?; + let o = pts.iter().fold(Vec3::ZERO, |acc, p| acc + *p) * (1.0 / pts.len() as f64); + let e1 = k.cross(if k.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?; + let e2 = k.cross(e1); + let angles: Vec = pts.iter().map(|p| (*p - o).dot(e2).atan2((*p - o).dot(e1))).collect(); + // Too coarse to follow the turns (a step of a radian or more): not taken as winding. + let steps: Vec = angles.windows(2).map(|w| half_turn(w[1] - w[0])).collect(); + if steps.iter().any(|x| x.abs() >= 1.0) { + return None; + } + (steps.iter().sum::().abs() > tau * 1.01).then_some((o, k)) + }) +} diff --git a/crates/kernel/src/tess/tests.rs b/crates/kernel/src/tess/tests.rs index 9423c9fe8d..a77d79eaa1 100644 --- a/crates/kernel/src/tess/tests.rs +++ b/crates/kernel/src/tess/tests.rs @@ -632,6 +632,37 @@ fn assert_faces_lie_flat(name: &str, plan: &solidcraft_geom::tess::TessPlan) { } } +/// #1995: an M8x1 thread through a 7 mm hole in a 20 mm block, both ends open. Truck meshed the +/// groove's winding walls with triangles reaching past both ends of the block; the body's mesh +/// now comes from the display plan, inside the block and with the analytic volume. +#[cfg(feature = "smlib")] +#[test] +fn a_modelled_thread_in_a_bore_meshes_inside_its_block() { + use solidcraft_smlib::Brep as S; + let (Ok(block), Ok(bore)) = (S::make_box([-10.0, -10.0, 0.0], [20.0, 20.0, 10.0]), S::make_cylinder([0.0, 0.0, -1.0], 3.5, 12.0)) else { + panic!("stock") + }; + let threaded = smlib_cut(&smlib_cut(&block, &bore), &smlib_groove(iso_groove(1.0, 8.0, 3.5, true), -1.0, 1.0, 12.0)); + let body = ok(crate::native::body_from_smlib(&threaded)); + assert!(seam::turns_more_than_once(&body.solid), "the groove's walls wind round the bore"); + let m = ok(body.tessellate(0.01)); + let (lo, hi) = m + .positions + .iter() + .fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + assert!(lo.z > -1e-6 && hi.z < 10.0 + 1e-6 && lo.x > -10.0 - 1e-6 && hi.x < 10.0 + 1e-6, "mesh outside the block: {lo:?} {hi:?}"); + // Block less the bore less ten turns of the groove where it is in the material (as the + // kernel case works it out from the ISO profile). + let volume = m.measure().volume; + assert!((volume - 3567.1757).abs() < 0.002 * 3567.1757, "volume {volume}"); + for tol in [0.1, 0.01] { + let m = closed_display_mesh("thread in a bore", &body, tol); + let chord = max_chord(&body, &m); + assert!(chord <= tol * 1.05, "thread in a bore at {tol}: chord {chord}"); + assert_faces_lie_flat("thread in a bore", &ok(body.display_plan_angular(tol, crate::DISPLAY_ANGLE))); + } +} + /// An M10 thread over the lower half of a 30 mm shaft. The groove's root band leaves the shaft's /// end face obliquely: a strip a quarter pitch wide winding ten turns, narrower everywhere than /// a triangle's side, with no four corners to blend. The butterfly of a round face made it a @@ -643,6 +674,12 @@ fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() { let Ok(shaft) = solidcraft_smlib::Brep::make_cylinder([0.0; 3], 5.0, 30.0) else { panic!("shaft") }; let threaded = smlib_cut(&shaft, &smlib_groove(iso_groove(1.5, 10.0, 5.0, false), -1.5, 1.5, 11.0)); let body = ok(crate::native::body_from_smlib(&threaded)); + let m = ok(body.tessellate(0.01)); + let (lo, hi) = m + .positions + .iter() + .fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(lo, hi), p| (lo.min(*p), hi.max(*p))); + assert!(lo.z > -1e-6 && hi.z < 30.0 + 1e-6 && hi.x < 5.0 + 1e-6, "mesh outside the shaft: {lo:?} {hi:?}"); for tol in [0.1, 0.01] { let m = closed_display_mesh("thread run out", &body, tol); let chord = max_chord(&body, &m);