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:
1 parent
bf2a9f5541
commit
69e54f85d2
9 files changed
+856
-38
No files matched your search
@@ -148,17 +148,38 @@ pub struct FaceInfo {
|
||||
|
||||
type MeshedShell = truck_topology::Shell<mt::Point3, PolylineCurve<mt::Point3>, Option<PolygonMesh>>;
|
||||
|
||||
/// The most one facet of the display mesh turns (radians): a curved face or edge is divided
|
||||
/// until no facet spans more of it than this, however small its radius is beside the body.
|
||||
/// 20 degrees: a quarter turn is drawn with at least five facets, a full circle with at least 18.
|
||||
pub const DISPLAY_ANGLE: f64 = std::f64::consts::PI / 9.0;
|
||||
|
||||
/// The sag, as a fraction of the chord tolerance, below which the angle no longer divides a
|
||||
/// facet: at [`DISPLAY_ANGLE`] and the application's tolerance of 1/1000 of the body, a radius
|
||||
/// under about 1/15000 of the body gets fewer facets than the angle asks for, and one under
|
||||
/// a millionth of it only those of the chord tolerance.
|
||||
const DISPLAY_ANGLE_FLOOR: f64 = 1e-3;
|
||||
|
||||
/// The finest angle a display mesh is divided by (one degree).
|
||||
const MIN_DISPLAY_ANGLE: f64 = std::f64::consts::PI / 180.0;
|
||||
|
||||
/// Triangulate each shell (not the solid: truck's solid constructor panics on the open or
|
||||
/// non-manifold shells an import can produce). Boundaries that lie on their surface only within
|
||||
/// tolerance (imports, boolean results) are projected onto it.
|
||||
fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
|
||||
mesh_shells_within(solid, Deflection::chord(tol))
|
||||
}
|
||||
|
||||
/// [`mesh_shells`] divided by a chord tolerance and, when it has one, a bound on the angle a
|
||||
/// facet spans. Each edge is divided once for both its faces, so the faces still share its
|
||||
/// points.
|
||||
fn mesh_shells_within(solid: &Solid, deflection: Deflection) -> Vec<MeshedShell> {
|
||||
solid
|
||||
.boundaries()
|
||||
.iter()
|
||||
// The robust search tries the exact one first, so exact geometry costs nothing extra; a
|
||||
// face can also come back as an empty mesh rather than none (boolean results with
|
||||
// approximate intersection curves), so always use it.
|
||||
.map(|sh| sh.robust_triangulation(tol))
|
||||
.map(|sh| sh.robust_triangulation_within(deflection))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -838,13 +859,24 @@ impl Body {
|
||||
/// Like [`Body::tessellate`]; with `refine`, triangles that cut through curved faces are
|
||||
/// split until within `tol` of the surface (for measuring).
|
||||
pub fn tessellate_with(&self, tol: f64, refine: bool) -> Result<Mesh> {
|
||||
self.tessellate_within(tol, f64::INFINITY, refine)
|
||||
}
|
||||
|
||||
/// [`Body::tessellate_with`] in which no facet turns more than `angle` radians either
|
||||
/// (infinite: the chord tolerance alone).
|
||||
fn tessellate_within(&self, tol: f64, angle: f64, refine: bool) -> Result<Mesh> {
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
|
||||
let tol = tol.max(self.size() * 1e-6);
|
||||
if let Some(m) = &self.mesh {
|
||||
return Ok(m.to_mesh());
|
||||
}
|
||||
let deflection = if angle.is_finite() && angle > 0.0 {
|
||||
Deflection { chord: tol, angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR }
|
||||
} else {
|
||||
Deflection::chord(tol)
|
||||
};
|
||||
guard("tessellate", || {
|
||||
let meshed = mesh_shells(&self.solid, tol);
|
||||
let meshed = mesh_shells_within(&self.solid, deflection);
|
||||
let mut out = Mesh::default();
|
||||
let solid_faces: Vec<&mt::Face> = self.solid.face_iter().collect();
|
||||
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
|
||||
@@ -941,7 +973,20 @@ impl Body {
|
||||
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged.
|
||||
pub fn display_mesh(&self, tol: f64) -> Result<Mesh> {
|
||||
let mut m = self.tessellate(tol)?;
|
||||
self.flagged(self.tessellate(tol)?)
|
||||
}
|
||||
|
||||
/// Display mesh whose curved faces and edges look curved whatever the body's size: like
|
||||
/// [`Body::display_mesh`], and no facet turns more than `angle` radians (see
|
||||
/// [`DISPLAY_ANGLE`]). A chord tolerance that follows the body's size is larger than the
|
||||
/// sagitta of a small radius on a large body (a sheet-metal bend, a small hole), which the
|
||||
/// tolerance alone leaves as one flat facet.
|
||||
pub fn display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
|
||||
self.flagged(self.tessellate_within(tol, angle, false)?)
|
||||
}
|
||||
|
||||
/// A tessellation with its edges flagged for display (seams, tangent joins, protected creases).
|
||||
fn flagged(&self, mut m: Mesh) -> Result<Mesh> {
|
||||
if self.is_mesh() {
|
||||
return Ok(m);
|
||||
}
|
||||
@@ -1221,6 +1266,10 @@ mod lump_tests;
|
||||
#[path = "refine_cache_tests.rs"]
|
||||
mod refine_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "display_mesh_tests.rs"]
|
||||
mod display_mesh_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The display mesh of a large body with small radii: bends and holes look round, the mesh
|
||||
//! stays closed, and the cost stays bounded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use super::{Body, DISPLAY_ANGLE};
|
||||
use crate::{FaceSurface, extrude, face_surface};
|
||||
|
||||
/// The chord tolerance the application draws a body with.
|
||||
fn display_tol(b: &Body) -> f64 {
|
||||
(b.size() * 1e-3).max(1e-3)
|
||||
}
|
||||
|
||||
fn rect(w: f64, h: f64) -> Loop2 {
|
||||
Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)])
|
||||
}
|
||||
|
||||
/// A `length` long angle of 2 mm sheet: a 100 mm leg along X, a 90 degree bend of inner
|
||||
/// radius 1 (outer 3) about the line x = y = 3, and a 40 mm leg along Y, extruded along Z.
|
||||
fn bent_sheet(length: f64) -> Body {
|
||||
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
|
||||
let c = Vec2::new(3.0, 3.0);
|
||||
let outline = Loop2 {
|
||||
segs: vec![
|
||||
line((100.0, 0.0), (100.0, 2.0)),
|
||||
line((100.0, 2.0), (3.0, 2.0)),
|
||||
Seg2::Arc { center: c, radius: 1.0, start: 1.5 * PI, sweep: -FRAC_PI_2 },
|
||||
line((2.0, 3.0), (2.0, 40.0)),
|
||||
line((2.0, 40.0), (0.0, 40.0)),
|
||||
line((0.0, 40.0), (0.0, 3.0)),
|
||||
Seg2::Arc { center: c, radius: 3.0, start: PI, sweep: FRAC_PI_2 },
|
||||
line((3.0, 0.0), (100.0, 0.0)),
|
||||
],
|
||||
};
|
||||
assert!(outline.signed_area() > 0.0);
|
||||
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![] }], 0.0, length).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// A 3000 x 1000 x 2 plate with round holes of `radius` at `centres`.
|
||||
fn plate_with_holes(centres: &[Vec2], radius: f64) -> Body {
|
||||
let holes = centres.iter().map(|c| Loop2::circle(*c, radius).reversed()).collect();
|
||||
extrude(&Plane::XY, &[Region2 { outer: rect(3000.0, 1000.0), holes }], 0.0, 2.0).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// The faces of a body on cylinders of this radius.
|
||||
fn cylinder_faces(b: &Body, radius: f64) -> Vec<(usize, Vec3, Vec3)> {
|
||||
(0..b.face_count())
|
||||
.filter_map(|i| match face_surface(b, i)? {
|
||||
FaceSurface::Cylinder { origin, axis, radius: r } if (r - radius).abs() < 1e-6 => Some((i, origin, axis)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distance of a point from an axis.
|
||||
fn radial(p: Vec3, origin: Vec3, axis: Vec3) -> f64 {
|
||||
let d = p - origin;
|
||||
(d - axis * d.dot(axis)).len()
|
||||
}
|
||||
|
||||
/// How a face on a cylinder is divided around its axis: the angles (radians, sorted, from the
|
||||
/// first) at which it has vertices.
|
||||
fn angles_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> Vec<f64> {
|
||||
let u = axis.any_perp();
|
||||
let v = axis.cross(u);
|
||||
let mut a: Vec<f64> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| **f as usize == face)
|
||||
.flat_map(|(t, _)| t.iter())
|
||||
.filter_map(|k| m.positions.get(*k as usize))
|
||||
.map(|p| (*p - origin).dot(v).atan2((*p - origin).dot(u)))
|
||||
.collect();
|
||||
a.sort_by(f64::total_cmp);
|
||||
// Start at the widest gap, so an arc that crosses ±π stays in one piece.
|
||||
let n = a.len();
|
||||
let gap = |i: usize| (a[(i + 1) % n] - a[i]).rem_euclid(TAU);
|
||||
let start = (0..n).max_by(|i, j| gap(*i).total_cmp(&gap(*j))).map_or(0, |i| (i + 1) % n);
|
||||
let first = a[start];
|
||||
let mut out: Vec<f64> = (0..n).map(|i| (a[(start + i) % n] - first).rem_euclid(TAU)).collect();
|
||||
out.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
|
||||
out
|
||||
}
|
||||
|
||||
/// The most a triangle of `face` lies inside the cylinder it is on (its corners are on it).
|
||||
fn sag(m: &Mesh, face: usize, origin: Vec3, axis: Vec3, radius: f64) -> f64 {
|
||||
let mut worst = 0.0f64;
|
||||
for (t, _) in m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face) {
|
||||
let [a, b, c] = m.tri(t).unwrap();
|
||||
for p in [a, b, c] {
|
||||
assert!((radial(p, origin, axis) - radius).abs() < 1e-6, "a corner off the cylinder");
|
||||
}
|
||||
for p in [(a + b) * 0.5, (b + c) * 0.5, (c + a) * 0.5, (a + b + c) / 3.0] {
|
||||
worst = worst.max((radius - radial(p, origin, axis)).abs());
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
/// Triangle sides that are not shared by exactly two triangles once corners closer than `eps`
|
||||
/// are joined (a closed mesh has none).
|
||||
fn open_sides(m: &Mesh, eps: f64) -> usize {
|
||||
let cell = |p: &Vec3| ((p.x / eps).floor() as i64, (p.y / eps).floor() as i64, (p.z / eps).floor() as i64);
|
||||
let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut points: Vec<Vec3> = Vec::new();
|
||||
let ids: Vec<usize> = m
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (cx, cy, cz) = cell(p);
|
||||
for (dx, dy, dz) in (-1..=1).flat_map(|x| (-1..=1).flat_map(move |y| (-1..=1).map(move |z| (x, y, z)))) {
|
||||
if let Some(i) = grid.get(&(cx + dx, cy + dy, cz + dz)).and_then(|v| v.iter().find(|i| points[**i].dist(*p) < eps)) {
|
||||
return *i;
|
||||
}
|
||||
}
|
||||
points.push(*p);
|
||||
grid.entry((cx, cy, cz)).or_default().push(points.len() - 1);
|
||||
points.len() - 1
|
||||
})
|
||||
.collect();
|
||||
let mut sides: HashMap<(usize, usize), i32> = HashMap::new();
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = t.map(|k| ids[k as usize]);
|
||||
// A triangle at a pole has two corners in one point: no area, no sides.
|
||||
if a == b || b == c || c == a {
|
||||
continue;
|
||||
}
|
||||
for (p, q) in [(a, b), (b, c), (c, a)] {
|
||||
*sides.entry((p.min(q), p.max(q))).or_default() += 1;
|
||||
}
|
||||
}
|
||||
sides.values().filter(|n| **n != 2).count()
|
||||
}
|
||||
|
||||
/// The polyline segments around a circle of `radius` about `centre` in the plane z = `z`,
|
||||
/// and the widest angle one of them spans.
|
||||
fn rim(m: &Mesh, centre: Vec2, radius: f64, z: f64) -> (usize, f64) {
|
||||
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && (Vec2::new(p.x, p.y).dist(centre) - radius).abs() < 1e-6;
|
||||
let (mut segments, mut widest) = (0, 0.0f64);
|
||||
for e in m.edges.iter().filter(|e| e.len() >= 2 && e.iter().all(on)) {
|
||||
for w in e.windows(2) {
|
||||
segments += 1;
|
||||
widest = widest.max(2.0 * (w[0].dist(w[1]) / (2.0 * radius)).clamp(0.0, 1.0).asin());
|
||||
}
|
||||
}
|
||||
(segments, widest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_bend_on_a_large_sheet_is_meshed_round() {
|
||||
let b = bent_sheet(3000.0);
|
||||
let tol = display_tol(&b);
|
||||
// The tolerance is larger than either side's sagitta: 1 - cos 45° of the radius.
|
||||
assert!(tol > 3.0 * (1.0 - (PI / 4.0).cos()));
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
// A quarter turn in facets of at most DISPLAY_ANGLE.
|
||||
let facets = (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(facets, 5);
|
||||
for radius in [1.0, 3.0] {
|
||||
let faces = cylinder_faces(&b, radius);
|
||||
assert_eq!(faces.len(), 1, "one bend face of radius {radius}");
|
||||
let (face, origin, axis) = faces[0];
|
||||
// The chord tolerance alone: one flat strip from tangent line to tangent line.
|
||||
assert_eq!(angles_around(&coarse, face, origin, axis).len(), 2, "radius {radius}");
|
||||
assert!(sag(&coarse, face, origin, axis, radius) > 0.29 * radius);
|
||||
let a = angles_around(&round, face, origin, axis);
|
||||
assert!(a.len() > facets, "radius {radius}: {} facets across the bend", a.len() - 1);
|
||||
assert!((a[a.len() - 1] - FRAC_PI_2).abs() < 1e-6, "the bend is a quarter turn: {a:?}");
|
||||
let widest = a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
|
||||
// Within the chord tolerance of the cylinder, and by much: a facet that turns no more
|
||||
// than the angle sags no more than 1 - cos(angle / 2) of the radius.
|
||||
let s = sag(&round, face, origin, axis, radius);
|
||||
assert!(s <= radius * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001 && s < tol, "radius {radius}: sag {s}");
|
||||
}
|
||||
// The flat faces along the sheet meet the bends in straight lines and need nothing more:
|
||||
// only the bends, and the two ends where their arcs now have more points, gain triangles.
|
||||
let count = |m: &Mesh, face: usize| m.tri_face.iter().filter(|f| **f as usize == face).count();
|
||||
let mut along = 0;
|
||||
for face in 0..b.face_count() {
|
||||
if let Some(FaceSurface::Plane { normal, .. }) = face_surface(&b, face)
|
||||
&& normal.dot(Vec3::Z).abs() < 0.5
|
||||
{
|
||||
along += 1;
|
||||
assert_eq!(count(&round, face), count(&coarse, face), "face {face}");
|
||||
}
|
||||
}
|
||||
assert_eq!(along, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_display_mesh_stays_closed_and_keeps_its_edges_and_bounds() {
|
||||
let b = bent_sheet(3000.0);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
// Neighbouring faces share the points of the edge between them.
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
// The same edges in the same order, between the same faces, with the same ends and flags.
|
||||
assert_eq!(round.edges.len(), coarse.edges.len());
|
||||
assert_eq!(round.edge_faces, coarse.edge_faces);
|
||||
assert_eq!((&round.seams, &round.smooth_edges, &round.sharp_edges), (&coarse.seams, &coarse.smooth_edges, &coarse.sharp_edges));
|
||||
for (fine, rough) in round.edges.iter().zip(&coarse.edges) {
|
||||
assert!(fine.len() >= rough.len());
|
||||
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
|
||||
}
|
||||
// The body's box: flat faces and tangent lines bound it, so nothing moves.
|
||||
let (r, c) = (round.bounds(), coarse.bounds());
|
||||
assert!(r.min.dist(c.min) < 1e-9 && r.max.dist(c.max) < 1e-9);
|
||||
// Closer to the solid: the flat strips cut the outer bend's corner off and fill the inner one.
|
||||
let exact = crate::measure(&b).unwrap().volume;
|
||||
assert!((round.measure().volume - exact).abs() < (coarse.measure().volume - exact).abs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_hole_on_a_large_plate_has_a_round_rim() {
|
||||
let centre = Vec2::new(30.0, 20.0);
|
||||
let b = plate_with_holes(&[centre], 2.0);
|
||||
let tol = display_tol(&b);
|
||||
assert!(tol > 2.0, "the hole's radius is below the chord tolerance");
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(segments, 18);
|
||||
for z in [0.0, 2.0] {
|
||||
// The chord tolerance alone: each half circle is one segment, the rim a line.
|
||||
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
|
||||
let (n, widest) = rim(&round, centre, 2.0, z);
|
||||
assert!(n >= segments, "{n} segments around the rim at z = {z}");
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
|
||||
}
|
||||
// The hole's wall follows its rims.
|
||||
for (face, origin, axis) in cylinder_faces(&b, 2.0) {
|
||||
let a = angles_around(&round, face, origin, axis);
|
||||
assert!(a.windows(2).map(|w| w[1] - w[0]).fold(0.0, f64::max) <= DISPLAY_ANGLE * 1.001);
|
||||
assert!(sag(&round, face, origin, axis, 2.0) <= 2.0 * (1.0 - (DISPLAY_ANGLE / 2.0).cos()) * 1.001);
|
||||
}
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_arc_the_chord_tolerance_already_divides_finely_is_left_alone() {
|
||||
// A 40 mm cylinder at a tolerance of 0.01 mm: 64 facets around, far inside the angle.
|
||||
let b = crate::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 30.0).unwrap();
|
||||
let coarse = b.display_mesh(0.01).unwrap();
|
||||
let round = b.display_mesh_angular(0.01, DISPLAY_ANGLE).unwrap();
|
||||
assert_eq!(round.positions, coarse.positions);
|
||||
assert_eq!(round.triangles, coarse.triangles);
|
||||
assert_eq!(round.edges, coarse.edges);
|
||||
// And a body with nothing curved at any tolerance.
|
||||
let b = crate::box_solid(Vec3::ZERO, Vec3::new(3000.0, 1000.0, 2.0)).unwrap();
|
||||
let tol = display_tol(&b);
|
||||
assert_eq!(b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap().positions, b.display_mesh(tol).unwrap().positions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_far_below_the_tolerance_keeps_its_chord_facets() {
|
||||
// A half circle of radius 0.002 in a 3 m plate sags less than a thousandth of the chord
|
||||
// tolerance: below that floor nothing is divided for the angle, however far the body
|
||||
// outgrows its smallest radius.
|
||||
let centre = Vec2::new(30.0, 20.0);
|
||||
let b = plate_with_holes(&[centre], 0.002);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
assert_eq!(round.triangles.len(), coarse.triangles.len());
|
||||
assert_eq!(round.edges, coarse.edges);
|
||||
// Just above the floor the rim is divided, but coarsely: the angle stops where the sag
|
||||
// falls below the floor, not where the facets reach the angle.
|
||||
let b = plate_with_holes(&[centre], 0.01);
|
||||
let round = b.display_mesh_angular(display_tol(&b), DISPLAY_ANGLE).unwrap();
|
||||
let (n, _) = rim(&round, centre, 0.01, 2.0);
|
||||
assert!((3..18).contains(&n), "{n} segments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
|
||||
// 1000 holes of diameter 4 in a 3 m plate. At the full angle the top and bottom faces
|
||||
// would each be bounded by more than 20 000 segments, and the work of triangulating a
|
||||
// face grows faster than its boundary.
|
||||
let centres: Vec<Vec2> = (0..50).flat_map(|i| (0..20).map(move |j| Vec2::new(30.0 + 60.0 * i as f64, 25.0 + 50.0 * j as f64))).collect();
|
||||
let b = plate_with_holes(¢res, 2.0);
|
||||
let tol = display_tol(&b);
|
||||
let coarse = b.display_mesh(tol).unwrap();
|
||||
let round = b.display_mesh_angular(tol, DISPLAY_ANGLE).unwrap();
|
||||
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
|
||||
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
|
||||
for z in [0.0, 2.0] {
|
||||
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
|
||||
let n = boundary(&round, z);
|
||||
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
|
||||
assert!(n <= budget, "{n} segments bound the face at z = {z}");
|
||||
}
|
||||
// Every rim got the same share, and the walls follow their rims.
|
||||
let per_rim: Vec<usize> = centres.iter().map(|c| rim(&round, *c, 2.0, 2.0).0).collect();
|
||||
assert!(per_rim.iter().all(|n| *n == per_rim[0] && *n >= 3), "{:?}", &per_rim[..4]);
|
||||
assert!(
|
||||
round.triangles.len() < 4 * coarse.triangles.len(),
|
||||
"{} triangles, {} at the chord tolerance",
|
||||
round.triangles.len(),
|
||||
coarse.triangles.len()
|
||||
);
|
||||
assert_eq!(open_sides(&round, 1e-6), 0);
|
||||
// A plate with few holes keeps the full angle.
|
||||
let few = plate_with_holes(¢res[..40], 2.0);
|
||||
let round = few.display_mesh_angular(display_tol(&few), DISPLAY_ANGLE).unwrap();
|
||||
assert!(rim(&round, centres[0], 2.0, 2.0).0 >= 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_faces_of_every_kind_are_divided_by_the_angle() {
|
||||
// A sphere, a torus and a cone a few millimetres across, drawn at the 3 mm tolerance of a
|
||||
// body 3 m long: more than any of their sagittas.
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 4.0), holes: vec![] };
|
||||
let bodies = [
|
||||
("sphere", crate::sphere(Vec3::ZERO, 4.0).unwrap()),
|
||||
("torus", crate::torus(Vec3::ZERO, 6.0, 2.0).unwrap()),
|
||||
("cone", crate::extrude_tapered(&Plane::XY, &disc, 6.0, 1.0, -0.3).unwrap()),
|
||||
];
|
||||
// The most the surface normal turns along one side of a triangle of a curved face.
|
||||
let turn = |b: &Body, m: &Mesh| {
|
||||
let flat: Vec<bool> = (0..b.face_count()).map(|i| matches!(face_surface(b, i), Some(FaceSurface::Plane { .. }))).collect();
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| !flat[**f as usize])
|
||||
.flat_map(|(t, _)| [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])])
|
||||
.filter(|(a, b)| m.positions[*a as usize].dist(m.positions[*b as usize]) > 1e-9)
|
||||
.map(|(a, b)| m.normals[a as usize].dot(m.normals[b as usize]).clamp(-1.0, 1.0).acos())
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
let check = |name: &str, b: &Body| {
|
||||
let coarse = b.display_mesh(3.0).unwrap();
|
||||
let round = b.display_mesh_angular(3.0, DISPLAY_ANGLE).unwrap();
|
||||
assert!(turn(b, &coarse) > 2.0 * DISPLAY_ANGLE, "{name}: the chord tolerance leaves facets of {} degrees", turn(b, &coarse).to_degrees());
|
||||
// A side crosses at most one facet in each direction.
|
||||
assert!(turn(b, &round) <= 1.5 * DISPLAY_ANGLE, "{name}: a side turns {} degrees", turn(b, &round).to_degrees());
|
||||
assert_eq!(open_sides(&round, 1e-6), 0, "{name}");
|
||||
// Bounded: no more than a full turn of facets each way on every face.
|
||||
assert!(round.triangles.len() <= b.face_count() * 2 * 36 * 36, "{name}: {} triangles", round.triangles.len());
|
||||
};
|
||||
for (name, b) in &bodies {
|
||||
check(name, b);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ mod tspline;
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_max};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, CapSignature, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
|
||||
Reference in new issue
Block a user