Files
solidcraft-upstream/crates/kernel/src/tess/cutgrid.rs
T
Irakli Kourtis e3459edea8 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.
2026-10-14 15:01:05 -04:00

519 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A trimmed face as an iso-line T-mesh: full-length grid lines from the size rules plus the
//! face's motorcycle traces (which run only as far as they reach), whose rectangles that lie
//! well inside the face are meshed directly. The band between those rectangles (a staircase)
//! and the face's boundary nodes is triangulated with both kept as constraints and no point
//! added. The boundary stays exactly the shared edge nodes, so the face meets its neighbours
//! exactly, and only the band next to the boundary is irregular (cut-cell meshing).
use super::cdt;
use super::motorcycle::Trace;
/// A cell is kept when the boundary stays at least this fraction of its smaller side away from
/// it (in the metric): closer, the band triangle between them would be a sliver.
const CLEARANCE: f64 = 0.35;
/// Two lines closer than this fraction of a grid cell would leave a strip of slivers between
/// them: a grid line that close to a trace moves onto the trace (the trace runs through its
/// peak, which is the point of it), and a trace that close to an earlier one joins it.
const SNAP: f64 = 0.25;
/// One direction's lines: positions (increasing) and whether each runs the whole face.
struct Lines {
at: Vec<f64>,
full: Vec<bool>,
}
/// The full lines `grid` plus the traces' lines in this direction (see [`SNAP`]; the first and
/// last grid lines, the face's parameter box, never move). Returns the lines and, per trace
/// (in order), its line's index.
fn lines(grid: &[f64], traces: &[f64]) -> Option<(Lines, Vec<usize>)> {
let mut at: Vec<f64> = grid.to_vec();
let mut full = vec![true; at.len()];
let mut place = Vec::with_capacity(traces.len());
for &c in traces {
// The full-grid cell around c, and every line inside it.
let k = grid.partition_point(|g| *g < c);
let (lo, hi) = (*grid.get(k.saturating_sub(1))?, *grid.get(k.min(grid.len().saturating_sub(1)))?);
let w = (hi - lo).max(f64::MIN_POSITIVE);
let last = grid.len().saturating_sub(1);
let near = at.iter().enumerate().filter(|(_, x)| (**x - c).abs() < SNAP * w).min_by(|a, b| (a.1 - c).abs().total_cmp(&(b.1 - c).abs()));
match near {
// An earlier trace, or a box edge: join it.
Some((i, _)) if !full.get(i).copied().unwrap_or(false) || i == 0 || i == last => place.push(i),
// An inner grid line: it moves onto the trace.
Some((i, _)) => {
*at.get_mut(i)? = c;
place.push(i);
}
None => {
at.push(c);
full.push(false);
place.push(at.len() - 1);
}
}
}
// Sort, carrying the trace indices along.
let mut order: Vec<usize> = (0..at.len()).collect();
order.sort_by(|a, b| at.get(*a).copied().unwrap_or(0.0).total_cmp(&at.get(*b).copied().unwrap_or(0.0)));
let mut rank = vec![0; at.len()];
for (r, &i) in order.iter().enumerate() {
*rank.get_mut(i)? = r;
}
let lines =
Lines { at: order.iter().filter_map(|&i| at.get(i).copied()).collect(), full: order.iter().filter_map(|&i| full.get(i).copied()).collect() };
let place = place.iter().filter_map(|&i| rank.get(i).copied()).collect::<Vec<_>>();
(place.len() == traces.len()).then_some((lines, place))
}
/// Mesh the region bounded by `loops` (ranges of `boundary`, each closed; even–odd, so inner
/// loops are holes) with the full grid lines `us` × `vs` (increasing, spanning the region's
/// parameter box) and the motorcycle `traces`, measured in the metric `scale`. Boundary
/// vertices keep their indices; grid points follow. Triangles counter-clockwise in (u, v).
/// `None` when no cell is kept or the band cannot be triangulated (the face then goes to
/// Delaunay).
pub(crate) fn cut_grid(
boundary: &[[f64; 2]],
loops: &[std::ops::Range<usize>],
us: &[f64],
vs: &[f64],
traces: &[Trace],
scale: [f64; 2],
) -> Option<(Vec<[f64; 2]>, Vec<[u32; 3]>)> {
if us.len() < 2 || vs.len() < 2 || !scale.iter().all(|s| s.is_finite() && *s > 0.0) {
return None;
}
// Traces on u = c (axis 0) are lines in u; on v = c, lines in v.
let tu: Vec<&Trace> = traces.iter().filter(|t| t.axis == 0).collect();
let tv: Vec<&Trace> = traces.iter().filter(|t| t.axis == 1).collect();
let (lu, pu) = lines(us, &tu.iter().map(|t| t.at).collect::<Vec<_>>())?;
let (lv, pv) = lines(vs, &tv.iter().map(|t| t.at).collect::<Vec<_>>())?;
let (nu, nv) = (lu.at.len(), lv.at.len());
let (cu, cv) = (nu - 1, nv - 1);
let cells = cu.checked_mul(cv)?;
if cells > super::grid::MAX_FACE_GRID {
return None;
}
// Which sides of the fine cells are lines: the vertical side at column i in row j, the
// horizontal side at row j in column i. A trace covers a side it runs at least half along.
let covers = |t: &Trace, a: f64, b: f64| (t.to.min(b) - t.from.max(a)) >= 0.5 * (b - a);
let mut vert = vec![false; nu * cv];
for i in 0..nu {
let full = lu.full.get(i).copied().unwrap_or(false);
for j in 0..cv {
let (a, b) = (*lv.at.get(j)?, *lv.at.get(j + 1)?);
*vert.get_mut(j * nu + i)? = full || tu.iter().zip(&pu).any(|(t, &k)| k == i && covers(t, a, b));
}
}
let mut horiz = vec![false; nv * cu];
for j in 0..nv {
let full = lv.full.get(j).copied().unwrap_or(false);
for i in 0..cu {
let (a, b) = (*lu.at.get(i)?, *lu.at.get(i + 1)?);
*horiz.get_mut(j * cu + i)? = full || tv.iter().zip(&pv).any(|(t, &k)| k == j && covers(t, a, b));
}
}
// Rectangles: fine cells joined across sides that are no line. A T-mesh's faces are
// rectangles; anything else (a trace ending in the open after snapping) falls back to
// every line running the whole face.
let rects = rectangles(cu, cv, &vert, &horiz).or_else(|| {
vert.iter_mut().for_each(|s| *s = true);
horiz.iter_mut().for_each(|s| *s = true);
rectangles(cu, cv, &vert, &horiz)
})?;
// In the metric, so clearances are lengths on the surface.
let gx: Vec<f64> = lu.at.iter().map(|u| u * scale[0]).collect();
let gy: Vec<f64> = lv.at.iter().map(|v| v * scale[1]).collect();
let segs: Vec<([f64; 2], [f64; 2])> = loops
.iter()
.flat_map(|r| {
let n = r.len();
(0..n).filter_map(move |k| Some((*boundary.get(r.start + k)?, *boundary.get(r.start + (k + 1) % n)?)))
})
.map(|(a, b)| ([a[0] * scale[0], a[1] * scale[1]], [b[0] * scale[0], b[1] * scale[1]]))
.collect();
let keep = clear_cells(&gx, &gy, &segs, &rects)?;
if !keep.iter().any(|k| *k) {
return None;
}
let kept = |i: isize, j: isize| -> bool {
i >= 0
&& j >= 0
&& (i as usize) < cu
&& (j as usize) < cv
&& rects.of.get(j as usize * cu + i as usize).and_then(|r| keep.get(*r)).copied().unwrap_or(false)
};
// Grid points on the kept rectangles' corners, numbered after the boundary.
let mut used = vec![false; nu * nv];
for (r, b) in rects.boxes.iter().enumerate() {
if keep.get(r).copied().unwrap_or(false) {
for (i, j) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] {
*used.get_mut(j * nu + i)? = true;
}
}
}
let n = boundary.len();
let mut id: Vec<Option<u32>> = vec![None; nu * nv];
let mut extra: Vec<[f64; 2]> = Vec::new();
let mut node = |i: usize, j: usize, extra: &mut Vec<[f64; 2]>| -> Option<u32> {
let slot = id.get_mut(j * nu + i)?;
if let Some(k) = *slot {
return Some(k);
}
let k = u32::try_from(n + extra.len()).ok()?;
extra.push([*lu.at.get(i)?, *lv.at.get(j)?]);
*slot = Some(k);
Some(k)
};
let mut tris: Vec<[u32; 3]> = Vec::new();
// The staircase: pieces of kept rectangles' sides with no kept rectangle across,
// counter-clockwise around the kept region (so the band lies to their right).
let mut stairs: Vec<(u32, u32)> = Vec::new();
for (r, b) in rects.boxes.iter().enumerate() {
if !keep.get(r).copied().unwrap_or(false) {
continue;
}
let [i0, j0, i1, j1] = *b;
// The perimeter counter-clockwise as grid points, each with the fine cell across the
// side that leaves it.
let mut ring: Vec<((usize, usize), (isize, isize))> = Vec::new();
for i in i0..i1 {
ring.push(((i, j0), (i as isize, j0 as isize - 1)));
}
for j in j0..j1 {
ring.push(((i1, j), (i1 as isize, j as isize)));
}
for i in (i0 + 1..=i1).rev() {
ring.push(((i, j1), (i as isize - 1, j1 as isize)));
}
for j in (j0 + 1..=j1).rev() {
ring.push(((i0, j), (i0 as isize - 1, j as isize - 1)));
}
let corners = ring.iter().filter(|((i, j), _)| used.get(j * nu + i).copied().unwrap_or(false)).count();
let mut poly: Vec<u32> = Vec::with_capacity(corners);
let mut across: Vec<(isize, isize)> = Vec::with_capacity(corners);
for &((i, j), c) in &ring {
if used.get(j * nu + i).copied().unwrap_or(false) {
poly.push(node(i, j, &mut extra)?);
across.push(c);
}
}
if poly.len() == 4 {
let [a, b, c, d] = [*poly.first()?, *poly.get(1)?, *poly.get(2)?, *poly.get(3)?];
tris.push([a, b, c]);
tris.push([a, c, d]);
} else {
// Hanging nodes from neighbours: a fan from the rectangle's centre.
let centre = [0.5 * (lu.at.get(i0)? + lu.at.get(i1)?), 0.5 * (lv.at.get(j0)? + lv.at.get(j1)?)];
let m = u32::try_from(n + extra.len()).ok()?;
extra.push(centre);
for k in 0..poly.len() {
tris.push([m, *poly.get(k)?, *poly.get((k + 1) % poly.len())?]);
}
}
for k in 0..poly.len() {
let (ci, cj) = *across.get(k)?;
if !kept(ci, cj) {
stairs.push((*poly.get(k)?, *poly.get((k + 1) % poly.len())?));
}
}
}
let mut all = boundary.to_vec();
all.extend_from_slice(&extra);
let band = cdt::band(&all, n, loops, &stairs, scale)?;
tris.extend(band);
// Every triangle counter-clockwise with some area: one that is not has folded.
for t in &tris {
let (a, b, c) = (all.get(t[0] as usize)?, all.get(t[1] as usize)?, all.get(t[2] as usize)?);
let area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
if !(area > 0.0) {
return None;
}
}
Some((extra, tris))
}
/// The rectangles of a T-mesh over `cu` × `cv` fine cells: per cell its rectangle, and per
/// rectangle its corners as grid indices `[i0, j0, i1, j1]`. `None` if a face is not a
/// rectangle.
struct Rects {
of: Vec<usize>,
boxes: Vec<[usize; 4]>,
}
fn rectangles(cu: usize, cv: usize, vert: &[bool], horiz: &[bool]) -> Option<Rects> {
let nu = cu + 1;
let mut of = vec![usize::MAX; cu * cv];
let mut boxes = Vec::new();
for j0 in 0..cv {
for i0 in 0..cu {
if *of.get(j0 * cu + i0)? != usize::MAX {
continue;
}
// Grow right while no line separates, then up while the whole row is open.
let mut i1 = i0 + 1;
while i1 < cu && !*vert.get(j0 * nu + i1)? {
i1 += 1;
}
let mut j1 = j0 + 1;
while j1 < cv && (i0..i1).all(|i| horiz.get(j1 * cu + i).is_some_and(|h| !*h)) {
j1 += 1;
}
let r = boxes.len();
for j in j0..j1 {
for i in i0..i1 {
let slot = of.get_mut(j * cu + i)?;
if *slot != usize::MAX {
return None;
}
*slot = r;
}
}
boxes.push([i0, j0, i1, j1]);
}
}
// A rectangle: no line inside it, and lines all round it.
for b in &boxes {
let [i0, j0, i1, j1] = *b;
for j in j0..j1 {
for i in i0 + 1..i1 {
if *vert.get(j * nu + i)? {
return None;
}
}
}
for i in i0..i1 {
for j in j0 + 1..j1 {
if *horiz.get(j * cu + i)? {
return None;
}
}
}
for j in j0..j1 {
if (i0 > 0 && !*vert.get(j * nu + i0)?) || (i1 < cu && !*vert.get(j * nu + i1)?) {
return None;
}
}
for i in i0..i1 {
if (j0 > 0 && !*horiz.get(j0 * cu + i)?) || (j1 < cv && !*horiz.get(j1 * cu + i)?) {
return None;
}
}
}
Some(Rects { of, boxes })
}
/// Per rectangle: kept when every fine cell in it is inside the region and clear of the
/// boundary (see [`CLEARANCE`]). `gx`, `gy`: the lines in the metric; `segs`: the boundary.
fn clear_cells(gx: &[f64], gy: &[f64], segs: &[([f64; 2], [f64; 2])], rects: &Rects) -> Option<Vec<bool>> {
let (cu, cv) = (gx.len().saturating_sub(1), gy.len().saturating_sub(1));
let cells = cu * cv;
let cell = |i: usize, j: usize| -> Option<([f64; 2], [f64; 2])> {
let (x0, x1, y0, y1) = (*gx.get(i)?, *gx.get(i + 1)?, *gy.get(j)?, *gy.get(j + 1)?);
// The clearance of the rectangle the cell belongs to, not of the (possibly thin) cell.
let b = rects.boxes.get(*rects.of.get(j * cu + i)?)?;
let (w, h) = (gx.get(b[2])? - gx.get(b[0])?, gy.get(b[3])? - gy.get(b[1])?);
let pad = CLEARANCE * w.min(h);
Some(([x0 - pad, y0 - pad], [x1 + pad, y1 + pad]))
};
// Cells the boundary comes near: each segment marks the cells whose padded box it touches.
let mut near = vec![false; cells];
let maxpad = rects
.boxes
.iter()
.filter_map(|b| Some(CLEARANCE * (gx.get(b[2])? - gx.get(b[0])?).min(gy.get(b[3])? - gy.get(b[1])?)))
.fold(0.0f64, f64::max);
let span = |g: &[f64], lo: f64, hi: f64| {
let a = g.partition_point(|t| *t < lo - maxpad).saturating_sub(1);
let b = (g.partition_point(|t| *t <= hi + maxpad) + 1).min(g.len().saturating_sub(1));
(a, b)
};
for (a, b) in segs {
let (i0, i1) = span(gx, a[0].min(b[0]), a[0].max(b[0]));
let (j0, j1) = span(gy, a[1].min(b[1]), a[1].max(b[1]));
for j in j0..j1 {
for i in i0..i1 {
let Some((lo, hi)) = cell(i, j) else { continue };
if segment_meets_box(*a, *b, lo, hi)
&& let Some(c) = near.get_mut(j * cu + i)
{
*c = true;
}
}
}
}
// The other cells are wholly inside or wholly outside: even–odd along each row's middle.
let mut keep = vec![true; rects.boxes.len()];
for j in 0..cv {
let (Some(y0), Some(y1)) = (gy.get(j), gy.get(j + 1)) else { return None };
let ym = 0.5 * (y0 + y1);
let mut cross: Vec<f64> =
segs.iter().filter(|(a, b)| (a[1] > ym) != (b[1] > ym)).map(|(a, b)| a[0] + (ym - a[1]) / (b[1] - a[1]) * (b[0] - a[0])).collect();
cross.sort_by(f64::total_cmp);
for i in 0..cu {
let k = j * cu + i;
let (Some(x0), Some(x1)) = (gx.get(i), gx.get(i + 1)) else { return None };
let xm = 0.5 * (x0 + x1);
let inside = cross.partition_point(|c| *c < xm) % 2 == 1;
if near.get(k).copied().unwrap_or(true) || !inside {
*keep.get_mut(*rects.of.get(k)?)? = false;
}
}
}
Some(keep)
}
/// Whether the segment `a`–`b` meets the closed box `lo`–`hi` (Liang–Barsky clipping).
fn segment_meets_box(a: [f64; 2], b: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> bool {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for d in 0..2 {
let delta = b[d] - a[d];
for (p, q) in [(-delta, a[d] - lo[d]), (delta, hi[d] - a[d])] {
if p == 0.0 {
if q < 0.0 {
return false;
}
} else {
let r = q / p;
if p < 0.0 {
t0 = t0.max(r);
} else {
t1 = t1.min(r);
}
if t0 > t1 {
return false;
}
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn circle(c: [f64; 2], r: f64, n: usize, ccw: bool) -> Vec<[f64; 2]> {
(0..n)
.map(|k| {
let a = std::f64::consts::TAU * k as f64 / n as f64 * if ccw { 1.0 } else { -1.0 };
[c[0] + r * a.cos(), c[1] + r * a.sin()]
})
.collect()
}
/// A single loop over all `n` boundary points.
fn one(n: usize) -> [std::ops::Range<usize>; 1] {
[std::ops::Range { start: 0, end: n }]
}
fn lines(a: f64, b: f64, n: usize) -> Vec<f64> {
(0..=n).map(|k| a + (b - a) * k as f64 / n as f64).collect()
}
/// Every boundary segment is the side of exactly one triangle, every inner side of two,
/// and the triangles cover the region's area.
fn check(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>], area: f64, out: &(Vec<[f64; 2]>, Vec<[u32; 3]>)) {
let (extra, tris) = out;
let mut all = boundary.to_vec();
all.extend_from_slice(extra);
let mut sides: std::collections::HashMap<(u32, u32), i32> = std::collections::HashMap::new();
let mut sum = 0.0;
for t in tris {
let [a, b, c] = t.map(|i| all[i as usize]);
let ar = 0.5 * ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]));
assert!(ar > 0.0, "counter-clockwise");
sum += ar;
for e in 0..3 {
*sides.entry((t[e], t[(e + 1) % 3])).or_default() += 1;
}
}
for r in loops {
let n = r.len();
for k in 0..n {
let (a, b) = ((r.start + k) as u32, (r.start + (k + 1) % n) as u32);
assert_eq!(sides.get(&(a, b)).copied().unwrap_or(0), 1, "boundary side {a}-{b} used once, forwards");
sides.remove(&(a, b));
}
}
for (&(a, b), &k) in &sides {
assert_eq!(k, 1);
assert_eq!(sides.get(&(b, a)).copied(), Some(1), "inner side {a}-{b} shared");
}
assert!((sum - area).abs() < 1e-9 * area.max(1.0), "area {sum} vs {area}");
}
#[test]
fn a_disc_is_cells_and_a_band() {
let b = circle([0.0, 0.0], 1.0, 64, true);
let loops = one(64);
let poly_area = 0.5 * 64.0 * (std::f64::consts::TAU / 64.0).sin();
let Some(out) = cut_grid(&b, &loops, &lines(-1.0, 1.0, 10), &lines(-1.0, 1.0, 10), &[], [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, poly_area, &out);
// Most of the disc is grid cells (two triangles each).
assert!(out.1.len() > 100, "{}", out.1.len());
}
#[test]
fn a_square_with_a_hole() {
let mut b = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
b.extend(circle([2.0, 2.0], 1.0, 32, false));
let loops = [0..4, 4..36];
let hole = 0.5 * 32.0 * (std::f64::consts::TAU / 32.0).sin();
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 4.0, 16), &lines(0.0, 4.0, 16), &[], [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, 16.0 - hole, &out);
}
#[test]
fn a_curved_triangle_in_a_stretched_metric() {
// A three-cornered patch (as a vertex blend in its parameters), u twice as long as v.
let mut b: Vec<[f64; 2]> = (0..=8).map(|k| [k as f64 / 8.0, 0.0]).collect();
b.extend((1..8).map(|k| {
let a = std::f64::consts::FRAC_PI_2 * k as f64 / 8.0;
[a.cos(), a.sin()]
}));
b.extend((1..8).rev().map(|k| [0.0, k as f64 / 8.0]));
let n = b.len();
let loops = one(n);
let area: f64 = (0..n)
.map(|k| {
let (p, q) = (b[k], b[(k + 1) % n]);
0.5 * (p[0] * q[1] - q[0] * p[1])
})
.sum();
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 1.0, 12), &lines(0.0, 1.0, 6), &[], [2.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, area, &out);
}
/// The hole's motorcycle traces (lines through its four extremes) become T-mesh lines that
/// stop where the traces stop; the mesh stays exact on the boundary and covers the region.
#[test]
fn a_square_with_a_hole_and_its_traces() {
let mut b = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
b.extend(circle([1.7, 2.3], 1.0, 32, false));
let loops = [0..4, 4..36];
let hole = 0.5 * 32.0 * (std::f64::consts::TAU / 32.0).sin();
let traces = super::super::motorcycle::traces(&b, &loops, [1.0, 1.0], 0.0);
assert_eq!(traces.len(), 4, "{traces:?}");
let Some(out) = cut_grid(&b, &loops, &lines(0.0, 4.0, 16), &lines(0.0, 4.0, 16), &traces, [1.0, 1.0]) else { panic!("meshes") };
check(&b, &loops, 16.0 - hole, &out);
// The traces run through the hole's extremes: along v = 2.3 from its u-extremes and
// along u = 1.7 from its v-extremes; their lines are in the grid.
for (d, x) in [(0, 1.7), (1, 2.3)] {
assert!(out.0.iter().any(|p| (p[d] - x).abs() < 1e-9), "a grid point on line {d} = {x}");
}
}
#[test]
fn nothing_kept_is_refused() {
let b = circle([0.0, 0.0], 0.1, 16, true);
assert!(cut_grid(&b, &one(16), &lines(-1.0, 1.0, 1), &lines(-1.0, 1.0, 1), &[], [1.0, 1.0]).is_none());
}
#[test]
fn segments_meet_boxes() {
assert!(segment_meets_box([-1.0, 0.5], [2.0, 0.5], [0.0, 0.0], [1.0, 1.0]));
assert!(!segment_meets_box([-1.0, 2.0], [2.0, 2.0], [0.0, 0.0], [1.0, 1.0]));
assert!(segment_meets_box([0.5, 0.5], [0.6, 0.6], [0.0, 0.0], [1.0, 1.0]));
assert!(!segment_meets_box([2.0, 0.0], [3.0, 1.0], [0.0, 0.0], [1.0, 1.0]));
}
}