Display tessellation: trimmed faces as four-sided patches (#303) (#1163)

Trimmed faces are cut into four-sided patches (squares around holes, butterflies for round faces, motorcycle traces from concave corners) and each is meshed as a Coons grid, with a cut grid as the fallback; cells are at most 2:1 along straight directions. Folded blends are refused rather than overlapped. The README lists display meshes and the gpu crate.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-14 15:01:05 -04:00
1 parent e519fbd14d
commit e3459edea8
11 files changed
+2068 -38

No files matched your search

+285 -21
View File
@@ -14,11 +14,20 @@ const CORNER_TURN: f64 = 0.6;
struct Side {
nodes: Vec<u32>,
at: Vec<f64>,
/// Its length in 3D (mm).
len: f64,
}
/// The four corners of a single boundary loop, by how sharply the loop turns there in (u, v)
/// scaled by `scale` (one unit per tolerance cell); `None` unless there are exactly four.
fn corners(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<[usize; 4]> {
let [a, b, c, d] = candidates(uv, scale)?[..] else { return None };
Some([a, b, c, d])
}
/// Every node where the loop turns more than [`CORNER_TURN`] (or meets a pole copy), in loop
/// order.
fn candidates(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<Vec<usize>> {
let n = uv.len();
if n < 4 {
return None;
@@ -39,8 +48,60 @@ fn corners(uv: &[[f64; 2]], scale: [f64; 2]) -> Option<[usize; 4]> {
found.push(i);
}
}
let [a, b, c, d] = found[..] else { return None };
Some([a, b, c, d])
Some(found)
}
/// Most segments a patch's own level gives one direction.
const MAX_LEVEL: usize = 4096;
/// Most corner candidates [`coons_best`] chooses four from (8 choose 4 = 70 blends at most).
const MAX_CANDIDATES: usize = 8;
/// Like [`coons`] for a loop with more than four corners (a patch of a trimmed face with a
/// corner cut off, the wing of a butterfly around a hole): every choice of four of its corners
/// is blended, and the one whose smallest triangle angle (in the metric `scale`) is largest
/// wins. Its other corners lie on sides. `None` if no choice gives a blend that does not fold.
pub(crate) fn coons_best(uv: &[[f64; 2]], fixed: &[Vec3], scale: [f64; 2], max_points: usize, h: f64) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let c = candidates(uv, scale)?;
if c.len() < 4 || c.len() > MAX_CANDIDATES {
return None;
}
let mut best: Option<(f64, (Vec<[f64; 2]>, Vec<[u32; 3]>))> = None;
let k = c.len();
for a in 0..k {
for b in a + 1..k {
for d in b + 1..k {
for e in d + 1..k {
let pick = [*c.get(a)?, *c.get(b)?, *c.get(d)?, *c.get(e)?];
let Some(r) = coons_at(uv, fixed, pick, max_points, h, None) else { continue };
let q = min_angle(uv, &r, scale);
if best.as_ref().is_none_or(|(bq, _)| q > *bq) {
best = Some((q, r));
}
}
}
}
}
best.map(|(_, r)| r)
}
/// Smallest corner angle (radians) of the triangles, in the metric.
fn min_angle(uv: &[[f64; 2]], r: &(Vec<[f64; 2]>, Vec<[u32; 3]>), scale: [f64; 2]) -> f64 {
let at = |i: u32| {
let i = i as usize;
let q = if i < uv.len() { uv.get(i) } else { r.0.get(i - uv.len()) };
q.map(|q| [q[0] * scale[0], q[1] * scale[1]])
};
let mut worst = std::f64::consts::PI;
for t in &r.1 {
let (Some(a), Some(b), Some(c)) = (at(t[0]), at(t[1]), at(t[2])) else { continue };
for (p, q, s) in [(a, b, c), (b, c, a), (c, a, b)] {
let (u, v) = ([q[0] - p[0], q[1] - p[1]], [s[0] - p[0], s[1] - p[1]]);
let ang = (u[0] * v[1] - u[1] * v[0]).abs().atan2(u[0] * v[0] + u[1] * v[1]);
worst = worst.min(ang);
}
}
worst
}
fn side(fixed: &[Vec3], n: usize, from: usize, to: usize) -> Option<Side> {
@@ -58,9 +119,9 @@ fn side(fixed: &[Vec3], n: usize, from: usize, to: usize) -> Option<Side> {
if !(total > 0.0) {
// A pole side: evenly along it.
let m = (nodes.len() - 1).max(1) as f64;
return Some(Side { at: (0..nodes.len()).map(|k| k as f64 / m).collect(), nodes });
return Some(Side { at: (0..nodes.len()).map(|k| k as f64 / m).collect(), nodes, len: 0.0 });
}
Some(Side { nodes, at: at.into_iter().map(|x| x / total).collect() })
Some(Side { nodes, at: at.into_iter().map(|x| x / total).collect(), len: total })
}
/// The point of a side at position `s` (0 to 1), linear between its nodes.
@@ -72,9 +133,12 @@ fn along(side: &Side, uv: &[[f64; 2]], s: f64) -> Option<[f64; 2]> {
Some([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f])
}
/// Interior line positions for a pair of opposite sides: the sides' own when they have the
/// same nodes (so the grid meets both node for node), else evenly as many as the denser side.
fn lines(a: &Side, b: &Side) -> Vec<f64> {
/// Interior line positions for a pair of opposite sides: the sides' own when they have as many
/// nodes as each other (so the grid meets both node for node); else evenly at the inner level a
/// GPU tessellator takes, the larger of the two sides' (outer) levels, and never coarser than the
/// size rules ask (the sides' mean length over the target side `h`). The denser side then meets
/// the grid node for node and the sparser one steps down to it through its ring.
fn lines(a: &Side, b: &Side, h: f64) -> Vec<f64> {
let inner = |s: &Side| s.at.get(1..s.at.len().saturating_sub(1)).map(<[f64]>::to_vec).unwrap_or_default();
if a.at.len() == b.at.len() {
let l: Vec<f64> = inner(a).iter().zip(inner(b)).map(|(x, y)| 0.5 * (x + y)).collect();
@@ -82,23 +146,43 @@ fn lines(a: &Side, b: &Side) -> Vec<f64> {
return l;
}
}
let n = a.at.len().max(b.at.len()).saturating_sub(1).max(2);
let size = if h > 0.0 { (0.5 * (a.len + b.len) / h).ceil() } else { 1.0 };
let size = if size.is_finite() { size as usize } else { 1 };
let outer = a.at.len().max(b.at.len()).saturating_sub(1);
let n = outer.max(size).clamp(2, MAX_LEVEL);
(1..n).map(|i| i as f64 / n as f64).collect()
}
/// Triangulate the loop `uv` (with its fixed 3D nodes) as a Coons patch: boundary vertices
/// keep their indices, the interior grid follows. Triangles counter-clockwise in (u, v).
/// `fine(a, b)`: whether the surface between two parameter points needs no point between them
/// (the grid lines, taken from the sides, are split inside the face where it does not).
///
/// `fine(a, b)`: whether the surface between two parameter points needs no point between them.
/// Given for a whole face, whose sides are its edges' nodes: where its grid converges (towards a
/// pole) the interior can need more lines than the sides carry, and the lines are split there.
/// A motorcycle patch passes `None`: its sides are cuts seeded at the face's global seed.
pub(crate) fn coons(
uv: &[[f64; 2]],
fixed: &[Vec3],
scale: [f64; 2],
max_points: usize,
fine: &dyn Fn([f64; 2], [f64; 2]) -> bool,
h: f64,
fine: Option<&dyn Fn([f64; 2], [f64; 2]) -> bool>,
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let pick = corners(uv, scale)?;
coons_at(uv, fixed, pick, max_points, h, fine)
}
/// [`coons`] with the four corners given (loop indices, in loop order).
pub(crate) fn coons_at(
uv: &[[f64; 2]],
fixed: &[Vec3],
pick: [usize; 4],
max_points: usize,
h: f64,
fine: Option<&dyn Fn([f64; 2], [f64; 2]) -> bool>,
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
let n = uv.len();
let [c0, c1, c2, c3] = corners(uv, scale)?;
let [c0, c1, c2, c3] = pick;
// Sides in loop order; the bottom (0) and right (1) run with the loop, the top (2) and
// left (3) against it, so s and t both run from corner c0.
let bottom = side(fixed, n, c0, c1)?;
@@ -116,8 +200,16 @@ pub(crate) fn coons(
};
Some([blend(0), blend(1)])
};
let ss = refine(lines(&bottom, &top), max_points, &|a, b, x| Some(fine(at(a, x)?, at(b, x)?)));
let ts = refine(lines(&left, &right), max_points, &|a, b, x| Some(fine(at(x, a)?, at(x, b)?)));
// The lines come from the sides' seeds (see [`lines`]): every side is seeded at the face's
// global seed, which the size rules set for the whole surface, so the interior needs no finer
// spacing than its sides.
let (ss, ts) = (lines(&bottom, &top, h), lines(&left, &right, h));
let (ss, ts) = match fine {
Some(fine) => {
(refine(ss, max_points, &|a, b, x| Some(fine(at(a, x)?, at(b, x)?))), refine(ts, max_points, &|a, b, x| Some(fine(at(x, a)?, at(x, b)?))))
}
None => (ss, ts),
};
if ss.len().checked_mul(ts.len())? > max_points {
return None;
}
@@ -141,10 +233,46 @@ pub(crate) fn coons(
// the bottom and right strips come out counter-clockwise, the top and left clockwise.
let row = |j: usize| (0..ns).map(|i| g(i, j)).collect::<Vec<u32>>();
let col = |i: usize| (0..nt).map(|j| g(i, j)).collect::<Vec<u32>>();
zip(&bottom, &row(0), &ss, false, &mut tris)?;
zip(&right, &col(ns - 1), &ts, false, &mut tris)?;
zip(&top, &row(nt - 1), &ss, true, &mut tris)?;
zip(&left, &col(0), &ts, true, &mut tris)?;
// Where a side's segments and its grid row's differ, a transition row steps between them
// halfway to the first grid line (its segments the two counts' geometric mean), each zipped
// to it, with one triangle at each end closing it against the corner.
let (s_first, s_last) = (*ss.first()?, *ss.last()?);
let (t_first, t_last) = (*ts.first()?, *ts.last()?);
// Each side: its grid row, the row's positions, the orientation, where the transition row
// runs, and the point at a position along it. The row lies halfway between the side and the
// grid row; its ends lie halfway between the diagonal from the corner to the grid row's end
// and that end, so they are strictly inside the side's own ring.
#[allow(clippy::type_complexity)]
let sides: [(&Side, Vec<u32>, &[f64], bool, (f64, f64), &dyn Fn(f64) -> [f64; 2]); 4] = [
(&bottom, row(0), &ss, false, (0.75 * s_first, 1.0 - 0.75 * (1.0 - s_last)), &|x| [x, 0.5 * t_first]),
(&right, col(ns - 1), &ts, false, (0.75 * t_first, 1.0 - 0.75 * (1.0 - t_last)), &|x| [0.5 * (1.0 + s_last), x]),
(&top, row(nt - 1), &ss, true, (0.75 * s_first, 1.0 - 0.75 * (1.0 - s_last)), &|x| [x, 0.5 * (1.0 + t_last)]),
(&left, col(0), &ts, true, (0.75 * t_first, 1.0 - 0.75 * (1.0 - t_last)), &|x| [0.5 * s_first, x]),
];
for (side, inner, inner_at, flip, (x0, x1), place) in sides {
let (outer_segs, inner_segs) = (side.nodes.len().saturating_sub(1), inner.len() + 1);
let mean = ((outer_segs * inner_segs) as f64).sqrt().round() as usize;
if mean == outer_segs || mean == inner_segs || mean < 2 {
zip(side, &inner, inner_at, flip, &mut tris)?;
continue;
}
let k = mean - 1;
let at_t: Vec<f64> = (0..k).map(|j| if k > 1 { x0 + (x1 - x0) * j as f64 / (k - 1) as f64 } else { 0.5 * (x0 + x1) }).collect();
let mut mid: Vec<u32> = Vec::with_capacity(k);
for &x in &at_t {
let [sx, tx] = place(x);
mid.push(u32::try_from(out.len()).ok()?);
out.push(at(sx, tx)?);
}
zip(side, &mid, &at_t, flip, &mut tris)?;
zip(&Side { nodes: mid.clone(), at: at_t.clone(), len: 0.0 }, &inner, inner_at, flip, &mut tris)?;
// The two end triangles: corner, transition end, grid end, turned like the zips' (in
// (s, t) the first runs with them and the last against). Never turned to fit the blend:
// a blend that folds there leaves one inverted, and the patch is refused below.
for t in [[*side.nodes.first()?, *mid.first()?, *inner.first()?], [*side.nodes.last()?, *inner.last()?, *mid.last()?]] {
tris.push(if flip { [t[0], t[2], t[1]] } else { t });
}
}
// The (s, t) square maps to (u, v) keeping or reversing orientation, with the loop: all
// triangles must share one sign, which is then made counter-clockwise.
let area = |t: &[u32; 3]| -> Option<f64> {
@@ -188,7 +316,7 @@ fn refine(lines: Vec<f64>, max: usize, fine: &dyn Fn(f64, f64, f64) -> Option<bo
}
fn reversed(s: Side) -> Side {
Side { nodes: s.nodes.into_iter().rev().collect(), at: s.at.into_iter().rev().map(|x| 1.0 - x).collect() }
Side { nodes: s.nodes.into_iter().rev().collect(), at: s.at.into_iter().rev().map(|x| 1.0 - x).collect(), len: s.len }
}
/// Join a side (its nodes at positions `side.at`) to a grid row at positions `inner_at`.
@@ -251,7 +379,7 @@ mod tests {
b.reverse();
}
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, &|_, _| true) else { panic!("meshes (k {k})") };
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, 1.0, None) else { panic!("meshes (k {k})") };
let mut uv = b.clone();
uv.extend(extra);
assert!((area(&uv, &tris) - 16.0).abs() < 1e-9, "k {k} reverse {reverse}: {}", area(&uv, &tris));
@@ -259,10 +387,146 @@ mod tests {
}
}
/// Opposite sides with different node counts (20 against 4): the interior grid takes the
/// larger side's level (a tessellator's inner level), so the dense side meets it node for node
/// and the sparse side steps down to it through a transition row. Covered exactly, nothing
/// missing or doubled.
#[test]
fn the_interior_level_is_the_larger_sides_and_the_sparser_side_steps_down() {
// A 4 × 4 square: bottom in 20 segments, the other sides in 4.
let mut b: Vec<[f64; 2]> = (0..20).map(|k| [k as f64 * 0.2, 0.0]).collect();
b.extend((0..4).map(|k| [4.0, k as f64]));
b.extend((0..4).map(|k| [4.0 - k as f64, 4.0]));
b.extend((0..4).map(|k| [0.0, 4.0 - k as f64]));
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 10_000, 1.0, None) else { panic!("meshes") };
let mut uv = b.clone();
uv.extend(extra.iter().copied());
assert!((area(&uv, &tris) - 16.0).abs() < 1e-9);
// Level 20 along s (the bottom's), 4 along t: 19 × 3 interior points; plus the transition
// row by the 4-segment top (√(4 · 20) ≈ 9 segments: 8 points).
assert_eq!(extra.len(), 19 * 3 + 8, "{}", extra.len());
// Every inner side shared by two triangles, every boundary side used once: nothing
// missing or doubled at the transition's ends.
let mut sides: std::collections::HashMap<(u32, u32), i32> = std::collections::HashMap::new();
for t in &tris {
for e in 0..3 {
*sides.entry((t[e], t[(e + 1) % 3])).or_default() += 1;
}
}
let n = b.len() as u32;
for (&(a, c), &k) in &sides {
assert_eq!(k, 1, "side {a}-{c} once each way");
let boundary = a < n && c < n && (c == (a + 1) % n);
assert!(boundary || sides.contains_key(&(c, a)), "inner side {a}-{c} shared");
}
}
#[test]
fn five_corners_is_not_a_coons_patch() {
let b = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.5, 1.0], [0.0, 1.0]];
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
assert!(coons(&b, &fixed, [1.0, 1.0], 100, &|_, _| true).is_none());
assert!(coons(&b, &fixed, [1.0, 1.0], 100, 1.0, None).is_none());
}
/// A planar strip with a fillet at one end and a chamfer at the other, in mm (a face of
/// robustness seed 33): its corners make the one-segment chamfer a side facing the long one,
/// and at some target sides the blend folds beside the chamfer. The transition row's end
/// triangles are turned like its zips, never to fit the blend, so a fold leaves a triangle
/// inverted and the blend is refused; any blend given covers the loop exactly once.
#[test]
#[allow(clippy::approx_constant)] // measured coordinates, one of them near ln 10
fn a_blend_folding_beside_a_short_side_is_refused_not_overlapped() {
let b: Vec<[f64; 2]> = vec![
[24.0000, 3.0150],
[23.0000, 4.4292],
[16.6667, 4.4292],
[10.3333, 4.4292],
[4.0000, 4.4292],
[4.0000, 3.0150],
[4.0002, 2.9836],
[4.0010, 2.9520],
[4.0023, 2.9201],
[4.0040, 2.8880],
[4.0064, 2.8556],
[4.0092, 2.8231],
[4.0127, 2.7905],
[4.0167, 2.7577],
[4.0213, 2.7248],
[4.0265, 2.6919],
[4.0322, 2.6589],
[4.0386, 2.6259],
[4.0456, 2.5929],
[4.0532, 2.5600],
[4.0614, 2.5272],
[4.0702, 2.4945],
[4.0797, 2.4619],
[4.0897, 2.4295],
[4.1004, 2.3974],
[4.1117, 2.3655],
[4.1236, 2.3339],
[4.1362, 2.3026],
[4.1493, 2.2717],
[4.1630, 2.2412],
[4.1773, 2.2111],
[4.1922, 2.1814],
[4.2076, 2.1523],
[4.2236, 2.1237],
[4.2402, 2.0956],
[4.2573, 2.0681],
[4.2748, 2.0412],
[4.2929, 2.0150],
[4.3114, 1.9895],
[4.3304, 1.9646],
[4.3499, 1.9405],
[4.3697, 1.9171],
[4.3899, 1.8945],
[4.4105, 1.8726],
[4.4315, 1.8516],
[4.4528, 1.8313],
[4.4744, 1.8119],
[4.4962, 1.7934],
[4.5184, 1.7757],
[4.5407, 1.7588],
[4.5633, 1.7428],
[4.5860, 1.7277],
[4.6089, 1.7135],
[4.6319, 1.7001],
[4.6550, 1.6876],
[4.6783, 1.6760],
[4.7015, 1.6653],
[4.7248, 1.6554],
[4.7482, 1.6464],
[4.7715, 1.6382],
[4.7948, 1.6309],
[4.8180, 1.6244],
[4.8412, 1.6188],
[4.8643, 1.6139],
[4.8873, 1.6098],
[4.9101, 1.6065],
[4.9329, 1.6040],
[4.9554, 1.6022],
[4.9778, 1.6012],
[5.0000, 1.6008],
[11.3333, 1.6008],
[17.6667, 1.6008],
[24.0000, 1.6008],
];
let fixed: Vec<Vec3> = b.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect();
let n = b.len();
let loop_area: f64 = (0..n).map(|k| 0.5 * (b[k][0] * b[(k + 1) % n][1] - b[(k + 1) % n][0] * b[k][1])).sum();
for h in [0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0] {
let Some((extra, tris)) = coons(&b, &fixed, [1.0, 1.0], 100_000, h, None) else { continue };
let mut uv = b.clone();
uv.extend(extra);
assert!((area(&uv, &tris) - loop_area).abs() < 1e-9 * loop_area, "h {h}: {} for {loop_area}", area(&uv, &tris));
let mut sides: std::collections::HashMap<(u32, u32), usize> = std::collections::HashMap::new();
for t in &tris {
for k in 0..3 {
*sides.entry((t[k], t[(k + 1) % 3])).or_default() += 1;
}
}
assert!(sides.values().all(|&k| k == 1), "h {h}: a side used twice the same way");
}
}
}