Skip to main content

ifc_lite_geometry/
mesh.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Mesh data structures
6
7use nalgebra::{Point3, Vector3};
8
9/// Coordinate shift for RTC (Relative-to-Center) rendering
10/// Stores the offset subtracted from coordinates to improve Float32 precision
11#[derive(Debug, Clone, Copy, Default)]
12pub struct CoordinateShift {
13    /// X offset (subtracted from all X coordinates)
14    pub x: f64,
15    /// Y offset (subtracted from all Y coordinates)
16    pub y: f64,
17    /// Z offset (subtracted from all Z coordinates)
18    pub z: f64,
19}
20
21impl CoordinateShift {
22    /// Create a new coordinate shift
23    #[inline]
24    pub fn new(x: f64, y: f64, z: f64) -> Self {
25        Self { x, y, z }
26    }
27
28    /// Create shift from a Point3
29    #[inline]
30    pub fn from_point(point: Point3<f64>) -> Self {
31        Self {
32            x: point.x,
33            y: point.y,
34            z: point.z,
35        }
36    }
37
38    /// Check if shift is significant (>10km from origin)
39    #[inline]
40    pub fn is_significant(&self) -> bool {
41        const THRESHOLD: f64 = 10000.0; // 10km
42        self.x.abs() > THRESHOLD || self.y.abs() > THRESHOLD || self.z.abs() > THRESHOLD
43    }
44
45    /// Check if shift is zero (no shifting needed)
46    #[inline]
47    pub fn is_zero(&self) -> bool {
48        self.x == 0.0 && self.y == 0.0 && self.z == 0.0
49    }
50}
51
52/// Triangle mesh
53#[derive(Debug, Clone)]
54pub struct Mesh {
55    /// Vertex positions (x, y, z)
56    pub positions: Vec<f32>,
57    /// Vertex normals (nx, ny, nz)
58    pub normals: Vec<f32>,
59    /// Triangle indices (i0, i1, i2)
60    pub indices: Vec<u32>,
61    /// Whether RTC offset has already been subtracted from positions.
62    /// Set by `FacetedBrepProcessor::process_with_rtc` to prevent
63    /// `transform_mesh` from double-subtracting RTC.
64    pub rtc_applied: bool,
65}
66
67/// A sub-mesh with its source geometry item ID.
68/// Used to track which geometry items contribute to an element's mesh,
69/// allowing per-item color/style lookup.
70#[derive(Debug, Clone)]
71pub struct SubMesh {
72    /// The geometry item ID (e.g., IfcFacetedBrep ID) for style lookup
73    pub geometry_id: u32,
74    /// The triangulated mesh data
75    pub mesh: Mesh,
76}
77
78impl SubMesh {
79    /// Create a new sub-mesh
80    pub fn new(geometry_id: u32, mesh: Mesh) -> Self {
81        Self { geometry_id, mesh }
82    }
83}
84
85/// Collection of sub-meshes from an element, preserving per-item identity
86#[derive(Debug, Clone, Default)]
87pub struct SubMeshCollection {
88    pub sub_meshes: Vec<SubMesh>,
89}
90
91impl SubMeshCollection {
92    /// Create a new empty collection
93    pub fn new() -> Self {
94        Self {
95            sub_meshes: Vec::new(),
96        }
97    }
98
99    /// Add a sub-mesh
100    pub fn add(&mut self, geometry_id: u32, mesh: Mesh) {
101        if !mesh.is_empty() {
102            self.sub_meshes.push(SubMesh::new(geometry_id, mesh));
103        }
104    }
105
106    /// Check if collection is empty
107    pub fn is_empty(&self) -> bool {
108        self.sub_meshes.is_empty()
109    }
110
111    /// Get number of sub-meshes
112    pub fn len(&self) -> usize {
113        self.sub_meshes.len()
114    }
115
116    /// Merge all sub-meshes into a single mesh (loses per-item identity)
117    pub fn into_combined_mesh(self) -> Mesh {
118        let mut combined = Mesh::new();
119        for sub in self.sub_meshes {
120            combined.merge(&sub.mesh);
121        }
122        combined
123    }
124
125    /// Iterate over sub-meshes
126    pub fn iter(&self) -> impl Iterator<Item = &SubMesh> {
127        self.sub_meshes.iter()
128    }
129}
130
131impl Mesh {
132    /// Create a new empty mesh
133    pub fn new() -> Self {
134        Self {
135            positions: Vec::new(),
136            normals: Vec::new(),
137            indices: Vec::new(),
138            rtc_applied: false,
139        }
140    }
141
142    /// Create a mesh with capacity
143    pub fn with_capacity(vertex_count: usize, index_count: usize) -> Self {
144        Self {
145            positions: Vec::with_capacity(vertex_count * 3),
146            normals: Vec::with_capacity(vertex_count * 3),
147            indices: Vec::with_capacity(index_count),
148            rtc_applied: false,
149        }
150    }
151
152    /// Create a mesh from a single triangle
153    pub fn from_triangle(
154        v0: &Point3<f64>,
155        v1: &Point3<f64>,
156        v2: &Point3<f64>,
157        normal: &Vector3<f64>,
158    ) -> Self {
159        let mut mesh = Self::with_capacity(3, 3);
160        mesh.positions = vec![
161            v0.x as f32,
162            v0.y as f32,
163            v0.z as f32,
164            v1.x as f32,
165            v1.y as f32,
166            v1.z as f32,
167            v2.x as f32,
168            v2.y as f32,
169            v2.z as f32,
170        ];
171        mesh.normals = vec![
172            normal.x as f32,
173            normal.y as f32,
174            normal.z as f32,
175            normal.x as f32,
176            normal.y as f32,
177            normal.z as f32,
178            normal.x as f32,
179            normal.y as f32,
180            normal.z as f32,
181        ];
182        mesh.indices = vec![0, 1, 2];
183        mesh
184    }
185
186    /// Add a vertex with normal
187    #[inline]
188    pub fn add_vertex(&mut self, position: Point3<f64>, normal: Vector3<f64>) {
189        self.positions.push(position.x as f32);
190        self.positions.push(position.y as f32);
191        self.positions.push(position.z as f32);
192
193        self.normals.push(normal.x as f32);
194        self.normals.push(normal.y as f32);
195        self.normals.push(normal.z as f32);
196    }
197
198    /// Add a vertex with normal, applying coordinate shift in f64 BEFORE f32 conversion
199    /// This preserves precision for large coordinates (georeferenced models)
200    ///
201    /// # Arguments
202    /// * `position` - Vertex position in world coordinates (f64)
203    /// * `normal` - Vertex normal
204    /// * `shift` - Coordinate shift to subtract (in f64) before converting to f32
205    ///
206    /// # Precision
207    /// For coordinates like 5,000,000m (Swiss UTM), direct f32 conversion loses ~1m precision.
208    /// By subtracting the centroid first (in f64), we convert small values (0-100m range)
209    /// which preserves sub-millimeter precision.
210    #[inline]
211    pub fn add_vertex_with_shift(
212        &mut self,
213        position: Point3<f64>,
214        normal: Vector3<f64>,
215        shift: &CoordinateShift,
216    ) {
217        // Subtract shift in f64 precision BEFORE converting to f32
218        // This is the key to preserving precision for large coordinates
219        let shifted_x = position.x - shift.x;
220        let shifted_y = position.y - shift.y;
221        let shifted_z = position.z - shift.z;
222
223        self.positions.push(shifted_x as f32);
224        self.positions.push(shifted_y as f32);
225        self.positions.push(shifted_z as f32);
226
227        self.normals.push(normal.x as f32);
228        self.normals.push(normal.y as f32);
229        self.normals.push(normal.z as f32);
230    }
231
232    /// Apply coordinate shift to existing positions in-place
233    /// Uses f64 intermediate for precision when subtracting large offsets
234    #[inline]
235    pub fn apply_shift(&mut self, shift: &CoordinateShift) {
236        if shift.is_zero() {
237            return;
238        }
239        for chunk in self.positions.chunks_exact_mut(3) {
240            // Convert to f64, subtract, convert back to f32
241            chunk[0] = (chunk[0] as f64 - shift.x) as f32;
242            chunk[1] = (chunk[1] as f64 - shift.y) as f32;
243            chunk[2] = (chunk[2] as f64 - shift.z) as f32;
244        }
245        self.rtc_applied = true;
246    }
247
248    /// Add a triangle
249    #[inline]
250    pub fn add_triangle(&mut self, i0: u32, i1: u32, i2: u32) {
251        self.indices.push(i0);
252        self.indices.push(i1);
253        self.indices.push(i2);
254    }
255
256    /// Merge another mesh into this one
257    #[inline]
258    pub fn merge(&mut self, other: &Mesh) {
259        if other.is_empty() {
260            return;
261        }
262
263        let vertex_offset = (self.positions.len() / 3) as u32;
264
265        // Pre-allocate for the incoming data
266        self.positions.reserve(other.positions.len());
267        self.normals.reserve(other.normals.len());
268        self.indices.reserve(other.indices.len());
269
270        self.positions.extend_from_slice(&other.positions);
271        self.normals.extend_from_slice(&other.normals);
272
273        // Vectorized index offset - more cache-friendly than loop
274        self.indices
275            .extend(other.indices.iter().map(|&i| i + vertex_offset));
276
277        // Preserve RTC state: if either mesh has RTC applied, the merged result does too
278        if other.rtc_applied {
279            self.rtc_applied = true;
280        }
281    }
282
283    /// Batch merge multiple meshes at once (more efficient than individual merges)
284    #[inline]
285    pub fn merge_all(&mut self, meshes: &[Mesh]) {
286        // Calculate total size needed
287        let total_positions: usize = meshes.iter().map(|m| m.positions.len()).sum();
288        let total_indices: usize = meshes.iter().map(|m| m.indices.len()).sum();
289
290        // Reserve capacity upfront to avoid reallocations
291        self.positions.reserve(total_positions);
292        self.normals.reserve(total_positions);
293        self.indices.reserve(total_indices);
294
295        // Merge all meshes
296        for mesh in meshes {
297            if !mesh.is_empty() {
298                let vertex_offset = (self.positions.len() / 3) as u32;
299                self.positions.extend_from_slice(&mesh.positions);
300                self.normals.extend_from_slice(&mesh.normals);
301                self.indices
302                    .extend(mesh.indices.iter().map(|&i| i + vertex_offset));
303
304                // Preserve RTC state: if any mesh has RTC applied, the merged result does too
305                if mesh.rtc_applied {
306                    self.rtc_applied = true;
307                }
308            }
309        }
310    }
311
312    /// Get vertex count
313    #[inline]
314    pub fn vertex_count(&self) -> usize {
315        self.positions.len() / 3
316    }
317
318    /// Get triangle count
319    #[inline]
320    pub fn triangle_count(&self) -> usize {
321        self.indices.len() / 3
322    }
323
324    /// Remove triangle indices that reference vertices beyond the positions array.
325    /// This prevents panics from malformed IFC data (e.g. Revit exports with invalid indices).
326    #[inline]
327    pub fn validate_indices(&mut self) {
328        let vertex_count = self.positions.len() / 3;
329        if vertex_count == 0 {
330            self.indices.clear();
331            return;
332        }
333        let mut valid = Vec::with_capacity(self.indices.len());
334        for chunk in self.indices.chunks(3) {
335            if chunk.len() == 3
336                && (chunk[0] as usize) < vertex_count
337                && (chunk[1] as usize) < vertex_count
338                && (chunk[2] as usize) < vertex_count
339            {
340                valid.extend_from_slice(chunk);
341            }
342        }
343        self.indices = valid;
344    }
345
346    /// Check if mesh is empty
347    #[inline]
348    pub fn is_empty(&self) -> bool {
349        self.positions.is_empty()
350    }
351
352    /// Calculate bounds (min, max) - optimized with chunk iteration
353    #[inline]
354    pub fn bounds(&self) -> (Point3<f32>, Point3<f32>) {
355        if self.is_empty() {
356            return (Point3::origin(), Point3::origin());
357        }
358
359        let mut min = Point3::new(f32::MAX, f32::MAX, f32::MAX);
360        let mut max = Point3::new(f32::MIN, f32::MIN, f32::MIN);
361
362        // Use chunks for better cache locality
363        self.positions.chunks_exact(3).for_each(|chunk| {
364            let (x, y, z) = (chunk[0], chunk[1], chunk[2]);
365            min.x = min.x.min(x);
366            min.y = min.y.min(y);
367            min.z = min.z.min(z);
368            max.x = max.x.max(x);
369            max.y = max.y.max(y);
370            max.z = max.z.max(z);
371        });
372
373        (min, max)
374    }
375
376    /// Calculate centroid in f64 precision (for RTC offset calculation)
377    /// Returns the average of all vertex positions
378    #[inline]
379    pub fn centroid_f64(&self) -> Point3<f64> {
380        if self.is_empty() {
381            return Point3::origin();
382        }
383
384        let mut sum = Point3::new(0.0f64, 0.0f64, 0.0f64);
385        let count = self.positions.len() / 3;
386
387        self.positions.chunks_exact(3).for_each(|chunk| {
388            sum.x += chunk[0] as f64;
389            sum.y += chunk[1] as f64;
390            sum.z += chunk[2] as f64;
391        });
392
393        Point3::new(
394            sum.x / count as f64,
395            sum.y / count as f64,
396            sum.z / count as f64,
397        )
398    }
399
400    /// Clear the mesh
401    #[inline]
402    pub fn clear(&mut self) {
403        self.positions.clear();
404        self.normals.clear();
405        self.indices.clear();
406        self.rtc_applied = false;
407    }
408
409    /// Filter out triangles with edges exceeding the threshold
410    /// This removes "stretched" triangles that span unreasonably large distances,
411    /// which can occur when disconnected geometry is incorrectly merged.
412    ///
413    /// Uses a conservative threshold (500m) to only catch clearly broken geometry,
414    /// not legitimate large elements like long beams or walls.
415    ///
416    /// # Arguments
417    /// * `max_edge_length` - Maximum allowed edge length in meters (default: 500m)
418    ///
419    /// # Returns
420    /// Number of triangles removed
421    pub fn filter_stretched_triangles(&mut self, max_edge_length: f32) -> usize {
422        if self.is_empty() {
423            return 0;
424        }
425
426        let max_edge_sq = max_edge_length * max_edge_length;
427        let mut valid_indices = Vec::new();
428        let mut removed_count = 0;
429
430        // Check each triangle
431        for i in (0..self.indices.len()).step_by(3) {
432            if i + 2 >= self.indices.len() {
433                break;
434            }
435            let i0 = self.indices[i] as usize;
436            let i1 = self.indices[i + 1] as usize;
437            let i2 = self.indices[i + 2] as usize;
438
439            if i0 * 3 + 2 >= self.positions.len()
440                || i1 * 3 + 2 >= self.positions.len()
441                || i2 * 3 + 2 >= self.positions.len()
442            {
443                // Invalid indices - skip
444                removed_count += 1;
445                continue;
446            }
447
448            let p0 = (
449                self.positions[i0 * 3],
450                self.positions[i0 * 3 + 1],
451                self.positions[i0 * 3 + 2],
452            );
453            let p1 = (
454                self.positions[i1 * 3],
455                self.positions[i1 * 3 + 1],
456                self.positions[i1 * 3 + 2],
457            );
458            let p2 = (
459                self.positions[i2 * 3],
460                self.positions[i2 * 3 + 1],
461                self.positions[i2 * 3 + 2],
462            );
463
464            // Calculate squared edge lengths
465            let edge01_sq = (p1.0 - p0.0).powi(2) + (p1.1 - p0.1).powi(2) + (p1.2 - p0.2).powi(2);
466            let edge12_sq = (p2.0 - p1.0).powi(2) + (p2.1 - p1.1).powi(2) + (p2.2 - p1.2).powi(2);
467            let edge20_sq = (p0.0 - p2.0).powi(2) + (p0.1 - p2.1).powi(2) + (p0.2 - p2.2).powi(2);
468
469            // Check if any edge exceeds threshold
470            if edge01_sq <= max_edge_sq && edge12_sq <= max_edge_sq && edge20_sq <= max_edge_sq {
471                // Triangle is valid - keep it
472                valid_indices.push(self.indices[i]);
473                valid_indices.push(self.indices[i + 1]);
474                valid_indices.push(self.indices[i + 2]);
475            } else {
476                // Triangle has stretched edge - remove it
477                removed_count += 1;
478            }
479        }
480
481        self.indices = valid_indices;
482        removed_count
483    }
484}
485
486impl Default for Mesh {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_mesh_creation() {
498        let mesh = Mesh::new();
499        assert!(mesh.is_empty());
500        assert_eq!(mesh.vertex_count(), 0);
501        assert_eq!(mesh.triangle_count(), 0);
502    }
503
504    #[test]
505    fn test_add_vertex() {
506        let mut mesh = Mesh::new();
507        mesh.add_vertex(Point3::new(1.0, 2.0, 3.0), Vector3::new(0.0, 0.0, 1.0));
508        assert_eq!(mesh.vertex_count(), 1);
509        assert_eq!(mesh.positions, vec![1.0, 2.0, 3.0]);
510        assert_eq!(mesh.normals, vec![0.0, 0.0, 1.0]);
511    }
512
513    #[test]
514    fn test_merge() {
515        let mut mesh1 = Mesh::new();
516        mesh1.add_vertex(Point3::new(0.0, 0.0, 0.0), Vector3::z());
517        mesh1.add_triangle(0, 1, 2);
518
519        let mut mesh2 = Mesh::new();
520        mesh2.add_vertex(Point3::new(1.0, 1.0, 1.0), Vector3::y());
521        mesh2.add_triangle(0, 1, 2);
522
523        mesh1.merge(&mesh2);
524        assert_eq!(mesh1.vertex_count(), 2);
525        assert_eq!(mesh1.triangle_count(), 2);
526    }
527
528    #[test]
529    fn test_coordinate_shift_creation() {
530        let shift = CoordinateShift::new(500000.0, 5000000.0, 100.0);
531        assert!(shift.is_significant());
532        assert!(!shift.is_zero());
533
534        let zero_shift = CoordinateShift::default();
535        assert!(!zero_shift.is_significant());
536        assert!(zero_shift.is_zero());
537    }
538
539    #[test]
540    fn test_add_vertex_with_shift_preserves_precision() {
541        // Test case: Swiss UTM coordinates (typical large coordinate scenario)
542        // Without shifting: 5000000.123 as f32 = 5000000.0 (loses 0.123m precision!)
543        // With shifting: (5000000.123 - 5000000.0) as f32 = 0.123 (full precision preserved)
544
545        let mut mesh = Mesh::new();
546
547        // Large coordinates typical of Swiss UTM (EPSG:2056)
548        let p1 = Point3::new(2679012.123456, 1247892.654321, 432.111);
549        let p2 = Point3::new(2679012.223456, 1247892.754321, 432.211);
550
551        // Create shift from approximate centroid
552        let shift = CoordinateShift::new(2679012.0, 1247892.0, 432.0);
553
554        mesh.add_vertex_with_shift(p1, Vector3::z(), &shift);
555        mesh.add_vertex_with_shift(p2, Vector3::z(), &shift);
556
557        // Verify shifted positions have sub-millimeter precision
558        // p1 shifted: (0.123456, 0.654321, 0.111)
559        // p2 shifted: (0.223456, 0.754321, 0.211)
560        assert!((mesh.positions[0] - 0.123456).abs() < 0.0001); // X1
561        assert!((mesh.positions[1] - 0.654321).abs() < 0.0001); // Y1
562        assert!((mesh.positions[2] - 0.111).abs() < 0.0001); // Z1
563        assert!((mesh.positions[3] - 0.223456).abs() < 0.0001); // X2
564        assert!((mesh.positions[4] - 0.754321).abs() < 0.0001); // Y2
565        assert!((mesh.positions[5] - 0.211).abs() < 0.0001); // Z2
566
567        // Verify relative distances are preserved with high precision
568        let dx = mesh.positions[3] - mesh.positions[0];
569        let dy = mesh.positions[4] - mesh.positions[1];
570        let dz = mesh.positions[5] - mesh.positions[2];
571
572        // Expected: dx=0.1, dy=0.1, dz=0.1
573        assert!((dx - 0.1).abs() < 0.0001);
574        assert!((dy - 0.1).abs() < 0.0001);
575        assert!((dz - 0.1).abs() < 0.0001);
576    }
577
578    #[test]
579    fn test_apply_shift_to_existing_mesh() {
580        let mut mesh = Mesh::new();
581
582        // Add vertices with large coordinates (already converted to f32 - some precision lost)
583        mesh.positions = vec![500000.0, 5000000.0, 0.0, 500010.0, 5000010.0, 10.0];
584        mesh.normals = vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
585
586        // Apply shift
587        let shift = CoordinateShift::new(500000.0, 5000000.0, 0.0);
588        mesh.apply_shift(&shift);
589
590        // Verify positions are shifted
591        assert!((mesh.positions[0] - 0.0).abs() < 0.001);
592        assert!((mesh.positions[1] - 0.0).abs() < 0.001);
593        assert!((mesh.positions[3] - 10.0).abs() < 0.001);
594        assert!((mesh.positions[4] - 10.0).abs() < 0.001);
595    }
596
597    #[test]
598    fn test_centroid_f64() {
599        let mut mesh = Mesh::new();
600        mesh.positions = vec![0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0];
601        mesh.normals = vec![0.0; 9];
602
603        let centroid = mesh.centroid_f64();
604        assert!((centroid.x - 10.0).abs() < 0.001);
605        assert!((centroid.y - 10.0).abs() < 0.001);
606        assert!((centroid.z - 10.0).abs() < 0.001);
607    }
608
609    #[test]
610    fn test_precision_comparison_shifted_vs_unshifted() {
611        // This test quantifies the precision improvement from shifting
612        // Using Swiss UTM coordinates as example
613
614        // Two points that are exactly 0.001m (1mm) apart
615        let base_x = 2679012.0;
616        let base_y = 1247892.0;
617        let offset = 0.001; // 1mm
618
619        let p1 = Point3::new(base_x, base_y, 0.0);
620        let p2 = Point3::new(base_x + offset, base_y, 0.0);
621
622        // Without shift - convert directly to f32
623        let p1_f32_direct = (p1.x as f32, p1.y as f32);
624        let p2_f32_direct = (p2.x as f32, p2.y as f32);
625        let diff_direct = p2_f32_direct.0 - p1_f32_direct.0;
626
627        // With shift - subtract centroid first, then convert
628        let shift = CoordinateShift::new(base_x, base_y, 0.0);
629        let p1_shifted = ((p1.x - shift.x) as f32, (p1.y - shift.y) as f32);
630        let p2_shifted = ((p2.x - shift.x) as f32, (p2.y - shift.y) as f32);
631        let diff_shifted = p2_shifted.0 - p1_shifted.0;
632
633        println!("Direct f32 difference (should be ~0.001): {}", diff_direct);
634        println!(
635            "Shifted f32 difference (should be ~0.001): {}",
636            diff_shifted
637        );
638
639        // The shifted version should be much closer to the true 1mm difference
640        let error_direct = (diff_direct - offset as f32).abs();
641        let error_shifted = (diff_shifted - offset as f32).abs();
642
643        println!("Error without shift: {}m", error_direct);
644        println!("Error with shift: {}m", error_shifted);
645
646        // The shifted version should have significantly less error
647        // (At least 100x better precision for typical Swiss coordinates)
648        assert!(
649            error_shifted < error_direct || error_shifted < 0.0001,
650            "Shifted precision should be better than direct conversion"
651        );
652    }
653
654    #[test]
655    fn test_validate_indices_strips_out_of_bounds() {
656        let mut mesh = Mesh {
657            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], // 3 vertices
658            normals: vec![],
659            indices: vec![
660                0, 1, 2, // valid
661                0, 1, 5, // invalid: vertex 5 out of bounds
662                3, 4, 5, // invalid: all out of bounds
663            ],
664            rtc_applied: false,
665        };
666        mesh.validate_indices();
667        assert_eq!(mesh.indices, vec![0, 1, 2]);
668    }
669
670    #[test]
671    fn test_validate_indices_empty_positions() {
672        let mut mesh = Mesh {
673            positions: vec![],
674            normals: vec![],
675            indices: vec![0, 1, 2],
676            rtc_applied: false,
677        };
678        mesh.validate_indices();
679        assert!(mesh.indices.is_empty());
680    }
681
682    #[test]
683    fn test_validate_indices_incomplete_triangle() {
684        let mut mesh = Mesh {
685            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
686            normals: vec![],
687            indices: vec![0, 1, 2, 0, 1], // trailing incomplete triangle
688            rtc_applied: false,
689        };
690        mesh.validate_indices();
691        assert_eq!(mesh.indices, vec![0, 1, 2]);
692    }
693
694    #[test]
695    fn test_validate_indices_all_valid() {
696        let mut mesh = Mesh {
697            positions: vec![0.0; 12], // 4 vertices
698            normals: vec![],
699            indices: vec![0, 1, 2, 1, 2, 3],
700            rtc_applied: false,
701        };
702        mesh.validate_indices();
703        assert_eq!(mesh.indices, vec![0, 1, 2, 1, 2, 3]);
704    }
705}