Compare commits

...

3 Commits

Author SHA1 Message Date
AlexMitchellMus 2447775f9b nurbs-grid-mesh: document LOD and scheduling contract 2026-11-08 16:36:11 -05:00
AlexMitchellMus 6ce4d255d4 nurbs-grid-mesh: use earcut for planar faces with holes
Add the ISC-licensed Mapbox earcut port (safe, no unchecked indexing) and use it for planar face triangulation before the existing bridge/ear-clip fallback. Earcut preserves all outer and hole vertices and is validated against the exact outer-minus-holes area before its result is accepted. Tests cover a 90-vertex hole, hole-boundary edge preservation, and two holes.
2026-11-08 16:36:11 -05:00
AlexMitchellMus 73cc61dc95 nurbs-grid-mesh: place grid lines by sampled chord sag
Recursively subdivide each knot span around its midpoint until the sampled chord sag and max step are satisfied, instead of using a closed-form curvature bound with uniform lines. Flat spans stay sparse and high-curvature regions get lines where they are needed. Existing grid topology and capped-span accounting are preserved.
2026-11-08 16:36:11 -05:00
4 changed files with 2175 additions and 49 deletions
File diff suppressed because it is too large Load Diff
+92 -44
View File
@@ -1,6 +1,6 @@
//! Grid lines: as many in each knot span as its curvature needs for the tolerance.
use crate::surface::{norm, sub, NurbsSurface};
use crate::surface::NurbsSurface;
use crate::MeshError;
/// A parameter direction.
@@ -40,37 +40,15 @@ pub fn grid_lines(
max_step: f64,
max_per_span: usize,
) -> Result<GridLines, MeshError> {
let (p, k, n, m) = match dir {
Dir::U => (s.u_degree, &s.u_knots, s.nu, s.nv),
Dir::V => (s.v_degree, &s.v_knots, s.nv, s.nu),
let (n, k) = match dir {
Dir::U => (s.nu, &s.u_knots),
Dir::V => (s.nv, &s.v_knots),
};
let at = |i: usize, j: usize| match dir {
Dir::U => i * s.nv + j,
Dir::V => j * s.nv + i,
let p = match dir {
Dir::U => s.u_degree,
Dir::V => s.v_degree,
};
let knot = |i: usize| k.get(i).copied().ok_or(MeshError::Internal);
let pole = |i: usize| s.poles.get(i).copied().ok_or(MeshError::Internal);
// r[i]: the largest second-derivative control point i over every row.
let mut r = vec![0.0_f64; n.saturating_sub(2)];
if p >= 2 {
for (i, ri) in r.iter_mut().enumerate() {
let d1 = knot(i + p + 1)? - knot(i + 1)?;
let d2 = knot(i + p + 2)? - knot(i + 2)?;
let d3 = knot(i + p + 1)? - knot(i + 2)?;
// A span of zero length is a kink, and kinks sit on knots, which are grid lines.
if !(d1 > 0.0 && d2 > 0.0 && d3 > 0.0) {
continue;
}
let pf = p as f64;
for j in 0..m {
let (a, b, c) = (pole(at(i, j))?, pole(at(i + 1, j))?, pole(at(i + 2, j))?);
let [x0, y0, z0] = sub(b, a);
let [x1, y1, z1] = sub(c, b);
let q = |e0: f64, e1: f64| ((pf - 1.0) * (pf * e1 / d2 - pf * e0 / d1)) / d3;
*ri = ri.max(norm([q(x0, x1), q(y0, y1), q(z0, z1)]));
}
}
}
let mut lines = Vec::new();
let mut capped_spans = 0;
for span in p..n {
@@ -78,27 +56,97 @@ pub fn grid_lines(
if b <= a {
continue;
}
// Span [k[span], k[span + 1]] uses second-derivative control points span - p ..= span - 2.
let bound = if p >= 2 {
r.get(span - p..=span - 2)
.map_or(0.0, |w| w.iter().copied().fold(0.0, f64::max))
} else {
0.0
};
let for_curvature = ((b - a) * (bound / (8.0 * tol)).sqrt()).ceil();
// A flat span needs no line for curvature, but a face still needs cells for its
// boundary to cross, so no step is longer than max_step.
let for_step = ((b - a) / max_step).ceil();
let cap = max_per_span.max(1) as f64;
if for_curvature > cap {
let mut span_lines = vec![a];
let mut segments = 0usize;
let cap = max_per_span.max(1);
let mut capped = false;
push_span_lines(
s,
dir,
a,
b,
tol,
max_step,
cap,
&mut span_lines,
&mut segments,
&mut capped,
)?;
if capped {
capped_spans += 1;
}
let count = for_curvature.max(for_step).max(1.0).min(cap) as usize;
lines.extend((0..count).map(|q| a + (b - a) * q as f64 / count as f64));
lines.extend(span_lines);
}
lines.push(knot(n)?);
lines.dedup_by(|a, b| (*a - *b).abs() <= 1.0e-12 * a.abs().max(b.abs()).max(1.0));
Ok(GridLines {
lines,
capped_spans,
})
}
#[allow(clippy::too_many_arguments)]
fn push_span_lines(
s: &NurbsSurface,
dir: Dir,
lo: f64,
hi: f64,
tol: f64,
max_step: f64,
cap: usize,
lines: &mut Vec<f64>,
segments: &mut usize,
capped: &mut bool,
) -> Result<(), MeshError> {
if *segments >= cap.saturating_sub(1) {
*capped = true;
lines.push(hi);
*segments += 1;
return Ok(());
}
let step_too_long = hi - lo > max_step;
let sag = sampled_sag(s, dir, lo, hi)?;
if (sag > tol || step_too_long) && hi - lo > 1.0e-12 {
let mid = (lo + hi) * 0.5;
push_span_lines(s, dir, lo, mid, tol, max_step, cap, lines, segments, capped)?;
push_span_lines(s, dir, mid, hi, tol, max_step, cap, lines, segments, capped)?;
} else {
lines.push(hi);
*segments += 1;
}
Ok(())
}
fn sampled_sag(s: &NurbsSurface, dir: Dir, lo: f64, hi: f64) -> Result<f64, MeshError> {
let mid = (lo + hi) * 0.5;
let [[u0, u1], [v0, v1]] = s.domain();
let mut sag = 0.0f64;
for step in 0..=4 {
let other = (step as f64) / 4.0;
let (a, m, b) = match dir {
Dir::U => {
let v = v0 + (v1 - v0) * other;
(
s.evaluate(lo, v)?.point,
s.evaluate(mid, v)?.point,
s.evaluate(hi, v)?.point,
)
}
Dir::V => {
let u = u0 + (u1 - u0) * other;
(
s.evaluate(u, lo)?.point,
s.evaluate(u, mid)?.point,
s.evaluate(u, hi)?.point,
)
}
};
let chord = [
(a[0] + b[0]) * 0.5 - m[0],
(a[1] + b[1]) * 0.5 - m[1],
(a[2] + b[2]) * 0.5 - m[2],
];
sag = sag.max((chord[0] * chord[0] + chord[1] * chord[1] + chord[2] * chord[2]).sqrt());
}
Ok(sag)
}
+19
View File
@@ -37,9 +37,28 @@
//! it has to be taken on trust.
//!
//! Library code never panics, unwraps or indexes: bad input comes back as a [`MeshError`].
//!
//! ## LOD and scheduling
//!
//! [`MeshOptions::tolerance`] is a world-space maximum sag. A viewer should derive it
//! from a screen-space error target:
//!
//! ```text
//! requested_sag = desired_pixels * world_units_per_pixel_at_target
//! ```
//!
//! and clamp it by the model extent only as a safety bound. Meshing should not be
//! driven by a fixed line count or by model units alone.
//!
//! SolidCraft is expected to run exact geometry and display meshing on independent
//! thread pools. Mesh jobs therefore need to be immutable-input, revisioned,
//! cancellable and coalesced by target + quality. Close/selected work uses a small
//! screen-space sag; normal uses a bounded budget; far/interactive work is coarse.
//! The mesher receives an explicit tolerance and does not select LOD itself.
#![forbid(unsafe_code)]
mod deviation;
mod earcut;
mod error;
mod grid;
mod planar;
+145 -5
View File
@@ -114,6 +114,61 @@ fn same_position(a: Uv, b: Uv, tolerance: f64) -> bool {
}
impl PlanarBody {
/// Triangulates one face with earcut, preserving every loop vertex.
fn earcut_face(
&self,
face: usize,
coordinates: &[Uv],
expected: f64,
area_epsilon: f64,
) -> Option<Vec<[usize; 3]>> {
let outer = self.faces.get(face)?;
if outer.len() < 3 {
return None;
}
let mut data = Vec::new();
let mut map = Vec::new();
let mut hole_starts = Vec::new();
for (loop_index, loop_points) in self.loops(face).enumerate() {
if loop_index > 0 {
hole_starts.push(map.len());
}
for &index in loop_points {
let point = coordinates.get(index)?;
data.push(point.x);
data.push(point.y);
map.push(index);
}
}
let flat = crate::earcut::earcut(&data, &hole_starts, 2);
if flat.is_empty() {
return None;
}
let mut triangles = Vec::with_capacity(flat.len() / 3);
for triangle in flat.chunks_exact(3) {
let a = *map.get(triangle[0])?;
let b = *map.get(triangle[1])?;
let c = *map.get(triangle[2])?;
if a != b && b != c && c != a {
triangles.push([a, b, c]);
}
}
if triangles.len() < 3 {
return None;
}
let sq = |a: Uv, b: Uv, c: Uv| 0.5 * cross2(b.sub(a), c.sub(a)).abs();
let actual: f64 = triangles
.iter()
.map(|[a, b, c]| sq(coordinates[*a], coordinates[*b], coordinates[*c]))
.sum();
if actual.is_finite() && (actual - expected).abs() <= expected.abs() * 1.0e-9 + area_epsilon
{
Some(triangles)
} else {
None
}
}
/// Triangulates one face, honoring its holes.
pub fn face_triangles(&self, face: usize) -> Result<Vec<[usize; 3]>, String> {
let outer = self
@@ -191,6 +246,15 @@ impl PlanarBody {
holes.push(hole);
}
let expected = outer_area
- holes
.iter()
.map(|hole| signed_area(hole, &uv).abs())
.sum::<f64>();
if let Some(triangles) = self.earcut_face(face, &coordinates, expected, area_epsilon) {
return Ok(triangles);
}
let mut ring = outer.clone();
for hole in &holes {
ring = bridge_hole(&ring, hole, &holes, &uv, tolerance, face)?;
@@ -198,11 +262,6 @@ impl PlanarBody {
let mut triangles = Vec::new();
ear_clip(&ring, &uv, tolerance, area_epsilon, &mut triangles, face)?;
let expected = outer_area
- holes
.iter()
.map(|hole| signed_area(hole, &uv).abs())
.sum::<f64>();
let actual = triangles
.iter()
.map(|[a, b, c]| 0.5 * cross2(uv(*b).sub(uv(*a)), uv(*c).sub(uv(*a))).abs())
@@ -580,6 +639,87 @@ mod tests {
assert!((area(&points, &triangles) - (400.0 - 64.0)).abs() < 1e-9);
}
fn polygon_area_2d(points: &[[f64; 3]], loop_indices: &[usize]) -> f64 {
let mut area = 0.0;
for i in 0..loop_indices.len() {
let a = points[loop_indices[i]];
let b = points[loop_indices[(i + 1) % loop_indices.len()]];
area += a[0] * b[1] - b[0] * a[1];
}
area * 0.5
}
#[test]
fn earcut_preserves_a_dense_hole_boundary() {
let mut points = vec![
[-10.0, -10.0, 0.0],
[10.0, -10.0, 0.0],
[10.0, 10.0, 0.0],
[-10.0, 10.0, 0.0],
];
let hole_start = points.len();
for i in 0..90 {
let angle = std::f64::consts::TAU * i as f64 / 90.0;
points.push([4.0 * angle.cos(), 4.0 * angle.sin(), 0.0]);
}
let hole: Vec<usize> = (hole_start..points.len()).collect();
let triangles = triangulate_planar(&points, &[0, 1, 2, 3], &[hole.clone()])
.expect("dense holed cap triangulates");
let expected = 400.0 - polygon_area_2d(&points, &hole).abs();
assert!((area(&points, &triangles).abs() - expected).abs() < 1.0e-9);
// Every hole boundary edge must be used by exactly one triangle. A fan that
// ignored the hole, or a bridge that dropped vertices, fails this.
let mut edge_uses = std::collections::BTreeMap::new();
for [a, b, c] in &triangles {
for (u, v) in [(*a, *b), (*b, *c), (*c, *a)] {
let key = if u < v { (u, v) } else { (v, u) };
*edge_uses.entry(key).or_insert(0) += 1;
}
}
for i in 0..hole.len() {
let a = hole[i];
let b = hole[(i + 1) % hole.len()];
let key = if a < b { (a, b) } else { (b, a) };
assert_eq!(
edge_uses.get(&key),
Some(&1),
"hole edge {key:?} is not preserved"
);
}
}
#[test]
fn earcut_handles_two_holes_and_keeps_them_open() {
let mut points = vec![
[-10.0, -10.0, 0.0],
[10.0, -10.0, 0.0],
[10.0, 10.0, 0.0],
[-10.0, 10.0, 0.0],
];
let mut holes = Vec::new();
for center in [[-4.0, 0.0], [4.0, 0.0]] {
let start = points.len();
for i in 0..24 {
let angle = std::f64::consts::TAU * i as f64 / 24.0;
points.push([
center[0] + 2.5 * angle.cos(),
center[1] + 2.5 * angle.sin(),
0.0,
]);
}
holes.push((start..points.len()).collect::<Vec<_>>());
}
let triangles =
triangulate_planar(&points, &[0, 1, 2, 3], &holes).expect("two-holed cap triangulates");
let expected = 400.0
- holes
.iter()
.map(|hole| polygon_area_2d(&points, hole).abs())
.sum::<f64>();
assert!((area(&points, &triangles).abs() - expected).abs() < 1.0e-9);
}
#[test]
fn reflex_corner_on_a_candidate_edge_is_not_cut_across() {
// A U-shaped face. The reflex corners sit on lines that a naive ear clip may use.