diff --git a/crates/geom/src/brep/eval.rs b/crates/geom/src/brep/eval.rs index e4cf59bdd2..9b34a8d1fd 100644 --- a/crates/geom/src/brep/eval.rs +++ b/crates/geom/src/brep/eval.rs @@ -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) } } diff --git a/crates/geom/src/tess/uv.rs b/crates/geom/src/tess/uv.rs index 0ec7e28bb1..65040c9425 100644 --- a/crates/geom/src/tess/uv.rs +++ b/crates/geom/src/tess/uv.rs @@ -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::*; diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 97b46c9412..6bb9fcccd6 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -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 { 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 { 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) { diff --git a/crates/kernel/src/tess/mod.rs b/crates/kernel/src/tess/mod.rs index 08d9b192f8..d956b2adb2 100644 --- a/crates/kernel/src/tess/mod.rs +++ b/crates/kernel/src/tess/mod.rs @@ -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, + pub lenient: Vec, + /// 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, } -/// 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) -> 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) -> 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 = 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) -> 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 { 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 = 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, segments: Vec>, + 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> { 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 = (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 = (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 = (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 { +fn turning_poles(s: &EvalSurface, dom: &uv::Domain, reach: f64, per: f64, uv: &[[f64; 2]]) -> Vec { 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 { /// 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 = (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 { @@ -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 = (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 { diff --git a/crates/kernel/src/tess/sag.rs b/crates/kernel/src/tess/sag.rs index 1d96e79948..02ebb35ef5 100644 --- a/crates/kernel/src/tess/sag.rs +++ b/crates/kernel/src/tess/sag.rs @@ -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 = qs.iter().filter(|q| !super::uv::degenerate(s, **q, d)).map(|q| q[d]).collect(); + let free: Vec = qs.iter().filter(|q| !super::uv::degenerate_within(s, dom, **q, d, reach)).map(|q| q[d]).collect(); let pick: Vec = if free.is_empty() { qs.iter().map(|q| q[d]).collect() } else { free }; *slot = pick.iter().sum::() / pick.len().max(1) as f64; } diff --git a/crates/kernel/src/tess/tangle.rs b/crates/kernel/src/tess/tangle.rs index 68db1a9760..c53a982cef 100644 --- a/crates/kernel/src/tess/tangle.rs +++ b/crates/kernel/src/tess/tangle.rs @@ -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 = Vec::new(); for (li, l) in loops.iter().enumerate() { let n = l.uv.len(); diff --git a/crates/kernel/src/tess/tests.rs b/crates/kernel/src/tess/tests.rs index 5ee223aaa9..3efbef471d 100644 --- a/crates/kernel/src/tess/tests.rs +++ b/crates/kernel/src/tess/tests.rs @@ -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> = (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}"); +} diff --git a/crates/kernel/src/tess/uv.rs b/crates/kernel/src/tess/uv.rs index b4cb790ec9..7ee1b0f237 100644 --- a/crates/kernel/src/tess/uv.rs +++ b/crates/kernel/src/tess/uv.rs @@ -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;