Tessellation: a curved face's grid reads the quarter turn from its surface, not its parameter

uv::divide started every curved face from ceil(range / (pi/2)) lines, as if the parameter were
an angle. A spline's is not: SMLib holds a 90 degree bend as one rational quadratic span with
v in [0, pi/2], and the far pcurve of its boundary gives v = -6.7e-13, so the range is a
rounding over a quarter, the count rounds up to 2 and the bend gets a line at 45 degrees
whatever the tolerance (display_mesh_tests::a_small_bend_on_a_large_sheet_is_meshed_round:
3 angles across the bend at the chord tolerance instead of 2).

The division now starts from the whole range, and the limits refuse an interval that spans
more than a quarter turn of its iso-lines, read from the points as an edge segment's is
(edges::QUARTER_TURN: the half chords of an arc turning theta differ by theta/2, a closed
iso-line's by pi). On a revolved surface the parameter is the angle, so the lines are the
same as before; an iso-line within a millionth of the tolerance of one point (a pole) has no
turn to read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-29 22:20:20 -04:00
committed by Irakli Kourtis
parent 1af128a69a
commit 368b34e3bc
2 changed files with 43 additions and 16 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ const MAX_DEPTH: u32 = 24;
/// The most of its curve's turn one segment may span (a quarter turn, with rounding room): the
/// boundary of every face stays a polygon with the face's area, as a closed curve's first four
/// segments keep it.
const QUARTER_TURN: f64 = std::f64::consts::FRAC_PI_2 * (1.0 + 1e-9);
pub(crate) const QUARTER_TURN: f64 = std::f64::consts::FRAC_PI_2 * (1.0 + 1e-9);
/// An edge's nodes, from its start vertex (interval min) to its end vertex (interval max).
pub(crate) struct Nodes {
@@ -163,7 +163,7 @@ pub(crate) fn split_segments(b: &brep::Brep, ei: usize, nodes: &mut Nodes, segs:
}
/// Angle (radians) between two directions; zero when either is empty.
fn turn(a: Vec3, b: Vec3) -> f64 {
pub(crate) fn turn(a: Vec3, b: Vec3) -> f64 {
let (la, lb) = (a.len(), b.len());
if !(la > 0.0 && lb > 0.0) {
return 0.0;
+41 -14
View File
@@ -5,6 +5,7 @@
use solidcraft_geom::tess::EvalSurface;
pub(crate) use solidcraft_geom::tess::uv::{Domain, degenerate_within, domain, unwrap};
use super::edges::{QUARTER_TURN, turn};
use super::size::Rules;
/// Division of `range` along direction `d` so that between neighbouring lines the surface sags
@@ -16,19 +17,10 @@ pub(crate) fn divide(s: &EvalSurface, d: usize, range: (f64, f64), across: (f64,
if !(a1 > a0) {
return vec![a0, a1];
}
let flat = limits(s, d, across, rules, h);
// Start from quarter turns at most, so a closed iso-line is not taken for a flat one (a
// plane's parameters are lengths and its lines are never closed).
let initial = if matches!(s, EvalSurface::Plane { .. }) { 1 } else { ((a1 - a0) / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 64.0) as u32 };
// Both ends exactly (a0 + (a1 − a0) · 1 may round away from a1): the end lines are the
// face's own parameter box, which the boundary nodes sit on.
let starts: Vec<f64> = (0..=initial)
.map(|i| {
let t = f64::from(i) / f64::from(initial);
a0 * (1.0 - t) + a1 * t
})
.collect();
split(&starts, &flat, max_lines)
// The whole range first: the limits split it, and no interval spans more than a quarter
// turn of its iso-lines, so a closed iso-line is never taken for a flat one. Both ends
// exactly: the end lines are the face's own parameter box, which the boundary nodes sit on.
split(&[a0, a1], &limits(s, d, across, rules, h), max_lines)
}
/// `lines` (increasing, ends included) with every interval that fails the same limits as
@@ -80,8 +72,14 @@ pub(crate) fn fine_between(s: &EvalSurface, rules: &Rules, h: f64, a: [f64; 2],
}
}
/// An iso-line's extent below which it is a point, as a fraction of the chord tolerance (which
/// is never finer than a millionth of the body, so this is far above rounding).
const POINT: f64 = 1e-6;
/// Whether the surface between the iso-lines at `a` and `b` (direction `d`, sampled along
/// iso-lines across `across`) is within the tolerance, the side length `h` and the facet angle.
/// iso-lines across `across`) is within the tolerance, the side length `h` and the facet angle,
/// and spans at most a quarter turn of each iso-line (as an edge segment does, see
/// [`QUARTER_TURN`]): a turn read from the points, since a spline's parameter is no angle.
fn limits<'a>(s: &'a EvalSurface, d: usize, across: (f64, f64), rules: &'a Rules, h: f64) -> impl Fn(f64, f64) -> bool + 'a {
let tol = rules.tol;
let uv = move |x: f64, y: f64| if d == 0 { (x, y) } else { (y, x) };
@@ -105,6 +103,15 @@ fn limits<'a>(s: &'a EvalSurface, d: usize, across: (f64, f64), rules: &'a Rules
if sag > tol {
return false;
}
// The two half chords of an arc turning θ differ by θ/2; a closed iso-line's by π.
// An iso-line within a millionth of the tolerance of one point (a pole) turns no
// amount: its directions are rounding.
if let Some(pm) = at(0.5 * (a + b), y)
&& pa.dist(pm).max(pm.dist(pb)) > POINT * tol
&& 2.0 * turn(pm - pa, pb - pm) > QUARTER_TURN
{
return false;
}
// At a pole the normal is undefined: take it from just inside the segment.
let na = normal(a, y).or_else(|| normal(a + 1e-4 * (b - a), y));
let nb = normal(b, y).or_else(|| normal(b - 1e-4 * (b - a), y));
@@ -147,6 +154,26 @@ mod tests {
assert_eq!(along.len(), 9);
}
/// A quarter of a cylinder as a rational quadratic spline (as SMLib holds a bend), its range a
/// rounding over π/2 as a boundary's pcurves give it: the quarter turn is read from the
/// surface, not the parameter, so at a tolerance above its sag it is one interval; and a whole
/// turn is still divided into quarters however coarse the tolerance.
#[test]
fn a_quarter_turn_is_read_from_the_surface_not_the_parameter() {
let w = std::f64::consts::FRAC_1_SQRT_2;
let q = std::f64::consts::FRAC_PI_2;
let s = EvalSurface::nurbs(
[1, 2],
[vec![0.0, 0.0, 40.0, 40.0], vec![0.0, 0.0, 0.0, q, q, q]],
[2, 3],
vec![[1.0, 0.0, 0.0, 1.0], [1.0, 0.0, 40.0, 1.0], [w, w, 0.0, w], [w, w, 40.0 * w, w], [0.0, 1.0, 0.0, 1.0], [0.0, 1.0, 40.0, 1.0]],
);
let coarse = Rules { tol: 3.0, max_len: f64::INFINITY, turn: None };
assert_eq!(divide(&s, 1, (-6.7e-13, q), (0.0, 40.0), &coarse, f64::INFINITY, 10_000), vec![-6.7e-13, q]);
let whole = divide(&cylinder(), 1, (0.0, std::f64::consts::TAU), (0.0, 1.0), &Rules { tol: 100.0, ..coarse }, f64::INFINITY, 10_000);
assert_eq!(whole.len(), 5, "{whole:?}");
}
/// The first and last lines are the range's ends to the last bit: the boundary nodes sit on
/// them, and a line a rounding inside would be taken for an interior one (a drill point's
/// cone, where a0 + (a1 − a0) is not a1).