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}