Skip to main content

manifold_rust/
properties.rs

1// Copyright 2026 Lars Brubaker
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Phase 8: Properties — ported from src/properties.cpp
16//
17// Mesh property calculations: volume, surface area, curvature, convexity,
18// triangle validation, and degenerate detection.
19
20use crate::face_op::get_axis_aligned_projection;
21use crate::impl_mesh::ManifoldImpl;
22use crate::linalg::{cross, dot, length, IVec3, Vec3};
23use crate::math;
24use crate::polygon::ccw;
25use crate::types::K_TWO_PI;
26
27/// Which scalar property to compute over the mesh.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Property {
30    SurfaceArea,
31    Volume,
32}
33
34impl ManifoldImpl {
35    /// Compute a global scalar property (volume or surface area) using Kahan
36    /// summation for numerical stability.
37    pub fn get_property(&self, prop: Property) -> f64 {
38        if self.is_empty() {
39            return 0.0;
40        }
41
42        let mut value: f64 = 0.0;
43        let mut compensation: f64 = 0.0;
44
45        for tri in 0..self.num_tri() {
46            let sv = self.halfedge[3 * tri].start_vert;
47            if sv < 0 {
48                continue;
49            }
50            let v0 = self.vert_pos[sv as usize];
51            let v1 = self.vert_pos[self.halfedge[3 * tri + 1].start_vert as usize];
52            let v2 = self.vert_pos[self.halfedge[3 * tri + 2].start_vert as usize];
53
54            let value1 = match prop {
55                Property::Volume => {
56                    let cross_p = cross(v1 - v0, v2 - v0);
57                    dot(cross_p, v0) / 6.0
58                }
59                Property::SurfaceArea => {
60                    length(cross(v1 - v0, v2 - v0)) / 2.0
61                }
62            };
63
64            let t = value + value1;
65            compensation += (value - t) + value1;
66            value = t;
67        }
68        value + compensation
69    }
70
71    /// Returns true if all triangles are CCW relative to their face normals.
72    pub fn matches_tri_normals(&self) -> bool {
73        if self.halfedge.is_empty() || self.face_normal.len() != self.num_tri() {
74            return true;
75        }
76        for face in 0..self.num_tri() {
77            if self.halfedge[3 * face].paired_halfedge < 0 {
78                continue;
79            }
80
81            let projection = get_axis_aligned_projection(self.face_normal[face]);
82            let mut v = [crate::linalg::Vec2::new(0.0, 0.0); 3];
83            let mut max_d = f64::NEG_INFINITY;
84            let mut min_d = f64::INFINITY;
85            let mut any_non_finite = false;
86
87            for i in 0..3 {
88                let p = self.vert_pos[self.halfedge[3 * face + i].start_vert as usize];
89                v[i] = projection.apply(p);
90                let d = dot(p, self.face_normal[face]);
91                if !d.is_finite() {
92                    any_non_finite = true;
93                    break;
94                }
95                max_d = max_d.max(d);
96                min_d = min_d.min(d);
97            }
98
99            if any_non_finite {
100                continue;
101            }
102            if max_d - min_d > 2.0 * self.tolerance {
103                return false;
104            }
105
106            let winding = ccw(v[0], v[1], v[2], self.epsilon * 2.0);
107            if winding < 0 {
108                return false;
109            }
110        }
111        true
112    }
113
114    /// Returns the number of triangles that are colinear within epsilon.
115    pub fn num_degenerate_tris(&self) -> i32 {
116        if self.halfedge.is_empty() || self.face_normal.len() != self.num_tri() {
117            return 0;
118        }
119        let mut count = 0i32;
120        for face in 0..self.num_tri() {
121            if self.halfedge[3 * face].paired_halfedge < 0 {
122                count += 1;
123                continue;
124            }
125
126            let projection = get_axis_aligned_projection(self.face_normal[face]);
127            let mut v = [crate::linalg::Vec2::new(0.0, 0.0); 3];
128            for i in 0..3 {
129                v[i] = projection.apply(
130                    self.vert_pos[self.halfedge[3 * face + i].start_vert as usize],
131                );
132            }
133
134            // Per #1671: degeneracy is judged within tolerance_, not epsilon_.
135            let winding = ccw(v[0], v[1], v[2], self.tolerance / 2.0);
136            if winding == 0 {
137                count += 1;
138            }
139        }
140        count
141    }
142
143    /// Returns true if the manifold is genus 0 and contains no concave edges.
144    pub fn is_convex(&self) -> bool {
145        let chi = self.num_vert() as i64 - self.num_edge() as i64 + self.num_tri() as i64;
146        let genus = 1 - chi / 2;
147        if genus != 0 {
148            return false;
149        }
150
151        let nb_edges = self.halfedge.len();
152        for idx in 0..nb_edges {
153            let edge = &self.halfedge[idx];
154            if !edge.is_forward() {
155                continue;
156            }
157
158            let normal0 = self.face_normal[idx / 3];
159            let normal1 = self.face_normal[edge.paired_halfedge as usize / 3];
160
161            if normal0 == normal1 {
162                continue;
163            }
164
165            let edge_vec =
166                self.vert_pos[edge.end_vert as usize] - self.vert_pos[edge.start_vert as usize];
167            let convex = dot(edge_vec, cross(normal0, normal1)) > 0.0;
168            if !convex {
169                return false;
170            }
171        }
172        true
173    }
174
175    /// Compute Gaussian and/or mean curvature per vertex, storing results
176    /// into the property channels at the given indices. Pass -1 to skip.
177    pub fn calculate_curvature(&mut self, gaussian_idx: i32, mean_idx: i32) {
178        if self.is_empty() {
179            return;
180        }
181        if gaussian_idx < 0 && mean_idx < 0 {
182            return;
183        }
184
185        let num_vert = self.num_vert();
186        let mut mean_curvature = vec![0.0f64; num_vert];
187        let mut gaussian_curvature = vec![K_TWO_PI; num_vert];
188        let mut area = vec![0.0f64; num_vert];
189        let mut degree = vec![0.0f64; num_vert];
190
191        for tri in 0..self.num_tri() {
192            let mut edge_dirs = [Vec3::new(0.0, 0.0, 0.0); 3];
193            let mut edge_length = [0.0f64; 3];
194
195            for i in 0..3 {
196                let start_vert = self.halfedge[3 * tri + i].start_vert as usize;
197                let end_vert = self.halfedge[3 * tri + i].end_vert as usize;
198                edge_dirs[i] = self.vert_pos[end_vert] - self.vert_pos[start_vert];
199                edge_length[i] = length(edge_dirs[i]);
200                if edge_length[i] > 0.0 {
201                    edge_dirs[i] = edge_dirs[i] / edge_length[i];
202                }
203
204                let neighbor_tri = self.halfedge[3 * tri + i].paired_halfedge as usize / 3;
205                let dihedral = 0.25
206                    * edge_length[i]
207                    * math::asin(dot(
208                        cross(self.face_normal[tri], self.face_normal[neighbor_tri]),
209                        edge_dirs[i],
210                    ));
211                mean_curvature[start_vert] += dihedral;
212                mean_curvature[end_vert] += dihedral;
213                degree[start_vert] += 1.0;
214            }
215
216            let phi0 = math::acos(-dot(edge_dirs[2], edge_dirs[0]));
217            let phi1 = math::acos(-dot(edge_dirs[0], edge_dirs[1]));
218            let phi2 = std::f64::consts::PI - phi0 - phi1;
219            let area3 = edge_length[0] * edge_length[1] * length(cross(edge_dirs[0], edge_dirs[1]))
220                / 6.0;
221
222            let phi = [phi0, phi1, phi2];
223            for i in 0..3 {
224                let vert = self.halfedge[3 * tri + i].start_vert as usize;
225                gaussian_curvature[vert] -= phi[i];
226                area[vert] += area3;
227            }
228        }
229
230        for vert in 0..num_vert {
231            let factor = degree[vert] / (6.0 * area[vert]);
232            mean_curvature[vert] *= factor;
233            gaussian_curvature[vert] *= factor;
234        }
235
236        let old_num_prop = self.num_prop;
237        let num_prop = old_num_prop
238            .max(if gaussian_idx >= 0 { gaussian_idx as usize + 1 } else { 0 })
239            .max(if mean_idx >= 0 { mean_idx as usize + 1 } else { 0 });
240
241        let old_properties = self.properties.clone();
242        let num_prop_vert = self.num_prop_vert();
243        self.properties = vec![0.0f64; num_prop * num_prop_vert];
244        self.num_prop = num_prop;
245
246        let mut visited = vec![false; num_prop_vert];
247
248        for tri in 0..self.num_tri() {
249            for i in 0..3 {
250                let edge = &self.halfedge[3 * tri + i];
251                let vert = edge.start_vert as usize;
252                let prop_vert = edge.prop_vert as usize;
253
254                if visited[prop_vert] {
255                    continue;
256                }
257                visited[prop_vert] = true;
258
259                for p in 0..old_num_prop {
260                    self.properties[num_prop * prop_vert + p] =
261                        old_properties[old_num_prop * prop_vert + p];
262                }
263
264                if gaussian_idx >= 0 {
265                    self.properties[num_prop * prop_vert + gaussian_idx as usize] =
266                        gaussian_curvature[vert];
267                }
268                if mean_idx >= 0 {
269                    self.properties[num_prop * prop_vert + mean_idx as usize] =
270                        mean_curvature[vert];
271                }
272            }
273        }
274    }
275
276    /// Checks that all indices in the given triVerts array are within the
277    /// bounds of vert_pos.
278    pub fn is_index_in_bounds(&self, tri_verts: &[IVec3]) -> bool {
279        if tri_verts.is_empty() {
280            return true;
281        }
282        let num_vert = self.num_vert() as i32;
283        for tri in tri_verts {
284            let min_v = tri.x.min(tri.y).min(tri.z);
285            let max_v = tri.x.max(tri.y).max(tri.z);
286            if min_v < 0 || max_v >= num_vert {
287                return false;
288            }
289        }
290        true
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Tests
296// ---------------------------------------------------------------------------
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::linalg::Mat3x4;
302
303    #[test]
304    fn test_tetrahedron_volume() {
305        let m = ManifoldImpl::tetrahedron(&Mat3x4::identity());
306        let vol = m.get_property(Property::Volume);
307        // Regular tetrahedron with edge length 2*sqrt(2), vertices at distance
308        // sqrt(3) from origin. Volume = 8/3.
309        assert!(
310            (vol.abs() - 8.0 / 3.0).abs() < 1e-10,
311            "Expected volume ~2.6667, got {}",
312            vol
313        );
314    }
315
316    #[test]
317    fn test_cube_volume() {
318        let m = ManifoldImpl::cube(&Mat3x4::identity());
319        let vol = m.get_property(Property::Volume);
320        assert!(
321            (vol.abs() - 1.0).abs() < 1e-10,
322            "Expected unit cube volume = 1.0, got {}",
323            vol
324        );
325    }
326
327    #[test]
328    fn test_cube_surface_area() {
329        let m = ManifoldImpl::cube(&Mat3x4::identity());
330        let area = m.get_property(Property::SurfaceArea);
331        assert!(
332            (area - 6.0).abs() < 1e-10,
333            "Expected unit cube surface area = 6.0, got {}",
334            area
335        );
336    }
337
338    #[test]
339    fn test_octahedron_volume() {
340        let m = ManifoldImpl::octahedron(&Mat3x4::identity());
341        let vol = m.get_property(Property::Volume);
342        // Regular octahedron with vertices at ±1 on each axis: volume = 4/3
343        assert!(
344            (vol.abs() - 4.0 / 3.0).abs() < 1e-10,
345            "Expected octahedron volume ~1.3333, got {}",
346            vol
347        );
348    }
349
350    #[test]
351    fn test_octahedron_surface_area() {
352        let m = ManifoldImpl::octahedron(&Mat3x4::identity());
353        let area = m.get_property(Property::SurfaceArea);
354        // 8 equilateral triangles with edge length sqrt(2), each area = sqrt(3)/2
355        // Total = 8 * sqrt(3)/2 ≈ 6.9282
356        let expected = 4.0 * 3.0_f64.sqrt();
357        assert!(
358            (area - expected).abs() < 1e-10,
359            "Expected octahedron surface area ~{}, got {}",
360            expected,
361            area
362        );
363    }
364
365    #[test]
366    fn test_empty_mesh_properties() {
367        let m = ManifoldImpl::new();
368        assert_eq!(m.get_property(Property::Volume), 0.0);
369        assert_eq!(m.get_property(Property::SurfaceArea), 0.0);
370    }
371
372    #[test]
373    fn test_matches_tri_normals_cube() {
374        let m = ManifoldImpl::cube(&Mat3x4::identity());
375        assert!(
376            m.matches_tri_normals(),
377            "Cube should have CCW triangles matching normals"
378        );
379    }
380
381    #[test]
382    fn test_matches_tri_normals_tetrahedron() {
383        let m = ManifoldImpl::tetrahedron(&Mat3x4::identity());
384        assert!(
385            m.matches_tri_normals(),
386            "Tetrahedron should have CCW triangles matching normals"
387        );
388    }
389
390    #[test]
391    fn test_num_degenerate_tris_cube() {
392        let m = ManifoldImpl::cube(&Mat3x4::identity());
393        assert_eq!(
394            m.num_degenerate_tris(),
395            0,
396            "Cube should have no degenerate triangles"
397        );
398    }
399
400    #[test]
401    fn test_num_degenerate_tris_tetrahedron() {
402        let m = ManifoldImpl::tetrahedron(&Mat3x4::identity());
403        assert_eq!(
404            m.num_degenerate_tris(),
405            0,
406            "Tetrahedron should have no degenerate triangles"
407        );
408    }
409
410    #[test]
411    fn test_is_convex_cube() {
412        let m = ManifoldImpl::cube(&Mat3x4::identity());
413        assert!(m.is_convex(), "Unit cube should be convex");
414    }
415
416    #[test]
417    fn test_is_convex_tetrahedron() {
418        let m = ManifoldImpl::tetrahedron(&Mat3x4::identity());
419        assert!(m.is_convex(), "Tetrahedron should be convex");
420    }
421
422    #[test]
423    fn test_is_convex_octahedron() {
424        let m = ManifoldImpl::octahedron(&Mat3x4::identity());
425        assert!(m.is_convex(), "Octahedron should be convex");
426    }
427
428    #[test]
429    fn test_is_index_in_bounds() {
430        let m = ManifoldImpl::cube(&Mat3x4::identity());
431        let valid = vec![IVec3::new(0, 1, 2), IVec3::new(3, 4, 5)];
432        assert!(m.is_index_in_bounds(&valid));
433
434        let invalid = vec![IVec3::new(0, 1, 100)];
435        assert!(!m.is_index_in_bounds(&invalid));
436
437        let negative = vec![IVec3::new(-1, 0, 1)];
438        assert!(!m.is_index_in_bounds(&negative));
439
440        assert!(m.is_index_in_bounds(&[]));
441    }
442
443    #[test]
444    fn test_calculate_curvature_cube() {
445        let mut m = ManifoldImpl::cube(&Mat3x4::identity());
446        m.calculate_curvature(0, 1);
447        assert_eq!(m.num_prop, 2);
448        assert!(!m.properties.is_empty(), "Properties should be populated");
449    }
450
451    #[test]
452    fn test_calculate_curvature_skip_both() {
453        let mut m = ManifoldImpl::cube(&Mat3x4::identity());
454        let old_props = m.properties.clone();
455        let old_num_prop = m.num_prop;
456        m.calculate_curvature(-1, -1);
457        assert_eq!(m.num_prop, old_num_prop);
458        assert_eq!(m.properties, old_props);
459    }
460
461    #[test]
462    fn test_scaled_cube_volume() {
463        use crate::linalg::{mat4_to_mat3x4, scaling_matrix};
464        let scale = Vec3::new(2.0, 3.0, 4.0);
465        let t = mat4_to_mat3x4(scaling_matrix(scale));
466        let m = ManifoldImpl::cube(&t);
467        let vol = m.get_property(Property::Volume);
468        assert!(
469            (vol.abs() - 24.0).abs() < 1e-10,
470            "Expected 2×3×4 cube volume = 24.0, got {}",
471            vol
472        );
473    }
474
475    #[test]
476    fn test_scaled_cube_surface_area() {
477        use crate::linalg::{mat4_to_mat3x4, scaling_matrix};
478        let scale = Vec3::new(2.0, 3.0, 4.0);
479        let t = mat4_to_mat3x4(scaling_matrix(scale));
480        let m = ManifoldImpl::cube(&t);
481        let area = m.get_property(Property::SurfaceArea);
482        // 2(2*3 + 2*4 + 3*4) = 2(6+8+12) = 52
483        assert!(
484            (area - 52.0).abs() < 1e-10,
485            "Expected 2×3×4 cube surface area = 52.0, got {}",
486            area
487        );
488    }
489}