axiolid_reference/
tessellate.rs

1//! Scalar reference tessellation of surfaces (ADR 0012).
2//!
3//! # What this closes
4//!
5//! `axiolid-tessellation-contract` declares a `Tessellator` trait that nothing
6//! implemented, so no curved face could ever become triangles. A B-rep with a
7//! cylindrical or spline face was unreachable, which is most curved
8//! geometry in a real building model.
9//!
10//! # Method
11//!
12//! Uniform parameter sampling with the step chosen from a measured sagitta,
13//! not a guessed segment count. For each parameter direction the maximum
14//! deviation of the chord from the surface is probed at the midpoint, and the
15//! step is halved until the deviation is within the chord budget or a caller
16//! budget is exhausted. That is the same contract `flatten2` uses for curves,
17//! so a cylinder tessellated here and its silhouette circle flattened there
18//! agree on what a tolerance means.
19//!
20//! Adaptive *quad-tree* refinement would use fewer triangles on surfaces with
21//! localised curvature. It is deliberately not done here: this is the
22//! reference implementation, and a uniform grid is the version whose output a
23//! human can predict and check by hand. An optimised backend may refine
24//! locally and be validated against this one.
25
26use axiolid_contracts::{GeomError, GeomResult};
27use axiolid_core::{Point3, Scalar};
28use axiolid_mesh::TriMesh;
29use axiolid_surface::Surface;
30
31use axiolid_evaluate::surface::{evaluate, Patch};
32
33/// Sampling budget for one surface.
34///
35/// Explicit rather than defaulted: the acceptable triangle count depends on
36/// what the mesh is for. A clash test wants a coarse conservative hull; a
37/// quantity takeoff wants convergence. The kernel does not know which.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct TessellationBudget {
40    /// Maximum chord deviation from the true surface.
41    pub chord_tolerance: Scalar,
42    /// Hard cap on samples per parameter direction.
43    pub max_samples_per_direction: usize,
44}
45
46impl TessellationBudget {
47    /// Construct a validated budget.
48    pub fn new(chord_tolerance: Scalar, max_samples_per_direction: usize) -> GeomResult<Self> {
49        if !(chord_tolerance.is_finite() && chord_tolerance > 0.0) {
50            return Err(GeomError::InvalidInput(format!(
51                "chord tolerance must be positive and finite, got {chord_tolerance}"
52            )));
53        }
54        if max_samples_per_direction < 2 {
55            return Err(GeomError::InvalidInput(format!(
56                "need at least 2 samples per direction, got {max_samples_per_direction}"
57            )));
58        }
59        Ok(Self {
60            chord_tolerance,
61            max_samples_per_direction,
62        })
63    }
64}
65
66/// A tessellated patch plus the evidence needed to judge it.
67#[derive(Debug, Clone)]
68pub struct TessellationOutcome {
69    /// The triangulated patch.
70    pub mesh: TriMesh,
71    /// Samples used along u.
72    pub u_samples: usize,
73    /// Samples used along v.
74    pub v_samples: usize,
75    /// Largest measured chord deviation, or `None` when the surface is flat
76    /// in that direction and no deviation was observable.
77    pub max_sagitta: Option<Scalar>,
78    /// Whether the budget was exhausted before the tolerance was met. A
79    /// caller measuring quantities must treat this as a failed measurement,
80    /// not a coarse one.
81    pub budget_exhausted: bool,
82}
83
84/// Tessellate one bounded surface patch.
85///
86/// The patch is required: an elementary surface is infinite, and there is no
87/// defensible default bound for one. Returning a guessed extent would be a
88/// silent modelling decision.
89pub fn tessellate_patch(
90    surface: &Surface,
91    patch: Patch,
92    budget: TessellationBudget,
93) -> GeomResult<TessellationOutcome> {
94    let (nu, su) = resolve_samples(surface, patch, budget, Direction::U)?;
95    let (nv, sv) = resolve_samples(surface, patch, budget, Direction::V)?;
96    let exhausted =
97        nu >= budget.max_samples_per_direction || nv >= budget.max_samples_per_direction;
98
99    let mut positions = Vec::with_capacity(nu * nv);
100    for i in 0..nu {
101        let u = lerp(patch.u_start, patch.u_end, i, nu);
102        for j in 0..nv {
103            let v = lerp(patch.v_start, patch.v_end, j, nv);
104            positions.push(evaluate(surface, u, v)?);
105        }
106    }
107
108    // Grid connectivity. Each cell splits along the diagonal that keeps the
109    // two triangles closest in area, which is what keeps a highly anisotropic
110    // patch (a long thin cylinder band) from producing slivers.
111    let mut indices = Vec::with_capacity((nu - 1) * (nv - 1) * 6);
112    for i in 0..nu - 1 {
113        for j in 0..nv - 1 {
114            let a = (i * nv + j) as u32;
115            let b = (i * nv + j + 1) as u32;
116            let c = ((i + 1) * nv + j) as u32;
117            let d = ((i + 1) * nv + j + 1) as u32;
118            if shorter_diagonal_is_ad(&positions, a, b, c, d) {
119                indices.extend_from_slice(&[a, c, d]);
120                indices.extend_from_slice(&[a, d, b]);
121            } else {
122                indices.extend_from_slice(&[a, c, b]);
123                indices.extend_from_slice(&[b, c, d]);
124            }
125        }
126    }
127
128    let max_sagitta = match (su, sv) {
129        (Some(a), Some(b)) => Some(a.max(b)),
130        (Some(a), None) => Some(a),
131        (None, Some(b)) => Some(b),
132        (None, None) => None,
133    };
134
135    Ok(TessellationOutcome {
136        mesh: TriMesh::new(positions, indices),
137        u_samples: nu,
138        v_samples: nv,
139        max_sagitta,
140        budget_exhausted: exhausted,
141    })
142}
143
144/// Which parameter direction a sample count applies to.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum Direction {
147    /// First surface parameter.
148    U,
149    /// Second surface parameter.
150    V,
151}
152
153/// Uniform sample position `i` of `n` across `[a, b]`.
154///
155/// Computed from the endpoints rather than by accumulating a step so the last
156/// sample is exactly `b`. An accumulated step leaves a gap that reopens a
157/// closed surface into a sliver.
158fn lerp(a: Scalar, b: Scalar, i: usize, n: usize) -> Scalar {
159    if n <= 1 {
160        return a;
161    }
162    let t = i as Scalar / (n - 1) as Scalar;
163    a + (b - a) * t
164}
165
166/// Whether the `a-d` diagonal is shorter than `b-c` for one grid cell.
167///
168/// Splitting along the shorter diagonal keeps the two triangles closer in
169/// shape. On an anisotropic patch the wrong choice produces slivers, which
170/// `audit_mesh` then reports as degenerate and every downstream measure
171/// refuses.
172fn shorter_diagonal_is_ad(positions: &[Point3], a: u32, b: u32, c: u32, d: u32) -> bool {
173    let p = |i: u32| positions[i as usize];
174    (p(a) - p(d)).length_squared() <= (p(b) - p(c)).length_squared()
175}
176
177/// Choose a sample count for one direction by measuring, not guessing.
178///
179/// Doubles the interval count until the midpoint sagitta of every span is
180/// within the chord budget. Returns the count and the largest measured
181/// deviation; `None` means no deviation was observable, which is the honest
182/// answer for a direction in which the surface is straight.
183fn resolve_samples(
184    surface: &Surface,
185    patch: Patch,
186    budget: TessellationBudget,
187    direction: Direction,
188) -> GeomResult<(usize, Option<Scalar>)> {
189    let mut n = 2usize;
190    loop {
191        let worst = worst_sagitta(surface, patch, direction, n)?;
192        match worst {
193            Some(s) if s > budget.chord_tolerance => {}
194            // Within tolerance, or flat: this count stands.
195            _ => return Ok((n, worst)),
196        }
197        if n >= budget.max_samples_per_direction {
198            return Ok((n, worst));
199        }
200        // Grow the interval count, never exceeding the caller's cap.
201        n = (2 * (n - 1) + 1).min(budget.max_samples_per_direction);
202    }
203}
204
205/// Largest chord-to-surface deviation over all spans in one direction.
206///
207/// The deviation is probed at each span midpoint against the chord joining the
208/// span ends, sampled at a fixed set of positions in the other parameter so an
209/// error that only appears away from the patch border is still seen.
210fn worst_sagitta(
211    surface: &Surface,
212    patch: Patch,
213    direction: Direction,
214    n: usize,
215) -> GeomResult<Option<Scalar>> {
216    const CROSS_PROBES: usize = 3;
217    let mut worst: Option<Scalar> = None;
218    for span in 0..n.saturating_sub(1) {
219        for k in 0..CROSS_PROBES {
220            let (a, b, mid, other) = span_probe(patch, direction, span, n, k, CROSS_PROBES);
221            let pa = eval_at(surface, direction, a, other)?;
222            let pb = eval_at(surface, direction, b, other)?;
223            let pm = eval_at(surface, direction, mid, other)?;
224            let deviation = point_to_segment(pm, pa, pb);
225            worst = Some(worst.map_or(deviation, |w: Scalar| w.max(deviation)));
226        }
227    }
228    Ok(worst)
229}
230
231/// Parameters for one sagitta probe: span ends, span midpoint, cross position.
232fn span_probe(
233    patch: Patch,
234    direction: Direction,
235    span: usize,
236    n: usize,
237    k: usize,
238    probes: usize,
239) -> (Scalar, Scalar, Scalar, Scalar) {
240    let (start, end, cross_start, cross_end) = match direction {
241        Direction::U => (patch.u_start, patch.u_end, patch.v_start, patch.v_end),
242        Direction::V => (patch.v_start, patch.v_end, patch.u_start, patch.u_end),
243    };
244    let a = lerp(start, end, span, n);
245    let b = lerp(start, end, span + 1, n);
246    let other = lerp(cross_start, cross_end, k, probes);
247    (a, b, 0.5 * (a + b), other)
248}
249
250/// Evaluate with the two parameters ordered by `direction`.
251fn eval_at(
252    surface: &Surface,
253    direction: Direction,
254    along: Scalar,
255    other: Scalar,
256) -> GeomResult<Point3> {
257    match direction {
258        Direction::U => evaluate(surface, along, other),
259        Direction::V => evaluate(surface, other, along),
260    }
261}
262
263/// Distance from `m` to segment `a-b`.
264///
265/// Projects onto the segment and clamps, so a midpoint that falls beyond an
266/// end reports the true distance rather than the distance to the infinite
267/// line, which would understate the deviation.
268fn point_to_segment(m: Point3, a: Point3, b: Point3) -> Scalar {
269    let ab = b - a;
270    let len2 = ab.length_squared();
271    if len2 <= 0.0 {
272        return (m - a).length();
273    }
274    // Clamped because this measures distance to a finite SEGMENT, not to its
275    // infinite line. `tessellate_patch` only ever passes a chord midpoint,
276    // whose foot is always interior, so the clamp is unreachable from there
277    // and no mutation probe can kill it. It is kept for callers that pass an
278    // arbitrary point, where dropping it would silently under-report.
279    let t = ((m - a).dot(ab) / len2).clamp(0.0, 1.0);
280    (m - (a + ab * t)).length()
281}