Skip to main content

konjure_sdk/
procedural.rs

1//! Bounded procedural triangle meshes and pure mesh transforms.
2//!
3//! These helpers operate on owned [`crate::Mesh`] values.  They deliberately do
4//! not resolve assets or interact with a renderer, so the same mesh is usable by
5//! native and WASM adapters.
6use crate::{Diagnostic, MAX_COORDINATE_M, Mesh, Vec3};
7use alloc::{format, vec, vec::Vec};
8
9/// Largest number of vertices accepted by one procedural mesh.
10pub const MAX_PROCEDURAL_VERTICES: usize = 262_144;
11/// Largest number of triangle indices accepted by one procedural mesh.
12pub const MAX_PROCEDURAL_INDICES: usize = 1_572_864;
13
14/// Loads the bundled CC0 Suzanne test mesh in metres with its original normals.
15///
16/// Data is pinned to the Khronos sample asset credited to UX3D and Norbert
17/// Nopper. `scripts/generate-suzanne.py` records source hashes and reproducibly
18/// converts it into this allocation-bounded, platform-independent format.
19///
20/// # Errors
21/// Returns a diagnostic if the bundled asset is corrupt or fails mesh validation.
22pub fn suzanne() -> Result<Mesh, Diagnostic> {
23    let data = include_bytes!("../assets/suzanne.mesh");
24    let failure = || Diagnostic::plain("bundled Suzanne mesh is invalid");
25    let read_u32 = |start| {
26        data.get(start..start + 4)
27            .and_then(|bytes| bytes.try_into().ok())
28            .map(u32::from_le_bytes)
29            .ok_or_else(failure)
30    };
31    let count = read_u32(0)? as usize;
32    let index_count = read_u32(4)? as usize;
33    if count > MAX_PROCEDURAL_VERTICES
34        || index_count > MAX_PROCEDURAL_INDICES
35        || data.len() != 8 + count * 48 + index_count * 4
36    {
37        return Err(failure());
38    }
39    let read_f64 = |start| {
40        data.get(start..start + 8)
41            .and_then(|bytes| bytes.try_into().ok())
42            .map(f64::from_le_bytes)
43            .ok_or_else(failure)
44    };
45    let mut mesh = Mesh {
46        positions: Vec::with_capacity(count),
47        normals: Vec::with_capacity(count),
48        indices: Vec::with_capacity(index_count),
49    };
50    for i in 0..count {
51        let offset = 8 + i * 48;
52        mesh.positions.push(Vec3 {
53            x: read_f64(offset)?,
54            y: read_f64(offset + 8)?,
55            z: read_f64(offset + 16)?,
56        });
57        mesh.normals.push(Vec3 {
58            x: read_f64(offset + 24)?,
59            y: read_f64(offset + 32)?,
60            z: read_f64(offset + 40)?,
61        });
62    }
63    for i in 0..index_count {
64        mesh.indices.push(read_u32(8 + count * 48 + i * 4)?);
65    }
66    validate_mesh(&mesh)?;
67    Ok(mesh)
68}
69
70/// A radius and vertical position used by [`lathe`].
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub struct ProfilePoint {
73    /// Non-negative distance from the Y axis in metres.
74    pub radius_m: f64,
75    /// Vertical position in metres.
76    pub y_m: f64,
77}
78
79/// Validates mesh buffer shape, finite coordinates, normals, and triangle indices.
80///
81/// # Errors
82///
83/// Returns a diagnostic when buffers have inconsistent lengths, exceed the
84/// procedural allocation limit, or contain non-finite/out-of-range values,
85/// degenerate normals, or invalid triangle indices.
86pub fn validate_mesh(mesh: &Mesh) -> Result<(), Diagnostic> {
87    if mesh.positions.is_empty() || mesh.positions.len() != mesh.normals.len() {
88        return Err(Diagnostic::plain(
89            "mesh positions and normals must be nonempty and have equal length",
90        ));
91    }
92    if mesh.positions.len() > MAX_PROCEDURAL_VERTICES || mesh.indices.len() > MAX_PROCEDURAL_INDICES
93    {
94        return Err(Diagnostic::plain(
95            "mesh exceeds procedural vertex or index budget",
96        ));
97    }
98    if mesh.indices.is_empty() || !mesh.indices.len().is_multiple_of(3) {
99        return Err(Diagnostic::plain(
100            "mesh indices must contain nonempty triangle triples",
101        ));
102    }
103    for position in &mesh.positions {
104        valid_vector(*position, "mesh position")?;
105    }
106    for normal in &mesh.normals {
107        valid_vector(*normal, "mesh normal")?;
108        if length(*normal) <= 1e-12 {
109            return Err(Diagnostic::plain("mesh normals must be nonzero"));
110        }
111    }
112    if mesh
113        .indices
114        .iter()
115        .any(|&index| index as usize >= mesh.positions.len())
116    {
117        return Err(Diagnostic::plain(
118            "mesh indices must address an existing vertex",
119        ));
120    }
121    Ok(())
122}
123
124impl Mesh {
125    /// Validates this owned mesh for use with the procedural helpers.
126    pub fn validate(&self) -> Result<(), Diagnostic> {
127        validate_mesh(self)
128    }
129
130    /// Returns a copy translated by `offset_m` metres.
131    pub fn translated(&self, offset_m: Vec3) -> Result<Self, Diagnostic> {
132        self.validate()?;
133        valid_vector(offset_m, "translation")?;
134        let mut result = self.clone();
135        for point in &mut result.positions {
136            *point = add(*point, offset_m);
137            valid_vector(*point, "translated position")?;
138        }
139        Ok(result)
140    }
141
142    /// Returns a copy rotated by XYZ Euler angles in radians.
143    pub fn rotated_xyz(&self, radians: Vec3) -> Result<Self, Diagnostic> {
144        self.validate()?;
145        valid_vector(radians, "rotation")?;
146        let mut result = self.clone();
147        for point in &mut result.positions {
148            *point = rotate_xyz(*point, radians);
149            valid_vector(*point, "rotated position")?;
150        }
151        for normal in &mut result.normals {
152            *normal = normalized(rotate_xyz(*normal, radians))?;
153        }
154        Ok(result)
155    }
156
157    /// Returns a copy scaled per axis. Negative determinant scales reverse
158    /// winding once so triangles remain outward-facing relative to their normals.
159    pub fn scaled(&self, scale: Vec3) -> Result<Self, Diagnostic> {
160        self.validate()?;
161        valid_vector(scale, "scale")?;
162        if scale.x.abs() <= 1e-12 || scale.y.abs() <= 1e-12 || scale.z.abs() <= 1e-12 {
163            return Err(Diagnostic::plain("mesh scale axes must be nonzero"));
164        }
165        let mut result = self.clone();
166        for point in &mut result.positions {
167            *point = mul(*point, scale);
168            valid_vector(*point, "scaled position")?;
169        }
170        for normal in &mut result.normals {
171            *normal = normalized(Vec3 {
172                x: normal.x / scale.x,
173                y: normal.y / scale.y,
174                z: normal.z / scale.z,
175            })?;
176        }
177        if scale.x * scale.y * scale.z < 0.0 {
178            // The inverse-transpose operation above already transforms normals
179            // into their outward reflected direction. Reflection only reverses
180            // face orientation, so do not negate those corrected normals.
181            result.reverse_indices();
182        }
183        Ok(result)
184    }
185
186    /// Reverses all triangle winding and negates each normal.
187    pub fn reversed_winding(mut self) -> Self {
188        self.reverse_winding();
189        self
190    }
191
192    fn reverse_winding(&mut self) {
193        self.reverse_indices();
194        for normal in &mut self.normals {
195            *normal = Vec3 {
196                x: -normal.x,
197                y: -normal.y,
198                z: -normal.z,
199            };
200        }
201    }
202
203    fn reverse_indices(&mut self) {
204        for triangle in self.indices.as_chunks_mut::<3>().0 {
205            triangle.swap(1, 2);
206        }
207    }
208
209    /// Maps positions through a pure caller-supplied function and recalculates
210    /// smooth normals from the resulting triangles.
211    pub fn map_positions<F>(&self, map: F) -> Result<Self, Diagnostic>
212    where
213        F: Fn(Vec3) -> Vec3,
214    {
215        self.validate()?;
216        let mut result = self.clone();
217        for point in &mut result.positions {
218            *point = map(*point);
219            valid_vector(*point, "mapped position")?;
220        }
221        result.normals = smooth_normals(&result.positions, &result.indices)?;
222        Ok(result)
223    }
224}
225
226/// Creates an XZ grid centered at the origin with upward normals.
227pub fn grid(width_m: f64, depth_m: f64, columns: usize, rows: usize) -> Result<Mesh, Diagnostic> {
228    positive(width_m, "grid width")?;
229    positive(depth_m, "grid depth")?;
230    if columns == 0 || rows == 0 {
231        return Err(Diagnostic::plain("grid columns and rows must be positive"));
232    }
233    let vertices = (columns + 1)
234        .checked_mul(rows + 1)
235        .ok_or_else(|| Diagnostic::plain("grid vertex count overflow"))?;
236    let indices = columns
237        .checked_mul(rows)
238        .and_then(|n| n.checked_mul(6))
239        .ok_or_else(|| Diagnostic::plain("grid index count overflow"))?;
240    budget(vertices, indices)?;
241    let mut positions = Vec::with_capacity(vertices);
242    let mut normals = Vec::with_capacity(vertices);
243    let mut out = Vec::with_capacity(indices);
244    for row in 0..=rows {
245        for column in 0..=columns {
246            positions.push(Vec3 {
247                x: (column as f64 / columns as f64 - 0.5) * width_m,
248                y: 0.,
249                z: (row as f64 / rows as f64 - 0.5) * depth_m,
250            });
251            normals.push(Vec3 {
252                x: 0.,
253                y: 1.,
254                z: 0.,
255            });
256        }
257    }
258    for row in 0..rows {
259        for column in 0..columns {
260            let a = (row * (columns + 1) + column) as u32;
261            let b = a + columns as u32 + 1;
262            out.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]);
263        }
264    }
265    Ok(Mesh {
266        positions,
267        normals,
268        indices: out,
269    })
270}
271
272/// Creates a capped cone centered at the origin and aligned with Y.
273pub fn cone(radius_m: f64, height_m: f64, segments: usize) -> Result<Mesh, Diagnostic> {
274    positive(radius_m, "cone radius")?;
275    positive(height_m, "cone height")?;
276    if segments < 3 {
277        return Err(Diagnostic::plain("cone requires at least three segments"));
278    }
279    budget(
280        segments + 2,
281        segments
282            .checked_mul(6)
283            .ok_or_else(|| Diagnostic::plain("cone index count overflow"))?,
284    )?;
285    let mut positions = Vec::with_capacity(segments + 2);
286    let mut normals = Vec::with_capacity(segments + 2);
287    let mut indices = Vec::with_capacity(segments * 6);
288    let slope = radius_m / height_m;
289    for index in 0..segments {
290        let angle = index as f64 / segments as f64 * core::f64::consts::TAU;
291        let (c, s) = (libm::cos(angle), libm::sin(angle));
292        positions.push(Vec3 {
293            x: radius_m * c,
294            y: -height_m / 2.,
295            z: radius_m * s,
296        });
297        normals.push(normalized(Vec3 {
298            x: c,
299            y: slope,
300            z: s,
301        })?);
302    }
303    let apex = segments as u32;
304    positions.push(Vec3 {
305        x: 0.,
306        y: height_m / 2.,
307        z: 0.,
308    });
309    normals.push(Vec3 {
310        x: 0.,
311        y: 1.,
312        z: 0.,
313    });
314    let center = apex + 1;
315    positions.push(Vec3 {
316        x: 0.,
317        y: -height_m / 2.,
318        z: 0.,
319    });
320    normals.push(Vec3 {
321        x: 0.,
322        y: -1.,
323        z: 0.,
324    });
325    for index in 0..segments {
326        let next = ((index + 1) % segments) as u32;
327        let index = index as u32;
328        indices.extend_from_slice(&[index, next, apex, center, next, index]);
329    }
330    Ok(Mesh {
331        positions,
332        normals,
333        indices,
334    })
335}
336
337/// Creates a UV sphere centred at the origin with caller-selected resolution.
338pub fn uv_sphere(radius_m: f64, rings: usize, segments: usize) -> Result<Mesh, Diagnostic> {
339    positive(radius_m, "sphere radius")?;
340    if rings < 2 || segments < 3 {
341        return Err(Diagnostic::plain(
342            "sphere requires at least two rings and three segments",
343        ));
344    }
345    let vertices = (rings + 1)
346        .checked_mul(segments + 1)
347        .ok_or_else(|| Diagnostic::plain("sphere vertex count overflow"))?;
348    let indices = rings
349        .checked_mul(segments)
350        .and_then(|n| n.checked_mul(6))
351        .ok_or_else(|| Diagnostic::plain("sphere index count overflow"))?;
352    budget(vertices, indices)?;
353    let mut positions = Vec::with_capacity(vertices);
354    let mut normals = Vec::with_capacity(vertices);
355    let mut out = Vec::with_capacity(indices);
356    for ring in 0..=rings {
357        let phi = ring as f64 / rings as f64 * core::f64::consts::PI;
358        for segment in 0..=segments {
359            let theta = segment as f64 / segments as f64 * core::f64::consts::TAU;
360            let normal = Vec3 {
361                x: libm::sin(phi) * libm::cos(theta),
362                y: libm::cos(phi),
363                z: libm::sin(phi) * libm::sin(theta),
364            };
365            positions.push(Vec3 {
366                x: normal.x * radius_m,
367                y: normal.y * radius_m,
368                z: normal.z * radius_m,
369            });
370            normals.push(normal);
371        }
372    }
373    for ring in 0..rings {
374        for segment in 0..segments {
375            let a = (ring * (segments + 1) + segment) as u32;
376            let b = a + segments as u32 + 1;
377            out.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
378        }
379    }
380    Ok(Mesh {
381        positions,
382        normals,
383        indices: out,
384    })
385}
386
387/// Creates a torus around the Y axis with caller-selected ring and tube resolution.
388pub fn torus_mesh(
389    major_radius_m: f64,
390    minor_radius_m: f64,
391    rings: usize,
392    sides: usize,
393) -> Result<Mesh, Diagnostic> {
394    positive(major_radius_m, "torus major radius")?;
395    positive(minor_radius_m, "torus minor radius")?;
396    if minor_radius_m >= major_radius_m {
397        return Err(Diagnostic::plain(
398            "torus minor radius must be less than major radius",
399        ));
400    }
401    if rings < 3 || sides < 3 {
402        return Err(Diagnostic::plain(
403            "torus requires at least three rings and sides",
404        ));
405    }
406    let vertices = (rings + 1)
407        .checked_mul(sides + 1)
408        .ok_or_else(|| Diagnostic::plain("torus vertex count overflow"))?;
409    let indices = rings
410        .checked_mul(sides)
411        .and_then(|n| n.checked_mul(6))
412        .ok_or_else(|| Diagnostic::plain("torus index count overflow"))?;
413    budget(vertices, indices)?;
414    let mut positions = Vec::with_capacity(vertices);
415    let mut normals = Vec::with_capacity(vertices);
416    let mut out = Vec::with_capacity(indices);
417    for ring in 0..=rings {
418        let u = ring as f64 / rings as f64 * core::f64::consts::TAU;
419        let (cu, su) = (libm::cos(u), libm::sin(u));
420        for side in 0..=sides {
421            let v = side as f64 / sides as f64 * core::f64::consts::TAU;
422            let (cv, sv) = (libm::cos(v), libm::sin(v));
423            let normal = Vec3 {
424                x: cu * cv,
425                y: sv,
426                z: su * cv,
427            };
428            positions.push(Vec3 {
429                x: (major_radius_m + minor_radius_m * cv) * cu,
430                y: minor_radius_m * sv,
431                z: (major_radius_m + minor_radius_m * cv) * su,
432            });
433            normals.push(normal);
434        }
435    }
436    for ring in 0..rings {
437        for side in 0..sides {
438            let a = (ring * (sides + 1) + side) as u32;
439            let b = a + sides as u32 + 1;
440            out.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
441        }
442    }
443    Ok(Mesh {
444        positions,
445        normals,
446        indices: out,
447    })
448}
449
450/// Revolves a radius-height profile around Y. The profile must contain at least
451/// two finite points with non-negative radii.
452pub fn lathe(profile: &[ProfilePoint], segments: usize) -> Result<Mesh, Diagnostic> {
453    if profile.len() < 2 || segments < 3 {
454        return Err(Diagnostic::plain(
455            "lathe requires at least two profile points and three segments",
456        ));
457    }
458    for point in profile {
459        if !point.radius_m.is_finite()
460            || point.radius_m < 0.
461            || point.radius_m > MAX_COORDINATE_M
462            || !point.y_m.is_finite()
463            || point.y_m.abs() > MAX_COORDINATE_M
464        {
465            return Err(Diagnostic::plain(
466                "lathe profile must be finite, bounded, and have nonnegative radii",
467            ));
468        }
469    }
470    let columns = segments
471        .checked_add(1)
472        .ok_or_else(|| Diagnostic::plain("lathe segment overflow"))?;
473    let vertices = profile
474        .len()
475        .checked_mul(columns)
476        .ok_or_else(|| Diagnostic::plain("lathe vertex count overflow"))?;
477    let indices = (profile.len() - 1)
478        .checked_mul(segments)
479        .and_then(|n| n.checked_mul(6))
480        .ok_or_else(|| Diagnostic::plain("lathe index count overflow"))?;
481    budget(vertices, indices)?;
482    let mut positions = Vec::with_capacity(vertices);
483    let mut out = Vec::with_capacity(indices);
484    for point in profile {
485        for segment in 0..=segments {
486            let angle = segment as f64 / segments as f64 * core::f64::consts::TAU;
487            positions.push(Vec3 {
488                x: point.radius_m * libm::cos(angle),
489                y: point.y_m,
490                z: point.radius_m * libm::sin(angle),
491            });
492        }
493    }
494    for row in 0..profile.len() - 1 {
495        for column in 0..segments {
496            let a = (row * columns + column) as u32;
497            let b = a + columns as u32;
498            out.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
499        }
500    }
501    let normals = smooth_normals(&positions, &out)?;
502    Ok(Mesh {
503        positions,
504        normals,
505        indices: out,
506    })
507}
508
509/// Samples a pure parameterization over `[0, 1] × [0, 1]` and derives smooth normals.
510pub fn parametric_surface<F>(
511    u_segments: usize,
512    v_segments: usize,
513    sample: F,
514) -> Result<Mesh, Diagnostic>
515where
516    F: Fn(f64, f64) -> Vec3,
517{
518    if u_segments == 0 || v_segments == 0 {
519        return Err(Diagnostic::plain(
520            "parametric surface segments must be positive",
521        ));
522    }
523    let vertices = (u_segments + 1)
524        .checked_mul(v_segments + 1)
525        .ok_or_else(|| Diagnostic::plain("parametric surface vertex count overflow"))?;
526    let indices = u_segments
527        .checked_mul(v_segments)
528        .and_then(|n| n.checked_mul(6))
529        .ok_or_else(|| Diagnostic::plain("parametric surface index count overflow"))?;
530    budget(vertices, indices)?;
531    let mut positions = Vec::with_capacity(vertices);
532    let mut out = Vec::with_capacity(indices);
533    for v in 0..=v_segments {
534        for u in 0..=u_segments {
535            let point = sample(u as f64 / u_segments as f64, v as f64 / v_segments as f64);
536            valid_vector(point, "parametric sample")?;
537            positions.push(point);
538        }
539    }
540    for v in 0..v_segments {
541        for u in 0..u_segments {
542            let a = (v * (u_segments + 1) + u) as u32;
543            let b = a + u_segments as u32 + 1;
544            out.extend_from_slice(&[a, a + 1, b, a + 1, b + 1, b]);
545        }
546    }
547    let normals = smooth_normals(&positions, &out)?;
548    Ok(Mesh {
549        positions,
550        normals,
551        indices: out,
552    })
553}
554
555fn budget(vertices: usize, indices: usize) -> Result<(), Diagnostic> {
556    if vertices > MAX_PROCEDURAL_VERTICES
557        || indices > MAX_PROCEDURAL_INDICES
558        || vertices > u32::MAX as usize
559    {
560        Err(Diagnostic::plain(
561            "procedural mesh exceeds vertex or index budget",
562        ))
563    } else {
564        Ok(())
565    }
566}
567fn positive(value: f64, name: &str) -> Result<(), Diagnostic> {
568    if value.is_finite() && value > 0. && value <= MAX_COORDINATE_M {
569        Ok(())
570    } else {
571        Err(Diagnostic::plain(format!(
572            "{name} must be finite and in 0..={MAX_COORDINATE_M}"
573        )))
574    }
575}
576fn valid_vector(value: Vec3, name: &str) -> Result<(), Diagnostic> {
577    if [value.x, value.y, value.z]
578        .into_iter()
579        .all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE_M)
580    {
581        Ok(())
582    } else {
583        Err(Diagnostic::plain(format!(
584            "{name} must be finite and within coordinate bounds"
585        )))
586    }
587}
588fn add(a: Vec3, b: Vec3) -> Vec3 {
589    Vec3 {
590        x: a.x + b.x,
591        y: a.y + b.y,
592        z: a.z + b.z,
593    }
594}
595fn mul(a: Vec3, b: Vec3) -> Vec3 {
596    Vec3 {
597        x: a.x * b.x,
598        y: a.y * b.y,
599        z: a.z * b.z,
600    }
601}
602fn length(v: Vec3) -> f64 {
603    libm::hypot(libm::hypot(v.x, v.y), v.z)
604}
605fn normalized(v: Vec3) -> Result<Vec3, Diagnostic> {
606    let l = length(v);
607    if l <= 1e-12 || !l.is_finite() {
608        Err(Diagnostic::plain("normal must be finite and nonzero"))
609    } else {
610        Ok(Vec3 {
611            x: v.x / l,
612            y: v.y / l,
613            z: v.z / l,
614        })
615    }
616}
617fn rotate_xyz(v: Vec3, r: Vec3) -> Vec3 {
618    let (sx, cx) = (libm::sin(r.x), libm::cos(r.x));
619    let (sy, cy) = (libm::sin(r.y), libm::cos(r.y));
620    let (sz, cz) = (libm::sin(r.z), libm::cos(r.z));
621    let y = v.y * cx - v.z * sx;
622    let z = v.y * sx + v.z * cx;
623    let x = v.x * cy + z * sy;
624    let z = -v.x * sy + z * cy;
625    Vec3 {
626        x: x * cz - y * sz,
627        y: x * sz + y * cz,
628        z,
629    }
630}
631fn smooth_normals(positions: &[Vec3], indices: &[u32]) -> Result<Vec<Vec3>, Diagnostic> {
632    let mut normals = vec![Vec3::ZERO; positions.len()];
633    for triangle in indices.as_chunks::<3>().0 {
634        let a = positions[triangle[0] as usize];
635        let b = positions[triangle[1] as usize];
636        let c = positions[triangle[2] as usize];
637        let ab = Vec3 {
638            x: b.x - a.x,
639            y: b.y - a.y,
640            z: b.z - a.z,
641        };
642        let ac = Vec3 {
643            x: c.x - a.x,
644            y: c.y - a.y,
645            z: c.z - a.z,
646        };
647        let n = Vec3 {
648            x: ab.y * ac.z - ab.z * ac.y,
649            y: ab.z * ac.x - ab.x * ac.z,
650            z: ab.x * ac.y - ab.y * ac.x,
651        };
652        if length(n) <= 1e-18 {
653            return Err(Diagnostic::plain(
654                "procedural mesh contains a degenerate triangle",
655            ));
656        }
657        for &i in triangle {
658            normals[i as usize] = add(normals[i as usize], n);
659        }
660    }
661    normals.into_iter().map(normalized).collect()
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    #[test]
668    fn procedural_meshes_are_valid_and_negative_scale_preserves_faces() {
669        let mesh = grid(2., 2., 2, 2).unwrap();
670        assert!(mesh.validate().is_ok());
671        let mirrored = mesh
672            .scaled(Vec3 {
673                x: -1.,
674                y: 1.,
675                z: 1.,
676            })
677            .unwrap();
678        assert_eq!(mirrored.indices[0..3], [0, 1, 3]);
679        for triangle in mirrored.indices.as_chunks::<3>().0 {
680            let a = mirrored.positions[triangle[0] as usize];
681            let b = mirrored.positions[triangle[1] as usize];
682            let c = mirrored.positions[triangle[2] as usize];
683            let cross = Vec3 {
684                x: (b.y - a.y) * (c.z - a.z) - (b.z - a.z) * (c.y - a.y),
685                y: (b.z - a.z) * (c.x - a.x) - (b.x - a.x) * (c.z - a.z),
686                z: (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x),
687            };
688            let normal = mirrored.normals[triangle[0] as usize];
689            assert!(
690                cross.x * normal.x + cross.y * normal.y + cross.z * normal.z > 0.0,
691                "mirrored face winding must agree with transformed normals"
692            );
693        }
694        assert!(cone(1., 2., 16).unwrap().validate().is_ok());
695        assert!(
696            lathe(
697                &[
698                    ProfilePoint {
699                        radius_m: 0.2,
700                        y_m: -1.
701                    },
702                    ProfilePoint {
703                        radius_m: 0.8,
704                        y_m: 0.
705                    },
706                    ProfilePoint {
707                        radius_m: 0.3,
708                        y_m: 1.
709                    }
710                ],
711                16
712            )
713            .unwrap()
714            .validate()
715            .is_ok()
716        );
717    }
718    #[test]
719    fn parametric_surface_rejects_degenerate_or_nonfinite() {
720        assert!(parametric_surface(1, 1, |_, _| Vec3::ZERO).is_err());
721        assert!(
722            parametric_surface(1, 1, |_, _| Vec3 {
723                x: f64::NAN,
724                y: 0.,
725                z: 0.
726            })
727            .is_err()
728        );
729    }
730    #[test]
731    fn bundled_suzanne_is_a_valid_bounded_mesh() {
732        let mesh = suzanne().expect("bundled Suzanne");
733        assert_eq!(mesh.positions.len(), 2_012);
734        assert_eq!(mesh.normals.len(), mesh.positions.len());
735        assert_eq!(mesh.indices.len(), 3_936 * 3);
736        mesh.validate().expect("valid bundled mesh");
737    }
738}