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 <[email protected]>
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

+4 -2
View File
@@ -62,12 +62,14 @@ impl ModelBody {
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
/// hole that is small beside the body is still drawn round.
pub fn mesh(&self) -> Arc<Mesh> {
self.mesh
.get_or_init(|| {
let tol = (self.body.size() * 1e-3).max(1e-3);
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
Arc::new(self.body.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
})
.clone()
}
+153
View File
@@ -0,0 +1,153 @@
//! What the application draws of a large sheet with small bends and holes, and that what
//! reads the same mesh (bounds, picks, inside tests) answers as before.
use std::f64::consts::{FRAC_PI_2, TAU};
use serde_json::{Value, json};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_kernel::{DISPLAY_ANGLE, FaceSurface, face_surface};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
const HOLES: [[f64; 2]; 4] = [[30.0, 20.0], [1500.0, 20.0], [2970.0, 980.0], [30.0, 980.0]];
/// A 3000 x 1000 x 2 sheet with bend radius 1, four holes of diameter 4 and a 40 mm flange on
/// each long side.
fn large_sheet() -> Session {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [3000, 1000]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Holes"}));
for c in HOLES {
run(&mut s, "sketch.circle.center", json!({"center": c, "diameter": 4}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 10, "through_all": true, "operation": "cut"}));
run(&mut s, "sheet.flange", json!({"edges": [[1500, 0, 2], [1500, 1000, 2]], "height": 40}));
s
}
/// The widest angle between neighbouring lines of vertices around a cylinder face, and how
/// many such steps the face has.
fn steps_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> (usize, 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())
.map(|k| m.positions[*k as usize] - origin)
.map(|d| d.dot(v).atan2(d.dot(u)))
.collect();
a.sort_by(f64::total_cmp);
a.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
// Leave out the widest gap: the part of the turn the face does not cover.
let n = a.len();
let mut gaps: Vec<f64> = (0..n).map(|i| (a[(i + 1) % n] - a[i]).rem_euclid(TAU)).collect();
gaps.sort_by(f64::total_cmp);
gaps.pop();
(gaps.len(), gaps.last().copied().unwrap_or(0.0))
}
#[test]
fn a_large_sheet_shows_its_small_bends_and_holes_round() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
assert!(b.body.size() * 1e-3 > 3.0, "the chord tolerance is more than any radius here");
let (mut bends, mut walls) = (0, 0);
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let (steps, widest) = steps_around(&m, face, origin, axis);
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
if (radius - 2.0).abs() < 1e-6 {
// Half of a hole's wall.
walls += 1;
assert!(steps >= (TAU / 2.0 / DISPLAY_ANGLE).ceil() as usize, "{steps} facets around half a hole");
} else {
// A bend's inner (1 mm) or outer (3 mm) side: a quarter turn.
bends += 1;
assert!((radius - 1.0).abs() < 1e-6 || (radius - 3.0).abs() < 1e-6, "radius {radius}");
assert!(steps >= (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize, "radius {radius}: {steps} facets across the bend");
}
}
assert_eq!((bends, walls), (4, 8));
// The edges the viewport draws: every hole's rim is round, on both sides of the sheet.
for [x, y] in HOLES {
for z in [0.0, 2.0] {
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && ((p.x - x).hypot(p.y - y) - 2.0).abs() < 1e-6;
let rim: Vec<&Vec<Vec3>> = m.edges.iter().filter(|e| e.iter().all(on)).collect();
let segments: usize = rim.iter().map(|e| e.len() - 1).sum();
assert!(segments >= (TAU / DISPLAY_ANGLE).ceil() as usize, "{segments} segments around the rim at {x}, {y}, {z}");
let widest = rim.iter().flat_map(|e| e.windows(2)).map(|w| 2.0 * (w[0].dist(w[1]) / 4.0).asin()).fold(0.0, f64::max);
assert!(widest <= DISPLAY_ANGLE * 1.001, "a rim segment spans {} degrees", widest.to_degrees());
}
}
// And the scene the renderers take draws those same polylines.
let scene = crate::view::scene(&s, &crate::view::home_camera(&s));
let drawn: usize =
scene.lines.iter().filter(|l| l.points.iter().all(|p| ((p.x - 30.0).hypot(p.y - 20.0) - 2.0).abs() < 1e-6)).map(|l| l.points.len() - 1).sum();
assert!(drawn >= 2 * (TAU / DISPLAY_ANGLE).ceil() as usize, "{drawn} rim segments in the scene");
}
#[test]
fn what_reads_the_display_mesh_of_a_large_sheet_answers_as_before() {
let s = large_sheet();
let st = s.world_state();
let b = &st.bodies[0];
let m = b.mesh();
let coarse = b.body.display_mesh((b.body.size() * 1e-3).max(1e-3)).unwrap();
// Bounds: the sheet's box is set by flat faces, so the rounder bends move nothing.
let (now, before) = (m.bounds(), coarse.bounds());
assert!(now.min.dist(before.min) < 1e-9 && now.max.dist(before.max) < 1e-9, "{now:?} {before:?}");
let exact = solidcraft_kernel::measure(&b.body).unwrap().bbox;
assert!(now.min.dist(exact.min) < 1e-6 && now.max.dist(exact.max) < 1e-6, "{now:?} {exact:?}");
// The cost: the sheet takes 2212 triangles against the chord mesh's 92 when measured, a factor of
// 24; what grows is the number of round faces and the facets across each, not the size of the body.
assert!(
m.triangles.len() <= 30 * coarse.triangles.len() && m.triangles.len() < 4000,
"{} triangles, {} at the chord tolerance",
m.triangles.len(),
coarse.triangles.len()
);
// The same faces and edges, in the same order, between the same faces, with the same ends.
assert_eq!(m.edge_faces, coarse.edge_faces);
assert_eq!(m.tri_face.iter().max(), coarse.tri_face.iter().max());
assert_eq!(m.edges.len(), coarse.edges.len());
for (fine, rough) in m.edges.iter().zip(&coarse.edges) {
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
}
// Picks: a point on a round face (on the true cylinder, which a flat strip across a bend
// misses by up to 0.9 mm) is found on that face, as is one in the middle of a flat face.
let mut round = 0;
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let corners: Vec<Vec3> =
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).flat_map(|(t, _)| t.map(|k| m.positions[k as usize])).collect();
let middle = corners.iter().fold(Vec3::ZERO, |a, p| a + *p) / corners.len() as f64;
// The middle of the face's corners, pushed out onto the cylinder.
let along = origin + axis * (middle - origin).dot(axis);
let p = along + (middle - along).normalized().unwrap() * radius;
assert_eq!(solidcraft_doc::appearance::face_index_at(b, p), Some(face), "radius {radius} at {p:?}");
round += 1;
}
assert_eq!(round, 12);
let top = solidcraft_doc::appearance::face_index_at(b, Vec3::new(1500.0, 500.0, 2.0)).unwrap();
assert!(matches!(face_surface(&b.body, top), Some(FaceSurface::Plane { normal, .. }) if normal.dist(Vec3::Z) < 1e-9));
// Inside and outside: in the sheet, in a flange's wall, in a hole and above the sheet.
assert!(m.contains(Vec3::new(1500.0, 500.0, 1.0)) && m.contains(Vec3::new(1500.0, now.min.y + 1.0, now.max.z - 10.0)));
assert!(!m.contains(Vec3::new(30.0, 20.0, 1.0)) && !m.contains(Vec3::new(1500.0, 500.0, 3.0)));
}
+2
View File
@@ -793,6 +793,8 @@ mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod display_mesh_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod hole_face_tests;
+52 -3
View File
@@ -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;
+350
View File
@@ -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(&centres, 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(&centres[..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);
}
}
+1 -1
View File
@@ -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;