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}