tess: no edge segment spans more than a quarter turn; loops touching at a vertex close

Every edge segment now spans at most a quarter turn of its curve, whatever the chord
tolerance: a hole far below the tolerance (a 1 mm hole through a 990 mm plate at a 2 mm
chord) stays a polygon with area, where each half arc had been one chord and the two
chords lay on one line, a hole of no area.

The boundary check takes boundary nodes at the very same (u, v) as one vertex, as the
triangulation does: two loops touching at a vertex they share (a ring standing on a
perforated plate, tangent to the holes beside it) bound a face the triangles follow. A
seam's two uses, one point in space a period apart in (u, v), stay two.

Fixes rig booleans/3665 and sheet-metal/1040.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnQawySUFx57iaJZq7z1Fm

(cherry picked from commit 53f4f9a2a991fcd6d1374ca2d647e3a1be36554f)
This commit is contained in:
jyviko
2026-10-24 22:52:57 -04:00
parent 19cf11ef30
commit 773e627b48
3 changed files with 88 additions and 13 deletions
+15 -5
View File
@@ -12,6 +12,10 @@ use crate::{KernelError, Result};
pub(crate) const MAX_EDGE_NODES: usize = 20_000;
/// Deepest bisection of one edge segment.
const MAX_DEPTH: u32 = 24;
/// The most of its curve's turn one segment may span (a quarter turn, with rounding room): the
/// boundary of every face stays a polygon with the face's area, as a closed curve's first four
/// segments keep it.
const QUARTER_TURN: f64 = std::f64::consts::FRAC_PI_2 * (1.0 + 1e-9);
/// An edge's nodes, from its start vertex (interval min) to its end vertex (interval max).
pub(crate) struct Nodes {
@@ -23,9 +27,10 @@ pub(crate) struct Nodes {
/// Nodes of edge `ei` of `b` from its start to its end: every chord within the tolerance of
/// the curve (checked at the quarter points, so an S-bend in one segment is not missed), at
/// most `h` long, and within the rules' facet angle. The end nodes are the vertices' own
/// points, so edges meeting at a vertex meet exactly. A curve that does not evaluate somewhere
/// on its interval is an error naming the edge.
/// most `h` long, within the rules' facet angle and never spanning more than a quarter turn of
/// the curve. The end nodes are the vertices' own points, so edges meeting at a vertex meet
/// exactly. A curve that does not evaluate somewhere on its interval is an error naming the
/// edge.
pub(crate) fn sample_edge(b: &brep::Brep, ei: usize, rules: &Rules, h: f64) -> Result<Nodes> {
let fail = |why: String| KernelError::Failed(format!("tessellation: edge {ei}: {why}"));
let edge = b.edges.get(ei).ok_or_else(|| fail("no such edge".into()))?;
@@ -92,8 +97,13 @@ pub(crate) fn sample_edge(b: &brep::Brep, ei: usize, rules: &Rules, h: f64) -> R
let sag = [q1, pm, q3].iter().map(|q| q.dist_to_segment(pa, pb)).fold(0.0, f64::max);
let short = !(pa.dist(pb) > h * (1.0 + 1e-9));
// The two half chords of an arc turning θ differ by θ/2.
let straight = rules.turn.is_none_or(|t| !t.exceeds(2.0 * turn(pm - pa, pb - pm), sag));
short && straight && sag <= tol
let bend = 2.0 * turn(pm - pa, pb - pm);
let straight = rules.turn.is_none_or(|t| !t.exceeds(bend, sag));
// Whatever the tolerance, no segment spans more than a quarter turn of its curve: a
// small circle split into two half arcs would otherwise be two chords on one line,
// a hole of no area (the 1 mm hole of a 990 mm plate at its 2 mm chord).
let quarter = bend <= QUARTER_TURN;
short && straight && quarter && sag <= tol
};
if !done && !fine() {
stack.push(Seg { a: m, b, pa: pm, pb, mid: Some(q3), depth: sg.depth + 1 });
+25 -8
View File
@@ -644,7 +644,7 @@ fn plan_face(
let pole_pair = |i: u32, j: u32| i != j && fixed.get(i as usize).zip(fixed.get(j as usize)).is_some_and(|(p, q)| p == q);
let mut triangles = triangles;
triangles.retain(|t| !(pole_pair(t[0], t[1]) || pole_pair(t[1], t[2]) || pole_pair(t[2], t[0])));
if !closed_boundary(&ranges, &fixed, &triangles) {
if !closed_boundary(&ranges, &fixed, &uvs, &triangles) {
return Err(fail("triangulation does not follow the boundary"));
}
let mut all = uvs;
@@ -862,9 +862,13 @@ fn graded(a: [f64; 2], b: [f64; 2], sa: f64, sb: f64, h: f64, speed: [f64; 2], f
/// Every boundary segment (consecutive nodes of a loop) is the side of exactly one triangle:
/// the face covers its boundary with nothing missing or doubled, so it meets its neighbours
/// exactly. Pole copies of one node (consecutive in a loop, at one point) are one vertex: the
/// segment between them has no length, and a side ending on either copy is the same side.
fn closed_boundary(ranges: &[std::ops::Range<usize>], fixed: &[Vec3], triangles: &[[u32; 3]]) -> bool {
/// exactly. Some boundary nodes are one vertex, a side ending on any of them the same side: pole
/// copies of one node (consecutive in a loop, at one point, the segment between them of no
/// length), and nodes at the very same (u, v), which the triangulation takes as one point: two
/// loops touching at a vertex they share (a ring standing on a perforated plate, tangent to the
/// holes beside it). A seam's two uses are at one point in space but a period apart in (u, v),
/// and stay two.
fn closed_boundary(ranges: &[std::ops::Range<usize>], fixed: &[Vec3], uvs: &[[f64; 2]], triangles: &[[u32; 3]]) -> bool {
let mut one: Vec<u32> = (0..fixed.len() as u32).collect();
let root = |one: &[u32], mut i: u32| {
while let Some(&j) = one.get(i as usize).filter(|j| **j != i) {
@@ -873,17 +877,30 @@ fn closed_boundary(ranges: &[std::ops::Range<usize>], fixed: &[Vec3], triangles:
i
};
let segment = |r: &std::ops::Range<usize>, k: usize| ((r.start + k) as u32, (r.start + (k + 1) % r.len()) as u32);
let join = |one: &mut [u32], a: u32, b: u32| {
let (ra, rb) = (root(one, a), root(one, b));
if let Some(x) = one.get_mut(rb.max(ra) as usize) {
*x = ra.min(rb);
}
};
for r in ranges {
for k in 0..r.len() {
let (a, b) = segment(r, k);
if fixed.get(a as usize).zip(fixed.get(b as usize)).is_some_and(|(p, q)| p == q) {
let (ra, rb) = (root(&one, a), root(&one, b));
if let Some(x) = one.get_mut(rb.max(ra) as usize) {
*x = ra.min(rb);
}
join(&mut one, a, b);
}
}
}
// By value: −0.0 and 0.0 are one point.
let key = |q: &[f64; 2]| [(q[0] + 0.0).to_bits(), (q[1] + 0.0).to_bits()];
let mut first: HashMap<[u64; 2], u32> = HashMap::with_capacity(uvs.len());
for (i, q) in uvs.iter().enumerate().take(fixed.len()) {
let i = i as u32;
let f = *first.entry(key(q)).or_insert(i);
if f != i {
join(&mut one, f, i);
}
}
let side = |a: u32, b: u32| {
let (a, b) = (root(&one, a), root(&one, b));
(a != b).then_some((a.min(b), a.max(b)))
+48
View File
@@ -588,3 +588,51 @@ fn thinned_grid_lines_stay_within_the_cap() {
assert!(us.first() == Some(&0.0) && us.last() == Some(&1.0) && vs.first() == Some(&0.0) && vs.last() == Some(&1.0));
}
}
/// Whatever the chord tolerance, no edge segment spans more than a quarter turn of its curve:
/// a 1 mm hole through a 990 mm plate, at a 2 mm chord and no facet angle, keeps a polygon with
/// area. Each half arc of the hole was one chord, two chords on one line: a hole of no area.
#[test]
fn a_hole_far_below_the_tolerance_keeps_its_area() {
let plate = ok(crate::box_solid(Vec3::ZERO, Vec3::new(990.0, 990.0, 10.0)));
let (axis, r) = (Vec3::new(495.0, 495.0, 0.0), 0.5);
let body = cut(&plate, &ok(crate::cylinder(axis - Vec3::Z, Vec3::Z, r, 12.0)));
let rec = record(&body);
let (p, _) = ok(plan(&rec, 2.0, None));
let mut rims = 0;
for (ei, e) in rec.edges.iter().enumerate() {
if matches!(e.curve, Curve::Line { .. }) {
continue;
}
let pts = &p.edges[ei];
// On the hole's rim: every chord at most a quarter turn's, r·√2.
let off = |q: &Vec3| (Vec3::new(q.x, q.y, 0.0).dist(axis) - r).abs();
assert!(pts.iter().all(|q| off(q) < 1e-6), "edge {ei} is not on the hole");
for w in pts.windows(2) {
assert!(w[0].dist(w[1]) <= r * 2f64.sqrt() * (1.0 + 1e-6), "edge {ei}: a chord of {} spans more than a quarter turn", w[0].dist(w[1]));
}
rims += 1;
}
assert!(rims >= 2, "{rims} rim edges");
let m = p.evaluate();
assert_watertight(&m, "plate with a small hole");
// The hole's wall: π · 1 · 10 less its chords' shortfall (a square: 2√2 · 0.5 · 10 at least).
let wall = m.measure().area - 2.0 * (990.0 * 990.0 + 2.0 * 990.0 * 10.0) + 2.0 * PI * r * r;
assert!(wall > 4.0 * 2f64.sqrt() * r * 10.0 * 0.99, "the hole's wall measures {wall}");
}
/// Boundary nodes at the very same (u, v) are one vertex to the triangulation: two holes that
/// touch at a vertex they share (a ring standing on a perforated plate, tangent to the holes
/// beside it) bound a face the triangles follow, though each hole has its own copy of the node.
#[test]
fn loops_touching_at_a_shared_vertex_bound_the_face() {
let outer = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
let left = [[1.0, 1.0], [1.0, 3.0], [2.0, 2.0]];
let right = [[2.0, 2.0], [3.0, 3.0], [3.0, 1.0]];
let uvs: Vec<[f64; 2]> = outer.iter().chain(&left).chain(&right).copied().collect();
let fixed: Vec<Vec3> = uvs.iter().map(|q| Vec3::new(q[0], q[1], 0.0)).collect();
let ranges = vec![0..4, 4..7, 7..10];
let fill = cdt::Fill { seeds: &[], refine: None, shape: false };
let Some((_, triangles)) = cdt::triangulate(&uvs, &ranges, fill) else { panic!("triangulates") };
assert!(closed_boundary(&ranges, &fixed, &uvs, &triangles), "the triangles follow both holes through their shared vertex");
}