Display tessellation plans (2/4): size rules and the facet angle (#803)
Per-face target side lengths, the caller's facet angle, refined grid lines, and fixes for underflowing coordinates and straight edges ending exactly on their vertex. Nothing drawn changes yet. Refs #303 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9bb18842e9
commit
8dd583930e
12 files changed
+870
-189
No files matched your search
+173
-34
@@ -1,28 +1,41 @@
|
||||
//! Faces the structured grid cannot take (trimmed faces, holes): a constrained Delaunay
|
||||
//! triangulation of the boundary loops in (u, v) scaled by the surface's mean metric, with
|
||||
//! interior seed points, refinement wherever a triangle sags more than the tolerance from the
|
||||
//! surface, then Ruppert refinement for shape. Constraint edges are never split, so the
|
||||
//! Faces the structured grid cannot take (planar faces, trimmed faces, holes): a constrained
|
||||
//! Delaunay triangulation of the boundary loops in (u, v) scaled by the surface's mean metric,
|
||||
//! seeded with an equilateral lattice, refined wherever a triangle sags more than the
|
||||
//! tolerance from the surface, has a side longer than the target or bends more than
|
||||
//! the facet angle, then Ruppert refinement for shape. Constraint edges are never split, so the
|
||||
//! boundary stays exactly the shared edge nodes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::size::Turn;
|
||||
use spade::handles::FixedVertexHandle;
|
||||
use spade::{AngleLimit, ConstrainedDelaunayTriangulation, Point2, RefinementParameters, Triangulation};
|
||||
|
||||
type Cdt = ConstrainedDelaunayTriangulation<Point2<f64>>;
|
||||
|
||||
/// Smallest angle Ruppert refinement aims for.
|
||||
const MIN_ANGLE_DEG: f64 = 20.0;
|
||||
const MIN_ANGLE_DEG: f64 = 25.0;
|
||||
/// A side is split once it is this many times the target length (the lattice's own sides,
|
||||
/// exactly the target, and the slightly longer ones where it meets the boundary, stay whole).
|
||||
const LONG: f64 = 1.5;
|
||||
/// Seeds closer than this fraction of the side length to the boundary are left out (the
|
||||
/// boundary nodes are already there; a seed beside them makes a sliver).
|
||||
const CLEARANCE: f64 = 0.5;
|
||||
|
||||
/// Rounds of sag refinement.
|
||||
const SAG_ROUNDS: usize = 16;
|
||||
|
||||
/// The surface, for refining where the triangles leave it.
|
||||
pub(crate) struct Sag<'a> {
|
||||
pub eval: &'a dyn Fn(f64, f64) -> Option<Vec3>,
|
||||
/// Point and unit normal (when not degenerate) at (u, v).
|
||||
pub eval: &'a dyn Fn(f64, f64) -> Option<(Vec3, Option<Vec3>)>,
|
||||
pub tol: f64,
|
||||
/// Longest triangle side (mm).
|
||||
pub h: f64,
|
||||
/// The facet angle, if any: corner normals turning more get a point in the middle.
|
||||
pub turn: Option<Turn>,
|
||||
/// Scale of u and v in the triangulation (the surface's mean |S_u|, |S_v|).
|
||||
pub scale: [f64; 2],
|
||||
}
|
||||
@@ -33,6 +46,10 @@ pub(crate) struct Fill<'a> {
|
||||
pub seeds: &'a [[f64; 2]],
|
||||
/// Refinement, adding at most this many points in all.
|
||||
pub refine: Option<(Sag<'a>, usize)>,
|
||||
/// Ruppert refinement for shape after it. Shape matters for shading only where the normal
|
||||
/// varies: a plane is shaded the same by any triangles, so planar faces skip it (around many
|
||||
/// small holes it grades out from each rim and multiplies the triangles).
|
||||
pub shape: bool,
|
||||
}
|
||||
|
||||
/// Triangulate the region bounded by `loops` (ranges of `boundary`, each closed). Boundary
|
||||
@@ -46,7 +63,9 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
|
||||
Some((sag, _)) if sag.scale.iter().all(|k| k.is_finite() && *k > 0.0) => sag.scale,
|
||||
_ => [1.0, 1.0],
|
||||
};
|
||||
let to = |p: &[f64; 2]| Point2::new(p[0] * scale[0], p[1] * scale[1]);
|
||||
// spade refuses coordinates below about 1e-43 in magnitude (other than zero), which
|
||||
// projection leaves where a parameter is zero in exact arithmetic.
|
||||
let to = |p: &[f64; 2]| spade::mitigate_underflow(Point2::new(p[0] * scale[0], p[1] * scale[1]));
|
||||
let from = |p: Point2<f64>| [p.x / scale[0], p.y / scale[1]];
|
||||
let mut cdt = Cdt::new();
|
||||
let mut index: HashMap<FixedVertexHandle, u32> = HashMap::new();
|
||||
@@ -77,12 +96,14 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
|
||||
if let Some((sag, budget)) = &fill.refine {
|
||||
let constraints = cdt.num_constraints();
|
||||
let added = refine_sag(&mut cdt, sag, *budget, &from);
|
||||
let params = RefinementParameters::<f64>::new()
|
||||
.keep_constraint_edges()
|
||||
.exclude_outer_faces(true)
|
||||
.with_angle_limit(AngleLimit::from_deg(MIN_ANGLE_DEG))
|
||||
.with_max_additional_vertices(budget.saturating_sub(added));
|
||||
cdt.refine(params);
|
||||
if fill.shape {
|
||||
let params = RefinementParameters::<f64>::new()
|
||||
.keep_constraint_edges()
|
||||
.exclude_outer_faces(true)
|
||||
.with_angle_limit(AngleLimit::from_deg(MIN_ANGLE_DEG))
|
||||
.with_max_additional_vertices(budget.saturating_sub(added));
|
||||
cdt.refine(params);
|
||||
}
|
||||
// A split constraint would put a point on the boundary the neighbour does not have.
|
||||
if cdt.num_constraints() != constraints {
|
||||
return None;
|
||||
@@ -114,12 +135,18 @@ pub(crate) fn triangulate(boundary: &[[f64; 2]], loops: &[std::ops::Range<usize>
|
||||
Some((extra, tris))
|
||||
}
|
||||
|
||||
/// Insert the parameters of the surface point under every triangle centre and every
|
||||
/// non-constraint edge middle that lies more than the tolerance from the triangle, until none
|
||||
/// does or `budget` points were added. Returns the points added.
|
||||
/// Insert the parameters of the surface point under every triangle centre that lies more than
|
||||
/// the tolerance from the triangle or whose corner normals turn more than the facet angle, and
|
||||
/// under the middle of every non-constraint side that sags more than the tolerance or is
|
||||
/// longer than [`LONG`] times `h`, until none does or `budget` points were added. Returns the points added.
|
||||
fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>) -> [f64; 2]) -> usize {
|
||||
let mut added = 0;
|
||||
let mut cache: HashMap<FixedVertexHandle, Option<Vec3>> = HashMap::new();
|
||||
let mut cache: HashMap<FixedVertexHandle, Option<(Vec3, Option<Vec3>)>> = HashMap::new();
|
||||
// The widest turn between two corner normals.
|
||||
let spread = |n: [Option<Vec3>; 3]| match n {
|
||||
[Some(a), Some(b), Some(c)] => [(a, b), (b, c), (c, a)].iter().map(|(x, y)| x.dot(*y).clamp(-1.0, 1.0).acos()).fold(0.0, f64::max),
|
||||
_ => 0.0,
|
||||
};
|
||||
for _ in 0..SAG_ROUNDS {
|
||||
let mut todo: Vec<Point2<f64>> = Vec::new();
|
||||
let mut at = |h: FixedVertexHandle, p: Point2<f64>| {
|
||||
@@ -136,12 +163,17 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
let vs = face.vertices();
|
||||
let ps = vs.map(|v| v.position());
|
||||
let c = Point2::new((ps[0].x + ps[1].x + ps[2].x) / 3.0, (ps[0].y + ps[1].y + ps[2].y) / 3.0);
|
||||
let (Some(a), Some(b), Some(d)) = (at(vs[0].fix(), ps[0]), at(vs[1].fix(), ps[1]), at(vs[2].fix(), ps[2])) else { continue };
|
||||
let off = |q: Point2<f64>, chord: Vec3| {
|
||||
let uv = from(q);
|
||||
(sag.eval)(uv[0], uv[1]).is_some_and(|s| s.dist(chord) > sag.tol)
|
||||
let (Some((a, na)), Some((b, nb)), Some((d, nd))) = (at(vs[0].fix(), ps[0]), at(vs[1].fix(), ps[1]), at(vs[2].fix(), ps[2])) else {
|
||||
continue;
|
||||
};
|
||||
if off(c, (a + b + d) * (1.0 / 3.0)) {
|
||||
// How far the surface lies from the triangle at `q`.
|
||||
let gap = |q: Point2<f64>, chord: Vec3| {
|
||||
let uv = from(q);
|
||||
(sag.eval)(uv[0], uv[1]).map_or(0.0, |(s, _)| s.dist(chord))
|
||||
};
|
||||
let off = |q: Point2<f64>, chord: Vec3| gap(q, chord) > sag.tol;
|
||||
let middle = gap(c, (a + b + d) * (1.0 / 3.0));
|
||||
if middle > sag.tol || sag.turn.is_some_and(|t| t.exceeds(spread([na, nb, nd]), middle)) {
|
||||
todo.push(c);
|
||||
continue;
|
||||
}
|
||||
@@ -151,7 +183,7 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
}
|
||||
let [p, q] = e.positions();
|
||||
let m = Point2::new(0.5 * (p.x + q.x), 0.5 * (p.y + q.y));
|
||||
if off(m, (pa + pb) * 0.5) {
|
||||
if pa.dist(pb) > LONG * sag.h || off(m, (pa + pb) * 0.5) {
|
||||
todo.push(m);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +197,7 @@ fn refine_sag(cdt: &mut Cdt, sag: &Sag, budget: usize, from: &dyn Fn(Point2<f64>
|
||||
if added >= budget {
|
||||
return added;
|
||||
}
|
||||
if cdt.insert(p).is_ok() {
|
||||
if cdt.insert(spade::mitigate_underflow(p)).is_ok() {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
@@ -202,12 +234,24 @@ fn region(cdt: &Cdt) -> Vec<bool> {
|
||||
parity.into_iter().map(|p| p.unwrap_or(false)).collect()
|
||||
}
|
||||
|
||||
/// The grid points `us` × `vs` inside the region bounded by the loops (even–odd, so holes are
|
||||
/// out): per grid line, the loops' crossings sorted once, then each point by binary search.
|
||||
pub(crate) fn inside_grid(us: &[f64], vs: &[f64], polys: &[&[[f64; 2]]]) -> Vec<[f64; 2]> {
|
||||
/// Seeds for a face: an equilateral (hexagonal) lattice over the parameter box `bbox` with
|
||||
/// side `step` in each parameter (rows `step[1]·√3/2` apart, every other row shifted half a
|
||||
/// step), keeping the points inside the region bounded by the loops (even–odd, so holes are
|
||||
/// out) and at least [`CLEARANCE`] of a side from the boundary in the metric `scale`.
|
||||
pub(crate) fn lattice(bbox: [(f64, f64); 2], step: [f64; 2], polys: &[&[[f64; 2]]], scale: [f64; 2]) -> Vec<[f64; 2]> {
|
||||
let mut out = Vec::new();
|
||||
if !step.iter().all(|s| s.is_finite() && *s > 0.0) || !bbox.iter().all(|(a, b)| a.is_finite() && b.is_finite() && b > a) {
|
||||
return out;
|
||||
}
|
||||
let row = step[1] * 3f64.sqrt() * 0.5;
|
||||
let (rows, cols) = (((bbox[1].1 - bbox[1].0) / row).ceil(), ((bbox[0].1 - bbox[0].0) / step[0]).ceil());
|
||||
if !(rows * cols <= MAX_SEEDS as f64) {
|
||||
return out;
|
||||
}
|
||||
let near = Clearance::new(polys, scale, CLEARANCE * step[0] * scale[0]);
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for &v in vs {
|
||||
for j in 1..rows as usize {
|
||||
let v = bbox[1].0 + row * j as f64;
|
||||
xs.clear();
|
||||
for poly in polys {
|
||||
let n = poly.len();
|
||||
@@ -219,8 +263,10 @@ pub(crate) fn inside_grid(us: &[f64], vs: &[f64], polys: &[&[[f64; 2]]]) -> Vec<
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for &u in us {
|
||||
if xs.partition_point(|x| *x < u) % 2 == 1 {
|
||||
let shift = if j % 2 == 1 { 0.5 * step[0] } else { 0.0 };
|
||||
for i in 0..=cols as usize {
|
||||
let u = bbox[0].0 + shift + step[0] * i as f64;
|
||||
if u < bbox[0].1 && xs.partition_point(|x| *x < u) % 2 == 1 && !near.within([u, v]) {
|
||||
out.push([u, v]);
|
||||
}
|
||||
}
|
||||
@@ -228,6 +274,71 @@ pub(crate) fn inside_grid(us: &[f64], vs: &[f64], polys: &[&[[f64; 2]]]) -> Vec<
|
||||
out
|
||||
}
|
||||
|
||||
/// Most lattice seeds of one face.
|
||||
const MAX_SEEDS: usize = 200_000;
|
||||
|
||||
/// Boundary segments bucketed on a square grid (cell = the clearance), for "is this point
|
||||
/// within the clearance of the boundary" in constant time per point.
|
||||
struct Clearance {
|
||||
cells: HashMap<(i64, i64), Vec<([f64; 2], [f64; 2])>>,
|
||||
scale: [f64; 2],
|
||||
r: f64,
|
||||
}
|
||||
|
||||
impl Clearance {
|
||||
fn new(polys: &[&[[f64; 2]]], scale: [f64; 2], r: f64) -> Clearance {
|
||||
let mut cells: HashMap<(i64, i64), Vec<([f64; 2], [f64; 2])>> = HashMap::new();
|
||||
if !(r.is_finite() && r > 0.0) {
|
||||
return Clearance { cells, scale, r: 0.0 };
|
||||
}
|
||||
for poly in polys {
|
||||
let n = poly.len();
|
||||
for k in 0..n {
|
||||
let (Some(a), Some(b)) = (poly.get(k), poly.get((k + 1) % n)) else { continue };
|
||||
let (a, b) = ([a[0] * scale[0], a[1] * scale[1]], [b[0] * scale[0], b[1] * scale[1]]);
|
||||
let cell = |x: f64| (x / r).floor() as i64;
|
||||
let (x0, x1) = (cell(a[0].min(b[0])), cell(a[0].max(b[0])));
|
||||
let (y0, y1) = (cell(a[1].min(b[1])), cell(a[1].max(b[1])));
|
||||
// A segment much longer than the clearance (a straight side of a large face)
|
||||
// is checked from every cell of its box; cap that box.
|
||||
if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > 4096 {
|
||||
continue;
|
||||
}
|
||||
for x in x0..=x1 {
|
||||
for y in y0..=y1 {
|
||||
cells.entry((x, y)).or_default().push((a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Clearance { cells, scale, r }
|
||||
}
|
||||
|
||||
fn within(&self, p: [f64; 2]) -> bool {
|
||||
if self.r == 0.0 {
|
||||
return false;
|
||||
}
|
||||
let p = [p[0] * self.scale[0], p[1] * self.scale[1]];
|
||||
let (cx, cy) = ((p[0] / self.r).floor() as i64, (p[1] / self.r).floor() as i64);
|
||||
for x in cx - 1..=cx + 1 {
|
||||
for y in cy - 1..=cy + 1 {
|
||||
let Some(segs) = self.cells.get(&(x, y)) else { continue };
|
||||
if segs.iter().any(|(a, b)| dist_to_segment(p, *a, *b) < self.r) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn dist_to_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p[0] - a[0] - t * dx).hypot(p[1] - a[1] - t * dy)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -246,11 +357,13 @@ mod tests {
|
||||
let mut b = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
|
||||
b.extend([[0.4, 0.4], [0.4, 0.6], [0.6, 0.6], [0.6, 0.4]]);
|
||||
let loops = [0..4, 4..8];
|
||||
let flat = |u: f64, v: f64| Some(Vec3::new(u, v, 0.0));
|
||||
let flat = |u: f64, v: f64| Some((Vec3::new(u, v, 0.0), Some(Vec3::Z)));
|
||||
for refine in [false, true] {
|
||||
let refine = refine.then_some((Sag { eval: &flat, tol: 1e-3, scale: [2.0, 1.0] }, 200));
|
||||
let refine = refine.then_some((Sag { eval: &flat, tol: 1e-3, h: 0.1, turn: None, scale: [2.0, 1.0] }, 2000));
|
||||
let label = refine.is_some();
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine }) else { panic!("meshes") };
|
||||
let Some((extra, tris)) = triangulate(&b, &loops, Fill { seeds: &[[0.2, 0.2], [0.5, 0.5]], refine, shape: true }) else {
|
||||
panic!("meshes")
|
||||
};
|
||||
let mut uv = b.clone();
|
||||
uv.extend(extra);
|
||||
assert!((area(&uv, &tris) - 0.96).abs() < 1e-12, "refined: {label}");
|
||||
@@ -271,6 +384,32 @@ mod tests {
|
||||
fn crossing_loops_are_refused() {
|
||||
let b = [[0.0, 0.0], [1.0, 1.0], [1.0, 0.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None }).is_none());
|
||||
assert!(triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lattice_seeds_are_equilateral_and_clear_of_the_boundary() {
|
||||
let outer = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
let hole = [[4.0, 4.0], [4.0, 6.0], [6.0, 6.0], [6.0, 4.0]];
|
||||
let seeds = lattice([(0.0, 10.0), (0.0, 10.0)], [1.0, 1.0], &[&outer, &hole], [1.0, 1.0]);
|
||||
assert!(!seeds.is_empty());
|
||||
for p in &seeds {
|
||||
let clear = |poly: &[[f64; 2]]| (0..4).all(|k| dist_to_segment(*p, poly[k], poly[(k + 1) % 4]) >= 0.5);
|
||||
assert!(clear(&outer) && clear(&hole), "{p:?}");
|
||||
}
|
||||
// Nearest neighbours one side apart, six of them in the open.
|
||||
let near = |p: [f64; 2]| seeds.iter().filter(|q| ((q[0] - p[0]).hypot(q[1] - p[1]) - 1.0).abs() < 1e-9).count();
|
||||
assert!(seeds.iter().any(|p| near(*p) == 6));
|
||||
assert!(seeds.iter().all(|p| seeds.iter().all(|q| p == q || (q[0] - p[0]).hypot(q[1] - p[1]) > 1.0 - 1e-9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_that_underflow_are_taken_as_zero() {
|
||||
// A parameter that is zero in exact arithmetic, left at 1e-300 by projection.
|
||||
let b = [[1e-300, 0.0], [1.0, -1e-310], [1.0, 1.0], [0.0, 1.0]];
|
||||
let whole = std::slice::from_ref(&(0..4));
|
||||
let Some((extra, tris)) = triangulate(&b, whole, Fill { seeds: &[], refine: None, shape: false }) else { panic!("meshes") };
|
||||
assert!(extra.is_empty());
|
||||
assert_eq!(tris.len(), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user