Keep fine meshes whole at collapsed taper boundaries (#906)
The exact surface search could return an arbitrary parameter at a collapsed NURBS boundary, which corrupted the trimmed triangulation domain and lost volume on fine meshes of a fillet tapering to zero. Search results in the boundary projection are now validated, preferring a bounded parameter at a pole and snapping to a finite endpoint only when its transverse tangent has collapsed and the residual improves. Adds four regressions for decreasing chord tolerances, pole parameters, unbounded planar support and provisional preview. Fixes #796.
This commit is contained in:
1 parent
7da7bbb335
commit
3f1c023563
5 files changed
+235
-15
No files matched your search
+1
-1
@@ -106,7 +106,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
|||||||
|
|
||||||
| Directory | Source | Licence | Changes |
|
| Directory | Source | Licence | Changes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
|
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
|
||||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||||
|
|||||||
@@ -819,3 +819,7 @@ mod tests {
|
|||||||
assert!(rel(measure(&f).unwrap().volume, measure(&k).unwrap().volume) < 1e-9);
|
assert!(rel(measure(&f).unwrap().volume, measure(&k).unwrap().volume) < 1e-9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "taper_mesh_tests.rs"]
|
||||||
|
mod taper_mesh_tests;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use super::*;
|
||||||
|
use mt::{ParameterDivision2D, ParametricSurface, ParametricSurface3D, SearchNearestParameter, SearchParameter};
|
||||||
|
use truck_meshalgo::prelude::*;
|
||||||
|
|
||||||
|
fn wedge() -> (Body, RadiusSpline) {
|
||||||
|
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||||
|
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(40., 0.), Vec2::new(0., 20.)]);
|
||||||
|
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap();
|
||||||
|
let at = Vec3::new(20., 0., 15.);
|
||||||
|
let (filleted, tapers) = fillet_tapered(&body, &[at], 16.).unwrap();
|
||||||
|
let taper = tapers.into_iter().flatten().next().unwrap();
|
||||||
|
(filleted, taper.spline)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected(law: &RadiusSpline) -> f64 {
|
||||||
|
let n = 20_000;
|
||||||
|
let integral = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64;
|
||||||
|
6000. - (1. - std::f64::consts::FRAC_PI_4) * 40. * integral
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mesh_volume(polygon: &PolygonMesh) -> f64 {
|
||||||
|
let positions = polygon.positions();
|
||||||
|
polygon
|
||||||
|
.faces()
|
||||||
|
.triangle_iter()
|
||||||
|
.map(|t| {
|
||||||
|
let [a, b, c] = t.map(|v| positions[v.pos].to_vec());
|
||||||
|
a.dot(b.cross(c)) / 6.
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsed_taper_mesh_stays_accurate_as_tolerance_decreases() {
|
||||||
|
let (body, law) = wedge();
|
||||||
|
let wanted = expected(&law);
|
||||||
|
for tol in [0.002, 0.001, 0.0005, 0.0002] {
|
||||||
|
for refine in [false, true] {
|
||||||
|
let mesh = body.tessellate_with(tol, refine).unwrap();
|
||||||
|
assert!(mesh.positions.iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
|
||||||
|
let got = mesh.measure().volume;
|
||||||
|
assert!((got - wanted).abs() / wanted < 1e-4, "tol={tol}, refine={refine}: {got} vs {wanted}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid exact parameter search can choose any parameter along a collapsed
|
||||||
|
/// edge, even far outside its nominal range. Force that answer on every platform
|
||||||
|
/// instead of relying on the Windows Newton solve that originally exposed #796.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ArbitraryPole(mt::Surface);
|
||||||
|
impl ParametricSurface for ArbitraryPole {
|
||||||
|
type Point = mt::Point3;
|
||||||
|
type Vector = mt::Vector3;
|
||||||
|
fn subs(&self, u: f64, v: f64) -> Self::Point {
|
||||||
|
self.0.subs(u, v)
|
||||||
|
}
|
||||||
|
fn uder(&self, u: f64, v: f64) -> Self::Vector {
|
||||||
|
self.0.uder(u, v)
|
||||||
|
}
|
||||||
|
fn vder(&self, u: f64, v: f64) -> Self::Vector {
|
||||||
|
self.0.vder(u, v)
|
||||||
|
}
|
||||||
|
fn uuder(&self, u: f64, v: f64) -> Self::Vector {
|
||||||
|
self.0.uuder(u, v)
|
||||||
|
}
|
||||||
|
fn uvder(&self, u: f64, v: f64) -> Self::Vector {
|
||||||
|
self.0.uvder(u, v)
|
||||||
|
}
|
||||||
|
fn vvder(&self, u: f64, v: f64) -> Self::Vector {
|
||||||
|
self.0.vvder(u, v)
|
||||||
|
}
|
||||||
|
fn parameter_range(&self) -> (mt::ParameterRange, mt::ParameterRange) {
|
||||||
|
self.0.parameter_range()
|
||||||
|
}
|
||||||
|
fn u_period(&self) -> Option<f64> {
|
||||||
|
self.0.u_period()
|
||||||
|
}
|
||||||
|
fn v_period(&self) -> Option<f64> {
|
||||||
|
self.0.v_period()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ParametricSurface3D for ArbitraryPole {
|
||||||
|
fn normal(&self, u: f64, v: f64) -> mt::Vector3 {
|
||||||
|
self.0.normal(u, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ParameterDivision2D for ArbitraryPole {
|
||||||
|
fn parameter_division(&self, range: ((f64, f64), (f64, f64)), tol: f64) -> (Vec<f64>, Vec<f64>) {
|
||||||
|
self.0.parameter_division(range, tol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl SearchParameter<mt::D2> for ArbitraryPole {
|
||||||
|
type Point = mt::Point3;
|
||||||
|
fn search_parameter<H: Into<mt::SPHint2D>>(&self, point: mt::Point3, hint: H, trials: usize) -> Option<(f64, f64)> {
|
||||||
|
if matches!(self.0, mt::Surface::NurbsSurface(_)) && (point - mt::Point3::new(40., 0., 15.)).magnitude2() < 1e-20 {
|
||||||
|
Some((1., -1e14))
|
||||||
|
} else {
|
||||||
|
self.0.search_parameter(point, hint, trials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl SearchNearestParameter<mt::D2> for ArbitraryPole {
|
||||||
|
type Point = mt::Point3;
|
||||||
|
fn search_nearest_parameter<H: Into<mt::SPHint2D>>(&self, point: mt::Point3, hint: H, trials: usize) -> Option<(f64, f64)> {
|
||||||
|
self.0.search_nearest_parameter(point, hint, trials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_mesh_searches_keep_an_arbitrary_exact_pole_inside_the_domain() {
|
||||||
|
let (body, law) = wedge();
|
||||||
|
let shell = body.solid.boundaries()[0].mapped(Clone::clone, Clone::clone, |s| ArbitraryPole(s.clone()));
|
||||||
|
for robust in [false, true] {
|
||||||
|
let mesh = if robust { shell.robust_triangulation(0.0005) } else { shell.triangulation(0.0005) };
|
||||||
|
for (source, face) in shell.face_iter().zip(mesh.face_iter()) {
|
||||||
|
let polygon = face.surface().expect("valid pole must not discard its face");
|
||||||
|
if matches!(source.surface().0, mt::Surface::NurbsSurface(_)) {
|
||||||
|
assert!(polygon.uv_coords().iter().all(|p| (-1e-6..=1. + 1e-6).contains(&p.x) && (-1e-6..=1. + 1e-6).contains(&p.y)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let polygon = mesh.to_polygon();
|
||||||
|
let volume = mesh_volume(&polygon);
|
||||||
|
assert!((volume - expected(&law)).abs() / expected(&law) < 1e-4, "robust={robust}: {volume}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_projection_does_not_clip_a_planes_unbounded_support() {
|
||||||
|
let (body, law) = wedge();
|
||||||
|
let shell = body.solid.boundaries()[0].mapped(Clone::clone, Clone::clone, |surface| {
|
||||||
|
if let mt::Surface::Plane(plane) = surface {
|
||||||
|
let origin = plane.subs(0., 0.);
|
||||||
|
mt::Surface::Plane(mt::Plane::new(origin, origin + plane.uder(0., 0.).normalize(), origin + plane.vder(0., 0.).normalize()))
|
||||||
|
} else {
|
||||||
|
surface.clone()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for robust in [false, true] {
|
||||||
|
let mesh = if robust { shell.robust_triangulation(0.0005) } else { shell.triangulation(0.0005) };
|
||||||
|
let mut outside = false;
|
||||||
|
for (source, face) in shell.face_iter().zip(mesh.face_iter()) {
|
||||||
|
let polygon = face.surface().unwrap();
|
||||||
|
if matches!(source.surface(), mt::Surface::Plane(_)) {
|
||||||
|
outside |= polygon.uv_coords().iter().any(|p| p.x.abs() > 2. || p.y.abs() > 2.);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(outside, "fixture must extend beyond the plane's nominal unit box");
|
||||||
|
let got = mesh_volume(&mesh.to_polygon());
|
||||||
|
assert!((got - expected(&law)).abs() / expected(&law) < 1e-4, "robust={robust}: {got}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provisional_preview_bounds_the_same_arbitrary_pole() {
|
||||||
|
use mt::{BoundedCurve, ParameterDivision1D, ParametricCurve};
|
||||||
|
let (body, _) = wedge();
|
||||||
|
let face = body.solid.face_iter().find(|f| matches!(f.surface(), mt::Surface::NurbsSurface(_))).unwrap();
|
||||||
|
let loops: Vec<Vec<mt::Point3>> = face
|
||||||
|
.absolute_boundaries()
|
||||||
|
.iter()
|
||||||
|
.map(|wire| {
|
||||||
|
wire.edge_iter()
|
||||||
|
.flat_map(|edge| {
|
||||||
|
let curve = edge.oriented_curve();
|
||||||
|
let (mut parameters, _) = curve.parameter_division(curve.range_tuple(), 0.01);
|
||||||
|
parameters.pop();
|
||||||
|
parameters.into_iter().map(move |t| curve.subs(t))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mesh = truck_meshalgo::tessellation::preview_face(&ArbitraryPole(face.surface()), &loops, 8).unwrap();
|
||||||
|
assert!(mesh.faces().triangle_iter().next().is_some());
|
||||||
|
assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()));
|
||||||
|
assert!(mesh.uv_coords().iter().all(|p| (-1e-6..=1. + 1e-6).contains(&p.x) && (-1e-6..=1. + 1e-6).contains(&p.y)));
|
||||||
|
}
|
||||||
Vendored
+7
@@ -52,6 +52,13 @@ Changes:
|
|||||||
It does not run adaptive subdivision or grid balancing. The kernel caller bounds
|
It does not run adaptive subdivision or grid balancing. The kernel caller bounds
|
||||||
projection complexity and total allocation before using it; ordinary modelling
|
projection complexity and total allocation before using it; ordinary modelling
|
||||||
tessellation keeps its existing accuracy policy.
|
tessellation keeps its existing accuracy policy.
|
||||||
|
- `tessellation/triangulation.rs`: exact, robust and provisional-preview projections prefer an
|
||||||
|
equivalent in-domain parameter at collapsed surface edges, and snap to exact bounded
|
||||||
|
endpoints only at collapsed edges when that improves the spatial fit within the existing
|
||||||
|
search tolerance. Closed spline seams retain their distinct parameter endpoints.
|
||||||
|
This prevents huge arbitrary pole parameters from corrupting trimmed meshes (#796),
|
||||||
|
while retaining genuine extrapolation for unbounded planes. Work is bounded to four
|
||||||
|
endpoint evaluations per projected point; no search or tessellation tolerance is relaxed.
|
||||||
- `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose
|
- `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose
|
||||||
parameter box is clear of the point (it tested every segment of every loop, so a face with
|
parameter box is clear of the point (it tested every segment of every loop, so a face with
|
||||||
hundreds of holes meshed in quadratic time).
|
hundreds of holes meshed in quadratic time).
|
||||||
|
|||||||
+46
-14
@@ -46,17 +46,47 @@ fn clamped<S: ParametricSurface>(surface: &S, (u, v): (f64, f64)) -> (f64, f64)
|
|||||||
(c(u, ur, surface.u_period()), c(v, vr, surface.v_period()))
|
(c(u, ur, surface.u_period()), c(v, vr, surface.v_period()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SolidCraft: a nearest-parameter search can run off a B-spline's domain and extrapolate to
|
/// SolidCraft: exact and nearest searches can run off a B-spline's domain, especially at a
|
||||||
/// thousands of parameter units (meshing that grid never finishes). Outside the nominal domain
|
/// collapsed edge. Prefer a bounded equivalent within the existing spatial search tolerance;
|
||||||
/// keep the result only if it is really closer to the point than the clamped one (planes report
|
/// otherwise retain extrapolation only when it is closer (planes have a nominal unit range
|
||||||
/// a nominal unit range but extend for ever).
|
/// but extend for ever). At most four boundary candidates are evaluated, with no new search.
|
||||||
fn sane<S: RobustMeshableSurface>(surface: &S, point: Point3, uv: (f64, f64)) -> (f64, f64) {
|
fn sane<S: ParametricSurface3D>(surface: &S, point: Point3, uv: (f64, f64)) -> (f64, f64) {
|
||||||
if in_domain(surface, uv) {
|
|
||||||
return uv;
|
|
||||||
}
|
|
||||||
let c = clamped(surface, uv);
|
let c = clamped(surface, uv);
|
||||||
let d = |(u, v): (f64, f64)| surface.subs(u, v).distance2(point);
|
let d = |(u, v): (f64, f64)| surface.subs(u, v).distance2(point);
|
||||||
if d(uv) <= d(c) { uv } else { c }
|
let (mut best, mut error) = (uv, d(uv));
|
||||||
|
let dc = d(c);
|
||||||
|
let collapsed = |(u, v): (f64, f64), axis| {
|
||||||
|
let tangent = if axis == 0 { surface.vder(u, v) } else { surface.uder(u, v) };
|
||||||
|
tangent.magnitude2() <= TOLERANCE2
|
||||||
|
};
|
||||||
|
if !in_domain(surface, uv)
|
||||||
|
&& (dc < error || (dc <= TOLERANCE2 && (collapsed(c, 0) || collapsed(c, 1)))) {
|
||||||
|
(best, error) = (c, dc);
|
||||||
|
}
|
||||||
|
// SolidCraft: at a collapsed boundary an exact search can return arbitrary,
|
||||||
|
// enormous parameters, or stop just inside the pole. Prefer an equivalent
|
||||||
|
// in-domain point and the exact boundary when it improves the spatial fit.
|
||||||
|
// A plane's nominal unit box must not clip genuinely unbounded geometry.
|
||||||
|
let (ur, vr) = surface.try_range_tuple();
|
||||||
|
for (axis, range, period) in [(0, ur, surface.u_period()), (1, vr, surface.v_period())] {
|
||||||
|
if period.is_some() { continue; }
|
||||||
|
if let Some((a, b)) = range {
|
||||||
|
for end in [a, b] {
|
||||||
|
if !end.is_finite() { continue; }
|
||||||
|
let coordinate = if axis == 0 { best.0 } else { best.1 };
|
||||||
|
let other = if end == a { b } else { a };
|
||||||
|
if (coordinate - end).abs() > (coordinate - other).abs() { continue; }
|
||||||
|
let candidate = if axis == 0 { (end, best.1) } else { (best.0, end) };
|
||||||
|
let distance = d(candidate);
|
||||||
|
// A closed spline seam has equivalent endpoints too, but moving
|
||||||
|
// between them changes the trim loop. Only canonicalize poles.
|
||||||
|
if distance <= TOLERANCE2 && distance < error && collapsed(candidate, axis) {
|
||||||
|
(best, error) = (candidate, distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn by_search_parameter<S>(
|
pub(super) fn by_search_parameter<S>(
|
||||||
@@ -83,8 +113,8 @@ where
|
|||||||
surface
|
surface
|
||||||
.search_parameter(point, hint, 100)
|
.search_parameter(point, hint, 100)
|
||||||
.or_else(|| surface.search_parameter(point, None, 100))
|
.or_else(|| surface.search_parameter(point, None, 100))
|
||||||
.or_else(|| surface.search_nearest_parameter(point, hint, 100).map(|uv| sane(surface, point, uv)))
|
.or_else(|| surface.search_nearest_parameter(point, hint, 100))
|
||||||
.or_else(|| surface.search_nearest_parameter(point, None, 100).map(|uv| sane(surface, point, uv)))
|
.or_else(|| surface.search_nearest_parameter(point, None, 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tessellates faces
|
/// Tessellates faces
|
||||||
@@ -393,7 +423,9 @@ impl PolyBoundaryPiece {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|pt| {
|
.flat_map(|pt| {
|
||||||
let (mut u, mut v) = match sp(surface, pt, previous) {
|
let (mut u, mut v) = match sp(surface, pt, previous) {
|
||||||
Some(hint) => hint,
|
// SolidCraft: validate exact and nearest results alike, once per
|
||||||
|
// boundary point, including the bounded provisional preview.
|
||||||
|
Some(hint) => sane(surface, pt, hint),
|
||||||
None => return vec![None],
|
None => return vec![None],
|
||||||
};
|
};
|
||||||
if let (Some(up), Some((u0, _))) = (up, previous) {
|
if let (Some(up), Some((u0, _))) = (up, previous) {
|
||||||
@@ -704,8 +736,8 @@ pub(super) fn preview_face<S: RobustMeshableSurface>(surface: &S, loops: &[Vec<P
|
|||||||
let search = |surface: &S, point, hint| {
|
let search = |surface: &S, point, hint| {
|
||||||
surface.search_parameter(point, hint, 16)
|
surface.search_parameter(point, hint, 16)
|
||||||
.or_else(|| surface.search_parameter(point, None, 16))
|
.or_else(|| surface.search_parameter(point, None, 16))
|
||||||
.or_else(|| surface.search_nearest_parameter(point, hint, 16).map(|uv| sane(surface, point, uv)))
|
.or_else(|| surface.search_nearest_parameter(point, hint, 16))
|
||||||
.or_else(|| surface.search_nearest_parameter(point, None, 16).map(|uv| sane(surface, point, uv)))
|
.or_else(|| surface.search_nearest_parameter(point, None, 16))
|
||||||
};
|
};
|
||||||
let pieces = loops.iter().map(|w| {
|
let pieces = loops.iter().map(|w| {
|
||||||
PolyBoundaryPiece::try_new(surface, std::iter::once(PolylineCurve(w.clone())), search)
|
PolyBoundaryPiece::try_new(surface, std::iter::once(PolylineCurve(w.clone())), search)
|
||||||
|
|||||||
Reference in new issue
Block a user