mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
kernel: a spline wall winding round an axis sends its body to the display plan for meshing
SMLib writes a modelled thread's groove walls as splines winding once per pitch, and splits the bore or shaft into one face per turn, so no face of revolution has a loop turning more than once and turns_more_than_once missed the body. Truck's mesher then took the winding walls: meshes reaching 1.4 mm past the block (#1995, #2526, #3311, ladder 06/004), volumes of -1.8e7 and boxes 120 m wide (#2038 join, ladder 06/003), or spade's "!previous_region.is_empty()" assertion inside its constrained triangulation (#3952, ladder 06/001, 06/002). A spline face now has an axis too: the mean binormal of an iso-line that winds more than once about it, through the iso-line's centre; a boundary loop turning more than once about that axis routes the body to the display plan, as a helical trim on a cylinder already did. Truck's own coils (sweep_helix's spline walls) go the same way; their walls are longer than a grid's 1024 lines resolve at the measuring tolerance (three turns of a 20 mm coil need ~1200), so a direction may now have 8192, the grid as a whole still capped at MAX_FACE_GRID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<f64> = (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<Vec3> = (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<Vec3> = 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<f64> = 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<f64> = 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::<f64>().abs() > tau * 1.01).then_some((o, k))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user