fix: draw small radii on large bodies round (#515)

Display meshes also bound the angle one facet turns, so bends and small holes on a large body are drawn round instead of as chamfers or squares. Bounded per face, and only the display mesh changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-13 01:43:45 -04:00
1 parent bf2a9f5541
commit 69e54f85d2
9 files changed
+856 -38

No files matched your search

+10
View File
@@ -62,6 +62,16 @@ Changes:
point that does not evaluate counts as flat instead of recursing). Surface grids refine only
the direction that bends, and a ruling left undivided gets cells comparable to the other
direction (long triangles from a trim loop to the grid's far ends cut through cylinders).
- `tessellation/mod.rs`, `tessellation/triangulation.rs`, for display: a `Deflection` is the
chord tolerance plus an optional bound on the angle one facet spans, and
`RoundMeshableShape::robust_triangulation_within` divides by it: an edge or a grid direction
within the chord tolerance is still halved while a piece turns more than the angle, until its
sag is under a floor (a radius that is small beside the tolerance was one flat facet).
Bounded work: a face that would be bounded by more than 4096 segments has its curved edges
divided at twice the angle, and again, down to the chord tolerance alone, and the angle
stops refining a face's grid at 16 384 points. Each edge is still divided once for both its
faces. `triangulation` and `robust_triangulation` divide by the chord tolerance alone, as
before.
- `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are
dropped (their sources are not vendored).
+96 -5
View File
@@ -44,6 +44,71 @@ impl<S: MeshableSurface + SearchNearestParameter<D2, Point = Point3>> RobustMesh
type PolylineCurve = truck_polymesh::PolylineCurve<Point3>;
/// SolidCraft: how finely curves and surfaces are divided into facets. `chord` is the usual
/// tolerance: the most a facet may sag from the true shape. `angle`, when finite, also bounds
/// what one facet spans: a facet that turns more than `angle` is split whatever its size (a
/// radius that is small beside the tolerance would otherwise stay one flat facet), until its
/// sag is below `floor`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Deflection {
/// Most a facet may sag from the curve or surface.
pub chord: f64,
/// Most a facet may turn (radians: the arc it spans on a circle); infinite for no bound.
pub angle: f64,
/// Sag below which `angle` no longer splits a facet.
pub floor: f64,
}
impl Deflection {
/// Most boundary segments a face may get from the angle bound. Triangulating a face tests
/// each triangle against the boundary loops around it, so the work grows faster than the
/// boundary (with its square for one long loop): the curved edges of a face that would
/// get more are divided at twice the angle, and again, down to the chord tolerance alone.
pub const MAX_ANGLE_BOUNDARY: usize = 4096;
/// Most grid points a face may get from the angle bound (beyond them only `chord` refines).
pub const MAX_ANGLE_GRID: usize = 16_384;
/// How often the angle is doubled for a crowded face before only `chord` is left.
pub const MAX_RELAX: u8 = 4;
/// The chord tolerance alone: what `triangulation(tol)` divides by.
pub const fn chord(tol: f64) -> Self {
Self {
chord: tol,
angle: f64::INFINITY,
floor: 0.0,
}
}
/// Whether `angle` bounds anything.
pub fn angular(&self) -> bool { self.angle.is_finite() && self.angle > 0.0 }
/// The same with the angle doubled `level` times; from [`Deflection::MAX_RELAX`] times, or
/// past half a turn, the chord tolerance alone.
pub fn relaxed(&self, level: u8) -> Self {
let angle = self.angle * f64::from(1u32 << level.min(Self::MAX_RELAX));
match level == 0 || (level < Self::MAX_RELAX && angle < std::f64::consts::PI) {
true => Self { angle, ..*self },
false => Self::chord(self.chord),
}
}
/// The squared sag over squared length above which a facet turns more than `angle`: a
/// chord spanning `a` of a circle sags `tan(a / 4) / 2` of its length. Infinite without
/// an angle bound.
pub(super) fn turn2(&self) -> f64 {
match self.angular() && self.angle < std::f64::consts::PI {
true => (f64::tan(self.angle / 4.0) / 2.0).powi(2),
false => f64::INFINITY,
}
}
/// Whether the angle bound splits a facet of squared sag `sag2` and squared length `len2`
/// (`turn2` from [`Deflection::turn2`]).
pub(super) fn turns(&self, turn2: f64, sag2: f64, len2: f64) -> bool {
turn2.is_finite() && sag2 > self.floor * self.floor && sag2 > turn2 * len2
}
}
/// Trait for converting tessellated shape into polygon.
pub trait MeshedShape {
/// Converts tessellated shape into polygon.
@@ -258,11 +323,15 @@ impl<C: PolylineableCurve, S: MeshableSurface> MeshableShape for Shell<Point3, C
fn triangulation(&self, tol: f64) -> Self::MeshedShape {
nonpositive_tolerance!(tol);
#[cfg(not(target_arch = "wasm32"))]
let res = triangulation::shell_tessellation(self, tol, triangulation::by_search_parameter);
let res = triangulation::shell_tessellation(
self,
Deflection::chord(tol),
triangulation::by_search_parameter,
);
#[cfg(target_arch = "wasm32")]
let res = triangulation::shell_tessellation_single_thread(
self,
tol,
Deflection::chord(tol),
triangulation::by_search_parameter,
);
res
@@ -272,17 +341,39 @@ impl<C: PolylineableCurve, S: MeshableSurface> MeshableShape for Shell<Point3, C
impl<C: PolylineableCurve, S: RobustMeshableSurface> RobustMeshableShape for Shell<Point3, C, S> {
type MeshedShape = Shell<Point3, PolylineCurve, Option<PolygonMesh>>;
fn robust_triangulation(&self, tol: f64) -> Self::MeshedShape {
nonpositive_tolerance!(tol);
self.robust_triangulation_within(Deflection::chord(tol))
}
}
/// SolidCraft: tessellation with a bound on the angle one facet spans as well as on its sag.
pub trait RoundMeshableShape {
/// Shape whose edges are made polylines and faces polygon surface.
type MeshedShape: MeshedShape;
/// [`RobustMeshableShape::robust_triangulation`] divided by `deflection`: with
/// [`Deflection::chord`] the very same shape; with an angle, curved edges and faces whose
/// radius is small beside the chord tolerance are divided as well. Every edge is still
/// divided once, for both faces it bounds, so neighbouring faces keep sharing its points.
///
/// # Panics
///
/// `deflection.chord` must be more than `TOLERANCE`.
fn robust_triangulation_within(&self, deflection: Deflection) -> Self::MeshedShape;
}
impl<C: PolylineableCurve, S: RobustMeshableSurface> RoundMeshableShape for Shell<Point3, C, S> {
type MeshedShape = Shell<Point3, PolylineCurve, Option<PolygonMesh>>;
fn robust_triangulation_within(&self, deflection: Deflection) -> Self::MeshedShape {
nonpositive_tolerance!(deflection.chord);
#[cfg(not(target_arch = "wasm32"))]
let res = triangulation::shell_tessellation(
self,
tol,
deflection,
triangulation::by_search_nearest_parameter,
);
#[cfg(target_arch = "wasm32")]
let res = triangulation::shell_tessellation_single_thread(
self,
tol,
deflection,
triangulation::by_search_nearest_parameter,
);
res
+188 -27
View File
@@ -91,7 +91,7 @@ where
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn shell_tessellation<'a, C, S>(
shell: &Shell<Point3, C, S>,
tol: f64,
tol: Deflection,
sp: impl SP<S>,
) -> MeshedShell
where
@@ -104,18 +104,28 @@ where
.map(|v| (v.id(), v.mapped(Point3::clone)))
.collect();
let eset: HashMap<_, _> = shell.edge_par_iter().map(move |e| (e.id(), e)).collect();
// SolidCraft: the polylines first, so that the edges of a face crowded by the angle bound
// can be divided again before any face is meshed.
let mut polys: HashMap<_, _> = eset
.par_iter()
.map(|(id, edge)| {
let curve = edge.curve();
(*id, (bounded_polyline(&curve, curve.range_tuple(), tol), 0))
})
.collect();
relax_crowded_faces(shell, &mut polys, tol);
let edge_map: HashMap<_, _> = eset
.into_par_iter()
.map(move |(id, edge)| {
let v0 = vmap.get(&edge.absolute_front().id()).unwrap();
let v1 = vmap.get(&edge.absolute_back().id()).unwrap();
let curve = edge.curve();
let poly = bounded_polyline(&curve, curve.range_tuple(), tol);
(id, Edge::new_unchecked(v0, v1, poly))
let (poly, level) = polys.get(&id).unwrap().clone();
(id, (Edge::new_unchecked(v0, v1, poly), level))
})
.collect();
let edge_map = &edge_map;
let create_edge = |edge: &Edge<Point3, C>| -> Edge<_, _> {
let new_edge = edge_map.get(&edge.id()).unwrap();
let (new_edge, _) = edge_map.get(&edge.id()).unwrap();
match edge.orientation() {
true => new_edge.clone(),
false => new_edge.inverse(),
@@ -129,16 +139,83 @@ where
.iter()
.map(create_boundary)
.collect();
shell_create_polygon(&face.surface(), wires, face.orientation(), tol, &sp, cap)
// SolidCraft: a face is divided like the coarsest of its edges.
let level = face
.absolute_boundaries()
.iter()
.flat_map(Wire::edge_iter)
.filter_map(|edge| edge_map.get(&edge.id()).map(|(_, level)| *level))
.max()
.unwrap_or(0);
shell_create_polygon(
&face.surface(),
wires,
face.orientation(),
tol.relaxed(level),
&sp,
cap,
)
};
shell.face_par_iter().map(create_face).collect()
}
/// SolidCraft: bounded work for the angle bound. A face bounded by more than
/// `Deflection::MAX_ANGLE_BOUNDARY` segments has its curved edges divided again at twice the
/// angle, as often as it takes, down to the chord tolerance alone (a plate with a thousand
/// small holes is not worth a hundred times the work to draw each hole round). `polys` holds
/// each edge's polyline and how often its angle was doubled. Every edge still has one
/// polyline for both its faces, and the outcome does not depend on the order of the faces.
fn relax_crowded_faces<C, S>(
shell: &Shell<Point3, C, S>,
polys: &mut HashMap<EdgeID<C>, (PolylineCurve, u8)>,
tol: Deflection,
) where
C: PolylineableCurve,
{
if !tol.angular() {
return;
}
let faces: Vec<Vec<Edge<Point3, C>>> = shell
.face_iter()
.map(|face| {
let wires = face.absolute_boundaries();
wires.iter().flat_map(Wire::edge_iter).cloned().collect()
})
.collect();
for level in 1..=Deflection::MAX_RELAX {
let segments = |edge: &Edge<Point3, C>| match polys.get(&edge.id()) {
// Straight at the full angle: nothing to relax, and no say in how its faces are divided.
Some((poly, 0)) if poly.len() <= 2 => (1, false),
Some((poly, done)) => (poly.len().saturating_sub(1), *done < level),
None => (0, false),
};
let crowded: Vec<&Edge<Point3, C>> = faces
.iter()
.filter(|edges| {
edges.iter().map(|edge| segments(edge).0).sum::<usize>()
> Deflection::MAX_ANGLE_BOUNDARY
})
.flatten()
.filter(|edge| segments(edge).1)
.collect();
if crowded.is_empty() {
break;
}
let relaxed = tol.relaxed(level);
for edge in crowded {
if let Some(slot) = polys.get_mut(&edge.id()).filter(|(_, done)| *done < level) {
let curve = edge.curve();
*slot = (bounded_polyline(&curve, curve.range_tuple(), relaxed), level);
}
}
}
}
/// Tessellates faces
#[cfg(any(target_arch = "wasm32", test))]
pub(super) fn shell_tessellation_single_thread<'a, C, S>(
shell: &'a Shell<Point3, C, S>,
tol: f64,
tol: Deflection,
sp: impl SP<S>,
) -> MeshedShell
where
@@ -148,6 +225,16 @@ where
use truck_base::entry_map::FxEntryMap as EntryMap;
use truck_topology::Vertex as TVertex;
let cap = face_cap(shell.len());
// SolidCraft: the polylines first (see `shell_tessellation`).
let mut polys: HashMap<_, _> = shell
.edge_iter()
.map(|edge| {
let curve = edge.curve();
(edge.id(), (bounded_polyline(&curve, curve.range_tuple(), tol), 0))
})
.collect();
relax_crowded_faces(shell, &mut polys, tol);
let polys = &polys;
let mut vmap = EntryMap::new(
move |v: &TVertex<Point3>| v.id(),
move |v| v.mapped(Point3::clone),
@@ -159,8 +246,7 @@ where
let v0 = vmap.entry_or_insert(vf).clone();
let vb = edge.absolute_back();
let v1 = vmap.entry_or_insert(vb).clone();
let curve = edge.curve();
let poly = bounded_polyline(&curve, curve.range_tuple(), tol);
let (poly, _) = polys.get(&edge.id()).unwrap().clone();
Edge::new_unchecked(&v0, &v1, poly)
},
);
@@ -180,7 +266,21 @@ where
.iter()
.map(&mut create_boundary)
.collect();
shell_create_polygon(&face.surface(), wires, face.orientation(), tol, &sp, cap)
let level = face
.absolute_boundaries()
.iter()
.flat_map(Wire::edge_iter)
.filter_map(|edge| polys.get(&edge.id()).map(|(_, level)| *level))
.max()
.unwrap_or(0);
shell_create_polygon(
&face.surface(),
wires,
face.orientation(),
tol.relaxed(level),
&sp,
cap,
)
};
shell.face_iter().map(create_face).collect()
}
@@ -197,6 +297,7 @@ where
{
let vertices = shell.vertices.clone();
let cap = face_cap(shell.faces.len());
let tol = Deflection::chord(tol);
let tessellate_edge = |edge: &CompressedEdge<C>| {
let curve = &edge.curve;
CompressedEdge {
@@ -245,7 +346,7 @@ fn shell_create_polygon<S: PreMeshableSurface>(
surface: &S,
wires: Vec<Wire<Point3, PolylineCurve>>,
orientation: bool,
tol: f64,
tol: Deflection,
sp: impl SP<S>,
cap: usize,
) -> Face<Point3, PolylineCurve, Option<PolygonMesh>> {
@@ -382,7 +483,11 @@ fn loop_orientation(curve: &[Point2]) -> bool {
}
impl PolyBoundary {
fn new(pieces: Vec<PolyBoundaryPiece>, surface: &impl PreMeshableSurface, tol: f64) -> Self {
fn new(
pieces: Vec<PolyBoundaryPiece>,
surface: &impl PreMeshableSurface,
tol: Deflection,
) -> Self {
let (mut closed, mut open) = (Vec::new(), Vec::new());
pieces.into_iter().for_each(|PolyBoundaryPiece(mut vec)| {
match vec[0].distance(vec[vec.len() - 1]) < 1.0e-3 {
@@ -577,8 +682,15 @@ fn spade_round(x: f64) -> f64 {
}
/// Tessellates one surface trimmed by polyline.
fn trimming_tessellation<S>(surface: &S, polyboundary: &PolyBoundary, tol: f64, cap: usize) -> PolygonMesh
where S: PreMeshableSurface {
fn trimming_tessellation<S>(
surface: &S,
polyboundary: &PolyBoundary,
tol: Deflection,
cap: usize,
) -> PolygonMesh
where
S: PreMeshableSurface,
{
let mut triangulation = Cdt::new();
polyboundary.insert_to(&mut triangulation);
insert_surface(&mut triangulation, surface, polyboundary, tol, cap);
@@ -597,7 +709,7 @@ fn insert_surface(
triangulation: &mut Cdt,
surface: impl PreMeshableSurface,
polyline: &PolyBoundary,
tol: f64,
tol: Deflection,
cap: usize,
) {
let bdb: BoundingBox<Point2> = polyline.0.iter().flatten().collect();
@@ -656,8 +768,10 @@ pub(super) fn face_cap(faces: usize) -> usize {
/// SolidCraft: truck's curve division, bounded: at most `MAX_EDGE_POINTS` points and a
/// bisection depth of 24; a midpoint that does not evaluate (NaN) counts as flat (truck's
/// version recursed on it 100 levels deep, which never finishes).
fn bounded_polyline<C: PolylineableCurve>(curve: &C, range: (f64, f64), tol: f64) -> PolylineCurve {
/// version recursed on it 100 levels deep, which never finishes). A piece within the chord
/// tolerance is still halved while it turns more than the deflection's angle.
fn bounded_polyline<C: PolylineableCurve>(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve {
let (tol, turn2) = (d.chord, d.turn2());
let mut out = vec![curve.subs(range.0)];
let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)];
while let Some((a, b, depth)) = stack.pop() {
@@ -668,6 +782,7 @@ fn bounded_polyline<C: PolylineableCurve>(curve: &C, range: (f64, f64), tol: f64
let tm = 0.5 * (a + b);
let dm = curve.subs(tm).distance2(pa.midpoint(pb));
let flat = !(d2 >= tol * tol || dm >= tol * tol);
let flat = flat && !d.turns(turn2, d2.max(dm), pa.distance2(pb));
if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS {
out.push(pb);
} else {
@@ -680,12 +795,17 @@ fn bounded_polyline<C: PolylineableCurve>(curve: &C, range: (f64, f64), tol: f64
}
/// SolidCraft: truck's surface division, bounded: stops refining when the grid would exceed
/// `cap` points or after 24 rounds; cells that do not evaluate (NaN) are not refined.
fn bounded_surface_division<S: PreMeshableSurface>(surface: &S, (ur, vr): ((f64, f64), (f64, f64)), tol: f64, cap: usize) -> (Vec<f64>, Vec<f64>) {
/// `cap` points or after 24 rounds; cells that do not evaluate (NaN) are not refined. A
/// direction within the chord tolerance is still halved where a cell turns more than the
/// deflection's angle along it, while the grid stays within `Deflection::MAX_ANGLE_GRID`.
fn bounded_surface_division<S: PreMeshableSurface>(surface: &S, (ur, vr): ((f64, f64), (f64, f64)), d: Deflection, cap: usize) -> (Vec<f64>, Vec<f64>) {
let (tol, turn2) = (d.chord, d.turn2());
let (mut udiv, mut vdiv) = (vec![ur.0, ur.1], vec![vr.0, vr.1]);
for _ in 0..24 {
let mut fu = vec![false; udiv.len() - 1];
let mut fv = vec![false; vdiv.len() - 1];
// What the angle alone asks for.
let (mut au, mut av) = (fu.clone(), fv.clone());
for (i, u) in udiv.windows(2).enumerate() {
for (j, v) in vdiv.windows(2).enumerate() {
if fu[i] && fv[j] {
@@ -702,10 +822,12 @@ fn bounded_surface_division<S: PreMeshableSurface>(surface: &S, (ur, vr): ((f64,
let far = p0.distance2(pt) > tol * tol;
// Which direction bends: refine only that one (a cylinder needs no division
// along its rulings).
let alu = surface.subs(u[0], v0).to_vec() * (1.0 - p) + surface.subs(u[1], v0).to_vec() * p;
let alv = surface.subs(u0, v[0]).to_vec() * (1.0 - q) + surface.subs(u0, v[1]).to_vec() * q;
let bend_u = p0.distance2(Point3::from_vec(alu)) > tol * tol;
let bend_v = p0.distance2(Point3::from_vec(alv)) > tol * tol;
let (su, sv) = ([surface.subs(u[0], v0), surface.subs(u[1], v0)], [surface.subs(u0, v[0]), surface.subs(u0, v[1])]);
let alu = su[0].to_vec() * (1.0 - p) + su[1].to_vec() * p;
let alv = sv[0].to_vec() * (1.0 - q) + sv[1].to_vec() * q;
let (du, dv) = (p0.distance2(Point3::from_vec(alu)), p0.distance2(Point3::from_vec(alv)));
let bend_u = du > tol * tol;
let bend_v = dv > tol * tol;
if bend_u || bend_v {
fu[i] |= bend_u;
fv[j] |= bend_v;
@@ -713,8 +835,20 @@ fn bounded_surface_division<S: PreMeshableSurface>(surface: &S, (ur, vr): ((f64,
fu[i] = true;
fv[j] = true;
}
au[i] |= d.turns(turn2, du, su[0].distance2(su[1]));
av[j] |= d.turns(turn2, dv, sv[0].distance2(sv[1]));
}
}
let count = |f: &[bool], a: &[bool]| f.iter().zip(a).filter(|(f, a)| **f || **a).count();
let (nu, nv) = (udiv.len() + count(&fu, &au), vdiv.len() + count(&fv, &av));
// The angle refines only while the grid stays small: past that the chord tolerance alone.
let turning = turn2.is_finite() && nu.saturating_mul(nv) <= cap.min(Deflection::MAX_ANGLE_GRID);
if !turning {
au.fill(false);
av.fill(false);
}
fu.iter_mut().zip(&au).for_each(|(f, a)| *f |= *a);
fv.iter_mut().zip(&av).for_each(|(f, a)| *f |= *a);
let nu = udiv.len() + fu.iter().filter(|x| **x).count();
let nv = vdiv.len() + fv.iter().filter(|x| **x).count();
if nu == udiv.len() && nv == vdiv.len() {
@@ -848,15 +982,42 @@ fn polyline_on_surface(
surface: impl PreMeshableSurface,
p: Point2,
q: Point2,
tol: f64,
tol: Deflection,
) -> Vec<Point2> {
use truck_geometry::prelude::*;
let line = Line(p, q);
let pcurve = PCurve::new(line, surface);
let (vec, _) = pcurve.parameter_division(pcurve.range_tuple(), tol);
let (vec, _) = pcurve.parameter_division(pcurve.range_tuple(), tol.chord);
let vec = halved_while_turning(|t| pcurve.subs(t), vec, tol);
vec.into_iter().map(|t| line.subs(t)).collect()
}
/// SolidCraft: `params` with every interval halved while the curve turns more over it than the
/// deflection's angle (at most 12 times each, and `MAX_EDGE_POINTS` in all). Unchanged without
/// an angle.
fn halved_while_turning(curve: impl Fn(f64) -> Point3, params: Vec<f64>, d: Deflection) -> Vec<f64> {
let turn2 = d.turn2();
if !turn2.is_finite() || params.len() < 2 {
return params;
}
let mut out = vec![params[0]];
for w in params.windows(2) {
let mut stack: Vec<(f64, f64, usize)> = vec![(w[0], w[1], 0)];
while let Some((a, b, depth)) = stack.pop() {
let (pa, pb, tm) = (curve(a), curve(b), 0.5 * (a + b));
let sag2 = curve(tm).distance2(pa.midpoint(pb));
let split = d.turns(turn2, sag2, pa.distance2(pb));
if split && depth < 12 && out.len() + stack.len() < MAX_EDGE_POINTS {
stack.push((tm, b, depth + 1));
stack.push((a, tm, depth + 1));
} else {
out.push(b);
}
}
}
out
}
#[test]
#[ignore]
#[cfg(not(target_arch = "wasm32"))]
@@ -872,13 +1033,13 @@ fn par_bench() {
let instant = Instant::now();
(0..100).for_each(|_| {
let _shell = shell_tessellation(&shell, 0.01, by_search_parameter);
let _shell = shell_tessellation(&shell, Deflection::chord(0.01), by_search_parameter);
});
println!("{}ms", instant.elapsed().as_millis());
let instant = Instant::now();
(0..100).for_each(|_| {
let _shell = shell_tessellation_single_thread(&shell, 0.01, by_search_parameter);
let _shell = shell_tessellation_single_thread(&shell, Deflection::chord(0.01), by_search_parameter);
});
println!("{}ms", instant.elapsed().as_millis());
}