Tessellation: a loop corner on a side collapsed within its vertex's tolerance is a pole; a face no method plans is left out, named

A face's boundary node on a patch side collapsed to within the tolerance of
the vertex there (capped at the chord tolerance), not to a point, is a pole:
its parameter along the collapsed direction is free, as the record check and
the STEP loop check take it (`uv::collapsed`, shared with
`SurfaceEval::pole_within`). A loop through such a corner no longer winds
round the surface as projected, and plans strictly (Poppy Humanoid's
connector, face 43: a quarter of a sphere cap on a closed rational spline).

A face the strict plan and the lenient fill both refuse no longer fails the
whole body's mesh: it is left out with no triangles, named in
PlanStats::unmeshed (logged by the display plan, counted by
Body::unmeshed_faces), and the rest of the body is meshed; a body none of
whose faces plan is still the first face's error. Body::volume_at gives no
volume for a mesh with a face left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-28 08:01:22 -04:00
committed by Irakli Kourtis
parent cfee4a7208
commit 5bfecd8a03
8 changed files with 197 additions and 65 deletions
+2 -14
View File
@@ -294,20 +294,8 @@ impl SurfaceEval {
/// period (or its whole range) moves the point no more than `reach` (a boundary point that
/// lies at a pole to within a tolerance, where its periodic parameter is free).
pub fn pole_within(&self, uv: [f64; 2], reach: f64) -> [bool; 2] {
let mut out = self.pole(uv);
let Some((_, su, sv)) = self.derivs(uv[0], uv[1]) else { return out };
let dom = self.domain();
for (d, speed) in [su.len(), sv.len()].into_iter().enumerate() {
let span = match (dom.period.get(d).copied().flatten(), dom.range.get(d).copied().flatten()) {
(Some(period), _) => 0.5 * period,
(None, Some((a, b))) => (b - a).abs(),
(None, None) => continue,
};
if let Some(flag) = out.get_mut(d) {
*flag |= speed * span <= reach;
}
}
out
let Some((_, su, sv)) = self.derivs(uv[0], uv[1]) else { return [false; 2] };
uv::collapsed([su.len(), sv.len()], &self.domain(), reach)
}
}
+26
View File
@@ -320,6 +320,32 @@ pub fn degenerate(s: &EvalSurface, uv: [f64; 2], d: usize) -> bool {
this.len() <= 1e-9 * other.len().max(1e-12)
}
/// [`degenerate`], or within `reach` mm of a pole (see [`collapsed`]): a boundary point on a
/// patch side collapsed to within a tolerance, not to a point, where its parameter along `d` is
/// as free as at a pole.
pub fn degenerate_within(s: &EvalSurface, dom: &Domain, uv: [f64; 2], d: usize, reach: f64) -> bool {
let Some((_, su, sv)) = s.derivs(uv[0], uv[1]) else { return false };
let speed = [su.len(), sv.len()];
collapsed(speed, dom, reach).get(d).copied().unwrap_or(false)
}
/// Along each direction, whether a point whose parameter speeds (mm per unit) are `speed` is a
/// pole, or within `reach` mm of one: moving along the direction there not at all (to 1e-9 of
/// the other's speed), or across half its period (or its whole range) by no more than `reach`.
pub fn collapsed(speed: [f64; 2], dom: &Domain, reach: f64) -> [bool; 2] {
let [a, b] = speed;
let mut out = [a <= 1e-9 * b.max(1e-12), b <= 1e-9 * a.max(1e-12)];
for (d, flag) in out.iter_mut().enumerate() {
let span = match (dom.period[d], dom.range[d]) {
(Some(period), _) => 0.5 * period,
(None, Some((lo, hi))) => (hi - lo).abs(),
(None, None) => continue,
};
*flag |= speed[d] * span <= reach;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
+21 -3
View File
@@ -878,7 +878,8 @@ impl Body {
}
/// The body's volume from a mesh at `tol`, measured once per (record, tolerance): clones of
/// a body share the measurement. `None` when the body cannot be meshed.
/// a body share the measurement. `None` when the body cannot be meshed, or a face of it was
/// left out of the mesh (an open mesh has no volume).
pub fn volume_at(&self, tol: f64) -> Option<f64> {
let key = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let key = key.to_bits();
@@ -889,7 +890,11 @@ impl Body {
{
return Some(volume);
}
let volume = self.mesh(&MeshTolerance::chord(f64::from_bits(key))).ok()?.measure().volume;
let mesh = self.mesh(&MeshTolerance::chord(f64::from_bits(key))).ok()?;
if self.faces_without_triangles(&mesh) > 0 {
return None;
}
let volume = mesh.measure().volume;
if let Some(cell) = &self.volume
&& let Ok(mut slot) = cell.lock()
{
@@ -913,7 +918,8 @@ impl Body {
/// joins, protected creases). Triangles carry their face index and the edges their polylines,
/// in the record's order; neighbouring faces share every boundary node, so the mesh is
/// watertight wherever the B-rep is. A mesh body gives its own triangles. A face the planner
/// cannot mesh is an error that names it and why.
/// cannot mesh is left out (logged with why; [`Body::unmeshed_faces`] counts it) and the rest
/// meshed; a body none of whose faces mesh is an error that names the first and why.
pub fn mesh(&self, t: &MeshTolerance) -> Result<Mesh> {
crate::profile::timed(crate::profile::Kind::Tessellate, "mesh", || self.mesh_untimed(t))
}
@@ -956,6 +962,10 @@ impl Body {
for f in &stats.lenient {
log::warn!("display plan: face {} filled leniently in (u, v), a defect in its pcurves or seams: {}", f.face, f.reason);
}
// A face no method plans is left out (no triangles; `unmeshed_faces` counts it) and named.
for f in &stats.unmeshed {
log::warn!("display plan: face {} left out of the mesh: {}", f.face, f.reason);
}
Ok(plan)
}
@@ -1047,6 +1057,14 @@ impl Body {
return 0;
}
let Ok(m) = self.mesh(&MeshTolerance::chord(coarse_tolerance(self.size()))) else { return self.face_count() };
self.faces_without_triangles(&m)
}
/// Faces of the body with no triangles in its mesh `m` (left out by the mesher).
fn faces_without_triangles(&self, m: &Mesh) -> usize {
if self.is_mesh() {
return 0;
}
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
+73 -32
View File
@@ -18,9 +18,10 @@
//! named in the plan's stats ([`PlanStats::lenient`], with why it was refused) so it is
//! reported and fixed there; it is filled leniently in (u, v) (the sides crossing one already
//! kept are left out, see [`Lenient`]) so the display has no hole. A face that refuses even
//! that (its loops do not close in (u, v), or wind round a seam or pole), or whose pcurves do
//! not lie on its edges, is an error naming the face (and the edge) and why; there is no
//! other mesher to fall back to.
//! that (its loops do not close in (u, v), or wind round a seam or pole) is left out of the
//! plan with no triangles and named in [`PlanStats::unmeshed`], and the rest of the body is
//! meshed; there is no other mesher to fall back to. A face whose pcurves do not lie on its edges is an error naming the face and
//! the edge, as is a body none of whose faces can be planned.
pub(crate) mod cdt;
pub(crate) mod collar;
@@ -64,12 +65,15 @@ pub struct PlanStats {
pub delaunay: usize,
/// Faces every method refused, filled leniently in (u, v) (see [`Lenient`]) so the display
/// has no hole: each still a defect upstream, in its pcurves or seams.
pub lenient: Vec<LenientFace>,
pub lenient: Vec<RefusedFace>,
/// Faces even the lenient fill refused: left out of the plan (no triangles), so the rest of
/// the body is still meshed, each with why it could not be planned.
pub unmeshed: Vec<RefusedFace>,
}
/// A face filled leniently: its index in the record and why the strict plan refused it.
/// A face a method refused: its index in the record and why.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LenientFace {
pub struct RefusedFace {
pub face: usize,
pub reason: String,
}
@@ -198,7 +202,7 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
return Err(unsupported(format!("face {fi}: missing")));
};
let refused = tangled.iter().find(|(f, _)| *f == fi).map(|(_, why)| why.as_str());
let (fp, stats) = plan_or_fill(fi, kind(face.parameters()), eval, &fb.loops, &rules, *size, refused)?;
let (fp, stats) = plan_or_fill(fi, kind(face.parameters()), eval, &fb.loops, &rules, *size, fb.reach, refused)?;
let fwd = forward.get(fi).copied().unwrap_or(true);
let fp =
FacePlan { flip: !fwd, triangles: if fwd { fp.triangles } else { fp.triangles.into_iter().map(|[a, b, c]| [a, c, b]).collect() }, ..fp };
@@ -206,16 +210,31 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
});
let mut plan = TessPlan::default();
let mut stats = PlanStats::default();
for (r, eval) in planned.into_iter().zip(evals) {
let (fp, s) = r?;
// A face no method plans is left out, named in the stats, and the rest of the body is
// meshed; a body none of whose faces plan is the first face's error.
let mut first_error: Option<KernelError> = None;
for (fi, (r, eval)) in planned.into_iter().zip(evals).enumerate() {
let surface = u32::try_from(plan.surfaces.len()).map_err(|_| unsupported("too many faces"))?;
plan.surfaces.push(eval);
let (fp, s) = match r {
Ok(done) => done,
Err(e) => {
stats.unmeshed.push(RefusedFace { face: fi, reason: e.to_string() });
first_error.get_or_insert(e);
(FacePlan { face: u32::try_from(fi).unwrap_or(u32::MAX), ..FacePlan::default() }, PlanStats::default())
}
};
plan.faces.push(FacePlan { surface: Some(surface), ..fp });
stats.planar += s.planar;
stats.structured += s.structured;
stats.delaunay += s.delaunay;
stats.lenient.extend(s.lenient);
}
if let Some(e) = first_error
&& stats.unmeshed.len() == faces.len()
{
return Err(e);
}
plan.edges = nodes.into_iter().map(|n| n.points).collect();
plan.edge_faces = edge_faces.into_iter().map(|fs| fs.into_iter().map(|f| u32::try_from(f).unwrap_or(u32::MAX)).collect()).collect();
Ok((plan, stats))
@@ -226,7 +245,8 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
/// at the node's curve parameter. A seam edge's two uses on one face have their own pcurves, so
/// its two sides are told apart. A pcurve runs on continuously; where one edge meets the next, a
/// pcurve that starts a whole period along from where the previous one ended is moved back by
/// it, except in a direction the meeting point is a pole of: there the parameter is free, and
/// it, except in a direction the meeting point is a pole of (or lies on a patch side collapsed
/// to within its vertex's tolerance, see [`Boundary::reach`]): there the parameter is free, and
/// the two pcurves' own values are where the face's boundary runs along the pole from and to
/// (a meridian revolved half a turn meets the other at the pole half a period away; unwrapped,
/// its loop would wind round the surface). Each loop then starts at a node off every pole whose
@@ -251,7 +271,21 @@ fn boundary(
) -> Result<Boundary> {
let dom = uv::domain(s);
let fail = |e: u32, why: String| unsupported(format!("face {fi} ({}): edge {e}: {why}", kind(&face.surface)));
let mut out = Boundary { loops: Vec::with_capacity(face.loops.len()), segments: Vec::with_capacity(face.loops.len()) };
// How near a pole a node is one: the tolerance of a vertex of the face that lies on a patch
// side collapsed to within it, not to a point, as the record check takes a loop's pole; at
// most the chord tolerance, finer than which the mesh resolves nothing (a vertex tolerance
// as large as the surface's features would make a pole of every node near one).
let mut reach: f64 = 0.0;
for oe in face_edges(face) {
let (Some(edge), Some([p0, p1])) = (b.edges.get(oe.edge as usize), oe.pcurve.interval()) else { continue };
let tol = edge.vertices.get(usize::from(!oe.forward)).and_then(|v| b.vertices.get(*v as usize)).map_or(0.0, |v| v.tolerance.min(rules.tol));
let Some(q) = oe.pcurve.eval(if oe.forward { p0 } else { p1 }).map(|q| map.to_eval(q)) else { continue };
if tol.is_finite() && tol > reach && (0..2).any(|d| uv::degenerate_within(s, &dom, q, d, tol)) {
reach = tol;
}
}
let pole_along = |q: [f64; 2], d: usize| uv::degenerate_within(s, &dom, q, d, reach);
let mut out = Boundary { loops: Vec::with_capacity(face.loops.len()), segments: Vec::with_capacity(face.loops.len()), reach };
// Each vertex's parameters, from the first edge use of the face to start there.
let mut at_vertex: std::collections::HashMap<u32, [f64; 2]> = std::collections::HashMap::new();
for l in &face.loops {
@@ -290,7 +324,7 @@ fn boundary(
if let (Some(e), Some(&start)) = (end, eq.first()) {
for d in 0..2 {
let Some(per) = dom.period[d] else { continue };
if !(uv::degenerate(s, e, d) || uv::degenerate(s, start, d)) {
if !(pole_along(e, d) || pole_along(start, d)) {
shift[d] = per * ((e[d] - start[d]) / per).round();
}
}
@@ -321,7 +355,7 @@ fn boundary(
uvs.extend(eq);
}
let n = uvs.len();
let pole = |q: &[f64; 2]| (0..2).any(|d| uv::degenerate(s, *q, d));
let pole = |q: &[f64; 2]| (0..2).any(|d| pole_along(*q, d));
if let Some(k) = (0..n).find(|&k| uvs.get(k).is_some_and(|q| !pole(q)) && uvs.get((k + n - 1) % n).is_some_and(|q| !pole(q))) {
uvs.rotate_left(k);
pts.rotate_left(k);
@@ -336,10 +370,13 @@ fn boundary(
}
/// A face's boundary loops, and for each loop node the edge segment that starts there: the
/// edge, and the segment's place along it from its start vertex.
/// edge, and the segment's place along it from its start vertex. `reach`: how near a pole a
/// point of the face is taken as one (see [`uv::degenerate_within`]): the tolerance of a vertex
/// of it on a patch side collapsed to within that tolerance, else 0.
struct Boundary {
loops: Vec<seam::Loop>,
segments: Vec<Vec<(u32, u32)>>,
reach: f64,
}
/// Edges are sampled to the chord tolerance each on its own, so where two curves of one face
@@ -360,7 +397,7 @@ fn untangle(
) -> Result<Vec<(usize, String)>> {
let crossing = |fi: usize, fb: &Boundary| -> Vec<(u32, u32)> {
let Some(s) = evals.get(fi) else { return Vec::new() };
tangle::crossing(s, &fb.loops).into_iter().filter_map(|(l, k)| fb.segments.get(l)?.get(k).copied()).collect()
tangle::crossing(s, fb.reach, &fb.loops).into_iter().filter_map(|(l, k)| fb.segments.get(l)?.get(k).copied()).collect()
};
let indexed: Vec<usize> = (0..boundaries.len()).collect();
let curved = evals.iter().filter(|e| !matches!(e, EvalSurface::Plane { .. })).count();
@@ -499,9 +536,9 @@ fn area2(uv: &[[f64; 2]]) -> f64 {
/// unchanged across the pole. Across a pole it may jump by any amount, so the run from the
/// second pole back to the first is moved one period along, whichever way keeps the loop
/// within one period with a positive area: the seam's two uses then lie a period apart.
fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, reach: f64, l: &mut seam::Loop) {
let n = l.uv.len();
let poles: Vec<usize> = (0..n).filter(|&k| l.uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate(s, *q, d)))).collect();
let poles: Vec<usize> = (0..n).filter(|&k| l.uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate_within(s, dom, *q, d, reach)))).collect();
let [p0, p1] = poles.as_slice() else { return };
for d in 0..2 {
let Some(per) = dom.period[d] else { continue };
@@ -547,11 +584,11 @@ fn open_pole_to_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
/// The poles of a loop where it turns straight back along a seam: the nodes either side have
/// the same parameters.
fn turning_poles(s: &EvalSurface, per: f64, uv: &[[f64; 2]]) -> Vec<usize> {
fn turning_poles(s: &EvalSurface, dom: &uv::Domain, reach: f64, per: f64, uv: &[[f64; 2]]) -> Vec<usize> {
let n = uv.len();
let near = |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>| a.zip(b).is_some_and(|(a, b)| (a[0] - b[0]).abs().max((a[1] - b[1]).abs()) <= 1e-9 * per);
(0..n)
.filter(|&k| uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate(s, *q, d))))
.filter(|&k| uv.get(k).is_some_and(|q| (0..2).any(|d| uv::degenerate_within(s, dom, *q, d, reach))))
.filter(|&k| near(uv.get((k + n - 1) % n), uv.get((k + 1) % n)))
.collect()
}
@@ -564,7 +601,7 @@ fn turning_poles(s: &EvalSurface, per: f64, uv: &[[f64; 2]]) -> Vec<usize> {
/// to the pole, and starts at the node before it: the seam's uses then bound the face's parameter box on either side, and share
/// their nodes as every other edge does. Kept as it was unless the opened loop spans at most a
/// period with a positive area.
fn open_at_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
fn open_at_pole(s: &EvalSurface, dom: &uv::Domain, reach: f64, l: &mut seam::Loop) {
let n = l.uv.len();
if n < 4 || l.pts.len() != n {
return;
@@ -574,7 +611,7 @@ fn open_at_pole(s: &EvalSurface, dom: &uv::Domain, l: &mut seam::Loop) {
if (seam::drift(l, d, per).abs() - per).abs() > per * 1e-6 {
continue;
}
let [pole] = turning_poles(s, per, &l.uv)[..] else { continue };
let [pole] = turning_poles(s, dom, reach, per, &l.uv)[..] else { continue };
// From the node after the pole round to the pole, unwrapped along the way.
let order: Vec<usize> = (1..=n).map(|i| (pole + i) % n).collect();
let mut uv: Vec<[f64; 2]> = Vec::with_capacity(n);
@@ -633,9 +670,10 @@ fn holes_beside_outer(dom: &uv::Domain, loops: &mut [seam::Loop]) {
/// One face, planned by [`plan_face`]; a face it refuses because its loops cross in (u, v) is
/// planned once more leniently (see [`Lenient`]) and named in the stats with the first refusal,
/// so the defect upstream is still reported. When the lenient fill refuses too, the first
/// refusal is the error. `refused`: a face whose boundary still crosses itself after
/// [`untangle`] (two edges meeting tangentially at a cusp, whose chords cross however often
/// they are halved), filled leniently straight away with that reason.
/// refusal is the error (and [`plan`] leaves the face out, named). `refused`: a face whose
/// boundary still crosses itself after [`untangle`] (two edges meeting tangentially at a cusp,
/// whose chords cross however often they are halved), filled leniently straight away with that
/// reason.
fn plan_or_fill(
fi: usize,
kind: &str,
@@ -643,20 +681,21 @@ fn plan_or_fill(
loops: &[seam::Loop],
rules: &size::Rules,
size: (f64, [f64; 2]),
reach: f64,
refused: Option<&str>,
) -> Result<(FacePlan, PlanStats)> {
let mut stats = PlanStats::default();
let strict = match refused {
Some(why) => Err(unsupported(why.to_string())),
None => plan_face(fi, kind, s, loops, rules, size, &mut stats, Lenient::No),
None => plan_face(fi, kind, s, loops, rules, size, reach, &mut stats, Lenient::No),
};
match strict {
Ok(fp) => Ok((fp, stats)),
Err(first) => {
let mut stats = PlanStats::default();
match plan_face(fi, kind, s, loops, rules, size, &mut stats, Lenient::Yes) {
match plan_face(fi, kind, s, loops, rules, size, reach, &mut stats, Lenient::Yes) {
Ok(fp) => {
stats.lenient.push(LenientFace { face: fi, reason: first.to_string() });
stats.lenient.push(RefusedFace { face: fi, reason: first.to_string() });
Ok((fp, stats))
}
Err(_) => Err(first),
@@ -667,7 +706,8 @@ fn plan_or_fill(
/// One face: boundary loops in 3D (shared edge nodes) to parameters and triangles.
/// `size`: the face's target side length (mm) and the surface's mean speed along u and v.
/// `lenient`: the last resort for a face no method could plan (see [`Lenient`]).
/// `reach`: how near a pole a point is taken as one ([`Boundary::reach`]). `lenient`: the last resort
/// for a face no method could plan (see [`Lenient`]).
#[allow(clippy::too_many_arguments)]
fn plan_face(
fi: usize,
@@ -676,6 +716,7 @@ fn plan_face(
loops: &[seam::Loop],
rules: &size::Rules,
size: (f64, [f64; 2]),
reach: f64,
stats: &mut PlanStats,
lenient: Lenient,
) -> Result<FacePlan> {
@@ -689,7 +730,7 @@ fn plan_face(
// A loop that runs up a seam from a pole and straight back down it (a half sphere bounded
// by its rim and its seam used both ways) winds once as projected: it is opened at the pole.
for l in projected.iter_mut() {
open_at_pole(s, &dom, l);
open_at_pole(s, &dom, reach, l);
}
let wound: Vec<usize> = (0..2).filter(|&d| dom.period[d].is_some_and(|per| seam::winds(&projected, d, per))).collect();
// A face cut along a seam is a long winding strip whose sides are the cut lines and the
@@ -708,7 +749,7 @@ fn plan_face(
// kernel writes it) projects onto one parameter line: its second run is a period along.
if !seamed {
for l in projected.iter_mut() {
open_pole_to_pole(s, &dom, l);
open_pole_to_pole(s, &dom, reach, l);
}
holes_beside_outer(&dom, &mut projected);
}
@@ -723,7 +764,7 @@ fn plan_face(
// At a pole the parameter along the degenerate direction is free: one copy with
// the previous node's value, one with the next node's.
let (prev, next) = (luv.get((k + n - 1) % n).copied(), luv.get((k + 1) % n).copied());
let pole = (0..2).find(|&d| uv::degenerate(s, *q, d));
let pole = (0..2).find(|&d| uv::degenerate_within(s, &dom, *q, d, reach));
match (pole, prev, next) {
(Some(d), Some(a), Some(b)) => {
let mut qa = *q;
@@ -875,7 +916,7 @@ fn plan_face(
all.extend(extra);
// Whatever the layout, no triangle leaves the surface by more than the tolerance.
if !planar {
sag::refine(s, tol, &fixed, &mut all, &mut triangles, MAX_REFINE);
sag::refine(s, tol, reach, &fixed, &mut all, &mut triangles, MAX_REFINE);
}
// Both evaluators take a closed spline's parameters inside its active interval.
for q in &mut all {
+10 -7
View File
@@ -14,12 +14,15 @@ use solidcraft_geom::tess::EvalSurface;
/// Split the triangles `tris` of a face (indices into `uv`; the first `fixed.len()` points sit
/// at `fixed`, the rest on the surface at their parameters) until none of them lies farther
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`.
pub(crate) fn refine(s: &EvalSurface, tol: f64, fixed: &[Vec3], uv: &mut Vec<[f64; 2]>, tris: &mut Vec<[u32; 3]>, max_points: usize) {
/// than `tol` from the surface `s`, adding at most `max_points` points to `uv`. `reach`: how near
/// a pole a point is taken as one (see [`super::uv::degenerate_within`]).
pub(crate) fn refine(s: &EvalSurface, tol: f64, reach: f64, fixed: &[Vec3], uv: &mut Vec<[f64; 2]>, tris: &mut Vec<[u32; 3]>, max_points: usize) {
if !(tol.is_finite() && tol > 0.0) || tris.is_empty() {
return;
}
let at = |q: [f64; 2]| s.eval(q[0], q[1]).0;
let dom = super::uv::domain(s);
let mean = |qs: &[[f64; 2]]| mean(s, &dom, reach, qs);
// How far the flat point `c` lies from the surface, measured along the normal at the surface
// point of the same parameters: those parameters need not be the nearest (a straight line in
// the parameters is no geodesic near a pole), and only the departure across the surface
@@ -64,7 +67,7 @@ pub(crate) fn refine(s: &EvalSurface, tol: f64, fixed: &[Vec3], uv: &mut Vec<[f6
if len <= shortest {
continue;
}
let sag = *sag_of.entry(e).or_insert_with(|| off(mean(s, &[ua, ub]), (xa + xb) * 0.5));
let sag = *sag_of.entry(e).or_insert_with(|| off(mean(&[ua, ub]), (xa + xb) * 0.5));
inner.push((sag, len, e));
}
// The surface's departure from a flat triangle is about quadratic over it: at the
@@ -72,7 +75,7 @@ pub(crate) fn refine(s: &EvalSurface, tol: f64, fixed: &[Vec3], uv: &mut Vec<[f6
// inside the face divides by four. A triangle whose middle is off splits its side inside
// the face that sags most, while that side still contributes; what is left comes from
// its boundary sides, which the edges' own sampling keeps within the tolerance.
let centroid = mean(s, &[qa, qb, qc]);
let centroid = mean(&[qa, qb, qc]);
let worst = inner.iter().max_by(|x, y| x.0.total_cmp(&y.0)).copied();
let split = match worst {
Some((sag, _, e)) if sag > tol => e,
@@ -80,7 +83,7 @@ pub(crate) fn refine(s: &EvalSurface, tol: f64, fixed: &[Vec3], uv: &mut Vec<[f6
_ => continue,
};
let (Some(ua), Some(ub)) = (q(split.0), q(split.1)) else { continue };
let m = mean(s, &[ua, ub]);
let m = mean(&[ua, ub]);
let mi = uv.len() as u32;
uv.push(m);
pos.push(at(m));
@@ -92,10 +95,10 @@ pub(crate) fn refine(s: &EvalSurface, tol: f64, fixed: &[Vec3], uv: &mut Vec<[f6
/// The mean of parameter points, as the surface point between them: at a pole the parameter
/// along the degenerate direction is free (the pole copy carries a neighbour's), so there the
/// mean takes it from the other points alone.
fn mean(s: &EvalSurface, qs: &[[f64; 2]]) -> [f64; 2] {
fn mean(s: &EvalSurface, dom: &super::uv::Domain, reach: f64, qs: &[[f64; 2]]) -> [f64; 2] {
let mut out = [0.0; 2];
for (d, slot) in out.iter_mut().enumerate() {
let free: Vec<f64> = qs.iter().filter(|q| !super::uv::degenerate(s, **q, d)).map(|q| q[d]).collect();
let free: Vec<f64> = qs.iter().filter(|q| !super::uv::degenerate_within(s, dom, **q, d, reach)).map(|q| q[d]).collect();
let pick: Vec<f64> = if free.is_empty() { qs.iter().map(|q| q[d]).collect() } else { free };
*slot = pick.iter().sum::<f64>() / pick.len().max(1) as f64;
}
+3 -3
View File
@@ -33,10 +33,10 @@ impl Chord {
/// another, each once and in order. On a closed surface the boundary crosses itself where any
/// copies of two chords, whole periods apart, cross; chords ending on a pole, where the
/// parameter along the pole is free, are left out. Adjacent chords of a loop share a node and
/// never cross.
pub(crate) fn crossing(s: &EvalSurface, loops: &[seam::Loop]) -> Vec<(usize, usize)> {
/// never cross. `reach`: how near a pole a node is taken as one (see [`uv::degenerate_within`]).
pub(crate) fn crossing(s: &EvalSurface, reach: f64, loops: &[seam::Loop]) -> Vec<(usize, usize)> {
let dom = uv::domain(s);
let pole = |q: [f64; 2]| (0..2).any(|d| uv::degenerate(s, q, d));
let pole = |q: [f64; 2]| (0..2).any(|d| uv::degenerate_within(s, &dom, q, d, reach));
let mut chords: Vec<Chord> = Vec::new();
for (li, l) in loops.iter().enumerate() {
let n = l.uv.len();
+61 -5
View File
@@ -99,7 +99,7 @@ fn a_box_is_a_regular_grid_on_every_face() {
fn primitives_are_watertight_with_the_right_volume() {
for tol in [0.05, 0.005] {
let s = check("box", &ok(crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 6.0, 4.0))), tol, 240.0);
assert_eq!(s, PlanStats { planar: 6, structured: 0, delaunay: 0, lenient: Vec::new() });
assert_eq!(s, PlanStats { planar: 6, ..PlanStats::default() });
let s = check("cylinder", &ok(crate::cylinder(Vec3::new(1.0, 2.0, 3.0), Vec3::new(0.0, 1.0, 1.0), 2.5, 7.0)), tol, PI * 2.5 * 2.5 * 7.0);
assert_eq!(s.delaunay, 0, "cylinder walls are structured");
let s = check("torus", &ok(crate::torus(Vec3::ZERO, 6.0, 1.5)), tol, 2.0 * PI * PI * 6.0 * 1.5 * 1.5);
@@ -725,8 +725,8 @@ fn a_curved_face_crossing_itself_is_filled_on_its_surface() {
let size = size::face_size(&s, &uv::domain(&s), &rules);
let loops = std::slice::from_ref(&l);
let mut stats = PlanStats::default();
assert!(plan_face(0, "spline", &s, loops, &rules, size, &mut stats, Lenient::No).is_err());
let Ok(fp) = plan_face(0, "spline", &s, loops, &rules, size, &mut stats, Lenient::Yes) else { panic!("a lenient fill") };
assert!(plan_face(0, "spline", &s, loops, &rules, size, 0.0, &mut stats, Lenient::No).is_err());
let Ok(fp) = plan_face(0, "spline", &s, loops, &rules, size, 0.0, &mut stats, Lenient::Yes) else { panic!("a lenient fill") };
assert!(!fp.triangles.is_empty());
assert!(fp.uv.len() > fp.fixed.len(), "{} points, {} on the boundary", fp.uv.len(), fp.fixed.len());
// Each point inside the face on the surface where its parameters put it.
@@ -743,7 +743,7 @@ fn a_curved_face_crossing_itself_is_filled_on_its_surface() {
fn a_face_filled_leniently_is_named_in_the_stats() {
let (s, l, rules) = a_face_crossing_itself();
let size = size::face_size(&s, &uv::domain(&s), &rules);
let Ok((fp, stats)) = plan_or_fill(7, "spline", &s, std::slice::from_ref(&l), &rules, size, None) else { panic!("filled leniently") };
let Ok((fp, stats)) = plan_or_fill(7, "spline", &s, std::slice::from_ref(&l), &rules, size, 0.0, None) else { panic!("filled leniently") };
assert!(!fp.triangles.is_empty());
assert_eq!((stats.planar, stats.structured, stats.delaunay), (0, 0, 0));
let [lenient] = stats.lenient.as_slice() else { panic!("one lenient face: {:?}", stats.lenient) };
@@ -760,7 +760,7 @@ fn a_face_untangling_cannot_resolve_is_filled_leniently_and_named() {
let (s, l, rules) = a_face_crossing_itself();
let size = size::face_size(&s, &uv::domain(&s), &rules);
let why = "face 7 (spline): boundary crosses itself: edges [1, 2] run closer together than their chords can follow";
let Ok((fp, stats)) = plan_or_fill(7, "spline", &s, std::slice::from_ref(&l), &rules, size, Some(why)) else { panic!("filled leniently") };
let Ok((fp, stats)) = plan_or_fill(7, "spline", &s, std::slice::from_ref(&l), &rules, size, 0.0, Some(why)) else { panic!("filled leniently") };
assert!(!fp.triangles.is_empty());
let [lenient] = stats.lenient.as_slice() else { panic!("one lenient face: {:?}", stats.lenient) };
assert_eq!(lenient.face, 7);
@@ -807,3 +807,59 @@ fn a_face_bounded_by_two_short_arcs_keeps_an_area_to_mesh() {
let lens = made.faces.first().and_then(|&f| plan.faces.get(f)).expect("the lens's plan");
assert!(!lens.triangles.is_empty(), "the lens has triangles");
}
/// Half of a square pyramid's sides as one spline face: degree 1, closed round the square
/// (u in [0, 4], a corner at each whole u) and from the base (v = 0) up to a top side collapsed
/// to a square 1e-7 mm across, not to a point, as the patch's side in Poppy Humanoid's connector
/// a quarter of a sphere cap's corner sits on. Its loop runs down the side at u = 0, along the
/// base to u = 2 and up again, then across the top back to the corner it started at, half a
/// period along. Taken for no pole, that step is taken as the nearest copy, and the loop winds
/// once round the surface with no partner to cut it along.
fn pyramid_past_a_near_pole(pole_tolerance: f64) -> solidcraft_geom::brep::Brep {
use solidcraft_geom::brep::{self as b, Curve, Curve2, Surface};
let top = 1e-7;
let corner = |u: usize, r: f64, z: f64| {
let [x, y] = [[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0], [0.0, -1.0]][u % 4];
[r * x, r * y, z]
};
let points: Vec<Vec<[f64; 3]>> = (0..5).map(|u| vec![corner(u, 1.0, 0.0), corner(u, top, 1.0)]).collect();
let surface = Surface::Spline { u_knots: vec![0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 4.0], v_knots: vec![0.0, 0.0, 1.0, 1.0], points };
let edge = |curve: Curve, interval: [f64; 2], vertices: [u32; 2]| b::Edge { nurbs: None, curve, interval, vertices, tolerance: 1e-6, uses: Vec::new() };
let edges = vec![
edge(Curve::Line { start: corner(0, 1.0, 0.0), end: corner(0, top, 1.0) }, [0.0, 1.0], [1, 0]),
edge(Curve::Polyline { points: vec![corner(0, 1.0, 0.0), corner(1, 1.0, 0.0), corner(2, 1.0, 0.0)] }, [0.0, 2.0], [1, 2]),
edge(Curve::Line { start: corner(2, 1.0, 0.0), end: corner(2, top, 1.0) }, [0.0, 1.0], [2, 0]),
];
let line = |t: [f64; 2], a: [f64; 2], z: [f64; 2]| Curve2::Spline { knots: vec![t[0], t[0], t[1], t[1]], points: vec![a, z] };
let uses = vec![
b::OrientedEdge { edge: 0, forward: false, pcurve: line([0.0, 1.0], [0.0, 0.0], [0.0, 1.0]) },
b::OrientedEdge { edge: 1, forward: true, pcurve: line([0.0, 2.0], [0.0, 0.0], [2.0, 0.0]) },
b::OrientedEdge { edge: 2, forward: true, pcurve: line([0.0, 1.0], [2.0, 0.0], [2.0, 1.0]) },
];
let face = b::Face { nurbs: None, surface, domain: [[0.0, 2.0], [0.0, 1.0]], loops: vec![b::Loop::Edges { edges: uses }] };
let vertex = |point: [f64; 3], tolerance: f64| b::Vertex { point, tolerance };
let loose = b::Brep {
version: b::VERSION,
tolerance: 1e-7,
regions: vec![b::Region { shells: vec![0], void: false }],
shells: vec![b::Shell::Faces { faces: vec![b::OrientedFace { face: 0, forward: true }], wires: Vec::new() }],
faces: vec![face],
edges,
vertices: vec![vertex([0.0, 0.0, 1.0], pole_tolerance), vertex(corner(0, 1.0, 0.0), 1e-7), vertex(corner(2, 1.0, 0.0), 1e-7)],
};
ok(loose.canonical().map_err(|e| crate::KernelError::Invalid(format!("{e:?}")))).brep
}
/// The corner of [`pyramid_past_a_near_pole`] within its vertex's tolerance of the collapsed top
/// side is a pole, and the face plans strictly over both its sides; with a tolerance short of
/// the top's size it is no pole, and the loop winds.
#[test]
fn a_loop_corner_within_its_tolerance_of_a_pole_is_a_pole() {
let (made, stats) = ok(plan(&pyramid_past_a_near_pole(1e-6), 0.01, None));
assert!(stats.lenient.is_empty() && stats.unmeshed.is_empty(), "{stats:?}");
// Two of the pyramid's sides, each a triangle of area √3 / 2.
let area = made.evaluate().measure().area;
assert!((area - 3f64.sqrt()).abs() < 0.01 * 3f64.sqrt(), "area {area}");
let Err(e) = plan(&pyramid_past_a_near_pole(1e-8), 0.01, None) else { panic!("no pole without the tolerance") };
assert!(e.to_string().contains("winds around the surface"), "{e}");
}
+1 -1
View File
@@ -3,7 +3,7 @@
//! face's parameter box.
use solidcraft_geom::tess::EvalSurface;
pub(crate) use solidcraft_geom::tess::uv::{Domain, degenerate, domain, unwrap};
pub(crate) use solidcraft_geom::tess::uv::{Domain, degenerate_within, domain, unwrap};
use super::size::Rules;