tess: an edge loop of fewer than three segments is split until it encloses an area

Each edge is sampled on its own, and a short one within the chord tolerance is one
segment. A face bounded by two such edges (a lens of short arcs, a sliver strip of a
cylinder between two rulings) had two chords on one line: "degenerate boundary", and
the whole body's display mesh failed with it (one of Poppy Humanoid's bodies lost all
336 faces to one sliver). Such a loop's segments are now halved until it has three; the
new nodes are on the edges' curves and shared by every face that runs them.

Test: a lens of two radius-1000 arcs beside a 2 m face meshes; without the split it
fails with the same error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
This commit is contained in:
jyviko
2026-10-27 15:30:12 -04:00
committed by Irakli Kourtis
parent 07cb8c7ccb
commit 21c64fb24c
2 changed files with 75 additions and 0 deletions
+34
View File
@@ -182,6 +182,7 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
.into_iter()
.collect::<Result<_>>()?;
relax_crowded_faces(b, &jobs, &rules, &mut nodes)?;
split_thin_loops(b, &mut nodes)?;
let indexed: Vec<usize> = (0..faces.len()).collect();
let face_boundary = |fi: usize, nodes: &[edges::Nodes]| {
let (Some(face), Some(eval), Some(map)) = (faces.get(fi), evals.get(fi), params.get(fi)) else {
@@ -412,6 +413,39 @@ fn untangle(
/// as often as it takes, down to the tolerance alone (a plate with a thousand small holes is
/// not worth a hundred times the work to draw each hole round). An edge keeps one set of nodes
/// for both its faces, at the coarser of their levels, so every rim of a crowded face gets the
/// Segments an edge loop needs to enclose an area in its face's (u, v).
const MIN_LOOP_SEGMENTS: usize = 3;
/// Every edge loop with fewer than [`MIN_LOOP_SEGMENTS`] segments has its edges' segments halved
/// until it has them: two short arcs (a lens), each within the chord tolerance and so one
/// segment, would otherwise be two chords on one line, a boundary of no area. The new nodes are
/// on the edges' curves and shared by every face that runs them, as all edge nodes are.
fn split_thin_loops(b: &brep::Brep, nodes: &mut [edges::Nodes]) -> Result<()> {
for f in &b.faces {
for l in &f.loops {
let brep::Loop::Edges { edges: uses } = l else { continue };
let mut es: Vec<usize> = uses.iter().map(|u| u.edge as usize).collect();
es.sort_unstable();
es.dedup();
let count = |nodes: &[edges::Nodes]| -> usize {
uses.iter().map(|u| nodes.get(u.edge as usize).map_or(0, |n| n.points.len().saturating_sub(1))).sum()
};
// Each round at least doubles every edge's segments: a few rounds always do.
for _ in 0..MIN_LOOP_SEGMENTS {
if count(nodes) >= MIN_LOOP_SEGMENTS {
break;
}
for &e in &es {
let Some(slot) = nodes.get_mut(e) else { continue };
let segs: Vec<u32> = (0..slot.points.len().saturating_sub(1)).filter_map(|k| u32::try_from(k).ok()).collect();
edges::split_segments(b, e, slot, &segs)?;
}
}
}
}
Ok(())
}
/// same share whatever the order of the faces.
fn relax_crowded_faces(b: &brep::Brep, jobs: &[(usize, f64)], rules: &size::Rules, nodes: &mut [edges::Nodes]) -> Result<()> {
let Some(turn) = rules.turn else { return Ok(()) };
+41
View File
@@ -750,3 +750,44 @@ fn a_face_filled_leniently_is_named_in_the_stats() {
assert_eq!(lenient.face, 7);
assert!(lenient.reason.contains("face 7 (spline)"), "{}", lenient.reason);
}
#[test]
fn a_face_bounded_by_two_short_arcs_keeps_an_area_to_mesh() {
// A lens on z = 0 between two radius-1000 circles crossing at (±10, 0), in a record whose
// other face, a 2 m square, sets the segment length: each arc sags 0.05 mm off its chord,
// inside a 0.5 mm tolerance, so each samples as one segment and the two chords lie on one
// line, a boundary of no area. Its loop's segments are split until it encloses one.
use crate::sew::{PartEdge, PartFace, PartUse, Parts, assemble};
use solidcraft_geom::brep::{Curve, Frame, Surface};
use std::f64::consts::{PI, TAU};
let (r, half) = (1000.0f64, 10.0f64);
let c = (r * r - half * half).sqrt();
let circle = |cy: f64| Curve::Circle { frame: Frame { origin: [0.0, cy, 0.0], x_axis: [1.0, 0.0, 0.0], y_axis: [0.0, 1.0, 0.0] }, radius: r };
let top = c.atan2(half);
let line = |a: [f64; 3], z: [f64; 3]| Curve::Line { start: a, end: z };
let sq = [[3000.0, 0.0, 0.0], [5000.0, 0.0, 0.0], [5000.0, 2000.0, 0.0], [3000.0, 2000.0, 0.0]];
let plane = Surface::Plane { origin: [0.0; 3], u_axis: [1.0, 0.0, 0.0], v_axis: [0.0, 1.0, 0.0] };
let mut edges = vec![
PartEdge { curve: circle(-c), interval: [top, PI - top], vertices: [0, 1] },
PartEdge { curve: circle(c), interval: [PI + top, TAU - top], vertices: [1, 0] },
];
for k in 0..4 {
edges.push(PartEdge { curve: line(sq[k], sq[(k + 1) % 4]), interval: [0.0, 1.0], vertices: [2 + k as u32, 2 + ((k + 1) % 4) as u32] });
}
let uses = |es: &[u32]| es.iter().map(|&edge| PartUse { edge, forward: true, given: None }).collect::<Vec<_>>();
let mut vertices = vec![[half, 0.0, 0.0], [-half, 0.0, 0.0]];
vertices.extend(sq);
let parts = Parts {
vertices,
edges,
faces: vec![
PartFace { surface: plane.clone(), forward: true, loops: vec![uses(&[0, 1])] },
PartFace { surface: plane, forward: true, loops: vec![uses(&[2, 3, 4, 5])] },
],
tolerance: 1e-6,
};
let made = ok(assemble(parts, false));
let (plan, _) = ok(plan(&made.brep, 0.5, None));
let lens = made.faces.first().and_then(|&f| plan.faces.get(f)).expect("the lens's plan");
assert!(!lens.triangles.is_empty(), "the lens has triangles");
}