mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 11:11:59 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2447775f9b | |||
| 6ce4d255d4 | |||
| 73cc61dc95 |
+1919
File diff suppressed because it is too large
Load Diff
+92
-44
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user