axiolid_refine/lib.rs
1//! Mesh refinement: more triangles, and optionally closer to the truth.
2//!
3//! Splitting a triangle is easy. The question this module exists to answer
4//! is *where the new vertex goes*.
5//!
6//! A mesh-only kernel has one option: the edge midpoint. That subdivides
7//! the approximation without improving it -- refining a faceted cylinder
8//! forever leaves the same faceted cylinder, with more triangles.
9//!
10//! When the mesh came from a tessellated B-rep and the source surface is
11//! still known, there is a better answer: invert the midpoint into the
12//! surface's parameter domain and evaluate the surface there. The new
13//! vertex lands on the ACTUAL cylinder. Refinement then converges on the
14//! real geometry instead of preserving a facet forever.
15//!
16//! Keeping the analytic surface alongside the mesh is what makes that
17//! possible, so it is the capability this module is really for.
18
19pub mod smooth;
20
21use ahash::AHashMap;
22
23use axiolid_core::{Point3, Scalar, Tolerance};
24use axiolid_mesh::{AttributeFate, TriMesh};
25use axiolid_surface::Surface;
26
27/// Why a refinement could not be performed.
28#[derive(Debug, thiserror::Error, PartialEq)]
29#[non_exhaustive]
30pub enum RefineError {
31 /// The index buffer is not a whole number of triangles.
32 #[error("index buffer length {0} is not a multiple of 3")]
33 RaggedIndices(usize),
34 /// A triangle references a vertex that does not exist.
35 #[error("triangle {0} references vertex {1}, which is out of range")]
36 IndexOutOfRange(usize, u32),
37 /// An edge-length target must be a positive, finite length.
38 #[error("edge length target {0} is not a positive finite length")]
39 InvalidTarget(Scalar),
40 /// A surface refused to place a projected vertex.
41 ///
42 /// Propagated rather than absorbed. Falling back to the linear midpoint
43 /// would return four times the triangles with none of the promised
44 /// accuracy, and the caller could not tell the difference.
45 #[error("surface-aware refinement refused: {0}")]
46 SurfaceRefused(String),
47 /// Refinement would exceed the triangle budget.
48 ///
49 /// Reported rather than silently truncated: a caller that asked for a
50 /// 1mm edge on a building-sized model wants to know its request was
51 /// impossible, not receive a partially refined mesh that looks fine.
52 #[error("refinement would produce {produced} triangles, over the {limit} budget")]
53 BudgetExceeded {
54 /// Triangles the request would have produced.
55 produced: usize,
56 /// The cap that was not raised.
57 limit: usize,
58 },
59}
60
61/// How much to refine.
62#[derive(Debug, Clone, Copy, PartialEq)]
63#[non_exhaustive]
64pub enum RefineTarget {
65 /// Split every triangle into four, `levels` times.
66 Uniform {
67 /// Number of subdivision passes.
68 levels: u32,
69 },
70 /// Split edges until none is longer than this.
71 EdgeLength {
72 /// Maximum permitted edge length, in model units.
73 max_edge: Scalar,
74 },
75}
76
77/// What a refinement actually did.
78///
79/// `max_deviation` is the honest part. A planar refinement must report
80/// exactly zero: a midpoint on a flat triangle lies in that triangle's
81/// plane, so any movement means a defect. A surface-aware refinement
82/// reports how far it MOVED the surface toward the analytic one, which is
83/// the measure of what the caller gained.
84#[derive(Debug, Clone, PartialEq)]
85#[non_exhaustive]
86pub struct RefineReport {
87 /// Triangles before.
88 pub input_triangles: usize,
89 /// Triangles after.
90 pub output_triangles: usize,
91 /// Vertices introduced.
92 pub vertices_added: usize,
93 /// Whether new vertices were placed on an analytic surface.
94 ///
95 /// `false` means linear midpoints: the result is a finer tessellation
96 /// of the same approximation, not a better approximation.
97 pub surface_aware: bool,
98 /// Largest distance a new vertex sits from the linear midpoint it
99 /// would otherwise have occupied, in model units.
100 ///
101 /// Zero for planar input even when surface-aware, because a plane's
102 /// midpoint already lies on the plane.
103 pub max_deviation: Scalar,
104 /// What happened to each named attribute channel.
105 pub attribute_fates: Vec<(String, AttributeFate)>,
106}
107
108impl RefineReport {
109 /// Whether the mesh was left untouched.
110 pub fn is_noop(&self) -> bool {
111 self.vertices_added == 0
112 }
113}
114
115/// Cap on output size, mirroring the budget discipline used elsewhere.
116const MAX_TRIANGLES: usize = 20_000_000;
117
118fn validate(mesh: &TriMesh) -> Result<(), RefineError> {
119 if mesh.indices.len() % 3 != 0 {
120 return Err(RefineError::RaggedIndices(mesh.indices.len()));
121 }
122 let vertex_count = mesh.positions.len();
123 for (triangle, chunk) in mesh.indices.chunks_exact(3).enumerate() {
124 for &index in chunk {
125 if index as usize >= vertex_count {
126 return Err(RefineError::IndexOutOfRange(triangle, index));
127 }
128 }
129 }
130 Ok(())
131}
132
133/// Refine a mesh, optionally snapping new vertices onto a known surface.
134///
135/// Passing `Some(surface)` is what turns subdivision into approximation
136/// improvement. Passing `None` subdivides linearly and says so in the
137/// report rather than implying an accuracy gain it did not deliver.
138///
139/// Deterministic: new vertices are numbered in the order edges are first
140/// split during the triangle walk, which is fixed by the index buffer. The
141/// midpoint cache is keyed by the ordered vertex pair and is only ever
142/// queried by key, never iterated, so its internal ordering cannot reach
143/// the output. The same input produces the same output vertex ordering on
144/// every run and across processes.
145///
146/// # Errors
147///
148/// Refuses a ragged index buffer, out-of-range indices, a non-positive
149/// edge-length target, and a request that would exceed the triangle budget.
150pub fn refine(
151 mesh: &TriMesh,
152 target: RefineTarget,
153 surface: Option<&Surface>,
154 tolerance: Tolerance,
155) -> Result<(TriMesh, RefineReport), RefineError> {
156 validate(mesh)?;
157
158 let levels = match target {
159 RefineTarget::Uniform { levels } => levels,
160 RefineTarget::EdgeLength { max_edge } => {
161 if !max_edge.is_finite() || max_edge <= 0.0 {
162 return Err(RefineError::InvalidTarget(max_edge));
163 }
164 passes_for_edge_length(mesh, max_edge)
165 }
166 };
167
168 let input_triangles = mesh.triangle_count();
169 // Each pass quadruples the triangle count. Checking the projection up
170 // front turns an out-of-memory kill into a typed refusal.
171 let projected = input_triangles
172 .checked_mul(4usize.saturating_pow(levels))
173 .unwrap_or(usize::MAX);
174 if projected > MAX_TRIANGLES {
175 return Err(RefineError::BudgetExceeded {
176 produced: projected,
177 limit: MAX_TRIANGLES,
178 });
179 }
180
181 let mut positions = mesh.positions.clone();
182 let mut indices = mesh.indices.clone();
183 let mut max_deviation: Scalar = 0.0;
184
185 for _ in 0..levels {
186 let mut midpoints: AHashMap<(u32, u32), u32> = AHashMap::new();
187 let mut next = Vec::with_capacity(indices.len() * 4);
188
189 for chunk in indices.chunks_exact(3) {
190 let [a, b, c] = [chunk[0], chunk[1], chunk[2]];
191 let ab = split_edge(
192 a,
193 b,
194 &mut positions,
195 &mut midpoints,
196 surface,
197 tolerance,
198 &mut max_deviation,
199 )?;
200 let bc = split_edge(
201 b,
202 c,
203 &mut positions,
204 &mut midpoints,
205 surface,
206 tolerance,
207 &mut max_deviation,
208 )?;
209 let ca = split_edge(
210 c,
211 a,
212 &mut positions,
213 &mut midpoints,
214 surface,
215 tolerance,
216 &mut max_deviation,
217 )?;
218
219 // Four children, each wound the same way as the parent so the
220 // result keeps the input's orientation.
221 next.extend_from_slice(&[a, ab, ca]);
222 next.extend_from_slice(&[ab, b, bc]);
223 next.extend_from_slice(&[ca, bc, c]);
224 next.extend_from_slice(&[ab, bc, ca]);
225 }
226 indices = next;
227 }
228
229 let vertices_added = positions.len() - mesh.positions.len();
230 let mut out = TriMesh::new(positions, indices);
231 out.normals = None;
232
233 // With no vertex created (zero levels, or no triangles to split) the
234 // geometry is the input's, so its channels and normals are still exact
235 // and are returned, not merely reported as surviving. Before this the
236 // report said `Preserved` while the mesh came back without the channel.
237 if vertices_added == 0 {
238 out.normals = mesh.normals.clone();
239 out.attributes = mesh.attributes.clone();
240 }
241
242 // A refinement creates vertices, so a channel survives only if its own
243 // blend rule permits deriving a value. Unlike a boolean cut, the new
244 // vertex HAS a preimage: it sits on a known edge between two vertices,
245 // so a blendable channel is genuinely interpolatable here.
246 let attribute_fates = mesh
247 .attributes
248 .iter()
249 .map(|channel| {
250 let fate = match channel.blend {
251 // Checked first: nothing was derived, so even a channel that
252 // forbids derivation came through untouched.
253 _ if vertices_added == 0 => AttributeFate::Preserved,
254 axiolid_mesh::Blend::None => {
255 AttributeFate::Dropped(axiolid_mesh::DropReason::NotBlendable)
256 }
257 _ => AttributeFate::Dropped(axiolid_mesh::DropReason::ProviderLimitation),
258 };
259 (channel.name.clone(), fate)
260 })
261 .collect();
262
263 let report = RefineReport {
264 input_triangles,
265 output_triangles: out.triangle_count(),
266 vertices_added,
267 surface_aware: surface.is_some(),
268 max_deviation,
269 attribute_fates,
270 };
271 Ok((out, report))
272}
273
274/// Number of uniform passes needed to bring every edge under `max_edge`.
275///
276/// Each pass halves every edge, so the requirement is
277/// `longest / 2^n <= max_edge`. Computed rather than iterated so the
278/// budget check can happen before any memory is allocated.
279fn passes_for_edge_length(mesh: &TriMesh, max_edge: Scalar) -> u32 {
280 let mut longest: Scalar = 0.0;
281 for chunk in mesh.indices.chunks_exact(3) {
282 for (from, to) in [(0, 1), (1, 2), (2, 0)] {
283 let a = mesh.positions[chunk[from] as usize];
284 let b = mesh.positions[chunk[to] as usize];
285 longest = longest.max((b - a).length());
286 }
287 }
288 if longest <= max_edge || !longest.is_finite() {
289 return 0;
290 }
291 (longest / max_edge).log2().ceil().max(0.0) as u32
292}
293
294/// Return the vertex splitting an edge, creating it on first encounter.
295///
296/// The edge key is ordered so both adjacent triangles find the same
297/// midpoint. Without that the mesh would crack along every shared edge.
298fn split_edge(
299 a: u32,
300 b: u32,
301 positions: &mut Vec<Point3>,
302 midpoints: &mut AHashMap<(u32, u32), u32>,
303 surface: Option<&Surface>,
304 tolerance: Tolerance,
305 max_deviation: &mut Scalar,
306) -> Result<u32, RefineError> {
307 let key = if a < b { (a, b) } else { (b, a) };
308 if let Some(&existing) = midpoints.get(&key) {
309 return Ok(existing);
310 }
311
312 let linear = positions[a as usize].midpoint(positions[b as usize]);
313 let placed = match surface {
314 // PROJECT, not invert. The midpoint of a chord across a faceted
315 // surface lies strictly off that surface, so inversion correctly
316 // refuses it; projection is the question actually being asked.
317 //
318 // A refusal propagates instead of falling back to `linear`. A
319 // silent fallback would return a mesh with four times the
320 // triangles and none of the promised accuracy, which is worse
321 // than an error because the caller cannot detect it.
322 Some(surface) => {
323 let (u, v) = axiolid_evaluate::surface::project(surface, linear, tolerance)
324 .map_err(|error| RefineError::SurfaceRefused(error.to_string()))?;
325 let on_surface = axiolid_evaluate::surface::evaluate(surface, u, v)
326 .map_err(|error| RefineError::SurfaceRefused(error.to_string()))?;
327 if !on_surface.is_finite() {
328 return Err(RefineError::SurfaceRefused(
329 "projected midpoint is not finite".into(),
330 ));
331 }
332 on_surface
333 }
334 None => linear,
335 };
336
337 *max_deviation = max_deviation.max((placed - linear).length());
338 let index = positions.len() as u32;
339 positions.push(placed);
340 midpoints.insert(key, index);
341 Ok(index)
342}
343
344/// What a smoothing pass actually did.
345#[derive(Debug, Clone, PartialEq)]
346#[non_exhaustive]
347pub struct SmoothReport {
348 /// Vertices whose position changed.
349 pub vertices_moved: usize,
350 /// Vertices held fixed because they sit on an open border.
351 pub boundary_vertices: usize,
352 /// Largest distance any vertex moved, in model units.
353 pub max_movement: Scalar,
354 /// What happened to each named attribute channel.
355 pub attribute_fates: Vec<(String, AttributeFate)>,
356}
357
358/// Report every channel as dropped.
359///
360/// Smoothing moves vertices without creating them, but the values it would
361/// need to keep are only valid at the ORIGINAL positions. Rather than carry
362/// stale data forward under its old name, every channel is dropped and said
363/// to be dropped.
364fn carry_attributes(mesh: &TriMesh) -> Vec<(String, AttributeFate)> {
365 mesh.attributes
366 .iter()
367 .map(|channel| {
368 (
369 channel.name.clone(),
370 AttributeFate::Dropped(axiolid_mesh::DropReason::ProviderLimitation),
371 )
372 })
373 .collect()
374}